mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
feat(device): add device sync button
This commit is contained in:
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -26,6 +26,8 @@ dependencies = [
|
||||
"tauri-plugin-sql",
|
||||
"tauri-plugin-store",
|
||||
"tokio",
|
||||
"unicode-normalization",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
241
src-tauri/src/device_sync.rs
Normal file
241
src-tauri/src/device_sync.rs
Normal 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))
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user