diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bb1be099..81500b0e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3581,6 +3581,7 @@ dependencies = [ "serde_json", "souvlaki", "symphonia", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -4927,6 +4928,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sysinfo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +dependencies = [ + "core-foundation-sys", + "libc", + "windows 0.54.0", +] + [[package]] name = "system-deps" version = "6.2.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c0f0787f..5baec629 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,6 +45,7 @@ discord-rich-presence = "0.2" url = "2" thread-priority = "1" lofty = "0.22" +sysinfo = { version = "0.33", default-features = false, features = ["disk"] } id3 = "1.16.4" [target.'cfg(unix)'.dependencies] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a0bd57cd..a7c882e3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1351,7 +1351,68 @@ async fn purge_hot_cache(custom_dir: Option, app: tauri::AppHandle) -> R // ─── Device Sync ───────────────────────────────────────────────────────────── -#[derive(serde::Deserialize)] +/// Information about a single mounted removable drive. +#[derive(Clone, serde::Serialize)] +struct RemovableDrive { + name: String, + mount_point: String, + available_space: u64, + total_space: u64, + file_system: String, + is_removable: bool, +} + +/// Returns all currently mounted removable drives. +/// On Linux these are typically USB sticks / SD cards under /media or /run/media. +/// On macOS they appear under /Volumes. On Windows they are separate drive letters. +#[tauri::command] +fn get_removable_drives() -> Vec { + use sysinfo::Disks; + let disks = Disks::new_with_refreshed_list(); + disks + .list() + .iter() + .filter(|d| d.is_removable()) + .map(|d| RemovableDrive { + name: d.name().to_string_lossy().to_string(), + mount_point: d.mount_point().to_string_lossy().to_string(), + available_space: d.available_space(), + total_space: d.total_space(), + file_system: d.file_system().to_string_lossy().to_string(), + is_removable: true, + }) + .collect() +} + +/// Checks whether `path` sits on top of an active mount point (i.e. not the root +/// filesystem). This prevents accidentally writing to `/media/usb` after the +/// USB drive has been unmounted — at that point the path would fall through to `/` +/// and fill the root partition. +fn is_path_on_mounted_volume(path: &std::path::Path) -> bool { + use sysinfo::Disks; + let disks = Disks::new_with_refreshed_list(); + let canonical = match path.canonicalize() { + Ok(c) => c, + Err(_) => return false, // path doesn't exist or isn't accessible + }; + let canonical_str = canonical.to_string_lossy(); + // Find the longest mount-point prefix that matches this path. + // Exclude the root "/" (or "C:\" on Windows) so we never "match" a fallback. + let mut best_len: usize = 0; + for disk in disks.list() { + let mp = disk.mount_point().to_string_lossy().to_string(); + // Skip root mount points + if mp == "/" || (mp.len() == 3 && mp.ends_with(":\\")) { + continue; + } + if canonical_str.starts_with(&mp) && mp.len() > best_len { + best_len = mp.len(); + } + } + best_len > 0 +} + +#[derive(serde::Deserialize, Clone)] struct TrackSyncInfo { id: String, url: String, @@ -1366,6 +1427,14 @@ struct TrackSyncInfo { year: Option, } +/// Summary returned by `sync_batch_to_device` after all tracks are processed. +#[derive(Clone, serde::Serialize)] +struct SyncBatchResult { + done: u32, + skipped: u32, + failed: u32, +} + #[derive(serde::Serialize)] struct SyncTrackResult { path: String, @@ -1524,22 +1593,445 @@ async fn delete_device_file(path: String) -> Result<(), String> { let p = std::path::PathBuf::from(&path); if p.exists() { tokio::fs::remove_file(&p).await.map_err(|e| e.to_string())?; - // Prune empty parent dirs (album → artist) - let mut current = p.parent().map(|d| d.to_path_buf()); - for _ in 0..2 { - let Some(dir) = current else { break }; - let is_empty = std::fs::read_dir(&dir) - .map(|mut rd| rd.next().is_none()) - .unwrap_or(false); - if is_empty { - let _ = tokio::fs::remove_dir(&dir).await; - current = dir.parent().map(|d| d.to_path_buf()); - } else { - break; + prune_empty_parents(&p, 2).await; + } + Ok(()) +} + +/// Prune empty parent directories up to `levels` levels above `file_path`. +async fn prune_empty_parents(file_path: &std::path::Path, levels: usize) { + let mut current = file_path.parent().map(|d| d.to_path_buf()); + for _ in 0..levels { + let Some(dir) = current else { break }; + let is_empty = std::fs::read_dir(&dir) + .map(|mut rd| rd.next().is_none()) + .unwrap_or(false); + if is_empty { + let _ = tokio::fs::remove_dir(&dir).await; + current = dir.parent().map(|d| d.to_path_buf()); + } else { + break; + } + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SubsonicAuthPayload { + base_url: String, + u: String, + t: String, + s: String, + v: String, + c: String, + f: String, +} + +#[derive(serde::Deserialize)] +struct DeviceSyncSourcePayload { + #[serde(rename = "type")] + source_type: String, + id: String, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct SyncDeltaResult { + add_bytes: u64, + add_count: u32, + del_bytes: u64, + del_count: u32, + available_bytes: u64, + tracks: Vec, +} + +async fn fetch_subsonic_songs( + client: &reqwest::Client, + auth: &SubsonicAuthPayload, + endpoint: &str, + id: &str, +) -> Result, String> { + let url = format!("{}/{}", auth.base_url, endpoint); + let query = vec![ + ("u", auth.u.as_str()), + ("t", auth.t.as_str()), + ("s", auth.s.as_str()), + ("v", auth.v.as_str()), + ("c", auth.c.as_str()), + ("f", auth.f.as_str()), + ("id", id), + ]; + let res = client.get(&url).query(&query).send().await.map_err(|e| e.to_string())?; + let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; + + let root = json.get("subsonic-response").ok_or("No subsonic-response".to_string())?; + let songs = if endpoint == "getAlbum.view" { + root.get("album").and_then(|a| a.get("song")) + } else if endpoint == "getPlaylist.view" { + root.get("playlist").and_then(|p| p.get("entry")) + } else { + None + }; + + if let Some(arr) = songs.and_then(|s| s.as_array()) { + return Ok(arr.clone()); + } else if let Some(obj) = songs.and_then(|s| s.as_object()) { + return Ok(vec![serde_json::Value::Object(obj.clone())]); + } + Ok(vec![]) +} + +#[tauri::command] +async fn calculate_sync_payload( + sources: Vec, + deletion_ids: Vec, + auth: SubsonicAuthPayload, + target_dir: String, + template: String, +) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| e.to_string())?; + + let mut add_bytes = 0; + let mut add_count = 0; + let mut del_bytes = 0; + let mut del_count = 0; + + let mut sync_tracks = Vec::new(); + let (mut del_sources, mut add_sources) = (Vec::new(), Vec::new()); + for s in sources { + if deletion_ids.contains(&s.id) { + del_sources.push(s); + } else { + add_sources.push(s); + } + } + + let mut handles = Vec::new(); + for source in add_sources { + let auth_clone = SubsonicAuthPayload { + base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(), + v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(), + }; + let cli = client.clone(); + handles.push(tokio::spawn(async move { + let mut res_tracks = Vec::new(); + if source.source_type == "album" { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); } + } else if source.source_type == "playlist" { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); } + } else if source.source_type == "artist" { + let url = format!("{}/getArtist.view", auth_clone.base_url); + let query = vec![("u", auth_clone.u.as_str()), ("t", auth_clone.t.as_str()), ("s", auth_clone.s.as_str()), ("v", auth_clone.v.as_str()), ("c", auth_clone.c.as_str()), ("f", auth_clone.f.as_str()), ("id", &source.id)]; + if let Ok(re) = cli.get(&url).query(&query).send().await { + if let Ok(js) = re.json::().await { + if let Some(root) = js.get("subsonic-response").and_then(|r| r.get("artist")).and_then(|a| a.get("album")) { + let arr = root.as_array().map(|a| a.clone()).unwrap_or_else(|| { + root.as_object().map(|o| vec![serde_json::Value::Object(o.clone())]).unwrap_or_else(|| vec![]) + }); + for al in arr { + if let Some(aid) = al.get("id").and_then(|i| i.as_str()) { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", aid).await { + res_tracks.extend(ts); + } + } + } + } + } + } + } + res_tracks + })); + } + + let mut del_handles = Vec::new(); + for source in del_sources { + let auth_clone = SubsonicAuthPayload { + base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(), + v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(), + }; + let cli = client.clone(); + del_handles.push(tokio::spawn(async move { + let mut res_tracks = Vec::new(); + if source.source_type == "album" { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); } + } else if source.source_type == "playlist" { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); } + } + res_tracks + })); + } + + let mut seen = std::collections::HashSet::new(); + for handle in handles { + if let Ok(ts) = handle.await { + for track in ts { + if let Some(tid) = track.get("id").and_then(|i| i.as_str()) { + if !seen.contains(tid) { + seen.insert(tid.to_string()); + // Build the expected path and skip files already present on device. + let already_exists = { + let suffix = track.get("suffix").and_then(|s| s.as_str()).unwrap_or("mp3"); + let sync_info = TrackSyncInfo { + id: tid.to_string(), + url: String::new(), + suffix: suffix.to_string(), + artist: track.get("artist").and_then(|v| v.as_str()).unwrap_or("").to_string(), + album: track.get("album").and_then(|v| v.as_str()).unwrap_or("").to_string(), + title: track.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(), + track_number: track.get("track").and_then(|v| v.as_u64()).map(|n| n as u32), + disc_number: track.get("discNumber").and_then(|v| v.as_u64()).map(|n| n as u32), + year: track.get("year").and_then(|v| v.as_u64()).map(|n| n as u32), + }; + let relative = apply_device_sync_template(&template, &sync_info); + let file_name = format!("{}.{}", relative, suffix); + std::path::Path::new(&target_dir).join(&file_name).exists() + }; + if !already_exists { + add_count += 1; + let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| { + track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8 + }); + add_bytes += size; + sync_tracks.push(track); + } + } + } } } } - Ok(()) + + for handle in del_handles { + if let Ok(ts) = handle.await { + for track in ts { + del_count += 1; + let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| { + track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8 + }); + del_bytes += size; + } + } + } + + let mut available_bytes = 0; + for drive in get_removable_drives() { + if target_dir.starts_with(&drive.mount_point) { + available_bytes = drive.available_space; + break; + } + } + + Ok(SyncDeltaResult { + add_bytes, add_count, del_bytes, del_count, available_bytes, tracks: sync_tracks, + }) +} + +/// Downloads a batch of tracks to a USB/SD device with controlled concurrency. +/// At most 2 parallel writes run simultaneously to prevent I/O choking on USB. +/// Emits throttled `device:sync:progress` events (max once per 500ms) and a +/// final `device:sync:complete` event with the summary. +#[tauri::command] +async fn sync_batch_to_device( + tracks: Vec, + dest_dir: String, + template: String, + job_id: String, + expected_bytes: u64, + app: tauri::AppHandle, +) -> Result { + use std::sync::atomic::{AtomicU32, Ordering}; + use std::time::{Duration, Instant}; + use tokio::sync::Mutex; + + let dest_root = std::path::PathBuf::from(&dest_dir); + if !dest_root.exists() { + return Err("VOLUME_NOT_FOUND".to_string()); + } + // Safety: verify dest_dir is on an actual mounted volume, not the root FS. + // This catches the case where a USB drive was unmounted but the empty + // mount-point directory still exists — writing there fills the root partition. + if !is_path_on_mounted_volume(&dest_root) { + return Err("NOT_MOUNTED_VOLUME".to_string()); + } + + // Safety: Ensure target logic hasn't exceeded physical volume capacities securely stopping dead bytes natively. + let drives = get_removable_drives(); + let dest_canon = dest_root.canonicalize().unwrap_or_else(|_| dest_root.clone()); + let dest_str = dest_canon.to_string_lossy(); + + for drive in drives { + if dest_str.starts_with(&drive.mount_point) { + // Buffer of ~10 MB padding boundary natively mapped + if expected_bytes > drive.available_space.saturating_sub(10_000_000) { + return Err(format!("NOT_ENOUGH_SPACE")); + } + break; + } + } + + // Shared reqwest client — reused across all downloads. + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(300)) + .build() + .map_err(|e| e.to_string())?; + + // Concurrency limiter: max 2 parallel USB writes. + let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2)); + + // Counters. + let done = std::sync::Arc::new(AtomicU32::new(0)); + let skipped = std::sync::Arc::new(AtomicU32::new(0)); + let failed = std::sync::Arc::new(AtomicU32::new(0)); + + // Throttled event emission (max once per 500ms). + let last_emit = std::sync::Arc::new(Mutex::new(Instant::now())); + let total = tracks.len() as u32; + + let mut handles = Vec::with_capacity(tracks.len()); + + for track in tracks { + let sem = semaphore.clone(); + let cli = client.clone(); + let app2 = app.clone(); + let job = job_id.clone(); + let tmpl = template.clone(); + let dest = dest_dir.clone(); + let d = done.clone(); + let s = skipped.clone(); + let f = failed.clone(); + let le = last_emit.clone(); + + handles.push(tokio::spawn(async move { + let _permit = sem.acquire().await.expect("semaphore closed"); + + let relative = apply_device_sync_template(&tmpl, &track); + let file_name = format!("{}.{}", relative, track.suffix); + let dest_path = std::path::Path::new(&dest).join(&file_name); + let path_str = dest_path.to_string_lossy().to_string(); + + let status; + if dest_path.exists() { + s.fetch_add(1, Ordering::Relaxed); + status = "skipped"; + } else { + // Ensure parent directories exist. + if let Some(parent) = dest_path.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": e.to_string(), + })); + return; + } + } + + let response = match cli.get(&track.url).send().await { + Ok(r) if r.status().is_success() => r, + Ok(r) => { + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": format!("HTTP {}", r.status().as_u16()), + })); + return; + } + Err(e) => { + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": e.to_string(), + })); + return; + } + }; + + let part_path = dest_path.with_extension(format!("{}.part", track.suffix)); + if let Err(e) = stream_to_file(response, &part_path).await { + let _ = tokio::fs::remove_file(&part_path).await; + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": e, + })); + return; + } + if let Err(e) = tokio::fs::rename(&part_path, &dest_path).await { + let _ = tokio::fs::remove_file(&part_path).await; + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": e.to_string(), + })); + return; + } + + d.fetch_add(1, Ordering::Relaxed); + status = "done"; + } + + // Throttled progress event — max once per 500ms. + let should_emit = { + let mut guard = le.lock().await; + if guard.elapsed() >= Duration::from_millis(500) { + *guard = Instant::now(); + true + } else { + false + } + }; + if should_emit { + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": status, "path": path_str, + "done": d.load(Ordering::Relaxed), + "skipped": s.load(Ordering::Relaxed), + "failed": f.load(Ordering::Relaxed), + "total": total, + })); + } + })); + } + + // Wait for all tasks to complete. + for handle in handles { + let _ = handle.await; + } + + let result = SyncBatchResult { + done: done.load(Ordering::Relaxed), + skipped: skipped.load(Ordering::Relaxed), + failed: failed.load(Ordering::Relaxed), + }; + + // Final event so the frontend always sees 100%. + let _ = app.emit("device:sync:complete", serde_json::json!({ + "jobId": job_id, + "done": result.done, + "skipped": result.skipped, + "failed": result.failed, + "total": total, + })); + + Ok(result) +} + +/// Deletes multiple files from the device in one call and prunes empty parent +/// directories. Returns the number of files successfully deleted. +#[tauri::command] +async fn delete_device_files(paths: Vec) -> Result { + let mut deleted: u32 = 0; + for path in &paths { + let p = std::path::PathBuf::from(path); + if p.exists() { + if tokio::fs::remove_file(&p).await.is_ok() { + deleted += 1; + prune_empty_parents(&p, 2).await; + } + } + } + Ok(deleted) } /// Builds and returns a new system-tray icon with all menu items and event handlers. @@ -1884,6 +2376,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ greet, + calculate_sync_payload, exit_app, set_window_decorations, no_compositing_mode, @@ -1932,9 +2425,12 @@ pub fn run() { delete_hot_cache_track, purge_hot_cache, sync_track_to_device, + sync_batch_to_device, compute_sync_paths, list_device_dir_files, delete_device_file, + delete_device_files, + get_removable_drives, toggle_tray_icon, check_dir_accessible, download_zip, diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 863f03ae..bf80a6aa 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -23,7 +23,7 @@ function getAuthParams(username: string, password: string) { return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' }; } -function getClient() { +export function getClient() { const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const server = getActiveServer(); const baseUrl = getBaseUrl(); @@ -741,11 +741,18 @@ export async function fetchStatisticsFormatSample(): Promise { - const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', { + const data = await api<{ artists: { index: any } }>('getArtists.view', { ...libraryFilterParams(), }); - const indices = data.artists?.index ?? []; - return indices.flatMap(i => i.artist ?? []); + const rawIdx = data.artists?.index; + const indices = Array.isArray(rawIdx) ? rawIdx : (rawIdx ? [rawIdx] : []); + const artists: SubsonicArtist[] = []; + for (const idx of indices) { + const rawArt = idx.artist; + const arr = Array.isArray(rawArt) ? rawArt : (rawArt ? [rawArt] : []); + artists.push(...arr); + } + return artists; } export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> { diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 598e9540..5f0f599a 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'; import { usePlayerStore } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; import { useOfflineJobStore } from '../store/offlineJobStore'; +import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore'; import { useAuthStore } from '../store/authStore'; import { useSidebarStore } from '../store/sidebarStore'; import { NavLink } from 'react-router-dom'; @@ -50,6 +51,12 @@ export default function Sidebar({ const offlineJobs = useOfflineJobStore(s => s.jobs); const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads); const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading'); + const syncJobStatus = useDeviceSyncJobStore(s => s.status); + const syncJobDone = useDeviceSyncJobStore(s => s.done); + const syncJobSkip = useDeviceSyncJobStore(s => s.skipped); + const syncJobFail = useDeviceSyncJobStore(s => s.failed); + const syncJobTotal = useDeviceSyncJobStore(s => s.total); + const isSyncing = syncJobStatus === 'running'; const offlineAlbums = useOfflineStore(s => s.albums); const serverId = useAuthStore(s => s.activeServerId ?? ''); const isLoggedIn = useAuthStore(s => s.isLoggedIn); @@ -369,6 +376,19 @@ export default function Sidebar({ )} + + {isSyncing && ( +
+ + {!isCollapsed && ( + {t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal })} + )} +
+ )} ); diff --git a/src/locales/de.ts b/src/locales/de.ts index 3e6cd223..4d2411df 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -17,6 +17,7 @@ export const deTranslation = { expand: 'Sidebar einblenden', collapse: 'Sidebar ausblenden', downloadingTracks: '{{n}} Tracks werden gecacht…', + syncingTracks: 'Synchronisiere {{done}}/{{total}}…', cancelDownload: 'Download abbrechen', offlineLibrary: 'Offline-Bibliothek', genres: 'Genres', @@ -1026,8 +1027,15 @@ export const deTranslation = { title: 'Gerätesync', targetFolder: 'Zielordner', noFolderChosen: 'Kein Ordner gewählt', + selectDrive: 'Laufwerk auswählen…', + refreshDrives: 'Laufwerke aktualisieren', + noDrivesDetected: 'Keine Wechseldatenträger erkannt', + browseManual: 'Manuell durchsuchen…', + free: 'frei', + notMountedVolume: 'Ziel befindet sich nicht auf einem eingehängten Laufwerk. Das Gerät wurde möglicherweise getrennt.', chooseFolder: 'Auswählen…', filenameTemplate: 'Dateinamen-Vorlage', + targetDevice: 'Zielgerät', templateHint: 'Variablen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', onDevice: 'Auf dem Gerät', addSources: 'Hinzufügen…', @@ -1045,6 +1053,10 @@ export const deTranslation = { tabArtists: 'Künstler', searchPlaceholder: 'Suche…', syncButton: 'Auf Gerät übertragen', + actionTransfer: 'Auf Gerät übertragen', + actionDelete: 'Vom Gerät löschen', + actionApplyAll: 'Änderungen synchronisieren', + templatePreview: 'Vorschau', cancel: 'Abbrechen', noTargetDir: 'Bitte zuerst einen Zielordner auswählen.', noSources: 'Bitte mindestens eine Quelle auswählen.', @@ -1052,5 +1064,25 @@ export const deTranslation = { fetchError: 'Fehler beim Laden der Tracks vom Server.', syncComplete: 'Übertragung abgeschlossen.', dismiss: 'Schließen', + colStatus: 'Status', + statusSynced: 'Synchronisiert', + statusPending: 'Ausstehend', + statusDeletion: 'Löschung', + markForDeletion: 'Zur Löschung markieren', + removeSource: 'Entfernen', + undoDeletion: 'Löschung rückgängig', + syncInBackground: 'Sync gestartet — du kannst die Seite verlassen.', + syncInProgress: '{{done}} / {{total}} Tracks…', + syncSummary: 'Sync-Zusammenfassung', + calculating: 'Payload wird berechnet…', + filesToAdd: 'Hinzuzufügende Dateien:', + filesToDelete: 'Zu löschende Dateien:', + netChange: 'Nettoänderung:', + availableSpace: 'Verfügbarer Speicher:', + spaceWarning: 'Warnung: Das Zielgerät hat nicht genug gemeldeten Speicherplatz.', + proceed: 'Sync durchführen', + notEnoughSpace: 'Nicht genug physischer Speicherplatz erkannt!', + liveSearch: 'Live', + randomAlbumsLabel: 'Zufallsalben', }, }; diff --git a/src/locales/en.ts b/src/locales/en.ts index 0d7ccbfe..8e631655 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -18,6 +18,7 @@ export const enTranslation = { collapse: 'Collapse Sidebar', downloadingTracks: 'Caching {{n}} tracks…', + syncingTracks: 'Syncing {{done}}/{{total}}…', cancelDownload: 'Cancel download', offlineLibrary: 'Offline Library', genres: 'Genres', @@ -1028,15 +1029,23 @@ export const enTranslation = { title: 'Device Sync', targetFolder: 'Target Folder', noFolderChosen: 'No folder chosen', + selectDrive: 'Select a drive…', + refreshDrives: 'Refresh drives', + noDrivesDetected: 'No removable drives detected', + browseManual: 'Browse manually…', + free: 'free', + notMountedVolume: 'Target is not on a mounted volume. The drive may have been disconnected.', chooseFolder: 'Choose…', filenameTemplate: 'Filename Template', + targetDevice: 'Target Device', templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', - onDevice: 'On Device', + onDevice: 'Device Manager', addSources: 'Add…', colName: 'Name', colType: 'Type', + colStatus: 'Status', syncResult: '{{done}} transferred, {{skipped}} already up to date ({{total}} total)', - deleteFromDevice: 'Delete from device ({{count}})', + deleteFromDevice: 'Mark for deletion ({{count}})', confirmDelete: 'Delete {{count}} item(s) from the device: {{names}}?', deleteComplete: '{{count}} item(s) removed from device.', selectedSources: 'Selected Sources', @@ -1047,6 +1056,10 @@ export const enTranslation = { tabArtists: 'Artists', searchPlaceholder: 'Search…', syncButton: 'Sync to Device', + actionTransfer: 'Transfer to Device', + actionDelete: 'Delete from Device', + actionApplyAll: 'Apply All Changes', + templatePreview: 'Preview', cancel: 'Cancel', noTargetDir: 'Please choose a target folder first.', noSources: 'Please select at least one source.', @@ -1054,5 +1067,25 @@ export const enTranslation = { fetchError: 'Failed to fetch tracks from server.', syncComplete: 'Sync complete.', dismiss: 'Dismiss', + statusSynced: 'Synced', + statusPending: 'Pending', + statusDeletion: 'Deletion', + markForDeletion: 'Mark for deletion', + undoDeletion: 'Undo deletion', + removeSource: 'Remove', + syncInBackground: 'Sync started in background — you can navigate away.', + syncInProgress: '{{done}} / {{total}} tracks…', + scanningDevice: 'Scanning device…', + syncSummary: 'Sync Summary', + calculating: 'Calculating required payload…', + filesToAdd: 'Files to Add:', + filesToDelete: 'Files to Delete:', + netChange: 'Net Change:', + availableSpace: 'Available Disk Space:', + spaceWarning: 'Warning: Target device does not have enough reported space.', + proceed: 'Proceed with Sync', + notEnoughSpace: 'Not enough physical disk space detected!', + liveSearch: 'Live', + randomAlbumsLabel: 'Random Albums', }, }; diff --git a/src/locales/es.ts b/src/locales/es.ts index 29eb8ab3..685e4f70 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -18,6 +18,7 @@ export const esTranslation = { collapse: 'Colapsar Barra Lateral', downloadingTracks: 'Descargando {{n}} pistas…', + syncingTracks: 'Sincronizando {{done}}/{{total}}…', cancelDownload: 'Cancelar descarga', offlineLibrary: 'Biblioteca Offline', genres: 'Géneros', @@ -1029,8 +1030,15 @@ export const esTranslation = { title: 'Sincronizar dispositivo', targetFolder: 'Carpeta de destino', noFolderChosen: 'Ninguna carpeta seleccionada', + selectDrive: 'Seleccionar unidad…', + refreshDrives: 'Actualizar unidades', + noDrivesDetected: 'No se detectaron unidades extraíbles', + browseManual: 'Explorar manualmente…', + free: 'libre', + notMountedVolume: 'El destino no está en un volumen montado. La unidad puede haberse desconectado.', chooseFolder: 'Elegir…', filenameTemplate: 'Plantilla de nombre de archivo', + targetDevice: 'Dispositivo de destino', templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', cleanupButton: 'Eliminar archivos fuera de la selección', cleanupNothingToDelete: 'Nada que eliminar — el dispositivo ya está sincronizado.', @@ -1044,6 +1052,10 @@ export const esTranslation = { tabArtists: 'Artistas', searchPlaceholder: 'Buscar…', syncButton: 'Sincronizar al dispositivo', + actionTransfer: 'Transferir al dispositivo', + actionDelete: 'Eliminar del dispositivo', + actionApplyAll: 'Aplicar todos los cambios', + templatePreview: 'Vista previa', cancel: 'Cancelar', noTargetDir: 'Por favor, elige primero una carpeta de destino.', noSources: 'Por favor, selecciona al menos una fuente.', @@ -1051,5 +1063,33 @@ export const esTranslation = { fetchError: 'Error al obtener pistas del servidor.', syncComplete: 'Sincronización completada.', dismiss: 'Cerrar', + colName: 'Nombre', + colType: 'Tipo', + colStatus: 'Estado', + onDevice: 'Gestor de dispositivo', + addSources: 'Agregar…', + syncResult: '{{done}} transferido(s), {{skipped}} ya actualizado(s) ({{total}} total)', + deleteFromDevice: 'Marcar para eliminar ({{count}})', + confirmDelete: '¿Eliminar {{count}} elemento(s) del dispositivo: {{names}}?', + deleteComplete: '{{count}} elemento(s) eliminado(s) del dispositivo.', + statusSynced: 'Sincronizado', + statusPending: 'Pendiente', + statusDeletion: 'Eliminación', + markForDeletion: 'Marcar para eliminar', + removeSource: 'Eliminar', + undoDeletion: 'Deshacer eliminación', + syncInBackground: 'Sincronización iniciada en segundo plano — puedes navegar a otro lugar.', + syncInProgress: '{{done}} / {{total}} pistas…', + syncSummary: 'Resumen de sincronización', + calculating: 'Calculando la carga útil requerida…', + filesToAdd: 'Archivos a agregar:', + filesToDelete: 'Archivos a eliminar:', + netChange: 'Cambio neto:', + availableSpace: 'Espacio disponible en disco:', + spaceWarning: 'Advertencia: El dispositivo de destino no tiene suficiente espacio reportado.', + proceed: 'Proceder con la sincronización', + notEnoughSpace: '¡Espacio físico en disco insuficiente detectado!', + liveSearch: 'Live', + randomAlbumsLabel: 'Álbumes aleatorios', }, }; diff --git a/src/locales/fr.ts b/src/locales/fr.ts index b3270954..cb4bae94 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -17,6 +17,7 @@ export const frTranslation = { expand: 'Développer la barre latérale', collapse: 'Réduire la barre latérale', downloadingTracks: '{{n}} pistes en cache…', + syncingTracks: 'Synchro {{done}}/{{total}}…', cancelDownload: 'Annuler le téléchargement', offlineLibrary: 'Bibliothèque hors ligne', genres: 'Genres', @@ -1024,8 +1025,15 @@ export const frTranslation = { title: 'Sync appareil', targetFolder: 'Dossier cible', noFolderChosen: 'Aucun dossier sélectionné', + selectDrive: 'Sélectionner un lecteur…', + refreshDrives: 'Actualiser les lecteurs', + noDrivesDetected: 'Aucun lecteur amovible détecté', + browseManual: 'Parcourir manuellement…', + free: 'libre', + notMountedVolume: 'La cible n\u0027est pas sur un volume monté. Le lecteur a peut-être été déconnecté.', chooseFolder: 'Choisir…', filenameTemplate: 'Modèle de nom de fichier', + targetDevice: 'Appareil cible', templateHint: 'Variables : {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', cleanupButton: 'Supprimer les fichiers absents', cleanupNothingToDelete: 'Rien à supprimer — l\'appareil est déjà synchronisé.', @@ -1039,6 +1047,10 @@ export const frTranslation = { tabArtists: 'Artistes', searchPlaceholder: 'Rechercher…', syncButton: 'Synchroniser vers l\'appareil', + actionTransfer: 'Transférer vers l\'appareil', + actionDelete: 'Supprimer de l\'appareil', + actionApplyAll: 'Appliquer toutes les modifications', + templatePreview: 'Aperçu', cancel: 'Annuler', noTargetDir: 'Veuillez d\'abord choisir un dossier cible.', noSources: 'Veuillez sélectionner au moins une source.', @@ -1046,5 +1058,33 @@ export const frTranslation = { fetchError: 'Échec du chargement des pistes depuis le serveur.', syncComplete: 'Synchronisation terminée.', dismiss: 'Fermer', + colName: 'Nom', + colType: 'Type', + colStatus: 'Statut', + onDevice: 'Gestionnaire d\'appareil', + addSources: 'Ajouter…', + syncResult: '{{done}} transféré(s), {{skipped}} déjà à jour ({{total}} au total)', + deleteFromDevice: 'Marquer pour suppression ({{count}})', + confirmDelete: 'Supprimer {{count}} élément(s) du périphérique : {{names}} ?', + deleteComplete: '{{count}} élément(s) supprimé(s) du périphérique.', + statusSynced: 'Synchronisé', + statusPending: 'En attente', + statusDeletion: 'Suppression', + markForDeletion: 'Marquer pour suppression', + removeSource: 'Supprimer', + undoDeletion: 'Annuler la suppression', + syncInBackground: 'Sync démarré en arrière-plan — vous pouvez naviguer ailleurs.', + syncInProgress: '{{done}} / {{total}} pistes…', + syncSummary: 'Résumé de la synchronisation', + calculating: 'Calcul de la charge utile…', + filesToAdd: 'Fichiers à ajouter :', + filesToDelete: 'Fichiers à supprimer :', + netChange: 'Variation nette :', + availableSpace: 'Espace disque disponible :', + spaceWarning: 'Attention : L\'appareil cible ne dispose pas d\'assez d\'espace signalé.', + proceed: 'Procéder à la synchronisation', + notEnoughSpace: 'Espace disque physique insuffisant détecté !', + liveSearch: 'Live', + randomAlbumsLabel: 'Albums aléatoires', }, }; diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 68235cf9..89f36ae5 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -17,6 +17,7 @@ export const nbTranslation = { expand: 'Utvid sidefelt', collapse: 'Skjul sidefelt', downloadingTracks: 'Bufre {{n}} spor…', + syncingTracks: 'Synkroniserer {{done}}/{{total}}…', cancelDownload: 'Avbryt nedlasting', offlineLibrary: 'Frakoblet bibliotek', genres: 'Sjangere', @@ -1023,8 +1024,15 @@ export const nbTranslation = { title: 'Enhetssynk', targetFolder: 'Målmappe', noFolderChosen: 'Ingen mappe valgt', + selectDrive: 'Velg en stasjon…', + refreshDrives: 'Oppdater stasjoner', + noDrivesDetected: 'Ingen flyttbare stasjoner oppdaget', + browseManual: 'Bla manuelt…', + free: 'ledig', + notMountedVolume: 'Målet er ikke på en montert stasjon. Enheten kan ha blitt koblet fra.', chooseFolder: 'Velg…', filenameTemplate: 'Filnavnmal', + targetDevice: 'Målenhet', templateHint: 'Variabler: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', cleanupButton: 'Fjern filer som ikke er i utvalget', cleanupNothingToDelete: 'Ingenting å fjerne — enheten er allerede synkronisert.', @@ -1038,6 +1046,10 @@ export const nbTranslation = { tabArtists: 'Artister', searchPlaceholder: 'Søk…', syncButton: 'Synkroniser til enhet', + actionTransfer: 'Overfør til enhet', + actionDelete: 'Slett fra enhet', + actionApplyAll: 'Bruk alle endringer', + templatePreview: 'Forhåndsvisning', cancel: 'Avbryt', noTargetDir: 'Velg en målmappe først.', noSources: 'Velg minst én kilde.', @@ -1045,5 +1057,33 @@ export const nbTranslation = { fetchError: 'Kunne ikke hente spor fra serveren.', syncComplete: 'Synkronisering fullført.', dismiss: 'Lukk', + colName: 'Navn', + colType: 'Type', + colStatus: 'Status', + onDevice: 'Enhetsbehandling', + addSources: 'Legg til…', + syncResult: '{{done}} overført, {{skipped}} allerede oppdatert ({{total}} totalt)', + deleteFromDevice: 'Merk for sletting ({{count}})', + confirmDelete: 'Slette {{count}} element(er) fra enheten: {{names}}?', + deleteComplete: '{{count}} element(er) fjernet fra enheten.', + statusSynced: 'Synkronisert', + statusPending: 'Venter', + statusDeletion: 'Sletting', + markForDeletion: 'Merk for sletting', + removeSource: 'Fjern', + undoDeletion: 'Angre sletting', + syncInBackground: 'Synkronisering startet i bakgrunnen — du kan navigere bort.', + syncInProgress: '{{done}} / {{total}} spor…', + syncSummary: 'Synkroniseringssammendrag', + calculating: 'Beregner nødvendig nyttelast…', + filesToAdd: 'Filer som skal legges til:', + filesToDelete: 'Filer som skal slettes:', + netChange: 'Nettoendring:', + availableSpace: 'Tilgjengelig diskplass:', + spaceWarning: 'Advarsel: Målenheten har ikke nok rapportert plass.', + proceed: 'Fortsett med synkronisering', + notEnoughSpace: 'Ikke nok fysisk diskplass oppdaget!', + liveSearch: 'Live', + randomAlbumsLabel: 'Tilfeldige album', }, }; diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 1253d6bc..fe4fdd64 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -17,6 +17,7 @@ export const nlTranslation = { expand: 'Zijbalk uitklappen', collapse: 'Zijbalk inklappen', downloadingTracks: '{{n}} nummers worden gecached…', + syncingTracks: 'Synchroniseren {{done}}/{{total}}…', cancelDownload: 'Download annuleren', offlineLibrary: 'Offline bibliotheek', genres: 'Genres', @@ -1021,8 +1022,15 @@ export const nlTranslation = { title: 'Apparaatsync', targetFolder: 'Doelmap', noFolderChosen: 'Geen map gekozen', + selectDrive: 'Selecteer een schijf…', + refreshDrives: 'Schijven vernieuwen', + noDrivesDetected: 'Geen verwijderbare schijven gedetecteerd', + browseManual: 'Handmatig bladeren…', + free: 'vrij', + notMountedVolume: 'Het doel bevindt zich niet op een gekoppeld volume. De schijf is mogelijk losgekoppeld.', chooseFolder: 'Kiezen…', filenameTemplate: 'Bestandsnaamsjabloon', + targetDevice: 'Doelapparaat', templateHint: 'Variabelen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', cleanupButton: 'Niet-geselecteerde bestanden verwijderen', cleanupNothingToDelete: 'Niets te verwijderen — apparaat is al gesynchroniseerd.', @@ -1036,6 +1044,10 @@ export const nlTranslation = { tabArtists: 'Artiesten', searchPlaceholder: 'Zoeken…', syncButton: 'Synchroniseren naar apparaat', + actionTransfer: 'Overdragen naar apparaat', + actionDelete: 'Verwijderen van apparaat', + actionApplyAll: 'Alle wijzigingen toepassen', + templatePreview: 'Voorbeeld', cancel: 'Annuleren', noTargetDir: 'Kies eerst een doelmap.', noSources: 'Selecteer minimaal één bron.', @@ -1043,5 +1055,33 @@ export const nlTranslation = { fetchError: 'Ophalen van nummers van de server mislukt.', syncComplete: 'Synchronisatie voltooid.', dismiss: 'Sluiten', + colName: 'Naam', + colType: 'Type', + colStatus: 'Status', + onDevice: 'Apparaatbeheer', + addSources: 'Toevoegen…', + syncResult: '{{done}} overgedragen, {{skipped}} al up-to-date ({{total}} totaal)', + deleteFromDevice: 'Markeren voor verwijdering ({{count}})', + confirmDelete: '{{count}} item(s) van het apparaat verwijderen: {{names}}?', + deleteComplete: '{{count}} item(s) van het apparaat verwijderd.', + statusSynced: 'Gesynchroniseerd', + statusPending: 'In behandeling', + statusDeletion: 'Verwijdering', + markForDeletion: 'Markeren voor verwijdering', + removeSource: 'Verwijderen', + undoDeletion: 'Verwijdering ongedaan maken', + syncInBackground: 'Sync gestart op achtergrond — u kunt navigeren.', + syncInProgress: '{{done}} / {{total}} nummers…', + syncSummary: 'Synchronisatieoverzicht', + calculating: 'Vereiste payload berekenen…', + filesToAdd: 'Te toevoegen bestanden:', + filesToDelete: 'Te verwijderen bestanden:', + netChange: 'Nettoverandering:', + availableSpace: 'Beschikbare schijfruimte:', + spaceWarning: 'Waarschuwing: Het doelapparaat heeft niet genoeg gerapporteerde ruimte.', + proceed: 'Doorgaan met synchronisatie', + notEnoughSpace: 'Onvoldoende fysieke schijfruimte gedetecteerd!', + liveSearch: 'Live', + randomAlbumsLabel: 'Willekeurige albums', }, }; diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 9b71443f..3ac65eed 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -18,6 +18,7 @@ export const ruTranslation = { expand: 'Развернуть боковую панель', collapse: 'Свернуть боковую панель', downloadingTracks: 'Кэширование {{n}} треков…', + syncingTracks: 'Синхронизация {{done}}/{{total}}…', cancelDownload: 'Отменить загрузку', offlineLibrary: 'Офлайн-библиотека', genres: 'Жанры', @@ -1082,8 +1083,15 @@ export const ruTranslation = { title: 'Синхронизация устройства', targetFolder: 'Папка назначения', noFolderChosen: 'Папка не выбрана', + selectDrive: 'Выберите диск…', + refreshDrives: 'Обновить диски', + noDrivesDetected: 'Съёмные диски не обнаружены', + browseManual: 'Обзор вручную…', + free: 'свободно', + notMountedVolume: 'Цель не находится на смонтированном томе. Диск мог быть отключён.', chooseFolder: 'Выбрать…', filenameTemplate: 'Шаблон имени файла', + targetDevice: 'Целевое устройство', templateHint: 'Переменные: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', cleanupButton: 'Удалить файлы вне выборки', cleanupNothingToDelete: 'Нечего удалять — устройство уже синхронизировано.', @@ -1097,6 +1105,10 @@ export const ruTranslation = { tabArtists: 'Исполнители', searchPlaceholder: 'Поиск…', syncButton: 'Синхронизировать на устройство', + actionTransfer: 'Перенести на устройство', + actionDelete: 'Удалить с устройства', + actionApplyAll: 'Применить все изменения', + templatePreview: 'Предпросмотр', cancel: 'Отмена', noTargetDir: 'Сначала выберите папку назначения.', noSources: 'Выберите хотя бы один источник.', @@ -1104,5 +1116,33 @@ export const ruTranslation = { fetchError: 'Ошибка загрузки треков с сервера.', syncComplete: 'Синхронизация завершена.', dismiss: 'Закрыть', + colName: 'Название', + colType: 'Тип', + colStatus: 'Статус', + onDevice: 'Управление устройством', + addSources: 'Добавить…', + syncResult: '{{done}} перенесено, {{skipped}} уже актуально ({{total}} всего)', + deleteFromDevice: 'Пометить для удаления ({{count}})', + confirmDelete: 'Удалить {{count}} запись(ей) с устройства: {{names}}?', + deleteComplete: '{{count}} запись(ей) удалено с устройства.', + statusSynced: 'Синхронизировано', + statusPending: 'Ожидает', + statusDeletion: 'Удаление', + markForDeletion: 'Пометить для удаления', + removeSource: 'Удалить', + undoDeletion: 'Отменить удаление', + syncInBackground: 'Синхронизация запущена в фоне — можно перейти в другой раздел.', + syncInProgress: '{{done}} / {{total}} треков…', + syncSummary: 'Сводка синхронизации', + calculating: 'Вычисление необходимых данных…', + filesToAdd: 'Файлы для добавления:', + filesToDelete: 'Файлы для удаления:', + netChange: 'Чистое изменение:', + availableSpace: 'Доступное место на диске:', + spaceWarning: 'Предупреждение: на целевом устройстве недостаточно сообщаемого места.', + proceed: 'Продолжить синхронизацию', + notEnoughSpace: 'Обнаружено недостаточно физического места на диске!', + liveSearch: 'Live', + randomAlbumsLabel: 'Случайные альбомы', }, }; diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 987de952..666c23cd 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -17,6 +17,7 @@ export const zhTranslation = { expand: '展开侧边栏', collapse: '收起侧边栏', downloadingTracks: '正在缓存 {{n}} 首歌曲…', + syncingTracks: '同步中 {{done}}/{{total}}…', cancelDownload: '取消下载', offlineLibrary: '离线音乐库', genres: '流派', @@ -1017,8 +1018,15 @@ export const zhTranslation = { title: '设备同步', targetFolder: '目标文件夹', noFolderChosen: '未选择文件夹', + selectDrive: '选择驱动器…', + refreshDrives: '刷新驱动器', + noDrivesDetected: '未检测到可移动驱动器', + browseManual: '手动浏览…', + free: '可用', + notMountedVolume: '目标不在已挂载的卷上。驱动器可能已断开。', chooseFolder: '选择…', filenameTemplate: '文件名模板', + targetDevice: '目标设备', templateHint: '变量:{artist}、{album}、{title}、{track_number}、{disc_number}、{year}', cleanupButton: '删除不在选择范围内的文件', cleanupNothingToDelete: '无需删除 — 设备已同步。', @@ -1032,6 +1040,10 @@ export const zhTranslation = { tabArtists: '艺术家', searchPlaceholder: '搜索…', syncButton: '同步到设备', + actionTransfer: '传输到设备', + actionDelete: '从设备删除', + actionApplyAll: '应用所有更改', + templatePreview: '预览', cancel: '取消', noTargetDir: '请先选择目标文件夹。', noSources: '请至少选择一个来源。', @@ -1039,5 +1051,33 @@ export const zhTranslation = { fetchError: '从服务器加载曲目失败。', syncComplete: '同步完成。', dismiss: '关闭', + colName: '名称', + colType: '类型', + colStatus: '状态', + onDevice: '设备管理器', + addSources: '添加…', + syncResult: '已传输 {{done}},{{skipped}} 已是最新(共 {{total}})', + deleteFromDevice: '标记为删除({{count}})', + confirmDelete: '从设备删除 {{count}} 个项目:{{names}}?', + deleteComplete: '已从设备删除 {{count}} 个项目。', + statusSynced: '已同步', + statusPending: '待处理', + statusDeletion: '删除中', + markForDeletion: '标记为删除', + removeSource: '移除', + undoDeletion: '撤销删除', + syncInBackground: '同步已在后台启动 — 您可以离开此页面。', + syncInProgress: '{{done}} / {{total}} 首曲目…', + syncSummary: '同步摘要', + calculating: '正在计算所需数据…', + filesToAdd: '要添加的文件:', + filesToDelete: '要删除的文件:', + netChange: '净变化:', + availableSpace: '可用磁盘空间:', + spaceWarning: '警告:目标设备的可用空间不足。', + proceed: '继续同步', + notEnoughSpace: '检测到物理磁盘空间不足!', + liveSearch: '实时', + randomAlbumsLabel: '随机专辑', }, }; diff --git a/src/pages/DeviceSync.tsx b/src/pages/DeviceSync.tsx index d08ab45e..98dd0ae3 100644 --- a/src/pages/DeviceSync.tsx +++ b/src/pages/DeviceSync.tsx @@ -1,17 +1,20 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { HardDriveUpload, FolderOpen, Loader2, - ListMusic, Disc3, Users, CheckCircle2, AlertCircle, SkipForward, Trash2, - ChevronRight, ChevronDown, + ListMusic, Disc3, Users, CheckCircle2, AlertCircle, Clock, + ChevronRight, ChevronDown, Trash2, Undo2, Search, Usb, RefreshCw, Shuffle, Zap, } from 'lucide-react'; +import CustomSelect from '../components/CustomSelect'; import { useTranslation } from 'react-i18next'; import { useDeviceSyncStore, DeviceSyncSource } from '../store/deviceSyncStore'; +import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore'; import { getPlaylists, getAlbumList, getArtists, getAlbum, getPlaylist, getArtist, - buildDownloadUrl, SubsonicSong, SubsonicAlbum, SubsonicPlaylist, SubsonicArtist, + buildDownloadUrl, search as searchSubsonic, + SubsonicSong, SubsonicAlbum, SubsonicPlaylist, SubsonicArtist, } from '../api/subsonic'; import { showToast } from '../utils/toast'; @@ -43,6 +46,25 @@ function trackToSyncInfo(track: SubsonicSong, url: string) { }; } +type SyncStatus = 'synced' | 'pending' | 'deletion'; + +interface RemovableDrive { + name: string; + mount_point: string; + available_space: number; + total_space: number; + file_system: string; + is_removable: boolean; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; +} + // ─── component ─────────────────────────────────────────────────────────────── export default function DeviceSync() { @@ -52,41 +74,243 @@ export default function DeviceSync() { const filenameTemplate = useDeviceSyncStore(s => s.filenameTemplate); const sources = useDeviceSyncStore(s => s.sources); const checkedIds = useDeviceSyncStore(s => s.checkedIds); - const activeJob = useDeviceSyncStore(s => s.activeJob); - const { setTargetDir, setFilenameTemplate, addSource, removeSource, - clearSources, toggleChecked, setCheckedIds, setActiveJob, updateJob } = - useDeviceSyncStore.getState(); + const pendingDeletion = useDeviceSyncStore(s => s.pendingDeletion); + const deviceFilePaths = useDeviceSyncStore(s => s.deviceFilePaths); + const scanning = useDeviceSyncStore(s => s.scanning); + const { + setTargetDir, setFilenameTemplate, addSource, removeSource, + clearSources, toggleChecked, setCheckedIds, markForDeletion, + unmarkDeletion, removeSources, setDeviceFilePaths, setScanning, + } = useDeviceSyncStore.getState(); + + const jobStatus = useDeviceSyncJobStore(s => s.status); + const jobDone = useDeviceSyncJobStore(s => s.done); + const jobSkip = useDeviceSyncJobStore(s => s.skipped); + const jobFail = useDeviceSyncJobStore(s => s.failed); + const jobTotal = useDeviceSyncJobStore(s => s.total); const [activeTab, setActiveTab] = useState('albums'); const [search, setSearch] = useState(''); const [playlists, setPlaylists] = useState([]); - const [albums, setAlbums] = useState([]); + const [randomAlbums, setRandomAlbums] = useState([]); + const [albumSearchResults, setAlbumSearchResults] = useState([]); + const [albumSearchLoading, setAlbumSearchLoading] = useState(false); const [artists, setArtists] = useState([]); - const [loadingBrowser, setLoadingBrowser] = useState(false); - const [deleting, setDeleting] = useState(false); + const [loadingBrowser, setLoadingBrowser] = useState(false); const [expandedArtistIds, setExpandedArtistIds] = useState>(new Set()); - const [artistAlbumsMap, setArtistAlbumsMap] = useState>(new Map()); - const [loadingArtistIds, setLoadingArtistIds] = useState>(new Set()); + const [artistAlbumsMap, setArtistAlbumsMap] = useState>(new Map()); + const [loadingArtistIds, setLoadingArtistIds] = useState>(new Set()); - const cancelRef = useRef(false); + // Map source IDs → computed device paths (for status derivation) + const [sourcePathsMap, setSourcePathsMap] = useState>(new Map()); + + // ─── Removable drive detection ────────────────────────────────────────── + const [drives, setDrives] = useState([]); + const [drivesLoading, setDrivesLoading] = useState(false); + + const [preSyncOpen, setPreSyncOpen] = useState(false); + const [preSyncLoading, setPreSyncLoading] = useState(false); + const [syncDelta, setSyncDelta] = useState({ addBytes: 0, addCount: 0, delBytes: 0, delCount: 0, availableBytes: 0, tracks: [] as SubsonicSong[] }); + + const refreshDrives = useCallback(async () => { + setDrivesLoading(true); + try { + const result = await invoke('get_removable_drives'); + setDrives(result); + } catch { + setDrives([]); + } finally { + setDrivesLoading(false); + } + }, []); + + // Fetch drives on mount, then poll every 5 seconds + useEffect(() => { + refreshDrives(); + const interval = setInterval(refreshDrives, 5000); + return () => clearInterval(interval); + }, [refreshDrives]); + + // Detect if the current targetDir is on a detected removable drive + const activeDrive = useMemo(() => { + if (!targetDir) return null; + return drives.find(d => targetDir.startsWith(d.mount_point)) ?? null; + }, [targetDir, drives]); + + const driveDetected = activeDrive !== null; + + const isRunning = jobStatus === 'running'; + + // ─── Device scan on mount ─────────────────────────────────────────────── + + const scanDevice = useCallback(async () => { + if (!targetDir || sources.length === 0) { + setDeviceFilePaths([]); + return; + } + setScanning(true); + try { + const files = await invoke('list_device_dir_files', { dir: targetDir }); + setDeviceFilePaths(files); + } catch { + setDeviceFilePaths([]); + } finally { + setScanning(false); + } + }, [targetDir, sources.length]); + + // Scan device on mount and when targetDir changes + useEffect(() => { scanDevice(); }, [scanDevice]); + + // Compute expected paths for each source (for status comparison) + useEffect(() => { + if (!targetDir || sources.length === 0) { + setSourcePathsMap(new Map()); + return; + } + let cancelled = false; + (async () => { + const map = new Map(); + await Promise.all(sources.map(async source => { + if (cancelled) return; + try { + const tracks = await fetchTracksForSource(source); + const paths = await invoke('compute_sync_paths', { + tracks: tracks.map(t => trackToSyncInfo(t, '')), + destDir: targetDir, + template: filenameTemplate, + }); + map.set(source.id, paths); + } catch { + map.set(source.id, []); + } + })); + if (!cancelled) setSourcePathsMap(map); + })(); + return () => { cancelled = true; }; + }, [targetDir, filenameTemplate, sources]); + + // Derive sync status per source + const sourceStatuses = useMemo(() => { + const deviceSet = new Set(deviceFilePaths); + const statuses = new Map(); + for (const source of sources) { + if (pendingDeletion.includes(source.id)) { + statuses.set(source.id, 'deletion'); + } else { + const paths = sourcePathsMap.get(source.id) ?? []; + const allSynced = paths.length > 0 && paths.every(p => deviceSet.has(p)); + statuses.set(source.id, allSynced ? 'synced' : 'pending'); + } + } + return statuses; + }, [sources, pendingDeletion, sourcePathsMap, deviceFilePaths]); + + // ─── Desired State / Diff Logic ───────────────────────────────────────── + + const handleToggleSource = useCallback((source: DeviceSyncSource) => { + const isSelected = sources.some(s => s.id === source.id); + const isPendingDeletion = pendingDeletion.includes(source.id); + const isActuallySelected = isSelected && !isPendingDeletion; + + if (isActuallySelected) { + // User initiated a DE-SELECTION. Diff check against target device + const isSynced = sourceStatuses.get(source.id) === 'synced'; + const pathsOnDisk = sourcePathsMap.get(source.id)?.filter(p => deviceFilePaths.includes(p)).length || 0; + + if (pathsOnDisk > 0 || isSynced) { + // Source currently has physical footprint. Stage for deletion. + markForDeletion([source.id]); + } else { + // Zero physical footprint. Strip safely. + removeSource(source.id); + } + } else { + // User initiated a SELECTION. + if (isPendingDeletion) { + unmarkDeletion(source.id); // Cancel queued red/strikethrough state + } else if (!isSelected) { + addSource(source); // Trigger clean pending install state + } + } + }, [sources, pendingDeletion, sourceStatuses, sourcePathsMap, deviceFilePaths, markForDeletion, removeSource, unmarkDeletion, addSource]); + + // ─── Listen for background sync events ────────────────────────────────── + + useEffect(() => { + const jobStore = useDeviceSyncJobStore.getState; + const unlistenProgress = listen<{ + jobId: string; done: number; skipped: number; failed: number; total: number; + }>('device:sync:progress', ({ payload }) => { + const current = jobStore(); + if (current.jobId && payload.jobId === current.jobId) { + useDeviceSyncJobStore.getState().updateProgress( + payload.done, payload.skipped, payload.failed + ); + } + }); + + const unlistenComplete = listen<{ + jobId: string; done: number; skipped: number; failed: number; total: number; + }>('device:sync:complete', ({ payload }) => { + const current = jobStore(); + if (current.jobId && payload.jobId === current.jobId) { + useDeviceSyncJobStore.getState().complete( + payload.done, payload.skipped, payload.failed + ); + showToast( + t('deviceSync.syncResult', { + done: payload.done, skipped: payload.skipped, total: payload.total + }), + 5000, 'info' + ); + // Re-scan the device after sync completes + scanDevice(); + } + }); + + return () => { + unlistenProgress.then(f => f()); + unlistenComplete.then(f => f()); + }; + }, [t, scanDevice]); // Load browser data when tab switches useEffect(() => { setSearch(''); if (activeTab === 'playlists' && playlists.length === 0) loadPlaylists(); - if (activeTab === 'albums' && albums.length === 0) loadAlbums(); + if (activeTab === 'albums' && randomAlbums.length === 0) loadRandomAlbums(); if (activeTab === 'artists' && artists.length === 0) loadArtists(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTab]); + // Live album search with 300ms debounce + useEffect(() => { + if (activeTab !== 'albums') return; + const q = search.trim(); + if (!q) { setAlbumSearchResults([]); return; } + setAlbumSearchLoading(true); + const timer = setTimeout(async () => { + try { + const { albums } = await searchSubsonic(q, { albumCount: 20, artistCount: 0, songCount: 0 }); + setAlbumSearchResults(albums); + } catch { + setAlbumSearchResults([]); + } finally { + setAlbumSearchLoading(false); + } + }, 300); + return () => { clearTimeout(timer); setAlbumSearchLoading(false); }; + }, [search, activeTab]); + const loadPlaylists = useCallback(async () => { setLoadingBrowser(true); try { setPlaylists(await getPlaylists()); } catch { /* ignore */ } finally { setLoadingBrowser(false); } }, []); - const loadAlbums = useCallback(async () => { + const loadRandomAlbums = useCallback(async () => { setLoadingBrowser(true); - try { setAlbums(await getAlbumList('alphabeticalByName', 500, 0)); } catch { /* ignore */ } + try { setRandomAlbums(await getAlbumList('random', 10)); } catch { /* ignore */ } finally { setLoadingBrowser(false); } }, []); const loadArtists = useCallback(async () => { @@ -114,134 +338,161 @@ export default function DeviceSync() { }, [artistAlbumsMap]); const q = search.toLowerCase(); - const filteredPlaylists = playlists.filter(p => p.name.toLowerCase().includes(q)); - const filteredAlbums = albums.filter(a => - a.name.toLowerCase().includes(q) || (a.artist ?? '').toLowerCase().includes(q)); - const filteredArtists = artists.filter(a => a.name.toLowerCase().includes(q)); + const filteredPlaylists = useMemo(() => playlists.filter(p => p.name.toLowerCase().includes(q)), [playlists, q]); + const filteredArtists = useMemo(() => artists.filter(a => a.name.toLowerCase().includes(q)), [artists, q]); const handleChooseFolder = async () => { const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') }); - if (sel) setTargetDir(sel as string); + if (sel) { + setTargetDir(sel as string); + // Trigger a device scan after folder change + setTimeout(() => scanDevice(), 100); + } }; - // ─── Sync ──────────────────────────────────────────────────────────────── + // ─── Sync (non-blocking) ──────────────────────────────────────────────── - const handleSync = async () => { + const promptSyncSummary = async () => { if (!targetDir) { showToast(t('deviceSync.noTargetDir'), 3000, 'error'); return; } if (sources.length === 0){ showToast(t('deviceSync.noSources'), 3000, 'error'); return; } - cancelRef.current = false; - const jobId = uuid(); - setActiveJob({ id: jobId, total: 0, done: 0, skipped: 0, failed: 0, status: 'running' }); - - let allTracks: SubsonicSong[] = []; - try { - for (const source of sources) { - if (cancelRef.current) break; - allTracks.push(...await fetchTracksForSource(source)); - } - } catch { - showToast(t('deviceSync.fetchError'), 3000, 'error'); - setActiveJob(null); - return; - } - - const seen = new Set(); - allTracks = allTracks.filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; }); - - if (allTracks.length === 0) { - showToast(t('deviceSync.noTracks'), 3000, 'error'); - setActiveJob(null); - return; - } - - updateJob({ total: allTracks.length }); - - const unlisten = await listen<{ jobId: string; status: string }>( - 'device:sync:progress', - ({ payload }) => { - if (payload.jobId !== jobId) return; - const st = useDeviceSyncStore.getState().activeJob!; - if (payload.status === 'done') updateJob({ done: st.done + 1 }); - else if (payload.status === 'skipped') updateJob({ skipped: st.skipped + 1 }); - else if (payload.status === 'error') updateJob({ failed: st.failed + 1 }); - } - ); - - const CONCURRENCY = 4; - let idx = 0; - const worker = async () => { - while (idx < allTracks.length && !cancelRef.current) { - const track = allTracks[idx++]; - try { - await invoke('sync_track_to_device', { - track: trackToSyncInfo(track, buildDownloadUrl(track.id)), - destDir: targetDir, - template: filenameTemplate, - jobId, - }); - } catch { /* emitted via event */ } - } - }; + setPreSyncLoading(true); + setPreSyncOpen(true); try { - await Promise.all(Array.from({ length: CONCURRENCY }, worker)); - } finally { - unlisten(); - } - - updateJob({ status: cancelRef.current ? 'cancelled' : 'done' }); - }; - - // ─── Delete checked items from device ──────────────────────────────────── - - const handleDeleteChecked = async () => { - if (!targetDir || checkedIds.length === 0) return; - - const toDelete = sources.filter(s => checkedIds.includes(s.id)); - const confirmed = window.confirm( - t('deviceSync.confirmDelete', { count: toDelete.length, names: toDelete.map(s => s.name).join(', ') }) - ); - if (!confirmed) return; - - setDeleting(true); - try { - // Collect all tracks for the checked sources - let tracks: SubsonicSong[] = []; - for (const source of toDelete) { - tracks.push(...await fetchTracksForSource(source)); - } - const seen = new Set(); - tracks = tracks.filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; }); - - // Compute expected device paths via Rust (same sanitizer as sync) - const paths = await invoke('compute_sync_paths', { - tracks: tracks.map(t => trackToSyncInfo(t, '')), - destDir: targetDir, + const { getClient } = await import('../api/subsonic'); + const { baseUrl, params } = getClient(); + const payload = await invoke<{ + addBytes: number; addCount: number; delBytes: number; delCount: number; availableBytes: number; tracks: SubsonicSong[]; + }>('calculate_sync_payload', { + sources, + deletionIds: pendingDeletion, + auth: { baseUrl, ...params }, + targetDir, template: filenameTemplate, }); - for (const path of paths) { - await invoke('delete_device_file', { path }).catch(() => {}); - } - - // Remove from the list - for (const s of toDelete) removeSource(s.id); - showToast(t('deviceSync.deleteComplete', { count: toDelete.length }), 3000, 'info'); + setSyncDelta(payload); } catch { showToast(t('deviceSync.fetchError'), 3000, 'error'); + setPreSyncOpen(false); } finally { - setDeleting(false); + setPreSyncLoading(false); } }; - const handleCancel = () => { cancelRef.current = true; }; - const isRunning = activeJob?.status === 'running'; - const isDone = activeJob?.status === 'done'; + const handleSyncExecution = async () => { + setPreSyncOpen(false); + + // 1. Handle pending deletions first + const deletionSources = sources.filter(s => pendingDeletion.includes(s.id)); + if (deletionSources.length > 0) { + try { + const allPaths: string[] = []; + const trackArrays = await Promise.all(deletionSources.map(s => fetchTracksForSource(s))); + const deletionTracks = trackArrays.flat(); + + const paths = await invoke('compute_sync_paths', { + tracks: deletionTracks.map(t => trackToSyncInfo(t, '')), + destDir: targetDir, + template: filenameTemplate, + }); + allPaths.push(...paths); + + await invoke('delete_device_files', { paths: allPaths }); + removeSources(deletionSources.map(s => s.id)); + showToast( + t('deviceSync.deleteComplete', { count: deletionSources.length }), + 3000, 'info' + ); + } catch { + showToast(t('deviceSync.fetchError'), 3000, 'error'); + } + } + + const allTracks = syncDelta.tracks; + if (allTracks.length === 0) { + scanDevice(); + return; + } + + const jobId = uuid(); + useDeviceSyncJobStore.getState().startSync(jobId, allTracks.length); + + showToast(t('deviceSync.syncInBackground'), 3000, 'info'); + + invoke('sync_batch_to_device', { + tracks: allTracks.map(track => trackToSyncInfo(track, buildDownloadUrl(track.id))), + destDir: targetDir, + template: filenameTemplate, + jobId, + expectedBytes: syncDelta.addBytes, + }).catch((err: string) => { + useDeviceSyncJobStore.getState().complete(0, 0, allTracks.length); + if (err.includes('NOT_ENOUGH_SPACE')) { + showToast(t('deviceSync.notEnoughSpace'), 5000, 'error'); + } else if (err === 'NOT_MOUNTED_VOLUME') { + showToast(t('deviceSync.notMountedVolume'), 5000, 'error'); + } else { + showToast(t('deviceSync.fetchError'), 3000, 'error'); + } + }); + }; + + // ─── Actions ──────────────────────────────────────────────────────────── + + const handleMarkCheckedForDeletion = () => { + if (checkedIds.length === 0) return; + markForDeletion(checkedIds); + }; const allChecked = sources.length > 0 && sources.every(s => checkedIds.includes(s.id)); const toggleAll = () => setCheckedIds(allChecked ? [] : sources.map(s => s.id)); + const pendingCount = Array.from(sourceStatuses.values()).filter(s => s === 'pending').length; + const syncedCount = Array.from(sourceStatuses.values()).filter(s => s === 'synced').length; + const deletionCount = pendingDeletion.length; + + // ─── Dynamic action button label ──────────────────────────────────────── + const actionButtonLabel = useMemo(() => { + if (deletionCount > 0 && pendingCount === 0) return t('deviceSync.actionDelete'); + if (pendingCount > 0 && deletionCount === 0) return t('deviceSync.actionTransfer'); + if (pendingCount > 0 && deletionCount > 0) return t('deviceSync.actionApplyAll'); + return t('deviceSync.syncButton'); // both zero — button will be disabled + }, [pendingCount, deletionCount, t]); + + const actionButtonDisabled = + !targetDir || + sources.length === 0 || + isRunning || + (!driveDetected && !!targetDir) || + (pendingCount === 0 && deletionCount === 0); + + // ─── Template preview (dummy track) ───────────────────────────────────── + const PREVIEW_TRACK = { + artist: 'Volker Pispers', + album: '...Bis Neulich 2007', + title: 'Kapitalismus', + track_number: '01', + disc_number: '1', + year: '2007', + } as const; + + const templatePreviewText = useMemo(() => { + try { + const result = filenameTemplate + .replace(/\{artist\}/g, PREVIEW_TRACK.artist) + .replace(/\{album\}/g, PREVIEW_TRACK.album) + .replace(/\{title\}/g, PREVIEW_TRACK.title) + .replace(/\{track_number\}/g, PREVIEW_TRACK.track_number) + .replace(/\{disc_number\}/g, PREVIEW_TRACK.disc_number) + .replace(/\{year\}/g, PREVIEW_TRACK.year); + return `${result}.mp3`; + } catch { + return ''; + } + }, [filenameTemplate]); + const tabs: { key: SourceTab; icon: React.ReactNode; label: string }[] = [ { key: 'playlists', icon: , label: t('deviceSync.tabPlaylists') }, { key: 'albums', icon: , label: t('deviceSync.tabAlbums') }, @@ -253,30 +504,93 @@ export default function DeviceSync() { {/* ── Header ── */}
- -

{t('deviceSync.title')}

-
- - {targetDir ?? t('deviceSync.noFolderChosen')} - - +
+ +

{t('deviceSync.title')}

+
+ +
+ + {/* ── Left: Template ── */} +
+ {t('deviceSync.filenameTemplate')} +
+ setFilenameTemplate(e.target.value)} + spellCheck={false} + data-tooltip={t('deviceSync.templateHint')} + data-tooltip-pos="bottom" + /> + {templatePreviewText && ( + + {t('deviceSync.templatePreview')}: {templatePreviewText} + + )} +
+
+ + {/* ── Right: Drive config ── */} +
+ {t('deviceSync.targetDevice')} +
+
+ {/* Row 1: Controls */} +
+ {/* Fallback manual folder picker & Refresh */} + + + + {/* Dropdown element */} + {drives.length > 0 ? ( + <> + + { + setTargetDir(v); + if (v) { + setTimeout(() => scanDevice(), 100); + } + }} + options={[ + { value: '', label: t('deviceSync.selectDrive') }, + ...drives.map(d => ({ value: d.mount_point, label: d.name || d.mount_point })) + ]} + /> + + ) : ( + + + {t('deviceSync.noDrivesDetected')} + + )} +
+ + {/* Row 2: Metadata */} + {activeDrive && ( +
+ {formatBytes(activeDrive.available_space)} {t('deviceSync.free')} / {formatBytes(activeDrive.total_space)} • {activeDrive.file_system} +
+ )} +
+
+
+ - {/* ── Template (collapsed row) ── */} -
- {t('deviceSync.filenameTemplate')} - setFilenameTemplate(e.target.value)} - spellCheck={false} - data-tooltip={t('deviceSync.templateHint')} - data-tooltip-pos="bottom" - /> -
{/* ── Main ── */}
@@ -301,24 +615,30 @@ export default function DeviceSync() { value={search} onChange={e => setSearch(e.target.value)} /> + {activeTab === 'albums' && ( + + {t('deviceSync.liveSearch')} + + )}
- {loadingBrowser && ( + {(loadingBrowser || albumSearchLoading) && (
)} + {activeTab === 'albums' && !search.trim() && !loadingBrowser && randomAlbums.length > 0 && ( +
+ {t('deviceSync.randomAlbumsLabel')} +
+ )} {activeTab === 'playlists' && filteredPlaylists.map(pl => ( s.id === pl.id)} - onToggle={() => sources.some(s => s.id === pl.id) - ? removeSource(pl.id) - : addSource({ type: 'playlist', id: pl.id, name: pl.name })} /> + selected={sources.some(s => s.id === pl.id) && !pendingDeletion.includes(pl.id)} + onToggle={() => handleToggleSource({ type: 'playlist', id: pl.id, name: pl.name })} /> ))} - {activeTab === 'albums' && filteredAlbums.map(al => ( + {activeTab === 'albums' && (search.trim() ? albumSearchResults : randomAlbums).map(al => ( s.id === al.id)} - onToggle={() => sources.some(s => s.id === al.id) - ? removeSource(al.id) - : addSource({ type: 'album', id: al.id, name: al.name })} /> + selected={sources.some(s => s.id === al.id) && !pendingDeletion.includes(al.id)} + onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name })} /> ))} {activeTab === 'artists' && filteredArtists.map(ar => ( @@ -335,16 +655,14 @@ export default function DeviceSync() { {ar.name} {ar.albumCount != null && - {ar.albumCount} Alben} + {ar.albumCount} Albums}
{expandedArtistIds.has(ar.id) && artistAlbumsMap.has(ar.id) && artistAlbumsMap.get(ar.id)!.map(al => ( s.id === al.id)} + selected={sources.some(s => s.id === al.id) && !pendingDeletion.includes(al.id)} indent - onToggle={() => sources.some(s => s.id === al.id) - ? removeSource(al.id) - : addSource({ type: 'album', id: al.id, name: al.name })} /> + onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name })} /> )) } @@ -352,34 +670,65 @@ export default function DeviceSync() {
- {/* ── Device list (right) ── */} + {/* ── Device Manager (right) ── */}
- {t('deviceSync.onDevice')} + + {t('deviceSync.onDevice')} + {scanning && } +
- {!activeJob && ( - - )} + {/* Sync button */} + + + {/* Mark for deletion */} {checkedIds.length > 0 && !isRunning && ( )}
+ {/* Status summary badges */} + {sources.length > 0 && ( +
+ {syncedCount > 0 && ( + + {syncedCount} {t('deviceSync.statusSynced')} + + )} + {pendingCount > 0 && ( + + {pendingCount} {t('deviceSync.statusPending')} + + )} + {deletionCount > 0 && ( + + {deletionCount} {t('deviceSync.statusDeletion')} + + )} +
+ )} + {sources.length === 0 ? (

{t('deviceSync.noSourcesSelected')}

) : ( @@ -390,59 +739,157 @@ export default function DeviceSync() { {t('deviceSync.colName')} {t('deviceSync.colType')} + {t('deviceSync.colStatus')} +
- {sources.map(s => ( - - ))} + {sources.map(s => { + const status = sourceStatuses.get(s.id) ?? 'pending'; + return ( + + ); + })}
)} - {/* Progress / sync result */} - {activeJob && ( -
-
+ {/* Background sync progress (non-blocking) */} + {jobStatus === 'running' && ( +
+
0 - ? `${((activeJob.done + activeJob.skipped + activeJob.failed) / activeJob.total) * 100}%` + className="device-sync-bg-progress-bar" + style={{ width: jobTotal > 0 + ? `${((jobDone + jobSkip + jobFail) / jobTotal) * 100}%` : '0%' }} />
-
- {isRunning && } - {isDone && } - - {isDone - ? t('deviceSync.syncResult', { done: activeJob.done, skipped: activeJob.skipped, total: activeJob.total }) - : `${activeJob.done + activeJob.skipped + activeJob.failed} / ${activeJob.total}`} - - {activeJob.failed > 0 && ( - {activeJob.failed} - )} - {activeJob.skipped > 0 && ( - {activeJob.skipped} - )} - {isRunning - ? - : - } -
+ + + {t('deviceSync.syncInProgress', { done: jobDone + jobSkip, total: jobTotal })} + {jobFail > 0 && {jobFail}} + +
+ )} + + {jobStatus === 'done' && ( +
+ + + {t('deviceSync.syncResult', { done: jobDone, skipped: jobSkip, total: jobTotal })} + +
)}
+ + {/* Pre-Sync Summary Modal */} + {preSyncOpen && ( +
+
+

{t('deviceSync.syncSummary')}

+ + {preSyncLoading ? ( +
+ +

{t('deviceSync.calculating')}

+
+ ) : ( +
+
+ {t('deviceSync.filesToAdd')} + +{syncDelta.addCount} ({(syncDelta.addBytes / 1_048_576).toFixed(1)} MB) +
+
+ {t('deviceSync.filesToDelete')} + -{syncDelta.delCount} ({(syncDelta.delBytes / 1_048_576).toFixed(1)} MB) +
+
+
+ {t('deviceSync.netChange')} + {((syncDelta.addBytes - syncDelta.delBytes) / 1_048_576).toFixed(1)} MB +
+
syncDelta.availableBytes + syncDelta.delBytes ? 'var(--danger)' : 'inherit', marginTop: '10px' }}> + {t('deviceSync.availableSpace')} + {(syncDelta.availableBytes / 1_048_576).toFixed(1)} MB +
+ {syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes && ( +
+ + {t('deviceSync.spaceWarning')} +
+ )} +
+ )} + + {!preSyncLoading && ( +
+ + +
+ )} +
+
+ )}
); } diff --git a/src/store/deviceSyncJobStore.ts b/src/store/deviceSyncJobStore.ts new file mode 100644 index 00000000..3a9638b2 --- /dev/null +++ b/src/store/deviceSyncJobStore.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand'; + +export interface DeviceSyncJobState { + jobId: string | null; + total: number; + done: number; + skipped: number; + failed: number; + status: 'idle' | 'running' | 'done' | 'cancelled'; + + startSync: (jobId: string, total: number) => void; + updateProgress: (done: number, skipped: number, failed: number) => void; + complete: (done: number, skipped: number, failed: number) => void; + reset: () => void; +} + +export const useDeviceSyncJobStore = create()((set) => ({ + jobId: null, + total: 0, + done: 0, + skipped: 0, + failed: 0, + status: 'idle', + + startSync: (jobId, total) => + set({ jobId, total, done: 0, skipped: 0, failed: 0, status: 'running' }), + + updateProgress: (done, skipped, failed) => + set({ done, skipped, failed }), + + complete: (done, skipped, failed) => + set({ done, skipped, failed, status: 'done' }), + + reset: () => + set({ jobId: null, total: 0, done: 0, skipped: 0, failed: 0, status: 'idle' }), +})); diff --git a/src/store/deviceSyncStore.ts b/src/store/deviceSyncStore.ts index ec3924c2..c3b3641d 100644 --- a/src/store/deviceSyncStore.ts +++ b/src/store/deviceSyncStore.ts @@ -7,21 +7,14 @@ export interface DeviceSyncSource { name: string; } -export interface DeviceSyncJob { - id: string; - total: number; - done: number; - skipped: number; - failed: number; - status: 'running' | 'done' | 'cancelled'; -} - interface DeviceSyncState { targetDir: string | null; filenameTemplate: string; sources: DeviceSyncSource[]; // persistent device content list - checkedIds: string[]; // currently checked for deletion (not persisted) - activeJob: DeviceSyncJob | null; + checkedIds: string[]; // currently checked for bulk actions (not persisted) + pendingDeletion: string[]; // source IDs marked for deletion (not persisted) + deviceFilePaths: string[]; // actual file paths found on the device (not persisted) + scanning: boolean; // true while scanning the device setTargetDir: (dir: string | null) => void; setFilenameTemplate: (t: string) => void; @@ -30,8 +23,12 @@ interface DeviceSyncState { clearSources: () => void; toggleChecked: (id: string) => void; setCheckedIds: (ids: string[]) => void; - setActiveJob: (job: DeviceSyncJob | null) => void; - updateJob: (update: Partial) => void; + markForDeletion: (ids: string[]) => void; + unmarkDeletion: (id: string) => void; + clearPendingDeletion: () => void; + removeSources: (ids: string[]) => void; + setDeviceFilePaths: (paths: string[]) => void; + setScanning: (v: boolean) => void; } export const useDeviceSyncStore = create()( @@ -41,7 +38,9 @@ export const useDeviceSyncStore = create()( filenameTemplate: '{artist}/{album}/{track_number} - {title}', sources: [], checkedIds: [], - activeJob: null, + pendingDeletion: [], + deviceFilePaths: [], + scanning: false, setTargetDir: (dir) => set({ targetDir: dir }), setFilenameTemplate: (t) => set({ filenameTemplate: t }), @@ -57,9 +56,10 @@ export const useDeviceSyncStore = create()( set((s) => ({ sources: s.sources.filter((x) => x.id !== id), checkedIds: s.checkedIds.filter((x) => x !== id), + pendingDeletion: s.pendingDeletion.filter((x) => x !== id), })), - clearSources: () => set({ sources: [], checkedIds: [] }), + clearSources: () => set({ sources: [], checkedIds: [], pendingDeletion: [] }), toggleChecked: (id) => set((s) => ({ @@ -70,12 +70,28 @@ export const useDeviceSyncStore = create()( setCheckedIds: (ids) => set({ checkedIds: ids }), - setActiveJob: (job) => set({ activeJob: job }), - - updateJob: (update) => + markForDeletion: (ids) => set((s) => ({ - activeJob: s.activeJob ? { ...s.activeJob, ...update } : null, + pendingDeletion: [...new Set([...s.pendingDeletion, ...ids])], + checkedIds: s.checkedIds.filter((x) => !ids.includes(x)), })), + + unmarkDeletion: (id) => + set((s) => ({ + pendingDeletion: s.pendingDeletion.filter((x) => x !== id), + })), + + clearPendingDeletion: () => set({ pendingDeletion: [] }), + + removeSources: (ids) => + set((s) => ({ + sources: s.sources.filter((x) => !ids.includes(x.id)), + checkedIds: s.checkedIds.filter((x) => !ids.includes(x)), + pendingDeletion: s.pendingDeletion.filter((x) => !ids.includes(x)), + })), + + setDeviceFilePaths: (paths) => set({ deviceFilePaths: paths }), + setScanning: (v) => set({ scanning: v }), }), { name: 'psysonic_device_sync', diff --git a/src/styles/components.css b/src/styles/components.css index 04863167..72bebb9d 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -7573,24 +7573,51 @@ html.no-compositing .fs-seekbar-played { .device-sync-header { display: flex; - align-items: center; - gap: 10px; - margin-bottom: 10px; - color: var(--text-primary); + flex-direction: column; + gap: 16px; + margin-bottom: 20px; flex-shrink: 0; } -.device-sync-header h1 { +.device-sync-header-title { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-primary); +} + +.device-sync-header-title h1 { font-size: 1.3rem; font-weight: 600; margin: 0; - flex: 1; } -.device-sync-header-config { +/* ── Config Row (Template + Drive Layout) ── */ + +.device-sync-config-row { display: flex; - align-items: center; - gap: 8px; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: 30px; + min-height: 0; + width: 100%; +} + +.device-sync-template-section { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + max-width: 600px; +} + +.device-sync-target-section { + display: flex; + flex-direction: column; + gap: 6px; + align-items: flex-end; /* Flush right alignment */ + flex: 1; /* allow block expansion */ } .device-sync-folder-path { @@ -7606,28 +7633,104 @@ html.no-compositing .fs-seekbar-played { border-radius: var(--radius-sm); } -/* ── Template row ── */ +/* ── Drive layout ── */ -.device-sync-template-row { +.device-sync-drive-layout { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.device-sync-drive-controls { display: flex; align-items: center; - gap: 10px; - margin-bottom: 14px; + gap: 8px; +} + +.device-sync-drive-meta { + font-size: 0.75rem; + color: var(--text-muted); + text-align: right; + padding-right: 2px; +} + +.device-sync-drive-icon { + color: var(--accent); flex-shrink: 0; } +.device-sync-drive-select { + min-width: 200px; + max-width: 320px; + font-size: 0.95rem; + height: 40px; + padding: 0 10px; + line-height: normal; + display: flex; + align-items: center; + justify-content: space-between; + text-align: left; + cursor: pointer; +} + +.device-sync-drive-controls .btn-ghost { + height: 40px; + width: 40px; + padding: 0 !important; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.device-sync-no-drives { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.78rem; + color: var(--warning, #f59e0b); + padding: 5px 10px; + background: color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent); + border-radius: var(--radius-sm); +} + .device-sync-label-inline { font-size: 0.8rem; font-weight: 500; color: var(--text-secondary); white-space: nowrap; - flex-shrink: 0; +} + +.device-sync-template-input-wrap { + display: flex; + flex-direction: column; + gap: 4px; + position: relative; } .device-sync-template-input { - flex: 1; + width: 100%; font-family: monospace; - font-size: 0.82rem; + font-size: 0.95rem; + height: 40px; + padding: 0 10px; + line-height: normal; +} + +.device-sync-template-preview { + font-size: 0.72rem; + color: var(--text-muted); + font-family: monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-left: 2px; + opacity: 0.75; + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; } /* ── Main area (device panel + browser) ── */ @@ -7746,7 +7849,19 @@ html.no-compositing .fs-seekbar-played { } .device-sync-device-row:last-child { border-bottom: none; } -.device-sync-device-row:hover { background: var(--bg-hover); } +.device-sync-source-row:hover { + background: var(--bg-hover); +} + +.device-sync-source-row.deletion, .device-sync-row.deletion { + text-decoration: line-through; + opacity: 0.5; +} + +.device-sync-row-name { + flex: 1; +} + .device-sync-device-row.checked { background: var(--accent-dim); } .device-sync-source-type { @@ -7759,41 +7874,184 @@ html.no-compositing .fs-seekbar-played { flex-shrink: 0; } -/* ── Progress ── */ +/* ── Status summary badges ── */ -.device-sync-progress { +.device-sync-status-summary { display: flex; - flex-direction: column; + align-items: center; gap: 8px; - padding: 10px 14px; - border-top: 1px solid var(--border); + height: 52px; + padding: 0 14px; + border-bottom: 1px solid var(--border); flex-shrink: 0; } -.device-sync-progress-bar-wrap { - height: 4px; +.device-sync-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 12px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.device-sync-badge.synced { + background: color-mix(in srgb, var(--success, #4ade80) 15%, transparent); + color: var(--success, #4ade80); +} + +.device-sync-badge.pending { + background: color-mix(in srgb, var(--warning, #f59e0b) 15%, transparent); + color: var(--warning, #f59e0b); +} + +.device-sync-badge.deletion { + background: color-mix(in srgb, var(--danger, #f38ba8) 15%, transparent); + color: var(--danger, #f38ba8); +} + +/* ── Status column ── */ + +.device-sync-list-col-status { + width: 50px; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + flex-shrink: 0; + text-align: center; +} + +.device-sync-list-col-actions { + width: 32px; + flex-shrink: 0; +} + +/* ── Status icons per row ── */ + +.device-sync-status-icon { + width: 50px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.device-sync-status-icon.synced { color: var(--success, #4ade80); } +.device-sync-status-icon.pending { color: var(--warning, #f59e0b); } +.device-sync-status-icon.deletion { color: var(--danger, #f38ba8); } + +/* ── Per-row action buttons ── */ + +.device-sync-row-actions { + width: 32px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.device-sync-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: transparent; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.15s, color 0.15s, transform 0.1s; + opacity: 0; +} + +.device-sync-device-row:hover .device-sync-action-btn { + opacity: 1; +} + +.device-sync-action-btn.danger { + color: var(--danger, #f38ba8); +} +.device-sync-action-btn.danger:hover { + background: color-mix(in srgb, var(--danger, #f38ba8) 15%, transparent); + transform: scale(1.1); +} + +.device-sync-action-btn.muted { + color: var(--text-muted); +} +.device-sync-action-btn.muted:hover { + background: var(--bg-hover); + color: var(--text-primary); + transform: scale(1.1); +} + +.device-sync-action-btn.undo { + color: var(--accent); + opacity: 1; +} +.device-sync-action-btn.undo:hover { + background: color-mix(in srgb, var(--accent) 15%, transparent); + transform: scale(1.1); +} + +/* ── Row status variants ── */ + +.device-sync-device-row.synced {} +.device-sync-device-row.pending {} +.device-sync-device-row.deletion { + opacity: 0.55; +} +.device-sync-device-row.deletion .device-sync-row-name { + text-decoration: line-through; + text-decoration-color: var(--danger, #f38ba8); +} + +/* ── Background sync progress (non-blocking) ── */ + +.device-sync-bg-progress { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 14px; + border-top: 1px solid var(--border); + flex-shrink: 0; + background: color-mix(in srgb, var(--accent) 4%, transparent); +} + +.device-sync-bg-progress.done { + flex-direction: row; + align-items: center; + justify-content: space-between; + background: color-mix(in srgb, var(--success, #4ade80) 4%, transparent); +} + +.device-sync-bg-progress-bar-wrap { + height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; } -.device-sync-progress-bar { +.device-sync-bg-progress-bar { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s ease; } -.device-sync-progress-stats { +.device-sync-bg-progress-text { display: flex; align-items: center; - gap: 10px; - font-size: 0.82rem; + gap: 6px; + font-size: 0.78rem; color: var(--text-secondary); } -.device-sync-stat-muted { color: var(--text-secondary); display: flex; align-items: center; gap: 3px; } -.device-sync-stat-error { color: var(--danger); display: flex; align-items: center; gap: 3px; } +.device-sync-stat-error { color: var(--danger); display: flex; align-items: center; gap: 3px; } .color-success { color: var(--success, #4ade80); } /* ── Browser panel ── */ @@ -7842,7 +8100,35 @@ html.no-compositing .fs-seekbar-played { } .device-sync-search-wrap .input { - width: 100%; + flex: 1; +} + +.device-sync-live-badge { + display: flex; + align-items: center; + gap: 3px; + flex-shrink: 0; + margin-left: 8px; + padding: 2px 7px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--accent); + background: var(--accent-dim); + white-space: nowrap; +} + +.device-sync-section-label { + display: flex; + align-items: center; + gap: 5px; + padding: 6px 10px 4px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-muted); } .device-sync-list { diff --git a/src/styles/layout.css b/src/styles/layout.css index 697b7ae5..7515f2f8 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -912,6 +912,13 @@ background: color-mix(in srgb, var(--accent) 20%, transparent); } +/* ─── Sidebar device-sync queue ─── */ +.sidebar-sync-queue { + background: color-mix(in srgb, var(--success, #4ade80) 10%, var(--bg-sidebar)); + border-color: color-mix(in srgb, var(--success, #4ade80) 25%, transparent); + color: var(--success, #4ade80); +} + @keyframes spin-slow { to { transform: rotate(360deg); } }