feat(device): add device sync button

This commit is contained in:
2025-10-15 11:45:52 -04:00
parent af4f8ce77f
commit 8d773f8188
8 changed files with 1052 additions and 2 deletions

2
src-tauri/Cargo.lock generated
View File

@@ -26,6 +26,8 @@ dependencies = [
"tauri-plugin-sql",
"tauri-plugin-store",
"tokio",
"unicode-normalization",
"walkdir",
]
[[package]]

View File

@@ -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"

View File

@@ -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<FileInfo>,
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::<String>()
}
/// 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<SyncDiff, String> {
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<String, FileMetadata> = 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<FileInfo>,
) -> Result<String, String> {
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))
}

View File

@@ -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");