diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bf2c4d0..161a46c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -26,6 +26,8 @@ dependencies = [ "tauri-plugin-sql", "tauri-plugin-store", "tokio", + "unicode-normalization", + "walkdir", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9e5b83b..d07d246 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,4 +37,6 @@ reqwest = { version = "0.12.23", features = ["stream", "rustls-tls"] } tokio = { version = "1.47.1", features = ["fs", "io-util"] } futures-util = "0.3.31" tauri-plugin-os = "2" +walkdir = "2.5.0" +unicode-normalization = "0.1.24" diff --git a/src-tauri/src/device_sync.rs b/src-tauri/src/device_sync.rs new file mode 100644 index 0000000..e799c8e --- /dev/null +++ b/src-tauri/src/device_sync.rs @@ -0,0 +1,241 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Emitter}; +use unicode_normalization::UnicodeNormalization; +use walkdir::WalkDir; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileInfo { + relative_path: String, + size: u64, + status: String, // "new" | "updated" +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncDiff { + files_to_copy: Vec, + stats: SyncStats, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncStats { + new_files: usize, + updated_files: usize, + unchanged_files: usize, + total_size: u64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncProgress { + current: usize, + total: usize, + current_file: String, + status: String, +} + +struct FileMetadata { + size: u64, +} + +fn should_skip_file(path: &Path) -> bool { + let file_name = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // Skip system files + matches!(file_name, ".DS_Store" | "Thumbs.db" | "desktop.ini" | ".nomedia") +} + +fn should_skip_dir(path: &Path) -> bool { + let dir_name = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // Skip temp folders + dir_name == "_temp" +} + +/// Normalize a path string to NFC form for consistent comparison +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 + 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) + .follow_links(false) + .into_iter() + .filter_entry(|e| !should_skip_dir(e.path())) + { + let entry = entry.map_err(|e| format!("Error reading library: {}", e))?; + + if entry.file_type().is_file() && !should_skip_file(entry.path()) { + let relative_path = entry.path() + .strip_prefix(&library_path) + .map_err(|e| format!("Path error: {}", e))? + .to_path_buf(); + + let metadata = entry.metadata() + .map_err(|e| format!("Cannot read file metadata: {}", e))?; + + let file_size = metadata.len(); + let normalized_path = normalize_path(&relative_path); + + // Check if file exists on device (using normalized path) + if let Some(device_meta) = device_files.get(&normalized_path) { + // 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" + }; + + if should_copy { + files_to_copy.push(FileInfo { + relative_path: relative_path.to_string_lossy().to_string(), + size: file_size, + status: "updated".to_string(), + }); + updated_count += 1; + total_size += file_size; + } else { + unchanged_count += 1; + } + } else { + // File doesn't exist on device - new file + files_to_copy.push(FileInfo { + relative_path: relative_path.to_string_lossy().to_string(), + size: file_size, + status: "new".to_string(), + }); + new_count += 1; + total_size += file_size; + } + } + } + + Ok(SyncDiff { + files_to_copy, + stats: SyncStats { + new_files: new_count, + updated_files: updated_count, + unchanged_files: unchanged_count, + total_size, + }, + }) +} + +/// Sync files to device +#[tauri::command] +pub async fn sync_to_device( + app: AppHandle, + library_path: String, + device_path: String, + files_to_copy: Vec, +) -> Result { + let library_path = PathBuf::from(library_path); + let device_path = PathBuf::from(device_path); + + 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); + + // Emit progress + app.emit("sync-progress", SyncProgress { + current: index + 1, + total, + current_file: file_info.relative_path.clone(), + status: format!("Copying {} ({} of {})", file_info.relative_path, index + 1, total), + }).map_err(|e| format!("Failed to emit progress: {}", e))?; + + // Create parent directory if needed + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?; + } + + // Copy file + fs::copy(&source, &dest) + .map_err(|e| { + // Check for common errors + match e.kind() { + std::io::ErrorKind::PermissionDenied => { + format!("Permission denied: {}", dest.display()) + } + std::io::ErrorKind::NotFound => { + format!("Device disconnected or path not found: {}", dest.display()) + } + _ => format!("Failed to copy {}: {}", file_info.relative_path, e) + } + })?; + } + + // Emit completion + app.emit("sync-progress", SyncProgress { + current: total, + total, + current_file: String::new(), + status: format!("Sync complete! Copied {} files", total), + }).map_err(|e| format!("Failed to emit completion: {}", e))?; + + Ok(format!("Successfully synced {} files to device", total)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b1267f..f8c4b67 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ use tauri_plugin_sql::{Migration, MigrationKind}; mod deezer_crypto; +mod device_sync; mod metadata; mod tagger; @@ -317,7 +318,9 @@ pub fn run() { tag_audio_file, read_audio_metadata, decrypt_deezer_track, - download_and_decrypt_track + download_and_decrypt_track, + device_sync::index_and_compare, + device_sync::sync_to_device ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/ToolBar.svelte b/src/lib/ToolBar.svelte index e12c386..133e127 100644 --- a/src/lib/ToolBar.svelte +++ b/src/lib/ToolBar.svelte @@ -9,6 +9,7 @@ play: '/icons/speaker.png', search: '/icons/internet.png', computer: '/icons/computer.png', + device: '/icons/ipod.svg', }; let history: string[] = $state([]); @@ -80,7 +81,12 @@ Search Search - + + + Device Sync + Sync + + Settings Settings diff --git a/src/lib/services/deviceSync.ts b/src/lib/services/deviceSync.ts new file mode 100644 index 0000000..e527f44 --- /dev/null +++ b/src/lib/services/deviceSync.ts @@ -0,0 +1,107 @@ +/** + * Device sync service layer + * Handles communication with Tauri backend for device synchronization + */ + +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +export interface FileInfo { + relativePath: string; + size: number; + status: 'new' | 'updated'; +} + +export interface SyncStats { + newFiles: number; + updatedFiles: number; + unchangedFiles: number; + totalSize: number; +} + +export interface SyncDiff { + filesToCopy: FileInfo[]; + stats: SyncStats; +} + +export interface SyncProgress { + current: number; + total: number; + currentFile: string; + status: string; +} + +export type ProgressCallback = (progress: SyncProgress) => void; + +/** + * Index device and compare with library + * Returns a diff showing which files need to be synced + */ +export async function indexAndCompare( + libraryPath: string, + devicePath: string, + overwriteMode: string +): Promise { + try { + const result = await invoke('index_and_compare', { + libraryPath, + devicePath, + overwriteMode + }); + return result; + } catch (error) { + console.error('Error indexing and comparing:', error); + throw new Error(String(error)); + } +} + +/** + * Sync files to device with progress updates + */ +export async function syncToDevice( + libraryPath: string, + devicePath: string, + filesToCopy: FileInfo[], + onProgress?: ProgressCallback +): Promise { + let unlisten: UnlistenFn | null = null; + + try { + // Set up progress listener + unlisten = await listen('sync-progress', (event) => { + if (onProgress) { + onProgress(event.payload); + } + }); + + // Start sync operation + const result = await invoke('sync_to_device', { + libraryPath, + devicePath, + filesToCopy + }); + + return result; + } catch (error) { + console.error('Error syncing to device:', error); + throw new Error(String(error)); + } finally { + // Clean up event listener + if (unlisten) { + unlisten(); + } + } +} + +/** + * Format bytes to human-readable string + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; +} diff --git a/src/lib/stores/deviceSync.ts b/src/lib/stores/deviceSync.ts new file mode 100644 index 0000000..574c6c5 --- /dev/null +++ b/src/lib/stores/deviceSync.ts @@ -0,0 +1,81 @@ +import { LazyStore } from '@tauri-apps/plugin-store'; +import { writable, type Writable } from 'svelte/store'; + +// Device sync settings interface +export type OverwriteMode = 'skip' | 'different' | 'always'; + +export interface DeviceSyncSettings { + musicPath: string | null; + playlistsPath: string | null; + overwriteMode: OverwriteMode; +} + +// Initialize the store with device-sync.json +const store = new LazyStore('device-sync.json'); + +// Default settings +const defaultSettings: DeviceSyncSettings = { + musicPath: null, + playlistsPath: null, + overwriteMode: 'different' +}; + +// Create a writable store for reactive UI updates +export const deviceSyncSettings: Writable = writable(defaultSettings); + +// Load settings from store +export async function loadDeviceSyncSettings(): Promise { + const musicPath = await store.get('musicPath'); + const playlistsPath = await store.get('playlistsPath'); + const overwriteMode = await store.get('overwriteMode'); + + deviceSyncSettings.set({ + musicPath: musicPath ?? null, + playlistsPath: playlistsPath ?? null, + overwriteMode: overwriteMode ?? 'different' + }); +} + +// Save device music path setting +export async function setMusicPath(path: string | null): Promise { + if (path) { + await store.set('musicPath', path); + } else { + await store.delete('musicPath'); + } + await store.save(); + + deviceSyncSettings.update(s => ({ + ...s, + musicPath: path + })); +} + +// Save device playlists path setting +export async function setPlaylistsPath(path: string | null): Promise { + if (path) { + await store.set('playlistsPath', path); + } else { + await store.delete('playlistsPath'); + } + await store.save(); + + deviceSyncSettings.update(s => ({ + ...s, + playlistsPath: path + })); +} + +// Save overwrite mode setting +export async function setOverwriteMode(mode: OverwriteMode): Promise { + await store.set('overwriteMode', mode); + await store.save(); + + deviceSyncSettings.update(s => ({ + ...s, + overwriteMode: mode + })); +} + +// Initialize settings on import +loadDeviceSyncSettings(); diff --git a/src/routes/sync/+page.svelte b/src/routes/sync/+page.svelte new file mode 100644 index 0000000..094e130 --- /dev/null +++ b/src/routes/sync/+page.svelte @@ -0,0 +1,608 @@ + + +
+

Device Sync

+ + {#if !configured} + +
+
+
Configure Device Paths
+
+
+

Select the folders on your portable device to sync music and playlists:

+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + {#if error} +

{error}

+ {/if} + +
+ +
+
+
+ {:else if syncing} +
+ {#if syncProgress && syncProgress.total > 0} +

{syncProgress.current} / {syncProgress.total} files

+
+
+
+ {#if syncProgress.currentFile} +

{syncProgress.currentFile}

+ {/if} + {:else} +

Syncing...

+ {/if} +
+ {:else if indexing} +

Indexing device...

+ {:else if error} +

{error}

+ {:else} +
+ + + +
  • + +
  • +
  • + +
  • +
    + + +
    +
    + {#if viewMode === 'sync'} + +
    +

    + Device: + {#if checkingConnection} + Checking... + {:else if deviceConnected} + Connected - {$deviceSyncSettings.musicPath} + {:else} + Not Connected - {$deviceSyncSettings.musicPath} + {/if} +

    +
    + + +
    + {#if successMessage} +

    {successMessage}

    + {/if} + {#if syncDiff} +

    + New: {syncDiff.stats.newFiles} | Updated: {syncDiff.stats.updatedFiles} | + Unchanged: {syncDiff.stats.unchangedFiles} | + Total: {formatBytes(syncDiff.stats.totalSize)} +

    + {/if} +
    + + {#if syncDiff && syncDiff.filesToCopy.length > 0} +
    + + + + + + + + + + {#each syncDiff.filesToCopy as file, i} + handleFileClick(i)} + > + + + + + {/each} + +
    FileStatusSize
    {file.relativePath}{file.status}{formatBytes(file.size)}
    +
    + {/if} + {:else if viewMode === 'preferences'} + +
    +
    + Device Paths +
    + +
    + {$deviceSyncSettings.musicPath || 'Not set'} + +
    +
    + +
    + +
    + {$deviceSyncSettings.playlistsPath || 'Not set'} + +
    +
    +
    + +
    + When file exists on device +
    + setOverwriteMode($deviceSyncSettings.overwriteMode)} + /> + +
    + +
    + setOverwriteMode($deviceSyncSettings.overwriteMode)} + /> + +
    + +
    + setOverwriteMode($deviceSyncSettings.overwriteMode)} + /> + +
    + +

    + System files and _temp folders are always skipped. +

    +
    +
    + {/if} +
    +
    +
    + {/if} +
    + +