diff --git a/src-tauri/src/commands/playlist.rs b/src-tauri/src/commands/playlist.rs index cdd9b15e7..096db8b68 100644 --- a/src-tauri/src/commands/playlist.rs +++ b/src-tauri/src/commands/playlist.rs @@ -131,3 +131,27 @@ pub async fn update_playlist( .await .map_err(|e| format!("Failed to update playlist: {}", e)) } + +/// Get multiple tracks by their IDs +#[tauri::command] +pub async fn get_tracks_by_ids( + track_ids: Vec, + state: State<'_, AppState>, +) -> Result, String> { + log::info!("Command: get_tracks_by_ids ({} tracks)", track_ids.len()); + + let client = state.client.lock().await; + let mut tracks = Vec::new(); + + for track_id in track_ids { + match client.get_track(track_id).await { + Ok(track) => tracks.push(track), + Err(e) => { + log::warn!("Failed to get track {}: {}", track_id, e); + // Continue with other tracks even if one fails + } + } + } + + Ok(tracks) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 96a326fe1..0531e35de 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -386,6 +386,7 @@ pub fn run() { commands::add_tracks_to_playlist, commands::remove_tracks_from_playlist, commands::update_playlist, + commands::get_tracks_by_ids, // Playlist import commands commands::playlist_import_preview, commands::playlist_import_execute, @@ -439,7 +440,9 @@ pub fn run() { library::commands::library_get_stats, library::commands::library_clear, library::commands::library_get_track, + library::commands::get_track_by_path, library::commands::library_play_track, + library::commands::library_get_tracks_by_ids, // Playlist local settings commands library::commands::playlist_get_settings, library::commands::playlist_save_settings, @@ -602,6 +605,7 @@ pub fn run() { offline::commands::get_pending_playlists, offline::commands::add_tracks_to_pending_playlist, offline::commands::get_pending_playlist_count, + offline::commands::update_pending_playlist_qobuz_id, offline::commands::mark_pending_playlist_synced, offline::commands::delete_pending_playlist, // Scrobble queue commands (for offline Last.fm scrobbling) diff --git a/src-tauri/src/library/commands.rs b/src-tauri/src/library/commands.rs index c540ae5a6..65feaef6e 100644 --- a/src-tauri/src/library/commands.rs +++ b/src-tauri/src/library/commands.rs @@ -991,6 +991,19 @@ pub async fn library_get_track( .ok_or_else(|| "Track not found".to_string()) } +/// Get a track by file path (for offline playlist sync) +#[tauri::command] +pub async fn get_track_by_path( + file_path: String, + state: State<'_, LibraryState>, +) -> Result, String> { + log::info!("Command: get_track_by_path {}", file_path); + + let db = state.db.lock().await; + db.get_track_by_path(&file_path) + .map_err(|e| e.to_string()) +} + /// Play a local track by ID #[tauri::command] pub async fn library_play_track( @@ -1922,3 +1935,23 @@ pub async fn discogs_download_artwork( client.download_artwork_from_url(&image_url, &cache_dir, &artist, &album).await } + +/// Get multiple tracks by their IDs +#[tauri::command] +pub async fn library_get_tracks_by_ids( + track_ids: Vec, + state: State<'_, LibraryState>, +) -> Result, String> { + log::info!("Command: library_get_tracks_by_ids ({} tracks)", track_ids.len()); + + let db = state.db.lock().await; + let mut tracks = Vec::new(); + + for track_id in track_ids { + if let Some(track) = db.get_track(track_id).map_err(|e| e.to_string())? { + tracks.push(track); + } + } + + Ok(tracks) +} diff --git a/src-tauri/src/library/database.rs b/src-tauri/src/library/database.rs index 09bcd4250..5c22963fb 100644 --- a/src-tauri/src/library/database.rs +++ b/src-tauri/src/library/database.rs @@ -269,18 +269,13 @@ impl LibraryDatabase { .unwrap_or(false); if !has_file_nocue_index { - log::info!("Running migration: dedupe non-CUE tracks and add unique index"); + log::warn!("Skipping deduplication migration to prevent data loss"); + log::info!("Creating unique index for non-CUE tracks (INSERT OR REPLACE will handle duplicates)"); + // CHANGED: Don't delete duplicates automatically - let INSERT OR REPLACE handle it + // This prevents accidental data loss from aggressive deduplication self.conn .execute_batch( r#" - DELETE FROM local_tracks - WHERE cue_file_path IS NULL - AND rowid NOT IN ( - SELECT MAX(rowid) - FROM local_tracks - WHERE cue_file_path IS NULL - GROUP BY file_path - ); CREATE UNIQUE INDEX IF NOT EXISTS idx_tracks_file_nocue ON local_tracks(file_path) WHERE cue_file_path IS NULL; diff --git a/src-tauri/src/offline/mod.rs b/src-tauri/src/offline/mod.rs index 27baedccb..46e6fb3bc 100644 --- a/src-tauri/src/offline/mod.rs +++ b/src-tauri/src/offline/mod.rs @@ -56,7 +56,8 @@ pub struct PendingPlaylist { pub description: Option, pub is_public: bool, pub track_ids: Vec, - pub local_track_ids: Vec, + pub local_track_ids: Vec, // DEPRECATED: Use local_track_paths instead (kept for migration) + pub local_track_paths: Vec, // File paths - stable across re-scans pub created_at: i64, pub synced: bool, pub qobuz_playlist_id: Option, @@ -134,6 +135,7 @@ impl OfflineStore { "ALTER TABLE offline_settings ADD COLUMN allow_accumulated_scrobbling INTEGER NOT NULL DEFAULT 1", "ALTER TABLE offline_settings ADD COLUMN show_network_folders_in_manual_offline INTEGER NOT NULL DEFAULT 0", "ALTER TABLE pending_playlist_sync ADD COLUMN local_track_ids TEXT", + "ALTER TABLE pending_playlist_sync ADD COLUMN local_track_paths TEXT", ]; for migration in migrations { @@ -232,7 +234,7 @@ impl OfflineStore { description: Option<&str>, is_public: bool, track_ids: &[u64], - local_track_ids: &[i64], + local_track_paths: &[String], ) -> Result { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -242,14 +244,17 @@ impl OfflineStore { let track_ids_json = serde_json::to_string(track_ids) .map_err(|e| format!("Failed to serialize track IDs: {}", e))?; - let local_track_ids_json = serde_json::to_string(local_track_ids) - .map_err(|e| format!("Failed to serialize local track IDs: {}", e))?; + let local_track_paths_json = serde_json::to_string(local_track_paths) + .map_err(|e| format!("Failed to serialize local track paths: {}", e))?; + + // Keep local_track_ids as empty array for backward compatibility + let empty_ids_json = "[]"; self.conn .execute( - "INSERT INTO pending_playlist_sync (name, description, is_public, track_ids, local_track_ids, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![name, description, is_public as i64, track_ids_json, local_track_ids_json, now], + "INSERT INTO pending_playlist_sync (name, description, is_public, track_ids, local_track_ids, local_track_paths, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![name, description, is_public as i64, track_ids_json, empty_ids_json, local_track_paths_json, now], ) .map_err(|e| format!("Failed to create pending playlist: {}", e))?; @@ -260,7 +265,10 @@ impl OfflineStore { pub fn get_pending_playlists(&self) -> Result, String> { let mut stmt = self.conn .prepare( - "SELECT id, name, description, is_public, track_ids, COALESCE(local_track_ids, '[]'), created_at, synced, qobuz_playlist_id + "SELECT id, name, description, is_public, track_ids, + COALESCE(local_track_ids, '[]'), + COALESCE(local_track_paths, '[]'), + created_at, synced, qobuz_playlist_id FROM pending_playlist_sync WHERE synced = 0 ORDER BY created_at ASC" ) .map_err(|e| format!("Failed to prepare statement: {}", e))?; @@ -273,16 +281,20 @@ impl OfflineStore { let local_track_ids_json: String = row.get(5)?; let local_track_ids: Vec = serde_json::from_str(&local_track_ids_json).unwrap_or_default(); + let local_track_paths_json: String = row.get(6)?; + let local_track_paths: Vec = serde_json::from_str(&local_track_paths_json).unwrap_or_default(); + Ok(PendingPlaylist { id: row.get(0)?, name: row.get(1)?, description: row.get(2)?, is_public: row.get::<_, i64>(3)? != 0, track_ids, - local_track_ids, - created_at: row.get(6)?, - synced: row.get::<_, i64>(7)? != 0, - qobuz_playlist_id: row.get::<_, Option>(8)?.map(|id| id as u64), + local_track_ids, // Kept for migration + local_track_paths, + created_at: row.get(7)?, + synced: row.get::<_, i64>(8)? != 0, + qobuz_playlist_id: row.get::<_, Option>(9)?.map(|id| id as u64), }) }) .map_err(|e| format!("Failed to query pending playlists: {}", e))?; @@ -292,6 +304,17 @@ impl OfflineStore { .map_err(|e| format!("Failed to collect pending playlists: {}", e)) } + /// Update the Qobuz playlist ID without marking as synced (for partial sync recovery) + pub fn update_qobuz_playlist_id(&self, pending_id: i64, qobuz_playlist_id: u64) -> Result<(), String> { + self.conn + .execute( + "UPDATE pending_playlist_sync SET qobuz_playlist_id = ?1 WHERE id = ?2", + params![qobuz_playlist_id as i64, pending_id], + ) + .map_err(|e| format!("Failed to update Qobuz playlist ID: {}", e))?; + Ok(()) + } + /// Mark a pending playlist as synced with its Qobuz ID pub fn mark_playlist_synced(&self, pending_id: i64, qobuz_playlist_id: u64) -> Result<(), String> { self.conn @@ -319,14 +342,14 @@ impl OfflineStore { &self, pending_id: i64, qobuz_track_ids: &[u64], - local_track_ids: &[i64], + local_track_paths: &[String], ) -> Result<(), String> { // Get current tracks let mut stmt = self.conn - .prepare("SELECT track_ids, local_track_ids FROM pending_playlist_sync WHERE id = ?1") + .prepare("SELECT track_ids, COALESCE(local_track_paths, '[]') FROM pending_playlist_sync WHERE id = ?1") .map_err(|e| format!("Failed to prepare query: {}", e))?; - let (current_qobuz_json, current_local_json): (String, Option) = stmt + let (current_qobuz_json, current_local_paths_json): (String, String) = stmt .query_row(params![pending_id], |row| { Ok((row.get(0)?, row.get(1)?)) }) @@ -334,25 +357,24 @@ impl OfflineStore { let mut current_qobuz: Vec = serde_json::from_str(¤t_qobuz_json) .unwrap_or_default(); - let mut current_local: Vec = current_local_json - .and_then(|s| serde_json::from_str(&s).ok()) + let mut current_local_paths: Vec = serde_json::from_str(¤t_local_paths_json) .unwrap_or_default(); // Append new tracks current_qobuz.extend_from_slice(qobuz_track_ids); - current_local.extend_from_slice(local_track_ids); + current_local_paths.extend_from_slice(local_track_paths); // Serialize back let qobuz_json = serde_json::to_string(¤t_qobuz) .map_err(|e| format!("Failed to serialize Qobuz tracks: {}", e))?; - let local_json = serde_json::to_string(¤t_local) - .map_err(|e| format!("Failed to serialize local tracks: {}", e))?; + let local_paths_json = serde_json::to_string(¤t_local_paths) + .map_err(|e| format!("Failed to serialize local track paths: {}", e))?; // Update database self.conn .execute( - "UPDATE pending_playlist_sync SET track_ids = ?1, local_track_ids = ?2 WHERE id = ?3", - params![qobuz_json, local_json, pending_id], + "UPDATE pending_playlist_sync SET track_ids = ?1, local_track_paths = ?2 WHERE id = ?3", + params![qobuz_json, local_paths_json, pending_id], ) .map_err(|e| format!("Failed to update pending playlist: {}", e))?; @@ -667,11 +689,11 @@ pub mod commands { description: Option, is_public: bool, track_ids: Vec, - local_track_ids: Vec, + local_track_paths: Vec, state: State<'_, OfflineState>, ) -> Result { let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; - store.create_pending_playlist(&name, description.as_deref(), is_public, &track_ids, &local_track_ids) + store.create_pending_playlist(&name, description.as_deref(), is_public, &track_ids, &local_track_paths) } /// Get all playlists pending sync @@ -688,11 +710,11 @@ pub mod commands { pub fn add_tracks_to_pending_playlist( pending_id: i64, qobuz_track_ids: Vec, - local_track_ids: Vec, + local_track_paths: Vec, state: State<'_, OfflineState>, ) -> Result<(), String> { let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; - store.add_tracks_to_pending_playlist(pending_id, &qobuz_track_ids, &local_track_ids) + store.add_tracks_to_pending_playlist(pending_id, &qobuz_track_ids, &local_track_paths) } /// Get count of pending playlists @@ -704,6 +726,17 @@ pub mod commands { store.get_pending_playlist_count() } + /// Update Qobuz playlist ID without marking as synced (for partial sync recovery) + #[tauri::command] + pub fn update_pending_playlist_qobuz_id( + pending_id: i64, + qobuz_playlist_id: u64, + state: State<'_, OfflineState>, + ) -> Result<(), String> { + let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?; + store.update_qobuz_playlist_id(pending_id, qobuz_playlist_id) + } + /// Mark a pending playlist as synced after successful Qobuz creation #[tauri::command] pub fn mark_pending_playlist_synced( diff --git a/src/lib/app/bootstrap.ts b/src/lib/app/bootstrap.ts index 04b570f71..e1171f5f2 100644 --- a/src/lib/app/bootstrap.ts +++ b/src/lib/app/bootstrap.ts @@ -152,7 +152,7 @@ export function bootstrapApp(): BootstrapResult { } }); - // Sync pending playlists + // Sync pending playlists (with detailed logging enabled) syncPendingPlaylists().catch(err => { console.error('[Bootstrap] Failed to sync pending playlists:', err); }); diff --git a/src/lib/components/PlaylistModal.svelte b/src/lib/components/PlaylistModal.svelte index b81f03c44..fae1b78a3 100644 --- a/src/lib/components/PlaylistModal.svelte +++ b/src/lib/components/PlaylistModal.svelte @@ -210,12 +210,21 @@ if (selectedPlaylistId < 0) { const pendingId = -selectedPlaylistId; // Convert back to positive const qobuzTrackIds = isLocalTracks ? [] : trackIds; - const localTrackIds = isLocalTracks ? trackIds : []; + + // For local tracks, fetch file paths instead of using IDs + let localTrackPaths: string[] = []; + if (isLocalTracks && trackIds.length > 0) { + // Fetch tracks to get their file paths + const tracks = await invoke>('library_get_tracks_by_ids', { + trackIds + }); + localTrackPaths = tracks.map(t => t.file_path); + } await invoke('add_tracks_to_pending_playlist', { pendingId, qobuzTrackIds, - localTrackIds + localTrackPaths }); } else if (isLocalTracks) { // Regular playlist with local tracks @@ -264,7 +273,16 @@ if (isOffline()) { // In offline mode, create pending playlist with both Qobuz and local tracks const qobuzTrackIds = isLocalTracks ? [] : trackIds; - const localTrackIds = isLocalTracks ? trackIds : []; + + // For local tracks, fetch file paths instead of using IDs + let localTrackPaths: string[] = []; + if (isLocalTracks && trackIds.length > 0) { + // Fetch tracks to get their file paths + const tracks = await invoke>('library_get_tracks_by_ids', { + trackIds + }); + localTrackPaths = tracks.map(t => t.file_path); + } // Create pending playlist for sync when back online const pendingId = await createPendingPlaylist( @@ -272,7 +290,7 @@ description.trim() || null, false, qobuzTrackIds, - localTrackIds + localTrackPaths ); showToast(`Playlist "${name.trim()}" created offline - will sync when back online`, 'info'); diff --git a/src/lib/components/views/PlaylistDetailView.svelte b/src/lib/components/views/PlaylistDetailView.svelte index 9cce4d448..45a4a0174 100644 --- a/src/lib/components/views/PlaylistDetailView.svelte +++ b/src/lib/components/views/PlaylistDetailView.svelte @@ -292,12 +292,39 @@ async function loadLocalTracks() { try { - const result = await invoke('playlist_get_local_tracks_with_position', { playlistId }); - localTracks = result; - // Create a map for quick lookup - localTracksMap = new Map(result.map(t => [t.id, t])); + // Check if this is a pending playlist (negative ID) + if (playlistId < 0) { + // For pending playlists, load local tracks from the pending playlist data + const pendingId = -playlistId; + const pendingPlaylists = await invoke('get_pending_playlists'); + const pending = pendingPlaylists.find(p => p.id === pendingId); + + if (pending && pending.localTrackIds.length > 0) { + // Load the actual local track data + const localTrackData = await invoke('library_get_tracks_by_ids', { + trackIds: pending.localTrackIds + }); + + // Convert to PlaylistLocalTrack format with positions + localTracks = localTrackData.map((track, idx) => ({ + ...track, + playlist_position: pending.trackIds.length + idx // Local tracks come after Qobuz tracks + })); + localTracksMap = new Map(localTracks.map(t => [t.id, t])); + } else { + localTracks = []; + localTracksMap = new Map(); + } + } else { + // Regular playlist - use existing command + const result = await invoke('playlist_get_local_tracks_with_position', { playlistId }); + localTracks = result; + localTracksMap = new Map(result.map(t => [t.id, t])); + } } catch (err) { console.error('Failed to load local tracks:', err); + localTracks = []; + localTracksMap = new Map(); } } @@ -305,27 +332,87 @@ loading = true; error = null; try { - const result = await invoke('get_playlist', { playlistId }); - playlist = result; + // Check if this is a pending playlist (negative ID) + if (playlistId < 0) { + // Load pending playlist data + const pendingId = -playlistId; + const pendingPlaylists = await invoke('get_pending_playlists'); + const pending = pendingPlaylists.find(p => p.id === pendingId); - if (result.tracks?.items) { - tracks = result.tracks.items.map((t, idx) => ({ - id: t.id, - number: idx + 1, - title: t.title, - artist: t.performer?.name, - album: t.album?.title, - albumArt: t.album?.image?.large || t.album?.image?.thumbnail || t.album?.image?.small, - albumId: t.album?.id, - artistId: t.performer?.id, - duration: formatDuration(t.duration), - durationSeconds: t.duration, - hires: t.hires, - bitDepth: t.maximum_bit_depth, - samplingRate: t.maximum_sampling_rate, - isrc: t.isrc, - playlistTrackId: t.playlist_track_id, - })); + if (!pending) { + throw new Error('Pending playlist not found'); + } + + // Build playlist object from pending data + playlist = { + id: playlistId, // Keep negative ID + name: pending.name, + description: pending.description || undefined, + owner: { id: 0, name: 'You (Offline)' }, + images: [], + tracks_count: pending.trackIds.length, + duration: 0, + is_public: pending.isPublic, + tracks: { items: [], total: 0 } + }; + + // Load Qobuz tracks if any + if (pending.trackIds.length > 0) { + try { + const qobuzTracks = await invoke('get_tracks_by_ids', { + trackIds: pending.trackIds + }); + + tracks = qobuzTracks.map((t, idx) => ({ + id: t.id, + number: idx + 1, + title: t.title, + artist: t.performer?.name, + album: t.album?.title, + albumArt: t.album?.image?.large || t.album?.image?.thumbnail || t.album?.image?.small, + albumId: t.album?.id, + artistId: t.performer?.id, + duration: formatDuration(t.duration), + durationSeconds: t.duration, + hires: t.hires, + bitDepth: t.maximum_bit_depth, + samplingRate: t.maximum_sampling_rate, + isrc: t.isrc, + })); + + // Update duration + playlist.duration = qobuzTracks.reduce((sum, t) => sum + t.duration, 0); + } catch (err) { + console.error('Failed to load Qobuz tracks for pending playlist:', err); + tracks = []; + } + } else { + tracks = []; + } + } else { + // Regular playlist - use existing command + const result = await invoke('get_playlist', { playlistId }); + playlist = result; + + if (result.tracks?.items) { + tracks = result.tracks.items.map((t, idx) => ({ + id: t.id, + number: idx + 1, + title: t.title, + artist: t.performer?.name, + album: t.album?.title, + albumArt: t.album?.image?.large || t.album?.image?.thumbnail || t.album?.image?.small, + albumId: t.album?.id, + artistId: t.performer?.id, + duration: formatDuration(t.duration), + durationSeconds: t.duration, + hires: t.hires, + bitDepth: t.maximum_bit_depth, + samplingRate: t.maximum_sampling_rate, + isrc: t.isrc, + playlistTrackId: t.playlist_track_id, + })); + } } } catch (err) { console.error('Failed to load playlist:', err); @@ -344,6 +431,11 @@ playlistSettings = null; isFavorite = false; + // Skip loading settings for pending playlists + if (playlistId < 0) { + return; + } + try { const settings = await invoke('playlist_get_settings', { playlistId }); playlistSettings = settings; @@ -360,6 +452,12 @@ } async function loadStats() { + // Skip loading stats for pending playlists + if (playlistId < 0) { + playlistStats = null; + return; + } + try { const stats = await invoke('playlist_get_stats', { playlistId }); playlistStats = stats; diff --git a/src/lib/components/views/PlaylistManagerView.svelte b/src/lib/components/views/PlaylistManagerView.svelte index 37a1c426f..2f6a80824 100644 --- a/src/lib/components/views/PlaylistManagerView.svelte +++ b/src/lib/components/views/PlaylistManagerView.svelte @@ -53,6 +53,7 @@ let playlistSettings = $state>(new Map()); let playlistStats = $state>(new Map()); let localTrackCounts = $state>(new Map()); + let pendingPlaylistsMap = $state>(new Map()); let loading = $state(true); // Offline state @@ -203,32 +204,95 @@ async function loadData() { loading = true; try { - const [playlistsResult, settingsResult, statsResult, localCountsResult] = await Promise.all([ - invoke('get_user_playlists'), - invoke('playlist_get_all_settings'), - invoke('playlist_get_all_stats'), - invoke>('playlist_get_all_local_track_counts') - ]); + if (offlineStatus.isOffline) { + // Offline mode: Load both regular playlists AND pending playlists + const [playlistsResult, pendingPlaylistsResult, settingsResult, statsResult, localCountsResult] = await Promise.all([ + invoke('get_user_playlists'), + invoke('get_pending_playlists'), + invoke('playlist_get_all_settings'), + invoke('playlist_get_all_stats'), + invoke>('playlist_get_all_local_track_counts') + ]); - playlists = playlistsResult; + // Process regular playlists + playlists = playlistsResult; - const settingsMap = new Map(); - for (const s of settingsResult) { - settingsMap.set(s.qobuz_playlist_id, s); + // Process pending playlists and add them to the playlists array + const newPendingMap = new Map(); + const pendingAsPlaylists: Playlist[] = pendingPlaylistsResult.map(p => { + const negativeId = -p.id; + newPendingMap.set(negativeId, p); + + return { + id: negativeId, + name: p.name, + tracks_count: p.trackIds.length, // Only Qobuz tracks for correct filtering + images: [], + duration: 0, + owner: { id: 0, name: 'You (Offline)' } + }; + }); + + // Combine regular and pending playlists + playlists = [...playlistsResult, ...pendingAsPlaylists]; + pendingPlaylistsMap = newPendingMap; + + // Process settings + const settingsMap = new Map(); + for (const s of settingsResult) { + settingsMap.set(s.qobuz_playlist_id, s); + } + playlistSettings = settingsMap; + + // Process stats + const statsMap = new Map(); + for (const s of statsResult) { + statsMap.set(s.qobuz_playlist_id, s); + } + playlistStats = statsMap; + + // Process local track counts for regular playlists + const localCountsMap = new Map(); + for (const [id, count] of Object.entries(localCountsResult)) { + localCountsMap.set(Number(id), count); + } + + // Add local track counts for pending playlists + for (const [negativeId, pending] of newPendingMap.entries()) { + localCountsMap.set(negativeId, pending.localTrackIds.length); + } + + localTrackCounts = localCountsMap; + } else { + // Online mode: Load only regular playlists + const [playlistsResult, settingsResult, statsResult, localCountsResult] = await Promise.all([ + invoke('get_user_playlists'), + invoke('playlist_get_all_settings'), + invoke('playlist_get_all_stats'), + invoke>('playlist_get_all_local_track_counts') + ]); + + playlists = playlistsResult; + pendingPlaylistsMap = new Map(); // Clear pending playlists when online + + const settingsMap = new Map(); + for (const s of settingsResult) { + settingsMap.set(s.qobuz_playlist_id, s); + } + playlistSettings = settingsMap; + + const statsMap = new Map(); + for (const s of statsResult) { + statsMap.set(s.qobuz_playlist_id, s); + } + playlistStats = statsMap; + + const localCountsMap = new Map(); + for (const [id, count] of Object.entries(localCountsResult)) { + localCountsMap.set(Number(id), count); + } + localTrackCounts = localCountsMap; } - playlistSettings = settingsMap; - - const statsMap = new Map(); - for (const s of statsResult) { - statsMap.set(s.qobuz_playlist_id, s); - } - playlistStats = statsMap; - - const localCountsMap = new Map(); - for (const [id, count] of Object.entries(localCountsResult)) { - localCountsMap.set(Number(id), count); - } - localTrackCounts = localCountsMap; } catch (err) { console.error('Failed to load playlists:', err); } finally { diff --git a/src/lib/stores/offlineStore.ts b/src/lib/stores/offlineStore.ts index 8247fb642..a5f0217bf 100644 --- a/src/lib/stores/offlineStore.ts +++ b/src/lib/stores/offlineStore.ts @@ -19,7 +19,8 @@ export interface PendingPlaylist { description: string | null; isPublic: boolean; trackIds: number[]; - localTrackIds: number[]; + localTrackIds: number[]; // DEPRECATED: Use localTrackPaths instead + localTrackPaths: string[]; // File paths - stable across re-scans createdAt: number; synced: boolean; qobuzPlaylistId: number | null; @@ -168,14 +169,29 @@ export async function checkNetwork(): Promise { export async function setManualOffline(enabled: boolean): Promise { try { const wasOffline = status.isOffline; + console.log('[Offline] ========================================'); + console.log('[Offline] setManualOffline called with enabled:', enabled); + console.log('[Offline] wasOffline:', wasOffline); + console.log('[Offline] ========================================'); + const newStatus = await invoke('set_manual_offline', { enabled }); status = newStatus; settings.manualOfflineMode = enabled; + + console.log('[Offline] After invoke:'); + console.log('[Offline] newStatus.isOffline:', newStatus.isOffline); + console.log('[Offline] wasOffline:', wasOffline); + console.log('[Offline] hasCallback:', !!onOnlineTransitionCallback); + console.log('[Offline] will trigger?:', wasOffline && !newStatus.isOffline && !!onOnlineTransitionCallback); + notifyListeners(); // Check for offline -> online transition when disabling manual mode if (wasOffline && !newStatus.isOffline && onOnlineTransitionCallback) { - console.log('[Offline] Transitioning to online after disabling manual mode'); + console.log('[Offline] ✓✓✓ TRIGGERING ONLINE TRANSITION CALLBACK ✓✓✓'); onOnlineTransitionCallback(); + console.log('[Offline] ✓✓✓ CALLBACK EXECUTED ✓✓✓'); + } else { + console.log('[Offline] ✗✗✗ NOT triggering callback'); } } catch (error) { console.error('Failed to set manual offline mode:', error); @@ -323,14 +339,14 @@ export async function createPendingPlaylist( description: string | null, isPublic: boolean, trackIds: number[], - localTrackIds: number[] + localTrackPaths: string[] ): Promise { return invoke('create_pending_playlist', { name, description, isPublic, trackIds, - localTrackIds, + localTrackPaths, }); } @@ -441,15 +457,29 @@ export async function syncPendingPlaylists(): Promise { try { console.log(`[Offline] Syncing playlist: ${playlist.name}`); - // Create playlist on Qobuz - const createdPlaylist = await invoke<{ id: number }>('create_playlist', { - name: playlist.name, - description: playlist.description, - isPublic: playlist.isPublic - }); + let qobuzPlaylistId: number; - const qobuzPlaylistId = createdPlaylist.id; - console.log(`[Offline] Created playlist on Qobuz with ID: ${qobuzPlaylistId}`); + // Check if this playlist was already partially synced + if (playlist.qobuzPlaylistId) { + qobuzPlaylistId = playlist.qobuzPlaylistId; + console.log(`[Offline] Using existing Qobuz playlist ID: ${qobuzPlaylistId}`); + } else { + // Create playlist on Qobuz + const createdPlaylist = await invoke<{ id: number }>('create_playlist', { + name: playlist.name, + description: playlist.description, + isPublic: playlist.isPublic + }); + + qobuzPlaylistId = createdPlaylist.id; + console.log(`[Offline] Created playlist on Qobuz with ID: ${qobuzPlaylistId}`); + + // Save the Qobuz ID immediately (but keep synced=0 until fully complete) + await invoke('update_pending_playlist_qobuz_id', { + pendingId: playlist.id, + qobuzPlaylistId + }); + } // Add Qobuz tracks if any if (playlist.trackIds.length > 0) { @@ -461,23 +491,85 @@ export async function syncPendingPlaylists(): Promise { } // Add local tracks if any - if (playlist.localTrackIds.length > 0) { - console.log(`[Offline] Adding ${playlist.localTrackIds.length} local tracks`); - for (let i = 0; i < playlist.localTrackIds.length; i++) { - console.log(`[Offline] Adding local track ${playlist.localTrackIds[i]} at position ${playlist.trackIds.length + i}`); + // Prefer localTrackPaths (new system), fall back to localTrackIds (migration) + const localTracks = playlist.localTrackPaths?.length > 0 + ? playlist.localTrackPaths + : playlist.localTrackIds; + + if (localTracks && localTracks.length > 0) { + const usePathSystem = playlist.localTrackPaths?.length > 0; + console.log(`[Offline] ======================================`); + console.log(`[Offline] Adding ${localTracks.length} local tracks to playlist ${qobuzPlaylistId}`); + console.log(`[Offline] Using ${usePathSystem ? 'PATH-based' : 'ID-based (legacy)'} system`); + console.log(`[Offline] Local tracks:`, localTracks); + console.log(`[Offline] Qobuz tracks count:`, playlist.trackIds.length); + console.log(`[Offline] ======================================`); + + for (let i = 0; i < localTracks.length; i++) { + const trackIdentifier = localTracks[i]; + const position = playlist.trackIds.length + i; + + console.log(`[Offline] ------ Track ${i + 1}/${localTracks.length} ------`); + try { + let localTrackId: number; + + if (usePathSystem) { + // NEW SYSTEM: Resolve file path to current track ID + const filePath = trackIdentifier as string; + console.log(`[Offline] Resolving path: ${filePath}`); + + // Get track ID from file path + const trackInfo = await invoke<{ id: number } | null>('get_track_by_path', { + filePath + }); + + if (!trackInfo) { + console.warn(`[Offline] ⚠️ File not found in library: ${filePath}`); + console.warn(`[Offline] Skipping - file may have been moved or deleted`); + continue; + } + + localTrackId = trackInfo.id; + console.log(`[Offline] Resolved to track ID: ${localTrackId}`); + } else { + // OLD SYSTEM: Use ID directly (for migration) + localTrackId = trackIdentifier as number; + console.log(`[Offline] Using legacy track ID: ${localTrackId}`); + } + + console.log(`[Offline] Position: ${position}`); + console.log(`[Offline] Calling playlist_add_local_track with params:`, { + playlistId: qobuzPlaylistId, + localTrackId: localTrackId, + position: position + }); + await invoke('playlist_add_local_track', { playlistId: qobuzPlaylistId, - localTrackId: playlist.localTrackIds[i], - position: playlist.trackIds.length + i // Position after Qobuz tracks + localTrackId: localTrackId, + position: position }); - console.log(`[Offline] Successfully added local track ${playlist.localTrackIds[i]}`); + console.log(`[Offline] ✓✓✓ SUCCESS: Local track ${localTrackId} added at position ${position}`); } catch (localTrackErr) { - console.error(`[Offline] Failed to add local track ${playlist.localTrackIds[i]}:`, localTrackErr); - // Re-throw to prevent marking as synced - throw new Error(`Failed to add local track: ${localTrackErr}`); + console.error(`[Offline] ✗✗✗ ERROR: Failed to add track`); + console.error(`[Offline] Error:`, localTrackErr); + + // Check if it's a FOREIGN KEY error (track doesn't exist in library) + const errorStr = String(localTrackErr); + if (errorStr.includes('FOREIGN KEY constraint')) { + console.warn(`[Offline] ⚠️ Track no longer exists in library - skipping`); + // Don't throw - continue with next track + } else { + // Other errors should fail the sync + throw new Error(`Failed to add local track: ${localTrackErr}`); + } } } + + console.log(`[Offline] ======================================`); + console.log(`[Offline] All local tracks processed`); + console.log(`[Offline] ======================================`); } // Mark as synced ONLY if everything succeeded diff --git a/src/lib/stores/playerStore.ts b/src/lib/stores/playerStore.ts index cf1d1f5ee..d5a0fd64a 100644 --- a/src/lib/stores/playerStore.ts +++ b/src/lib/stores/playerStore.ts @@ -234,8 +234,17 @@ export async function togglePlay(): Promise { console.log('[Player] Loading restored track:', pendingSessionRestore.trackId); const savedPosition = pendingSessionRestore.position; pendingSessionRestore = null; // Clear before loading - // Load the track from Qobuz - await invoke('play_track', { trackId: currentTrack.id }); + + // Check if this is a local track (negative ID) or Qobuz track + if (currentTrack.id < 0) { + // Local track - use library_play_track with positive ID + const localTrackId = Math.abs(currentTrack.id); + console.log('[Player] Restoring local track:', localTrackId); + await invoke('library_play_track', { trackId: localTrackId }); + } else { + // Qobuz track - use play_track + await invoke('play_track', { trackId: currentTrack.id }); + } // Seek to saved position after a short delay to let audio load if (savedPosition > 0) {