Compare commits

..

2 Commits

Author SHA1 Message Date
d0c7cfef4c docs: add ss to README 2026-01-09 16:08:56 -05:00
Markury
e3e75f0256 feat(sync): support playlists 2026-01-09 16:07:58 -05:00
4 changed files with 175 additions and 72 deletions

View File

@@ -1,6 +1,7 @@
# Shark! # Shark!
Desktop music management application written in Typescript. 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 ## Inspiration
@@ -71,4 +72,4 @@ src-tauri/
## License ## 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.

View File

@@ -66,57 +66,19 @@ fn normalize_path(path: &Path) -> String {
path.to_string_lossy().nfc().collect::<String>() path.to_string_lossy().nfc().collect::<String>()
} }
/// Index device and compare with library /// Helper function to index and compare a single folder pair
#[tauri::command] fn compare_folder_pair(
pub async fn index_and_compare( library_path: &Path,
library_path: String, device_files: &HashMap<String, FileMetadata>,
device_path: String, overwrite_mode: &str,
overwrite_mode: String, // "skip" | "different" | "always" ) -> Result<(Vec<FileInfo>, usize, usize, usize, u64), String> {
) -> 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 files_to_copy = Vec::new();
let mut new_count = 0; let mut new_count = 0;
let mut updated_count = 0; let mut updated_count = 0;
let mut unchanged_count = 0; let mut unchanged_count = 0;
let mut total_size = 0u64; let mut total_size = 0u64;
for entry in WalkDir::new(&library_path) for entry in WalkDir::new(library_path)
.follow_links(false) .follow_links(false)
.into_iter() .into_iter()
.filter_entry(|e| !should_skip_dir(e.path())) .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()) { if entry.file_type().is_file() && !should_skip_file(entry.path()) {
let relative_path = entry.path() let relative_path = entry.path()
.strip_prefix(&library_path) .strip_prefix(library_path)
.map_err(|e| format!("Path error: {}", e))? .map_err(|e| format!("Path error: {}", e))?
.to_path_buf(); .to_path_buf();
@@ -140,11 +102,11 @@ pub async fn index_and_compare(
// File exists on device // File exists on device
let size_different = device_meta.size != file_size; let size_different = device_meta.size != file_size;
let should_copy = match overwrite_mode.as_str() { let should_copy = match overwrite_mode {
"skip" => false, // Never overwrite "skip" => false,
"different" => size_different, // Only if different size "different" => size_different,
"always" => true, // Always overwrite "always" => true,
_ => size_different, // Default to "different" _ => size_different,
}; };
if should_copy { 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 { Ok(SyncDiff {
files_to_copy, files_to_copy: all_files,
stats: SyncStats { stats: SyncStats {
new_files: new_count, new_files: total_new,
updated_files: updated_count, updated_files: total_updated,
unchanged_files: unchanged_count, unchanged_files: total_unchanged,
total_size, total_size: total_bytes,
}, },
}) })
} }
@@ -186,18 +251,43 @@ pub async fn index_and_compare(
#[tauri::command] #[tauri::command]
pub async fn sync_to_device( pub async fn sync_to_device(
app: AppHandle, app: AppHandle,
library_path: String, music_library_path: String,
device_path: String, music_device_path: String,
playlists_library_path: Option<String>,
playlists_device_path: Option<String>,
files_to_copy: Vec<FileInfo>, files_to_copy: Vec<FileInfo>,
) -> Result<String, String> { ) -> Result<String, String> {
let library_path = PathBuf::from(library_path); let music_library = PathBuf::from(music_library_path);
let device_path = PathBuf::from(device_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(); let total = files_to_copy.len();
for (index, file_info) in files_to_copy.iter().enumerate() { for (index, file_info) in files_to_copy.iter().enumerate() {
let source = library_path.join(&file_info.relative_path); // Determine source and destination by checking if file exists in music or playlists library
let dest = device_path.join(&file_info.relative_path); 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 // Emit progress
app.emit("sync-progress", SyncProgress { app.emit("sync-progress", SyncProgress {
@@ -222,7 +312,7 @@ pub async fn sync_to_device(
format!("Permission denied: {}", dest.display()) format!("Permission denied: {}", dest.display())
} }
std::io::ErrorKind::NotFound => { 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) _ => format!("Failed to copy {}: {}", file_info.relative_path, e)
} }

View File

@@ -38,14 +38,18 @@ export type ProgressCallback = (progress: SyncProgress) => void;
* Returns a diff showing which files need to be synced * Returns a diff showing which files need to be synced
*/ */
export async function indexAndCompare( export async function indexAndCompare(
libraryPath: string, musicLibraryPath: string,
devicePath: string, musicDevicePath: string,
playlistsLibraryPath: string | null | undefined,
playlistsDevicePath: string | null | undefined,
overwriteMode: string overwriteMode: string
): Promise<SyncDiff> { ): Promise<SyncDiff> {
try { try {
const result = await invoke<SyncDiff>('index_and_compare', { const result = await invoke<SyncDiff>('index_and_compare', {
libraryPath, musicLibraryPath,
devicePath, musicDevicePath,
playlistsLibraryPath: playlistsLibraryPath || undefined,
playlistsDevicePath: playlistsDevicePath || undefined,
overwriteMode overwriteMode
}); });
return result; return result;
@@ -59,8 +63,10 @@ export async function indexAndCompare(
* Sync files to device with progress updates * Sync files to device with progress updates
*/ */
export async function syncToDevice( export async function syncToDevice(
libraryPath: string, musicLibraryPath: string,
devicePath: string, musicDevicePath: string,
playlistsLibraryPath: string | null | undefined,
playlistsDevicePath: string | null | undefined,
filesToCopy: FileInfo[], filesToCopy: FileInfo[],
onProgress?: ProgressCallback onProgress?: ProgressCallback
): Promise<string> { ): Promise<string> {
@@ -76,8 +82,10 @@ export async function syncToDevice(
// Start sync operation // Start sync operation
const result = await invoke<string>('sync_to_device', { const result = await invoke<string>('sync_to_device', {
libraryPath, musicLibraryPath,
devicePath, musicDevicePath,
playlistsLibraryPath: playlistsLibraryPath || undefined,
playlistsDevicePath: playlistsDevicePath || undefined,
filesToCopy filesToCopy
}); });

View File

@@ -127,6 +127,8 @@
const result = await indexAndCompare( const result = await indexAndCompare(
$settings.musicFolder, $settings.musicFolder,
$deviceSyncSettings.musicPath, $deviceSyncSettings.musicPath,
$settings.playlistsFolder,
$deviceSyncSettings.playlistsPath,
$deviceSyncSettings.overwriteMode $deviceSyncSettings.overwriteMode
); );
@@ -160,6 +162,8 @@
const result = await syncToDevice( const result = await syncToDevice(
$settings.musicFolder, $settings.musicFolder,
$deviceSyncSettings.musicPath, $deviceSyncSettings.musicPath,
$settings.playlistsFolder,
$deviceSyncSettings.playlistsPath,
syncDiff.filesToCopy, syncDiff.filesToCopy,
(progress) => { (progress) => {
syncProgress = progress; syncProgress = progress;