mirror of
https://github.com/markuryy/shark.git
synced 2026-02-01 20:41:03 +00:00
Compare commits
2 Commits
3d8df1eb48
...
d0c7cfef4c
| Author | SHA1 | Date | |
|---|---|---|---|
| d0c7cfef4c | |||
|
|
e3e75f0256 |
@@ -1,6 +1,7 @@
|
||||
# Shark!
|
||||
|
||||
Desktop music management application written in Typescript.
|
||||
<img width="1218" height="683" alt="image" src="https://github.com/user-attachments/assets/112652a6-cc3f-47b7-8da5-a9f684ba5f07" />
|
||||
|
||||
## Inspiration
|
||||
|
||||
@@ -71,4 +72,4 @@ src-tauri/
|
||||
|
||||
## License
|
||||
|
||||
This repo has been made source available. It is not licensed under a single open source license. Check upstream libraries for license details.
|
||||
This repo has been made source available. It is not licensed under a single open source license. Check upstream libraries for license details.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<SyncDiff> {
|
||||
try {
|
||||
const result = await invoke<SyncDiff>('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<string> {
|
||||
@@ -76,8 +82,10 @@ export async function syncToDevice(
|
||||
|
||||
// Start sync operation
|
||||
const result = await invoke<string>('sync_to_device', {
|
||||
libraryPath,
|
||||
devicePath,
|
||||
musicLibraryPath,
|
||||
musicDevicePath,
|
||||
playlistsLibraryPath: playlistsLibraryPath || undefined,
|
||||
playlistsDevicePath: playlistsDevicePath || undefined,
|
||||
filesToCopy
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user