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-sql",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"unicode-normalization",
|
||||||
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -37,4 +37,6 @@ reqwest = { version = "0.12.23", features = ["stream", "rustls-tls"] }
|
|||||||
tokio = { version = "1.47.1", features = ["fs", "io-util"] }
|
tokio = { version = "1.47.1", features = ["fs", "io-util"] }
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
tauri-plugin-os = "2"
|
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};
|
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||||
|
|
||||||
mod deezer_crypto;
|
mod deezer_crypto;
|
||||||
|
mod device_sync;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
mod tagger;
|
mod tagger;
|
||||||
|
|
||||||
@@ -317,7 +318,9 @@ pub fn run() {
|
|||||||
tag_audio_file,
|
tag_audio_file,
|
||||||
read_audio_metadata,
|
read_audio_metadata,
|
||||||
decrypt_deezer_track,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
play: '/icons/speaker.png',
|
play: '/icons/speaker.png',
|
||||||
search: '/icons/internet.png',
|
search: '/icons/internet.png',
|
||||||
computer: '/icons/computer.png',
|
computer: '/icons/computer.png',
|
||||||
|
device: '/icons/ipod.svg',
|
||||||
};
|
};
|
||||||
|
|
||||||
let history: string[] = $state([]);
|
let history: string[] = $state([]);
|
||||||
@@ -80,7 +81,12 @@
|
|||||||
<img src={icons.search} alt="Search" />
|
<img src={icons.search} alt="Search" />
|
||||||
<span>Search</span>
|
<span>Search</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/sync" class="toolbar-button" title="Device Sync">
|
||||||
|
<img src={icons.device} alt="Device Sync" />
|
||||||
|
<span>Sync</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<a href="/settings" class="toolbar-button" title="Settings">
|
<a href="/settings" class="toolbar-button" title="Settings">
|
||||||
<img src={icons.computer} alt="Settings" />
|
<img src={icons.computer} alt="Settings" />
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
|
|||||||
107
src/lib/services/deviceSync.ts
Normal file
107
src/lib/services/deviceSync.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Device sync service layer
|
||||||
|
* Handles communication with Tauri backend for device synchronization
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
relativePath: string;
|
||||||
|
size: number;
|
||||||
|
status: 'new' | 'updated';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncStats {
|
||||||
|
newFiles: number;
|
||||||
|
updatedFiles: number;
|
||||||
|
unchangedFiles: number;
|
||||||
|
totalSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncDiff {
|
||||||
|
filesToCopy: FileInfo[];
|
||||||
|
stats: SyncStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncProgress {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
currentFile: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProgressCallback = (progress: SyncProgress) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index device and compare with library
|
||||||
|
* Returns a diff showing which files need to be synced
|
||||||
|
*/
|
||||||
|
export async function indexAndCompare(
|
||||||
|
libraryPath: string,
|
||||||
|
devicePath: string,
|
||||||
|
overwriteMode: string
|
||||||
|
): Promise<SyncDiff> {
|
||||||
|
try {
|
||||||
|
const result = await invoke<SyncDiff>('index_and_compare', {
|
||||||
|
libraryPath,
|
||||||
|
devicePath,
|
||||||
|
overwriteMode
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error indexing and comparing:', error);
|
||||||
|
throw new Error(String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync files to device with progress updates
|
||||||
|
*/
|
||||||
|
export async function syncToDevice(
|
||||||
|
libraryPath: string,
|
||||||
|
devicePath: string,
|
||||||
|
filesToCopy: FileInfo[],
|
||||||
|
onProgress?: ProgressCallback
|
||||||
|
): Promise<string> {
|
||||||
|
let unlisten: UnlistenFn | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set up progress listener
|
||||||
|
unlisten = await listen<SyncProgress>('sync-progress', (event) => {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(event.payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start sync operation
|
||||||
|
const result = await invoke<string>('sync_to_device', {
|
||||||
|
libraryPath,
|
||||||
|
devicePath,
|
||||||
|
filesToCopy
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error syncing to device:', error);
|
||||||
|
throw new Error(String(error));
|
||||||
|
} finally {
|
||||||
|
// Clean up event listener
|
||||||
|
if (unlisten) {
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human-readable string
|
||||||
|
*/
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
81
src/lib/stores/deviceSync.ts
Normal file
81
src/lib/stores/deviceSync.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { LazyStore } from '@tauri-apps/plugin-store';
|
||||||
|
import { writable, type Writable } from 'svelte/store';
|
||||||
|
|
||||||
|
// Device sync settings interface
|
||||||
|
export type OverwriteMode = 'skip' | 'different' | 'always';
|
||||||
|
|
||||||
|
export interface DeviceSyncSettings {
|
||||||
|
musicPath: string | null;
|
||||||
|
playlistsPath: string | null;
|
||||||
|
overwriteMode: OverwriteMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the store with device-sync.json
|
||||||
|
const store = new LazyStore('device-sync.json');
|
||||||
|
|
||||||
|
// Default settings
|
||||||
|
const defaultSettings: DeviceSyncSettings = {
|
||||||
|
musicPath: null,
|
||||||
|
playlistsPath: null,
|
||||||
|
overwriteMode: 'different'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a writable store for reactive UI updates
|
||||||
|
export const deviceSyncSettings: Writable<DeviceSyncSettings> = writable(defaultSettings);
|
||||||
|
|
||||||
|
// Load settings from store
|
||||||
|
export async function loadDeviceSyncSettings(): Promise<void> {
|
||||||
|
const musicPath = await store.get<string>('musicPath');
|
||||||
|
const playlistsPath = await store.get<string>('playlistsPath');
|
||||||
|
const overwriteMode = await store.get<OverwriteMode>('overwriteMode');
|
||||||
|
|
||||||
|
deviceSyncSettings.set({
|
||||||
|
musicPath: musicPath ?? null,
|
||||||
|
playlistsPath: playlistsPath ?? null,
|
||||||
|
overwriteMode: overwriteMode ?? 'different'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save device music path setting
|
||||||
|
export async function setMusicPath(path: string | null): Promise<void> {
|
||||||
|
if (path) {
|
||||||
|
await store.set('musicPath', path);
|
||||||
|
} else {
|
||||||
|
await store.delete('musicPath');
|
||||||
|
}
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
deviceSyncSettings.update(s => ({
|
||||||
|
...s,
|
||||||
|
musicPath: path
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save device playlists path setting
|
||||||
|
export async function setPlaylistsPath(path: string | null): Promise<void> {
|
||||||
|
if (path) {
|
||||||
|
await store.set('playlistsPath', path);
|
||||||
|
} else {
|
||||||
|
await store.delete('playlistsPath');
|
||||||
|
}
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
deviceSyncSettings.update(s => ({
|
||||||
|
...s,
|
||||||
|
playlistsPath: path
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save overwrite mode setting
|
||||||
|
export async function setOverwriteMode(mode: OverwriteMode): Promise<void> {
|
||||||
|
await store.set('overwriteMode', mode);
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
deviceSyncSettings.update(s => ({
|
||||||
|
...s,
|
||||||
|
overwriteMode: mode
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize settings on import
|
||||||
|
loadDeviceSyncSettings();
|
||||||
608
src/routes/sync/+page.svelte
Normal file
608
src/routes/sync/+page.svelte
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { exists } from '@tauri-apps/plugin-fs';
|
||||||
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import {
|
||||||
|
deviceSyncSettings,
|
||||||
|
loadDeviceSyncSettings,
|
||||||
|
setMusicPath,
|
||||||
|
setPlaylistsPath,
|
||||||
|
setOverwriteMode,
|
||||||
|
type OverwriteMode
|
||||||
|
} from '$lib/stores/deviceSync';
|
||||||
|
import {
|
||||||
|
indexAndCompare,
|
||||||
|
syncToDevice,
|
||||||
|
formatBytes,
|
||||||
|
type SyncDiff,
|
||||||
|
type SyncProgress
|
||||||
|
} from '$lib/services/deviceSync';
|
||||||
|
|
||||||
|
type ViewMode = 'sync' | 'preferences';
|
||||||
|
|
||||||
|
let viewMode = $state<ViewMode>('sync');
|
||||||
|
let configured = $state(false);
|
||||||
|
let deviceConnected = $state(false);
|
||||||
|
let checkingConnection = $state(false);
|
||||||
|
|
||||||
|
// Path inputs for initial setup
|
||||||
|
let musicPathInput = $state('');
|
||||||
|
let playlistsPathInput = $state('');
|
||||||
|
|
||||||
|
// Sync state
|
||||||
|
let indexing = $state(false);
|
||||||
|
let syncing = $state(false);
|
||||||
|
let syncDiff = $state<SyncDiff | null>(null);
|
||||||
|
let syncProgress = $state<SyncProgress | null>(null);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let successMessage = $state<string | null>(null);
|
||||||
|
let selectedFileIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadDeviceSyncSettings();
|
||||||
|
configured = !!$deviceSyncSettings.musicPath;
|
||||||
|
|
||||||
|
if (configured) {
|
||||||
|
await checkDeviceConnection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function checkDeviceConnection() {
|
||||||
|
if (!$deviceSyncSettings.musicPath) {
|
||||||
|
deviceConnected = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkingConnection = true;
|
||||||
|
try {
|
||||||
|
deviceConnected = await exists($deviceSyncSettings.musicPath);
|
||||||
|
} catch (e) {
|
||||||
|
deviceConnected = false;
|
||||||
|
} finally {
|
||||||
|
checkingConnection = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBrowseMusicPath() {
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: 'Select Device Music Folder'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
musicPathInput = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBrowsePlaylistsPath() {
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: 'Select Device Playlists Folder'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
playlistsPathInput = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveConfiguration() {
|
||||||
|
if (!musicPathInput) {
|
||||||
|
error = 'Please select a music folder path';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error = null;
|
||||||
|
await setMusicPath(musicPathInput);
|
||||||
|
await setPlaylistsPath(playlistsPathInput || null);
|
||||||
|
|
||||||
|
configured = true;
|
||||||
|
await checkDeviceConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIndexAndCompare() {
|
||||||
|
if (!$settings.musicFolder) {
|
||||||
|
error = 'No library music folder configured. Please set one in Settings.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$deviceSyncSettings.musicPath) {
|
||||||
|
error = 'No device music path configured.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
indexing = true;
|
||||||
|
error = null;
|
||||||
|
successMessage = null;
|
||||||
|
syncDiff = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await checkDeviceConnection();
|
||||||
|
if (!deviceConnected) {
|
||||||
|
throw new Error('Device is not connected or path does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await indexAndCompare(
|
||||||
|
$settings.musicFolder,
|
||||||
|
$deviceSyncSettings.musicPath,
|
||||||
|
$deviceSyncSettings.overwriteMode
|
||||||
|
);
|
||||||
|
|
||||||
|
syncDiff = result;
|
||||||
|
successMessage = `Found ${result.filesToCopy.length} files to sync`;
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Error indexing device: ' + (e instanceof Error ? e.message : String(e));
|
||||||
|
syncDiff = null;
|
||||||
|
} finally {
|
||||||
|
indexing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSync() {
|
||||||
|
if (!syncDiff || syncDiff.filesToCopy.length === 0) {
|
||||||
|
error = 'No files to sync. Run Index & Compare first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$settings.musicFolder || !$deviceSyncSettings.musicPath) {
|
||||||
|
error = 'Configuration error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncing = true;
|
||||||
|
error = null;
|
||||||
|
successMessage = null;
|
||||||
|
syncProgress = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncToDevice(
|
||||||
|
$settings.musicFolder,
|
||||||
|
$deviceSyncSettings.musicPath,
|
||||||
|
syncDiff.filesToCopy,
|
||||||
|
(progress) => {
|
||||||
|
syncProgress = progress;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
successMessage = result;
|
||||||
|
syncDiff = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Error syncing to device: ' + (e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
syncing = false;
|
||||||
|
syncProgress = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateMusicPath() {
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: 'Select Device Music Folder'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
await setMusicPath(selected);
|
||||||
|
await checkDeviceConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdatePlaylistsPath() {
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: 'Select Device Playlists Folder'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
await setPlaylistsPath(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileClick(index: number) {
|
||||||
|
selectedFileIndex = index;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sync-wrapper">
|
||||||
|
<h2 style="padding: 8px">Device Sync</h2>
|
||||||
|
|
||||||
|
{#if !configured}
|
||||||
|
<!-- Initial Configuration -->
|
||||||
|
<section class="window config-section" style="max-width: 600px; margin: 8px;">
|
||||||
|
<div class="title-bar">
|
||||||
|
<div class="title-bar-text">Configure Device Paths</div>
|
||||||
|
</div>
|
||||||
|
<div class="window-body" style="padding: 12px;">
|
||||||
|
<p>Select the folders on your portable device to sync music and playlists:</p>
|
||||||
|
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<label for="music-path-input">Device Music Folder</label>
|
||||||
|
<div class="input-with-button">
|
||||||
|
<input
|
||||||
|
id="music-path-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={musicPathInput}
|
||||||
|
placeholder="/Volumes/iPod/Music"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button onclick={handleBrowseMusicPath}>Browse...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<label for="playlists-path-input">Device Playlists Folder</label>
|
||||||
|
<div class="input-with-button">
|
||||||
|
<input
|
||||||
|
id="playlists-path-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={playlistsPathInput}
|
||||||
|
placeholder="/Volumes/iPod/Playlists"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button onclick={handleBrowsePlaylistsPath}>Browse...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button onclick={handleSaveConfiguration}>Save Configuration</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if syncing}
|
||||||
|
<div class="sync-status">
|
||||||
|
{#if syncProgress && syncProgress.total > 0}
|
||||||
|
<p class="progress-text">{syncProgress.current} / {syncProgress.total} files</p>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
style="width: {(syncProgress.current / syncProgress.total) * 100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{#if syncProgress.currentFile}
|
||||||
|
<p class="current-file">{syncProgress.currentFile}</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p>Syncing...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if indexing}
|
||||||
|
<p style="padding: 8px;">Indexing device...</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="error" style="padding: 8px;">{error}</p>
|
||||||
|
{:else}
|
||||||
|
<section class="sync-content">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<!--
|
||||||
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||||
|
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||||
|
-->
|
||||||
|
<menu role="tablist">
|
||||||
|
<li role="tab" aria-selected={viewMode === 'sync'}>
|
||||||
|
<button onclick={() => viewMode = 'sync'}>Sync</button>
|
||||||
|
</li>
|
||||||
|
<li role="tab" aria-selected={viewMode === 'preferences'}>
|
||||||
|
<button onclick={() => viewMode = 'preferences'}>Preferences</button>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="window tab-content" role="tabpanel">
|
||||||
|
<div class="window-body">
|
||||||
|
{#if viewMode === 'sync'}
|
||||||
|
<!-- Sync Tab -->
|
||||||
|
<div class="sync-info">
|
||||||
|
<p>
|
||||||
|
<strong>Device:</strong>
|
||||||
|
{#if checkingConnection}
|
||||||
|
Checking...
|
||||||
|
{:else if deviceConnected}
|
||||||
|
Connected - {$deviceSyncSettings.musicPath}
|
||||||
|
{:else}
|
||||||
|
Not Connected - {$deviceSyncSettings.musicPath}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<div class="button-group">
|
||||||
|
<button
|
||||||
|
onclick={handleIndexAndCompare}
|
||||||
|
disabled={indexing || syncing || !deviceConnected}
|
||||||
|
>
|
||||||
|
{indexing ? 'Indexing...' : 'Index & Compare'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleSync}
|
||||||
|
disabled={!syncDiff || syncDiff.filesToCopy.length === 0 || syncing || !deviceConnected}
|
||||||
|
>
|
||||||
|
{syncing ? 'Syncing...' : 'Sync to Device'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if successMessage}
|
||||||
|
<p class="success">{successMessage}</p>
|
||||||
|
{/if}
|
||||||
|
{#if syncDiff}
|
||||||
|
<p>
|
||||||
|
New: {syncDiff.stats.newFiles} | Updated: {syncDiff.stats.updatedFiles} |
|
||||||
|
Unchanged: {syncDiff.stats.unchangedFiles} |
|
||||||
|
Total: {formatBytes(syncDiff.stats.totalSize)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if syncDiff && syncDiff.filesToCopy.length > 0}
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Size</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each syncDiff.filesToCopy as file, i}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedFileIndex === i}
|
||||||
|
onclick={() => handleFileClick(i)}
|
||||||
|
>
|
||||||
|
<td>{file.relativePath}</td>
|
||||||
|
<td>{file.status}</td>
|
||||||
|
<td class="size-cell">{formatBytes(file.size)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if viewMode === 'preferences'}
|
||||||
|
<!-- Preferences Tab -->
|
||||||
|
<div class="prefs-container">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Device Paths</legend>
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<label>Device Music Path</label>
|
||||||
|
<div class="path-display">
|
||||||
|
<code>{$deviceSyncSettings.musicPath || 'Not set'}</code>
|
||||||
|
<button onclick={handleUpdateMusicPath}>Change...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<label>Device Playlists Path</label>
|
||||||
|
<div class="path-display">
|
||||||
|
<code>{$deviceSyncSettings.playlistsPath || 'Not set'}</code>
|
||||||
|
<button onclick={handleUpdatePlaylistsPath}>Change...</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset style="margin-top: 16px;">
|
||||||
|
<legend>When file exists on device</legend>
|
||||||
|
<div class="field-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="mode-skip"
|
||||||
|
name="overwrite-mode"
|
||||||
|
value="skip"
|
||||||
|
bind:group={$deviceSyncSettings.overwriteMode}
|
||||||
|
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
|
||||||
|
/>
|
||||||
|
<label for="mode-skip">Skip (don't overwrite)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="mode-different"
|
||||||
|
name="overwrite-mode"
|
||||||
|
value="different"
|
||||||
|
bind:group={$deviceSyncSettings.overwriteMode}
|
||||||
|
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
|
||||||
|
/>
|
||||||
|
<label for="mode-different">Overwrite if different size</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="mode-always"
|
||||||
|
name="overwrite-mode"
|
||||||
|
value="always"
|
||||||
|
bind:group={$deviceSyncSettings.overwriteMode}
|
||||||
|
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
|
||||||
|
/>
|
||||||
|
<label for="mode-always">Always overwrite</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="help-text">
|
||||||
|
System files and _temp folders are always skipped.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sync-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status {
|
||||||
|
padding: 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status p {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 20px;
|
||||||
|
background: #c0c0c0;
|
||||||
|
border: 2px inset #808080;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #000080, #0000ff);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-file {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #808080;
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row-stacked {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-button {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-button input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-content {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: -2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-body {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-info {
|
||||||
|
padding: 16px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-info p {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-cell {
|
||||||
|
text-align: right;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefs-container {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-display {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-display code {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--button-shadow, #2a2a2a);
|
||||||
|
border: 1px solid var(--button-highlight, #606060);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user