feat(sync): support playlists

This commit is contained in:
Markury
2026-01-09 16:07:58 -05:00
parent 3d8df1eb48
commit e3e75f0256
3 changed files with 173 additions and 71 deletions

View File

@@ -66,57 +66,19 @@ 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
/// Helper function to index and compare a single folder pair
fn compare_folder_pair(
library_path: &Path,
device_files: &HashMap<String, FileMetadata>,
overwrite_mode: &str,
) -> Result<(Vec<FileInfo>, 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<String>,
playlists_device_path: Option<String>,
overwrite_mode: String, // "skip" | "different" | "always"
) -> Result<SyncDiff, String> {
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<String, FileMetadata> = 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<String>,
playlists_device_path: Option<String>,
files_to_copy: Vec<FileInfo>,
) -> Result<String, String> {
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)
}