From e3e75f0256e08dd117b01773e5ba38844a3c5a1b Mon Sep 17 00:00:00 2001 From: Markury Date: Fri, 9 Jan 2026 16:07:58 -0500 Subject: [PATCH] feat(sync): support playlists --- src-tauri/src/device_sync.rs | 216 +++++++++++++++++++++++---------- src/lib/services/deviceSync.ts | 24 ++-- src/routes/sync/+page.svelte | 4 + 3 files changed, 173 insertions(+), 71 deletions(-) diff --git a/src-tauri/src/device_sync.rs b/src-tauri/src/device_sync.rs index e799c8e..676c07f 100644 --- a/src-tauri/src/device_sync.rs +++ b/src-tauri/src/device_sync.rs @@ -66,57 +66,19 @@ fn normalize_path(path: &Path) -> String { path.to_string_lossy().nfc().collect::() } -/// Index device and compare with library -#[tauri::command] -pub async fn index_and_compare( - library_path: String, - device_path: String, - overwrite_mode: String, // "skip" | "different" | "always" -) -> Result { - let library_path = PathBuf::from(library_path); - let device_path = PathBuf::from(device_path); - - // Validate paths exist - if !library_path.exists() { - return Err(format!("Library path does not exist: {}", library_path.display())); - } - if !device_path.exists() { - return Err(format!("Device path does not exist: {}", device_path.display())); - } - - // Step 1: Index device - build HashMap of existing files with normalized paths - let mut device_files: HashMap = HashMap::new(); - - for entry in WalkDir::new(&device_path) - .follow_links(false) - .into_iter() - .filter_entry(|e| !should_skip_dir(e.path())) - { - let entry = entry.map_err(|e| format!("Error reading device: {}", e))?; - - if entry.file_type().is_file() && !should_skip_file(entry.path()) { - let relative_path = entry.path() - .strip_prefix(&device_path) - .map_err(|e| format!("Path error: {}", e))? - .to_path_buf(); - - if let Ok(metadata) = entry.metadata() { - let normalized = normalize_path(&relative_path); - device_files.insert(normalized, FileMetadata { - size: metadata.len(), - }); - } - } - } - - // Step 2: Walk library and compare +/// Helper function to index and compare a single folder pair +fn compare_folder_pair( + library_path: &Path, + device_files: &HashMap, + overwrite_mode: &str, +) -> Result<(Vec, usize, usize, usize, u64), String> { let mut files_to_copy = Vec::new(); let mut new_count = 0; let mut updated_count = 0; let mut unchanged_count = 0; let mut total_size = 0u64; - for entry in WalkDir::new(&library_path) + for entry in WalkDir::new(library_path) .follow_links(false) .into_iter() .filter_entry(|e| !should_skip_dir(e.path())) @@ -125,7 +87,7 @@ pub async fn index_and_compare( if entry.file_type().is_file() && !should_skip_file(entry.path()) { let relative_path = entry.path() - .strip_prefix(&library_path) + .strip_prefix(library_path) .map_err(|e| format!("Path error: {}", e))? .to_path_buf(); @@ -140,11 +102,11 @@ pub async fn index_and_compare( // File exists on device let size_different = device_meta.size != file_size; - let should_copy = match overwrite_mode.as_str() { - "skip" => false, // Never overwrite - "different" => size_different, // Only if different size - "always" => true, // Always overwrite - _ => size_different, // Default to "different" + let should_copy = match overwrite_mode { + "skip" => false, + "different" => size_different, + "always" => true, + _ => size_different, }; if should_copy { @@ -171,13 +133,116 @@ pub async fn index_and_compare( } } + Ok((files_to_copy, new_count, updated_count, unchanged_count, total_size)) +} + +/// Index device and compare with library +#[tauri::command] +pub async fn index_and_compare( + music_library_path: String, + music_device_path: String, + playlists_library_path: Option, + playlists_device_path: Option, + overwrite_mode: String, // "skip" | "different" | "always" +) -> Result { + let music_library = PathBuf::from(music_library_path); + let music_device = PathBuf::from(music_device_path); + + // Validate music paths exist + if !music_library.exists() { + return Err(format!("Music library path does not exist: {}", music_library.display())); + } + if !music_device.exists() { + return Err(format!("Music device path does not exist: {}", music_device.display())); + } + + // Parse optional playlists paths + let playlists_pair = match (playlists_library_path, playlists_device_path) { + (Some(lib), Some(dev)) => { + let lib_path = PathBuf::from(lib); + let dev_path = PathBuf::from(dev); + if lib_path.exists() && dev_path.exists() { + Some((lib_path, dev_path)) + } else { + None // Skip if either path doesn't exist + } + }, + _ => None + }; + + // Step 1: Index device music folder + let mut device_files: HashMap = HashMap::new(); + + for entry in WalkDir::new(&music_device) + .follow_links(false) + .into_iter() + .filter_entry(|e| !should_skip_dir(e.path())) + { + let entry = entry.map_err(|e| format!("Error reading device music: {}", e))?; + + if entry.file_type().is_file() && !should_skip_file(entry.path()) { + let relative_path = entry.path() + .strip_prefix(&music_device) + .map_err(|e| format!("Path error: {}", e))? + .to_path_buf(); + + if let Ok(metadata) = entry.metadata() { + let normalized = normalize_path(&relative_path); + device_files.insert(normalized, FileMetadata { + size: metadata.len(), + }); + } + } + } + + // Index device playlists folder if it exists + if let Some((_, ref playlists_dev)) = playlists_pair { + for entry in WalkDir::new(playlists_dev) + .follow_links(false) + .into_iter() + .filter_entry(|e| !should_skip_dir(e.path())) + { + let entry = entry.map_err(|e| format!("Error reading device playlists: {}", e))?; + + if entry.file_type().is_file() && !should_skip_file(entry.path()) { + let relative_path = entry.path() + .strip_prefix(playlists_dev) + .map_err(|e| format!("Path error: {}", e))? + .to_path_buf(); + + if let Ok(metadata) = entry.metadata() { + let normalized = normalize_path(&relative_path); + device_files.insert(normalized, FileMetadata { + size: metadata.len(), + }); + } + } + } + } + + // Step 2: Compare music library + let (mut all_files, mut total_new, mut total_updated, mut total_unchanged, mut total_bytes) = + compare_folder_pair(&music_library, &device_files, &overwrite_mode)?; + + // Step 3: Compare playlists library if configured + if let Some((ref playlists_lib, _)) = playlists_pair { + let (pl_files, pl_new, pl_updated, pl_unchanged, pl_bytes) = + compare_folder_pair(playlists_lib, &device_files, &overwrite_mode)?; + + all_files.extend(pl_files); + total_new += pl_new; + total_updated += pl_updated; + total_unchanged += pl_unchanged; + total_bytes += pl_bytes; + } + Ok(SyncDiff { - files_to_copy, + files_to_copy: all_files, stats: SyncStats { - new_files: new_count, - updated_files: updated_count, - unchanged_files: unchanged_count, - total_size, + new_files: total_new, + updated_files: total_updated, + unchanged_files: total_unchanged, + total_size: total_bytes, }, }) } @@ -186,18 +251,43 @@ pub async fn index_and_compare( #[tauri::command] pub async fn sync_to_device( app: AppHandle, - library_path: String, - device_path: String, + music_library_path: String, + music_device_path: String, + playlists_library_path: Option, + playlists_device_path: Option, files_to_copy: Vec, ) -> Result { - let library_path = PathBuf::from(library_path); - let device_path = PathBuf::from(device_path); + let music_library = PathBuf::from(music_library_path); + let music_device = PathBuf::from(music_device_path); + + let playlists_pair = match (playlists_library_path, playlists_device_path) { + (Some(lib), Some(dev)) => Some((PathBuf::from(lib), PathBuf::from(dev))), + _ => None + }; let total = files_to_copy.len(); for (index, file_info) in files_to_copy.iter().enumerate() { - let source = library_path.join(&file_info.relative_path); - let dest = device_path.join(&file_info.relative_path); + // Determine source and destination by checking if file exists in music or playlists library + let rel_path = PathBuf::from(&file_info.relative_path); + let music_source = music_library.join(&rel_path); + + let (source, dest) = if music_source.exists() { + // File is in music library + (music_source, music_device.join(&rel_path)) + } else if let Some((ref pl_lib, ref pl_dev)) = playlists_pair { + // Check playlists library + let playlist_source = pl_lib.join(&rel_path); + if playlist_source.exists() { + (playlist_source, pl_dev.join(&rel_path)) + } else { + // Fallback to music (original behavior) + (music_source, music_device.join(&rel_path)) + } + } else { + // No playlists configured, use music paths + (music_source, music_device.join(&rel_path)) + }; // Emit progress app.emit("sync-progress", SyncProgress { @@ -222,7 +312,7 @@ pub async fn sync_to_device( format!("Permission denied: {}", dest.display()) } std::io::ErrorKind::NotFound => { - format!("Device disconnected or path not found: {}", dest.display()) + format!("Source file not found or device disconnected: {} -> {}", source.display(), dest.display()) } _ => format!("Failed to copy {}: {}", file_info.relative_path, e) } diff --git a/src/lib/services/deviceSync.ts b/src/lib/services/deviceSync.ts index e527f44..6b8edca 100644 --- a/src/lib/services/deviceSync.ts +++ b/src/lib/services/deviceSync.ts @@ -38,14 +38,18 @@ export type ProgressCallback = (progress: SyncProgress) => void; * Returns a diff showing which files need to be synced */ export async function indexAndCompare( - libraryPath: string, - devicePath: string, + musicLibraryPath: string, + musicDevicePath: string, + playlistsLibraryPath: string | null | undefined, + playlistsDevicePath: string | null | undefined, overwriteMode: string ): Promise { try { const result = await invoke('index_and_compare', { - libraryPath, - devicePath, + musicLibraryPath, + musicDevicePath, + playlistsLibraryPath: playlistsLibraryPath || undefined, + playlistsDevicePath: playlistsDevicePath || undefined, overwriteMode }); return result; @@ -59,8 +63,10 @@ export async function indexAndCompare( * Sync files to device with progress updates */ export async function syncToDevice( - libraryPath: string, - devicePath: string, + musicLibraryPath: string, + musicDevicePath: string, + playlistsLibraryPath: string | null | undefined, + playlistsDevicePath: string | null | undefined, filesToCopy: FileInfo[], onProgress?: ProgressCallback ): Promise { @@ -76,8 +82,10 @@ export async function syncToDevice( // Start sync operation const result = await invoke('sync_to_device', { - libraryPath, - devicePath, + musicLibraryPath, + musicDevicePath, + playlistsLibraryPath: playlistsLibraryPath || undefined, + playlistsDevicePath: playlistsDevicePath || undefined, filesToCopy }); diff --git a/src/routes/sync/+page.svelte b/src/routes/sync/+page.svelte index 365186b..afc02ab 100644 --- a/src/routes/sync/+page.svelte +++ b/src/routes/sync/+page.svelte @@ -127,6 +127,8 @@ const result = await indexAndCompare( $settings.musicFolder, $deviceSyncSettings.musicPath, + $settings.playlistsFolder, + $deviceSyncSettings.playlistsPath, $deviceSyncSettings.overwriteMode ); @@ -160,6 +162,8 @@ const result = await syncToDevice( $settings.musicFolder, $deviceSyncSettings.musicPath, + $settings.playlistsFolder, + $deviceSyncSettings.playlistsPath, syncDiff.filesToCopy, (progress) => { syncProgress = progress;