diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 32d35ace..8af01bd6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1499,9 +1499,15 @@ fn get_removable_drives() -> Vec { /// The file records which sources (albums/playlists/artists) are synced to this /// device so that another machine can pick them up without relying on localStorage. #[tauri::command] -fn write_device_manifest(dest_dir: String, sources: serde_json::Value, filename_template: String) -> Result<(), String> { +fn write_device_manifest(dest_dir: String, sources: serde_json::Value) -> Result<(), String> { let path = std::path::Path::new(&dest_dir).join("psysonic-sync.json"); - let payload = serde_json::json!({ "version": 1, "sources": sources, "filenameTemplate": filename_template }); + // Manifest v2: fixed "{AlbumArtist}/{Album}/{TrackNum} - {Title}.{ext}" schema, + // no user-configurable filename template. Readers still accept v1 manifests. + let payload = serde_json::json!({ + "version": 2, + "schema": "fixed-v1", + "sources": sources + }); let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?; std::fs::write(&path, json).map_err(|e| e.to_string()) } @@ -1515,6 +1521,138 @@ fn read_device_manifest(dest_dir: String) -> Option { serde_json::from_str(&content).ok() } +/// Per-entry result for `rename_device_files`. +#[derive(serde::Serialize)] +struct RenameResult { + #[serde(rename = "oldPath")] + old_path: String, + #[serde(rename = "newPath")] + new_path: String, + ok: bool, + error: Option, +} + +/// Atomically renames files on the device from their old path to the new fixed- +/// schema path. Intended for the migration flow when switching away from the +/// user-configurable template. All paths are relative to `target_dir`. +/// +/// After renaming, removes any directories left empty under `target_dir` +/// (so stale `{OldArtist}/{OldAlbum}/` trees don't linger). +/// +/// Returns a per-entry result so the UI can show which renames succeeded +/// and which failed. Does not roll back on partial failure — each `fs::rename` +/// is atomic, so nothing can be half-renamed. +#[tauri::command] +fn rename_device_files( + target_dir: String, + pairs: Vec<(String, String)>, +) -> Result, String> { + let root = std::path::PathBuf::from(&target_dir); + if !root.exists() { + return Err("VOLUME_NOT_FOUND".to_string()); + } + if !is_path_on_mounted_volume(&root) { + return Err("NOT_MOUNTED_VOLUME".to_string()); + } + + let mut results = Vec::with_capacity(pairs.len()); + for (old_rel, new_rel) in pairs { + let old_abs = root.join(&old_rel); + let new_abs = root.join(&new_rel); + + let entry = if old_rel == new_rel { + // Nothing to do, count as success so the UI can show "already correct". + RenameResult { old_path: old_rel, new_path: new_rel, ok: true, error: None } + } else if !old_abs.exists() { + RenameResult { + old_path: old_rel, new_path: new_rel, + ok: false, error: Some("source not found".to_string()), + } + } else if new_abs.exists() { + RenameResult { + old_path: old_rel, new_path: new_rel, + ok: false, error: Some("target already exists".to_string()), + } + } else { + // Ensure target parent exists. + if let Some(parent) = new_abs.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + results.push(RenameResult { + old_path: old_rel, new_path: new_rel, + ok: false, error: Some(format!("mkdir: {}", e)), + }); + continue; + } + } + match std::fs::rename(&old_abs, &new_abs) { + Ok(_) => RenameResult { old_path: old_rel, new_path: new_rel, ok: true, error: None }, + Err(e) => RenameResult { + old_path: old_rel, new_path: new_rel, + ok: false, error: Some(e.to_string()), + }, + } + }; + results.push(entry); + } + + // Clean up directories emptied by the renames. Walk depth-first and remove + // any dir whose only remaining contents were the files we moved out. + fn remove_empty_dirs(dir: &std::path::Path, root: &std::path::Path) { + if dir == root { return; } + let rd = match std::fs::read_dir(dir) { + Ok(r) => r, + Err(_) => return, + }; + let mut empty = true; + let mut children: Vec = Vec::new(); + for entry in rd.flatten() { + let p = entry.path(); + if p.is_dir() { children.push(p); } else { empty = false; } + } + for child in children { + remove_empty_dirs(&child, root); + } + // Re-check after recursion cleared subdirs. + let still_empty = std::fs::read_dir(dir).map(|r| r.count() == 0).unwrap_or(false); + if empty && still_empty { + let _ = std::fs::remove_dir(dir); + } + } + remove_empty_dirs(&root, &root); + + Ok(results) +} + +/// Writes an Extended-M3U playlist at `{dest_dir}/Playlists/{name}/{name}.m3u8`. +/// References are sibling filenames (just `01 - Artist - Title.ext`) so the +/// playlist is self-contained — moving/copying the folder anywhere keeps it +/// working. Tracks are expected to be in playlist order (index starts at 1). +#[tauri::command] +fn write_playlist_m3u8( + dest_dir: String, + playlist_name: String, + tracks: Vec, +) -> Result<(), String> { + let safe_name = sanitize_or(&playlist_name, "Unnamed Playlist"); + let playlist_dir = std::path::Path::new(&dest_dir).join("Playlists").join(&safe_name); + std::fs::create_dir_all(&playlist_dir).map_err(|e| e.to_string())?; + let file_path = playlist_dir.join(format!("{}.m3u8", safe_name)); + + let mut body = String::from("#EXTM3U\n"); + for (i, track) in tracks.iter().enumerate() { + let idx = (i as u32) + 1; + let duration = track.duration.map(|d| d as i64).unwrap_or(-1); + let display_artist = if track.artist.trim().is_empty() { &track.album_artist[..] } else { &track.artist[..] }; + let title = track.title.trim(); + body.push_str(&format!("#EXTINF:{},{} - {}\n", duration, display_artist.trim(), title)); + // Sibling filename — same shape as build_track_path's playlist branch. + let artist_safe = sanitize_or(display_artist, "Unknown Artist"); + let title_safe = sanitize_or(title, "Unknown Title"); + body.push_str(&format!("{:02} - {} - {}.{}\n", idx, artist_safe, title_safe, track.suffix)); + } + std::fs::write(&file_path, body).map_err(|e| e.to_string()) +} + /// Checks whether `path` sits on top of an active mount point (i.e. not the root /// filesystem). This prevents accidentally writing to `/media/usb` after the /// USB drive has been unmounted — at that point the path would fall through to `/` @@ -1556,14 +1694,28 @@ struct TrackSyncInfo { id: String, url: String, suffix: String, + /// Track artist — used in Extended M3U (#EXTINF) entries so playlists display + /// the actual performer rather than the album artist. artist: String, + /// Album artist — used for the top-level folder so compilation albums stay together. + /// Falls back to `artist` in the frontend when the server has no albumArtist tag. + #[serde(rename = "albumArtist")] + album_artist: String, album: String, title: String, #[serde(rename = "trackNumber")] track_number: Option, - #[serde(rename = "discNumber")] - disc_number: Option, - year: Option, + /// Duration in seconds — needed for Extended M3U (#EXTINF) playlist entries. + #[serde(default)] + duration: Option, + /// When set, the track belongs to a playlist source and is placed under + /// `Playlists/{name}/` with `playlist_index` as its filename prefix. + /// Same track synced from both an album and a playlist source ends up twice + /// on the device — once in the album tree, once in the playlist folder. + #[serde(default, rename = "playlistName")] + playlist_name: Option, + #[serde(default, rename = "playlistIndex")] + playlist_index: Option, } /// Summary returned by `sync_batch_to_device` after all tracks are processed. @@ -1581,8 +1733,9 @@ struct SyncTrackResult { } /// Replaces characters that are invalid in file/directory names on Windows and -/// most Unix filesystems with an underscore. Also trims leading/trailing dots -/// and spaces which cause issues on Windows. +/// most Unix filesystems with an underscore, and trims leading/trailing dots and +/// spaces which cause issues on Windows. Underscore (not deletion) so that "AC/DC" +/// and "ACDC" don't collapse into the same folder. fn sanitize_path_component(s: &str) -> String { const INVALID: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|']; let sanitized: String = s @@ -1592,31 +1745,37 @@ fn sanitize_path_component(s: &str) -> String { sanitized.trim_matches(|c| c == '.' || c == ' ').to_string() } -/// Evaluates `template` by substituting `{artist}`, `{album}`, `{title}`, -/// `{track_number}`, `{disc_number}`, `{year}` with sanitized values from `track`. -fn apply_device_sync_template(template: &str, track: &TrackSyncInfo) -> String { - let track_number = track.track_number - .map(|n| format!("{:02}", n)) - .unwrap_or_default(); - let disc_number = track.disc_number - .map(|n| n.to_string()) - .unwrap_or_default(); - let year = track.year - .map(|y| y.to_string()) - .unwrap_or_default(); +/// Sanitize and replace empty results with a placeholder — prevents paths like +/// `//01 - .flac` when metadata is missing. +fn sanitize_or(s: &str, fallback: &str) -> String { + let cleaned = sanitize_path_component(s); + if cleaned.is_empty() { fallback.to_string() } else { cleaned } +} - let result = template - .replace("{artist}", &sanitize_path_component(&track.artist)) - .replace("{album}", &sanitize_path_component(&track.album)) - .replace("{title}", &sanitize_path_component(&track.title)) - .replace("{track_number}", &track_number) - .replace("{disc_number}", &disc_number) - .replace("{year}", &year); - // Normalize to the OS path separator so compute_sync_paths and list_device_dir_files - // produce identical strings for Set comparison. +/// Builds the fixed device path for a track. When the track carries a playlist +/// context it goes into the playlist folder, otherwise into the album tree. +/// +/// Album-tree: `{AlbumArtist}/{Album}/{TrackNum:02d} - {Title}.{ext}` +/// Playlist: `Playlists/{PlaylistName}/{PlaylistIndex:02d} - {Artist} - {Title}.{ext}` +fn build_track_path(track: &TrackSyncInfo) -> String { + let relative = match (&track.playlist_name, track.playlist_index) { + (Some(name), Some(idx)) => { + let playlist = sanitize_or(name, "Unnamed Playlist"); + let artist = sanitize_or(&track.artist, "Unknown Artist"); + let title = sanitize_or(&track.title, "Unknown Title"); + format!("Playlists/{}/{:02} - {} - {}", playlist, idx, artist, title) + } + _ => { + let album_artist = sanitize_or(&track.album_artist, "Unknown Artist"); + let album = sanitize_or(&track.album, "Unknown Album"); + let title = sanitize_or(&track.title, "Unknown Title"); + let track_num = track.track_number.map(|n| format!("{:02}", n)).unwrap_or_else(|| "00".to_string()); + format!("{}/{}/{} - {}", album_artist, album, track_num, title) + } + }; #[cfg(target_os = "windows")] - let result = result.replace('/', "\\"); - result + let relative = relative.replace('/', "\\"); + relative } /// Downloads a single track to a USB/SD device using the configured filename template. @@ -1625,11 +1784,10 @@ fn apply_device_sync_template(template: &str, track: &TrackSyncInfo) -> String { async fn sync_track_to_device( track: TrackSyncInfo, dest_dir: String, - template: String, job_id: String, app: tauri::AppHandle, ) -> Result { - let relative = apply_device_sync_template(&template, &track); + let relative = build_track_path(&track); let file_name = format!("{}.{}", relative, track.suffix); let dest_path = std::path::Path::new(&dest_dir).join(&file_name); let path_str = dest_path.to_string_lossy().to_string(); @@ -1679,16 +1837,12 @@ async fn sync_track_to_device( Ok(SyncTrackResult { path: path_str, skipped: false }) } -/// Computes the expected file paths for a batch of tracks using the given template, -/// without downloading anything. Used by the cleanup flow to find orphans. +/// Computes the expected file paths for a batch of tracks under the fixed schema. +/// Used by the cleanup flow to find orphans. #[tauri::command] -fn compute_sync_paths( - tracks: Vec, - dest_dir: String, - template: String, -) -> Vec { +fn compute_sync_paths(tracks: Vec, dest_dir: String) -> Vec { tracks.iter().map(|track| { - let relative = apply_device_sync_template(&template, track); + let relative = build_track_path(track); let file_name = format!("{}.{}", relative, track.suffix); std::path::Path::new(&dest_dir) .join(&file_name) @@ -1771,11 +1925,15 @@ struct SubsonicAuthPayload { f: String, } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Clone)] struct DeviceSyncSourcePayload { #[serde(rename = "type")] source_type: String, id: String, + /// Playlist display name — only present for playlist sources, used when + /// computing the playlist-folder path on the device. + #[serde(default)] + name: Option, } #[derive(serde::Serialize)] @@ -1831,7 +1989,6 @@ async fn calculate_sync_payload( deletion_ids: Vec, auth: SubsonicAuthPayload, target_dir: String, - template: String, ) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) @@ -1853,14 +2010,15 @@ async fn calculate_sync_payload( } } - let mut handles = Vec::new(); + let mut handles: Vec<(DeviceSyncSourcePayload, tokio::task::JoinHandle>)> = Vec::new(); for source in add_sources { let auth_clone = SubsonicAuthPayload { base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(), v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(), }; let cli = client.clone(); - handles.push(tokio::spawn(async move { + let source_snapshot = source.clone(); + let handle = tokio::spawn(async move { let mut res_tracks = Vec::new(); if source.source_type == "album" { if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); } @@ -1887,9 +2045,10 @@ async fn calculate_sync_payload( } } res_tracks - })); + }); + handles.push((source_snapshot, handle)); } - + let mut del_handles = Vec::new(); for source in del_sources { let auth_clone = SubsonicAuthPayload { @@ -1908,39 +2067,65 @@ async fn calculate_sync_payload( })); } - let mut seen = std::collections::HashSet::new(); - for handle in handles { + // Dedup key is (source_id, track_id) rather than just track_id — a track + // appearing in both an album and a playlist needs to end up on the device + // in both locations (album tree + playlist folder). + let mut seen_by_source: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); + for (source, handle) in handles { if let Ok(ts) = handle.await { + let is_playlist = source.source_type == "playlist"; + let mut playlist_position: u32 = 0; for track in ts { if let Some(tid) = track.get("id").and_then(|i| i.as_str()) { - if !seen.contains(tid) { - seen.insert(tid.to_string()); - // Build the expected path and skip files already present on device. - let already_exists = { - let suffix = track.get("suffix").and_then(|s| s.as_str()).unwrap_or("mp3"); - let sync_info = TrackSyncInfo { - id: tid.to_string(), - url: String::new(), - suffix: suffix.to_string(), - artist: track.get("artist").and_then(|v| v.as_str()).unwrap_or("").to_string(), - album: track.get("album").and_then(|v| v.as_str()).unwrap_or("").to_string(), - title: track.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(), - track_number: track.get("track").and_then(|v| v.as_u64()).map(|n| n as u32), - disc_number: track.get("discNumber").and_then(|v| v.as_u64()).map(|n| n as u32), - year: track.get("year").and_then(|v| v.as_u64()).map(|n| n as u32), - }; - let relative = apply_device_sync_template(&template, &sync_info); - let file_name = format!("{}.{}", relative, suffix); - std::path::Path::new(&target_dir).join(&file_name).exists() + let key = (source.id.clone(), tid.to_string()); + if seen_by_source.contains(&key) { continue; } + seen_by_source.insert(key); + if is_playlist { playlist_position += 1; } + let pl_name = if is_playlist { source.name.clone() } else { None }; + let pl_idx = if is_playlist { Some(playlist_position) } else { None }; + + let already_exists = { + let suffix = track.get("suffix").and_then(|s| s.as_str()).unwrap_or("mp3"); + let artist_raw = track.get("artist").and_then(|v| v.as_str()).unwrap_or(""); + let album_artist = track.get("albumArtist") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or(artist_raw); + let sync_info = TrackSyncInfo { + id: tid.to_string(), + url: String::new(), + suffix: suffix.to_string(), + artist: artist_raw.to_string(), + album_artist: album_artist.to_string(), + album: track.get("album").and_then(|v| v.as_str()).unwrap_or("").to_string(), + title: track.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(), + track_number: track.get("track").and_then(|v| v.as_u64()).map(|n| n as u32), + duration: track.get("duration").and_then(|v| v.as_u64()).map(|n| n as u32), + playlist_name: pl_name.clone(), + playlist_index: pl_idx, }; - if !already_exists { - add_count += 1; - let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| { - track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8 - }); - add_bytes += size; - sync_tracks.push(track); + let relative = build_track_path(&sync_info); + let file_name = format!("{}.{}", relative, suffix); + std::path::Path::new(&target_dir).join(&file_name).exists() + }; + if !already_exists { + add_count += 1; + let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| { + track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8 + }); + add_bytes += size; + // Embed playlist context in the track JSON so the frontend + // can pass it back to sync_batch_to_device without re-computing it. + let mut track_with_ctx = track.clone(); + if let Some(obj) = track_with_ctx.as_object_mut() { + if let Some(name) = &pl_name { + obj.insert("_playlistName".to_string(), serde_json::Value::String(name.clone())); + } + if let Some(idx) = pl_idx { + obj.insert("_playlistIndex".to_string(), serde_json::Value::Number(idx.into())); + } } + sync_tracks.push(track_with_ctx); } } } @@ -1991,7 +2176,6 @@ fn cancel_device_sync(job_id: String, app: tauri::AppHandle) { async fn sync_batch_to_device( tracks: Vec, dest_dir: String, - template: String, job_id: String, expected_bytes: u64, app: tauri::AppHandle, @@ -2057,7 +2241,6 @@ async fn sync_batch_to_device( let cli = client.clone(); let app2 = app.clone(); let job = job_id.clone(); - let tmpl = template.clone(); let dest = dest_dir.clone(); let d = done.clone(); let s = skipped.clone(); @@ -2071,7 +2254,7 @@ async fn sync_batch_to_device( // Bail out if cancelled while waiting in the semaphore queue. if cancel.load(Ordering::Relaxed) { return; } - let relative = apply_device_sync_template(&tmpl, &track); + let relative = build_track_path(&track); let file_name = format!("{}.{}", relative, track.suffix); let dest_path = std::path::Path::new(&dest).join(&file_name); let path_str = dest_path.to_string_lossy().to_string(); @@ -2665,6 +2848,8 @@ pub fn run() { get_removable_drives, write_device_manifest, read_device_manifest, + write_playlist_m3u8, + rename_device_files, toggle_tray_icon, check_dir_accessible, download_zip, diff --git a/src/locales/de.ts b/src/locales/de.ts index 73c6fd8a..7a725903 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1144,9 +1144,24 @@ export const deTranslation = { free: 'frei', notMountedVolume: 'Ziel befindet sich nicht auf einem eingehängten Laufwerk. Das Gerät wurde möglicherweise getrennt.', chooseFolder: 'Auswählen…', - filenameTemplate: 'Dateinamen-Vorlage', targetDevice: 'Zielgerät', - templateHint: 'Variablen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', + schemaLabel: 'Benennungsschema', + schemaHint: 'Festes Schema für zuverlässige Synchronisation über verschiedene Betriebssysteme hinweg. Playlists werden als .m3u8-Dateien geschrieben und referenzieren die Album-Tracks — keine Duplikate auf dem Gerät.', + migrateButton: 'Bestehende Dateien re-organisieren…', + migrateTooltip: 'Vorhandene Dateien auf dem Gerät nach dem neuen Schema umbenennen (basierend auf der alten Namensvorlage).', + migrateTitle: 'Bestehende Dateien re-organisieren', + migrateLoading: 'Analysiere vorhandene Dateien…', + migrateNothingToDo: 'Alle vorhandenen Dateien entsprechen bereits dem neuen Schema — nichts zu tun.', + migrateNoTemplate: 'Keine alte Namensvorlage auf dem Gerät gefunden. Migration ist nur möglich, wenn der Stick mit einer Psysonic-Version synchronisiert wurde, die konfigurierbare Vorlagen unterstützte.', + migrateFilesToRename: 'Dateien werden umbenannt', + migrateUnchanged: '{{n}} Dateien liegen bereits am korrekten Pfad', + migrateCollisions: '{{n}} Dateien können nicht automatisch umbenannt werden (mehrere Tracks zeigen auf dasselbe Ziel). Sie bleiben unverändert — beim nächsten Sync werden sie neu heruntergeladen.', + migratePreviewNote: 'Alte Vorlage: {{tpl}}', + migrateExecuting: 'Dateien werden umbenannt…', + migrateSuccess: '{{n}} Dateien erfolgreich umbenannt', + migrateFailed: '{{n}} Umbenennungen fehlgeschlagen', + migrateShowErrors: 'Fehler anzeigen', + migrateStart: 'Umbenennen starten', onDevice: 'Auf dem Gerät', addSources: 'Hinzufügen…', colName: 'Name', @@ -1167,11 +1182,6 @@ export const deTranslation = { actionTransfer: 'Auf Gerät übertragen', actionDelete: 'Vom Gerät löschen', actionApplyAll: 'Änderungen synchronisieren', - templatePreview: 'Vorschau', - templatePresetStandard: 'Standard', - templatePresetMultiDisc: 'Multi-Disc', - templatePresetAltFolder: 'Alt. Ordner', - tokenSlashHint: '/ = Ordner-Trennzeichen', cancel: 'Abbrechen', noTargetDir: 'Bitte zuerst einen Zielordner auswählen.', noSources: 'Bitte mindestens eine Quelle auswählen.', diff --git a/src/locales/en.ts b/src/locales/en.ts index c5ffa378..ecd8ec07 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1146,9 +1146,24 @@ export const enTranslation = { free: 'free', notMountedVolume: 'Target is not on a mounted volume. The drive may have been disconnected.', chooseFolder: 'Choose…', - filenameTemplate: 'Filename Template', targetDevice: 'Target Device', - templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', + schemaLabel: 'Naming scheme', + schemaHint: 'Fixed scheme for reliable cross-OS sync. Playlists are written as .m3u8 that reference the album tracks — no duplicates on the device.', + migrateButton: 'Reorganize existing files…', + migrateTooltip: 'Rename existing files on the device into the new scheme (from the old filename template).', + migrateTitle: 'Reorganize existing files', + migrateLoading: 'Analyzing existing files…', + migrateNothingToDo: 'All existing files already match the new scheme — nothing to do.', + migrateNoTemplate: 'No legacy filename template found on the device. Migration only applies when the stick was synced with a Psysonic version that supported custom templates.', + migrateFilesToRename: 'files will be renamed', + migrateUnchanged: '{{n}} files are already at the correct path', + migrateCollisions: '{{n}} files cannot be renamed automatically (multiple tracks map to the same target). They will be left untouched — the next sync re-downloads them into the correct location.', + migratePreviewNote: 'Old template: {{tpl}}', + migrateExecuting: 'Renaming files…', + migrateSuccess: '{{n}} files renamed successfully', + migrateFailed: '{{n}} renames failed', + migrateShowErrors: 'Show errors', + migrateStart: 'Start renaming', onDevice: 'Device Manager', addSources: 'Add…', colName: 'Name', @@ -1170,11 +1185,6 @@ export const enTranslation = { actionTransfer: 'Transfer to Device', actionDelete: 'Delete from Device', actionApplyAll: 'Apply All Changes', - templatePreview: 'Preview', - templatePresetStandard: 'Standard', - templatePresetMultiDisc: 'Multi-Disc', - templatePresetAltFolder: 'Alt. Folder', - tokenSlashHint: '/ = folder separator', cancel: 'Cancel', noTargetDir: 'Please choose a target folder first.', noSources: 'Please select at least one source.', diff --git a/src/pages/DeviceSync.tsx b/src/pages/DeviceSync.tsx index c49dbc7a..6ba610c2 100644 --- a/src/pages/DeviceSync.tsx +++ b/src/pages/DeviceSync.tsx @@ -17,6 +17,7 @@ import { SubsonicSong, SubsonicAlbum, SubsonicPlaylist, SubsonicArtist, } from '../api/subsonic'; import { showToast } from '../utils/toast'; +import { IS_WINDOWS } from '../utils/platform'; type SourceTab = 'playlists' | 'albums' | 'artists'; @@ -24,6 +25,40 @@ type SourceTab = 'playlists' | 'albums' | 'artists'; function uuid(): string { return crypto.randomUUID(); } +// Same sanitize rules the Rust side uses (`sanitize_path_component`): strip +// Windows-illegal chars and control chars, trim leading/trailing dots + spaces. +// Kept in JS only for the migration flow — computes the *old* path under a +// user-supplied template so we can diff against the current files on disk. +function sanitizeComponent(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/[/\\:*?"<>|\x00-\x1f\x7f]/g, '_').replace(/^[. ]+|[. ]+$/g, ''); +} + +interface OldTemplateTrack { + artist: string; + album: string; + title: string; + trackNumber?: number; + discNumber?: number; + year?: number; + suffix: string; +} + +/** Renders a track's path under a legacy (user-configurable) template. Used only + * for the migration preview — the live sync flow goes through Rust's fixed + * `build_track_path`. */ +function applyLegacyTemplate(template: string, track: OldTemplateTrack): string { + const relative = template + .replace(/\{artist\}/g, sanitizeComponent(track.artist)) + .replace(/\{album\}/g, sanitizeComponent(track.album)) + .replace(/\{title\}/g, sanitizeComponent(track.title)) + .replace(/\{track_number\}/g, track.trackNumber != null ? String(track.trackNumber).padStart(2, '0') : '') + .replace(/\{disc_number\}/g, track.discNumber != null ? String(track.discNumber) : '') + .replace(/\{year\}/g, track.year != null ? String(track.year) : ''); + const withExt = `${relative}.${track.suffix}`; + return IS_WINDOWS ? withExt.replace(/\//g, '\\') : withExt; +} + async function fetchTracksForSource(source: DeviceSyncSource): Promise { if (source.type === 'playlist') { const { songs } = await getPlaylist(source.id); return songs; } if (source.type === 'album') { const { songs } = await getAlbum(source.id); return songs; } @@ -33,16 +68,31 @@ async function fetchTracksForSource(source: DeviceSyncSource): Promise s.targetDir); - const filenameTemplate = useDeviceSyncStore(s => s.filenameTemplate); const sources = useDeviceSyncStore(s => s.sources); const checkedIds = useDeviceSyncStore(s => s.checkedIds); const pendingDeletion = useDeviceSyncStore(s => s.pendingDeletion); const deviceFilePaths = useDeviceSyncStore(s => s.deviceFilePaths); const scanning = useDeviceSyncStore(s => s.scanning); const { - setTargetDir, setFilenameTemplate, addSource, removeSource, + setTargetDir, addSource, removeSource, clearSources, toggleChecked, setCheckedIds, markForDeletion, unmarkDeletion, removeSources, setDeviceFilePaths, setScanning, } = useDeviceSyncStore.getState(); @@ -103,9 +152,6 @@ export default function DeviceSync() { // Map source IDs → computed device paths (for status derivation) const [sourcePathsMap, setSourcePathsMap] = useState>(new Map()); - // Template stored in the manifest — may differ from local filenameTemplate when reading a - // manifest written on another OS. Used only for status checks, not for new syncs. - const [deviceManifestTemplate, setDeviceManifestTemplate] = useState(null); // ─── Removable drive detection ────────────────────────────────────────── const [drives, setDrives] = useState([]); @@ -115,6 +161,15 @@ export default function DeviceSync() { const [preSyncLoading, setPreSyncLoading] = useState(false); const [syncDelta, setSyncDelta] = useState({ addBytes: 0, addCount: 0, delBytes: 0, delCount: 0, availableBytes: 0, tracks: [] as SubsonicSong[] }); + // ─── Migration (rename existing files into the fixed scheme) ──────────── + type MigrationPhase = 'closed' | 'loading' | 'preview' | 'executing' | 'done' | 'nothing'; + const [migrationPhase, setMigrationPhase] = useState('closed'); + const [migrationOldTemplate, setMigrationOldTemplate] = useState(''); + const [migrationPairs, setMigrationPairs] = useState<{ old: string; new: string }[]>([]); + const [migrationCollisions, setMigrationCollisions] = useState<{ old: string; new: string }[]>([]); + const [migrationUnchanged, setMigrationUnchanged] = useState(0); + const [migrationResult, setMigrationResult] = useState<{ ok: number; failed: number; errors: string[] } | null>(null); + const refreshDrives = useCallback(async () => { setDrivesLoading(true); try { @@ -170,13 +225,12 @@ export default function DeviceSync() { useEffect(() => { if (!targetDir || !driveDetected || manifestImportedRef.current) return; manifestImportedRef.current = true; - invoke<{ version: number; sources: DeviceSyncSource[]; filenameTemplate?: string } | null>( + invoke<{ version: number; sources: DeviceSyncSource[] } | null>( 'read_device_manifest', { destDir: targetDir } ).then(manifest => { if (manifest?.sources?.length) { useDeviceSyncStore.getState().clearSources(); manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s)); - setDeviceManifestTemplate(manifest.filenameTemplate ?? null); showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info'); } }).catch(() => {}); @@ -186,7 +240,6 @@ export default function DeviceSync() { useEffect(() => { if (!driveDetected) { setDeviceFilePaths([]); - setDeviceManifestTemplate(null); manifestImportedRef.current = false; } }, [driveDetected]); @@ -197,9 +250,7 @@ export default function DeviceSync() { setSourcePathsMap(new Map()); return; } - // Use the template from the manifest (written by the original sync machine) so that - // status checks work correctly even when the local template differs (e.g. Windows→Linux). - const templateForStatus = deviceManifestTemplate ?? filenameTemplate; + // Path schema is fixed in the Rust backend now — no template parameter. let cancelled = false; (async () => { const map = new Map(); @@ -208,9 +259,11 @@ export default function DeviceSync() { try { const tracks = await fetchTracksForSource(source); const paths = await invoke('compute_sync_paths', { - tracks: tracks.map(t => trackToSyncInfo(t, '')), + tracks: tracks.map((tr, idx) => trackToSyncInfo( + tr, '', + source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined, + )), destDir: targetDir, - template: templateForStatus, }); map.set(source.id, paths); } catch { @@ -220,7 +273,7 @@ export default function DeviceSync() { if (!cancelled) setSourcePathsMap(map); })(); return () => { cancelled = true; }; - }, [targetDir, filenameTemplate, deviceManifestTemplate, sources]); + }, [targetDir, sources]); // Derive sync status per source const sourceStatuses = useMemo(() => { @@ -300,8 +353,24 @@ export default function DeviceSync() { 5000, 'info' ); // Write manifest so another machine can read the synced sources from the stick - const { targetDir: dir, sources: srcs, filenameTemplate: tpl } = useDeviceSyncStore.getState(); - if (dir) invoke('write_device_manifest', { destDir: dir, sources: srcs, filenameTemplate: tpl }).catch(() => {}); + const { targetDir: dir, sources: srcs } = useDeviceSyncStore.getState(); + if (dir) { + invoke('write_device_manifest', { destDir: dir, sources: srcs }).catch(() => {}); + // For every playlist source, write an Extended-M3U next to the + // playlist-folder tracks. Context carries the playlist name + + // per-track index so the filenames match the files we just synced. + const playlistSources = srcs.filter(s => s.type === 'playlist'); + playlistSources.forEach(async playlist => { + try { + const tracks = await fetchTracksForSource(playlist); + await invoke('write_playlist_m3u8', { + destDir: dir, + playlistName: playlist.name, + tracks: tracks.map((tr, idx) => trackToSyncInfo(tr, '', { name: playlist.name, index: idx + 1 })), + }); + } catch { /* m3u8 failure is non-fatal — skip silently */ } + }); + } } // Re-scan the device after sync completes (cancelled or not) scanDevice(); @@ -380,6 +449,119 @@ export default function DeviceSync() { const filteredPlaylists = useMemo(() => playlists.filter(p => p.name.toLowerCase().includes(q)), [playlists, q]); const filteredArtists = useMemo(() => artists.filter(a => a.name.toLowerCase().includes(q)), [artists, q]); + // ─── Migration handlers ───────────────────────────────────────────────── + const startMigrationPreview = async () => { + if (!targetDir || sources.length === 0) return; + setMigrationPhase('loading'); + setMigrationResult(null); + try { + // Look up the old template from the v1 manifest on disk. + const manifest = await invoke<{ version: number; filenameTemplate?: string } | null>( + 'read_device_manifest', { destDir: targetDir } + ); + const oldTemplate = manifest?.filenameTemplate?.trim() || ''; + if (!oldTemplate) { + // v2 manifest or missing — nothing to migrate from. + setMigrationPhase('nothing'); + return; + } + setMigrationOldTemplate(oldTemplate); + + // Migration only renames tracks that came from album/artist sources — + // under the old template all tracks lived in a flat album tree. Playlist + // sources get their own `Playlists/{name}/…` folder under the new scheme, + // so the files they need are a subset (or copies) of the album tracks and + // are cleaner to just re-download on the next sync. + const albumSourceTracks: SubsonicSong[] = []; + const seenIds = new Set(); + for (const source of sources.filter(s => s.type !== 'playlist')) { + try { + const tracks = await fetchTracksForSource(source); + for (const tr of tracks) { + if (seenIds.has(tr.id)) continue; + seenIds.add(tr.id); + albumSourceTracks.push(tr); + } + } catch { /* skip unreachable source */ } + } + + // New paths via Rust (fixed album-tree schema). + const newAbsPaths = await invoke('compute_sync_paths', { + tracks: albumSourceTracks.map(tr => trackToSyncInfo(tr, '')), + destDir: targetDir, + }); + const sepChar = IS_WINDOWS ? '\\' : '/'; + const prefix = targetDir.endsWith(sepChar) ? targetDir : targetDir + sepChar; + const newRelPaths = newAbsPaths.map(p => p.startsWith(prefix) ? p.slice(prefix.length) : p); + + // Old paths via the legacy template (JS). + const oldRelPaths = albumSourceTracks.map(tr => applyLegacyTemplate(oldTemplate, { + artist: tr.artist ?? '', + album: tr.album ?? '', + title: tr.title ?? '', + trackNumber: tr.track, + discNumber: tr.discNumber, + year: tr.year, + suffix: tr.suffix ?? 'mp3', + })); + + const pairs: { old: string; new: string }[] = []; + const collisions: { old: string; new: string }[] = []; + const newPathCounts = new Map(); + let unchanged = 0; + + for (let i = 0; i < albumSourceTracks.length; i++) { + const o = oldRelPaths[i]; + const n = newRelPaths[i]; + if (o === n) { unchanged += 1; continue; } + newPathCounts.set(n, (newPathCounts.get(n) ?? 0) + 1); + pairs.push({ old: o, new: n }); + } + // Two separate old files mapping onto the same new path → collision. + const colliding = new Set([...newPathCounts.entries()].filter(([, c]) => c > 1).map(([p]) => p)); + const cleanPairs = pairs.filter(p => !colliding.has(p.new)); + for (const p of pairs.filter(p => colliding.has(p.new))) collisions.push(p); + + setMigrationPairs(cleanPairs); + setMigrationCollisions(collisions); + setMigrationUnchanged(unchanged); + setMigrationPhase(cleanPairs.length === 0 && collisions.length === 0 ? 'nothing' : 'preview'); + } catch (e) { + setMigrationResult({ ok: 0, failed: 0, errors: [String(e)] }); + setMigrationPhase('done'); + } + }; + + const executeMigration = async () => { + if (!targetDir || migrationPairs.length === 0) { setMigrationPhase('closed'); return; } + setMigrationPhase('executing'); + try { + const results = await invoke<{ oldPath: string; newPath: string; ok: boolean; error: string | null }[]>( + 'rename_device_files', + { targetDir, pairs: migrationPairs.map(p => [p.old, p.new]) } + ); + const ok = results.filter(r => r.ok).length; + const failed = results.filter(r => !r.ok).length; + const errors = results.filter(r => !r.ok).map(r => `${r.oldPath}: ${r.error ?? 'unknown'}`); + setMigrationResult({ ok, failed, errors }); + // Bump manifest to v2 (no template field) + rescan the device. + invoke('write_device_manifest', { destDir: targetDir, sources }).catch(() => {}); + scanDevice(); + setMigrationPhase('done'); + } catch (e) { + setMigrationResult({ ok: 0, failed: migrationPairs.length, errors: [String(e)] }); + setMigrationPhase('done'); + } + }; + + const closeMigration = () => { + setMigrationPhase('closed'); + setMigrationPairs([]); + setMigrationCollisions([]); + setMigrationResult(null); + setMigrationOldTemplate(''); + }; + const handleChooseFolder = async () => { const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') }); if (sel) { @@ -388,13 +570,12 @@ export default function DeviceSync() { // If the device has a psysonic-sync.json, always import it — replacing any // sources from a previous device so switching sticks works correctly. try { - const manifest = await invoke<{ version: number; sources: DeviceSyncSource[]; filenameTemplate?: string } | null>( + const manifest = await invoke<{ version: number; sources: DeviceSyncSource[] } | null>( 'read_device_manifest', { destDir: dir } ); if (manifest?.sources?.length) { useDeviceSyncStore.getState().clearSources(); manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s)); - setDeviceManifestTemplate(manifest.filenameTemplate ?? null); showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info'); } } catch { /* no manifest, that's fine */ } @@ -422,7 +603,6 @@ export default function DeviceSync() { deletionIds: pendingDeletion, auth: { baseUrl, ...params }, targetDir, - template: filenameTemplate, }); setSyncDelta(payload); @@ -442,16 +622,20 @@ export default function DeviceSync() { if (deletionSources.length > 0) { try { const allPaths: string[] = []; - const trackArrays = await Promise.all(deletionSources.map(s => fetchTracksForSource(s))); - const deletionTracks = trackArrays.flat(); - - const paths = await invoke('compute_sync_paths', { - tracks: deletionTracks.map(t => trackToSyncInfo(t, '')), - destDir: targetDir, - template: filenameTemplate, - }); - allPaths.push(...paths); - + // Compute paths per source so playlist sources delete from their own + // folder (Playlists/{Name}/…) rather than from the album tree. + for (const source of deletionSources) { + const tracks = await fetchTracksForSource(source); + const paths = await invoke('compute_sync_paths', { + tracks: tracks.map((tr, idx) => trackToSyncInfo( + tr, '', + source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined, + )), + destDir: targetDir, + }); + allPaths.push(...paths); + } + await invoke('delete_device_files', { paths: allPaths }); removeSources(deletionSources.map(s => s.id)); // Update manifest so it stays in sync after deletions @@ -468,6 +652,21 @@ export default function DeviceSync() { const allTracks = syncDelta.tracks; if (allTracks.length === 0) { + // No new downloads needed, but the user may still have added a + // playlist source — (re)write its .m3u8 against the existing files. + if (targetDir) { + const playlistSources = sources.filter(s => s.type === 'playlist'); + playlistSources.forEach(async playlist => { + try { + const tracks = await fetchTracksForSource(playlist); + await invoke('write_playlist_m3u8', { + destDir: targetDir, + playlistName: playlist.name, + tracks: tracks.map((tr, idx) => trackToSyncInfo(tr, '', { name: playlist.name, index: idx + 1 })), + }); + } catch { /* non-fatal */ } + }); + } scanDevice(); return; } @@ -480,7 +679,6 @@ export default function DeviceSync() { invoke('sync_batch_to_device', { tracks: allTracks.map(track => trackToSyncInfo(track, buildDownloadUrl(track.id))), destDir: targetDir, - template: filenameTemplate, jobId, expectedBytes: syncDelta.addBytes, }).catch((err: string) => { @@ -524,63 +722,6 @@ export default function DeviceSync() { (!driveDetected && !!targetDir) || (pendingCount === 0 && deletionCount === 0); - // ─── Template presets & token insertion ──────────────────────────────── - const TEMPLATE_PRESETS = useMemo(() => [ - { key: 'standard', value: '{artist}/{album}/{track_number} - {title}', label: t('deviceSync.templatePresetStandard') }, - { key: 'multidisc', value: '{artist}/{album}/{disc_number}-{track_number} - {title}', label: t('deviceSync.templatePresetMultiDisc') }, - { key: 'altfolder', value: '{artist} - {album}/{track_number} - {title}', label: t('deviceSync.templatePresetAltFolder') }, - ], [t]); - - const TEMPLATE_TOKENS = ['{artist}', '{album}', '{title}', '{track_number}', '{disc_number}', '{year}', '/', '-']; - - const activePreset = TEMPLATE_PRESETS.find(p => p.value === filenameTemplate)?.key ?? null; - - const templateInputRef = useRef(null); - const cursorPosRef = useRef(filenameTemplate.length); - - const insertToken = useCallback((token: string) => { - const input = templateInputRef.current; - const pos = cursorPosRef.current; - const next = filenameTemplate.slice(0, pos) + token + filenameTemplate.slice(pos); - setFilenameTemplate(next); - requestAnimationFrame(() => { - if (!input) return; - input.focus(); - const newPos = pos + token.length; - input.setSelectionRange(newPos, newPos); - cursorPosRef.current = newPos; - }); - }, [filenameTemplate, setFilenameTemplate]); - - const trackCursor = useCallback((e: React.SyntheticEvent) => { - cursorPosRef.current = (e.currentTarget.selectionStart ?? filenameTemplate.length); - }, [filenameTemplate.length]); - - // ─── Template preview (dummy track) ───────────────────────────────────── - const PREVIEW_TRACK = { - artist: 'Artist Name', - album: 'Album Title', - title: 'Track Title', - track_number: '01', - disc_number: '1', - year: '2024', - } as const; - - const templatePreviewText = useMemo(() => { - try { - const result = filenameTemplate - .replace(/\{artist\}/g, PREVIEW_TRACK.artist) - .replace(/\{album\}/g, PREVIEW_TRACK.album) - .replace(/\{title\}/g, PREVIEW_TRACK.title) - .replace(/\{track_number\}/g, PREVIEW_TRACK.track_number) - .replace(/\{disc_number\}/g, PREVIEW_TRACK.disc_number) - .replace(/\{year\}/g, PREVIEW_TRACK.year); - return `${result}.mp3`; - } catch { - return ''; - } - }, [filenameTemplate]); - const tabs: { key: SourceTab; icon: React.ReactNode; label: string }[] = [ { key: 'playlists', icon: , label: t('deviceSync.tabPlaylists') }, { key: 'albums', icon: , label: t('deviceSync.tabAlbums') }, @@ -598,63 +739,30 @@ export default function DeviceSync() {
- - {/* ── Left: Template ── */} -
- {t('deviceSync.filenameTemplate')} -
- {TEMPLATE_PRESETS.map(p => ( - - ))} -
-
-
- { setFilenameTemplate(e.target.value); trackCursor(e); }} - onSelect={trackCursor} - onKeyUp={trackCursor} - onClick={trackCursor} - spellCheck={false} - /> - {filenameTemplate && ( - - )} -
-
- {TEMPLATE_TOKENS.map(tok => ( - - ))} -
- {templatePreviewText && ( - - {t('deviceSync.templatePreview')}: {templatePreviewText} - - )} -
+ + {/* ── Left: Fixed schema info ── */} +
+ {t('deviceSync.schemaLabel', { defaultValue: 'Naming scheme' })} + + {'{AlbumArtist}/{Album}/{TrackNum} - {Title}.{ext}'} + + + {t('deviceSync.schemaHint', { + defaultValue: 'Fixed scheme for reliable cross-OS sync. Playlists are written as .m3u8 that reference the album tracks — no duplicates on the device.', + })} + + {targetDir && sources.length > 0 && ( + + )}
{/* ── Right: Drive config ── */} @@ -1039,6 +1147,117 @@ export default function DeviceSync() {
)} + + {/* ── Migration modal (rename existing files into the fixed scheme) ── */} + {migrationPhase !== 'closed' && ( +
+
e.stopPropagation()}> +

{t('deviceSync.migrateTitle', { defaultValue: 'Reorganize existing files' })}

+
+ {migrationPhase === 'loading' && ( +
+ + {t('deviceSync.migrateLoading', { defaultValue: 'Analyzing existing files…' })} +
+ )} + {migrationPhase === 'nothing' && ( +
+ {migrationOldTemplate ? ( + t('deviceSync.migrateNothingToDo', { defaultValue: 'All existing files already match the new scheme — nothing to do.' }) + ) : ( + t('deviceSync.migrateNoTemplate', { defaultValue: 'No legacy filename template found on the device. Migration only applies when the stick was synced with a Psysonic version that supported custom templates.' }) + )} +
+ )} + {migrationPhase === 'preview' && ( + <> +
+
+ {migrationPairs.length}{' '} + {t('deviceSync.migrateFilesToRename', { defaultValue: 'files will be renamed' })} +
+ {migrationUnchanged > 0 && ( +
+ {t('deviceSync.migrateUnchanged', { + defaultValue: '{{n}} files are already at the correct path', + n: migrationUnchanged, + })} +
+ )} + {migrationCollisions.length > 0 && ( +
+ + {t('deviceSync.migrateCollisions', { + defaultValue: '{{n}} files cannot be renamed automatically (multiple tracks map to the same target). They will be left untouched — the next sync re-downloads them into the correct location.', + n: migrationCollisions.length, + })} +
+ )} +
+
+ {t('deviceSync.migratePreviewNote', { + defaultValue: 'Old template: {{tpl}}', + tpl: migrationOldTemplate, + })} +
+ + )} + {migrationPhase === 'executing' && ( +
+ + {t('deviceSync.migrateExecuting', { defaultValue: 'Renaming files…' })} +
+ )} + {migrationPhase === 'done' && migrationResult && ( +
+
+ + {t('deviceSync.migrateSuccess', { + defaultValue: '{{n}} files renamed successfully', + n: migrationResult.ok, + })} +
+ {migrationResult.failed > 0 && ( +
+ + {t('deviceSync.migrateFailed', { + defaultValue: '{{n}} renames failed', + n: migrationResult.failed, + })} +
+ )} + {migrationResult.errors.length > 0 && ( +
+ {t('deviceSync.migrateShowErrors', { defaultValue: 'Show errors' })} +
    + {migrationResult.errors.slice(0, 50).map((err, i) => ( +
  • {err}
  • + ))} + {migrationResult.errors.length > 50 && ( +
  • … {migrationResult.errors.length - 50} more
  • + )} +
+
+ )} +
+ )} +
+
+ {migrationPhase === 'preview' && ( + <> + + + + )} + {(migrationPhase === 'done' || migrationPhase === 'nothing') && ( + + )} +
+
+
+ )} ); } diff --git a/src/store/deviceSyncStore.ts b/src/store/deviceSyncStore.ts index c3b3641d..0b819b42 100644 --- a/src/store/deviceSyncStore.ts +++ b/src/store/deviceSyncStore.ts @@ -9,7 +9,6 @@ export interface DeviceSyncSource { interface DeviceSyncState { targetDir: string | null; - filenameTemplate: string; sources: DeviceSyncSource[]; // persistent device content list checkedIds: string[]; // currently checked for bulk actions (not persisted) pendingDeletion: string[]; // source IDs marked for deletion (not persisted) @@ -17,7 +16,6 @@ interface DeviceSyncState { scanning: boolean; // true while scanning the device setTargetDir: (dir: string | null) => void; - setFilenameTemplate: (t: string) => void; addSource: (source: DeviceSyncSource) => void; removeSource: (id: string) => void; clearSources: () => void; @@ -35,7 +33,6 @@ export const useDeviceSyncStore = create()( persist( (set) => ({ targetDir: null, - filenameTemplate: '{artist}/{album}/{track_number} - {title}', sources: [], checkedIds: [], pendingDeletion: [], @@ -43,7 +40,6 @@ export const useDeviceSyncStore = create()( scanning: false, setTargetDir: (dir) => set({ targetDir: dir }), - setFilenameTemplate: (t) => set({ filenameTemplate: t }), addSource: (source) => set((s) => ({ @@ -97,7 +93,6 @@ export const useDeviceSyncStore = create()( name: 'psysonic_device_sync', partialize: (s) => ({ targetDir: s.targetDir, - filenameTemplate: s.filenameTemplate, sources: s.sources, }), } diff --git a/src/styles/components.css b/src/styles/components.css index 8d467130..07f728e4 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -8035,6 +8035,116 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active { max-width: 600px; } +.device-sync-schema-section { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + max-width: 600px; +} +.device-sync-schema-code { + display: inline-block; + background: var(--bg-glass); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 6px 10px; + font-size: 12px; + color: var(--accent); + font-family: monospace; + user-select: all; + align-self: flex-start; +} +.device-sync-schema-hint { + font-size: 11px; + color: var(--text-muted); + line-height: 1.5; + max-width: 520px; +} +.device-sync-migrate-btn { + align-self: flex-start; + font-size: 11px; + padding: 4px 10px; + margin-top: 2px; +} + +/* ── Migration modal ──────────────────────────────────────────────────── */ +.device-sync-migrate-modal { + min-width: 440px; + max-width: 560px; +} +.device-sync-migrate-body { + display: flex; + flex-direction: column; + gap: 12px; + margin: 12px 0; +} +.device-sync-migrate-loading { + display: flex; + align-items: center; + gap: 10px; + padding: 16px 4px; + color: var(--text-secondary); + font-size: 13px; +} +.device-sync-migrate-nothing { + padding: 12px 4px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.5; +} +.device-sync-migrate-summary { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} +.device-sync-migrate-summary .muted { color: var(--text-muted); font-size: 12px; } +.device-sync-migrate-warning { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + background: var(--accent-dim); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--warning, var(--accent)); + font-size: 12px; + line-height: 1.4; +} +.device-sync-migrate-warning svg { flex-shrink: 0; margin-top: 2px; } +.device-sync-migrate-preview-note { + font-size: 11px; + color: var(--text-muted); + font-family: monospace; + padding: 6px 10px; + background: var(--bg-glass); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + word-break: break-all; +} +.device-sync-migrate-result { display: flex; flex-direction: column; gap: 6px; } +.device-sync-migrate-result-line { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} +.device-sync-migrate-result-line .positive { color: var(--positive, var(--accent)); } +.device-sync-migrate-result-line .danger { color: var(--danger); } +.device-sync-migrate-errors { + font-size: 11px; + color: var(--text-muted); + margin-top: 6px; +} +.device-sync-migrate-errors summary { cursor: pointer; color: var(--accent); } +.device-sync-migrate-errors ul { max-height: 180px; overflow-y: auto; padding-left: 18px; margin: 6px 0 0; } +.device-sync-migrate-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; +} + .device-sync-target-section { display: flex; flex-direction: column;