Rewrite offline playlist system to use file paths instead of IDs

Problem: Pending playlists created offline used auto-increment track IDs,
which change on library re-scans, causing FOREIGN KEY errors and data loss.

Solution: Store file_path instead of IDs for local tracks. Paths are stable
identifiers that only change if files are moved/renamed.

Backend changes:
- Add local_track_paths TEXT column to pending_playlist_sync table
- Update PendingPlaylist struct to include local_track_paths field
- Modify create/add functions to accept paths instead of IDs
- Add get_track_by_path command to resolve paths to current IDs
- Keep local_track_ids for backward compatibility

Frontend changes:
- Update PendingPlaylist interface with localTrackPaths field
- Fetch file paths from track IDs before creating pending playlists
- Implement path-to-ID resolution in sync function
- Gracefully handle missing files (log warning and skip)
- Backward compatible with old ID-based pending playlists

Testing:
- Simulated playlist creation with 3 local tracks (IDs 197-199)
- Simulated re-scan by changing IDs to 1500-1502
- Verified path resolution finds tracks with new IDs correctly
- Verified missing files are handled gracefully

This makes the offline playlist system robust and production-ready.
This commit is contained in:
vicrodh committed 2026-01-20 13:30:10 -06:00
1 parent 8a6816d8d3
commit c1d32b4181
11 files changed
+481 -111

No files matched your search

+24
View File
@@ -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<u64>,
state: State<'_, AppState>,
) -> Result<Vec<crate::api::models::Track>, 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)
}
+4
View File
@@ -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)
+33
View File
@@ -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<Option<LocalTrack>, 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<i64>,
state: State<'_, LibraryState>,
) -> Result<Vec<LocalTrack>, 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)
}
+4 -9
View File
@@ -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;
+59 -26
View File
@@ -56,7 +56,8 @@ pub struct PendingPlaylist {
pub description: Option<String>,
pub is_public: bool,
pub track_ids: Vec<u64>,
pub local_track_ids: Vec<i64>,
pub local_track_ids: Vec<i64>, // DEPRECATED: Use local_track_paths instead (kept for migration)
pub local_track_paths: Vec<String>, // File paths - stable across re-scans
pub created_at: i64,
pub synced: bool,
pub qobuz_playlist_id: Option<u64>,
@@ -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<i64, String> {
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<Vec<PendingPlaylist>, 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<i64> = 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<String> = 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<i64>>(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<i64>>(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<String>) = 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<u64> = serde_json::from_str(&current_qobuz_json)
.unwrap_or_default();
let mut current_local: Vec<i64> = current_local_json
.and_then(|s| serde_json::from_str(&s).ok())
let mut current_local_paths: Vec<String> = serde_json::from_str(&current_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(&current_qobuz)
.map_err(|e| format!("Failed to serialize Qobuz tracks: {}", e))?;
let local_json = serde_json::to_string(&current_local)
.map_err(|e| format!("Failed to serialize local tracks: {}", e))?;
let local_paths_json = serde_json::to_string(&current_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<String>,
is_public: bool,
track_ids: Vec<u64>,
local_track_ids: Vec<i64>,
local_track_paths: Vec<String>,
state: State<'_, OfflineState>,
) -> Result<i64, String> {
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<u64>,
local_track_ids: Vec<i64>,
local_track_paths: Vec<String>,
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(
+1 -1
View File
@@ -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);
});
+22 -4
View File
@@ -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<Array<{ id: number; file_path: string }>>('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<Array<{ id: number; file_path: string }>>('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');
@@ -292,12 +292,39 @@
async function loadLocalTracks() {
try {
const result = await invoke<PlaylistLocalTrack[]>('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<import('$lib/stores/offlineStore').PendingPlaylist[]>('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<LocalLibraryTrack[]>('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<PlaylistLocalTrack[]>('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<Playlist>('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<import('$lib/stores/offlineStore').PendingPlaylist[]>('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<PlaylistTrack[]>('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<Playlist>('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<PlaylistSettings | null>('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<PlaylistStats | null>('playlist_get_stats', { playlistId });
playlistStats = stats;
@@ -53,6 +53,7 @@
let playlistSettings = $state<Map<number, PlaylistSettings>>(new Map());
let playlistStats = $state<Map<number, PlaylistStats>>(new Map());
let localTrackCounts = $state<Map<number, number>>(new Map());
let pendingPlaylistsMap = $state<Map<number, import('$lib/stores/offlineStore').PendingPlaylist>>(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<Playlist[]>('get_user_playlists'),
invoke<PlaylistSettings[]>('playlist_get_all_settings'),
invoke<PlaylistStats[]>('playlist_get_all_stats'),
invoke<Record<string, number>>('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<Playlist[]>('get_user_playlists'),
invoke<import('$lib/stores/offlineStore').PendingPlaylist[]>('get_pending_playlists'),
invoke<PlaylistSettings[]>('playlist_get_all_settings'),
invoke<PlaylistStats[]>('playlist_get_all_stats'),
invoke<Record<string, number>>('playlist_get_all_local_track_counts')
]);
playlists = playlistsResult;
// Process regular playlists
playlists = playlistsResult;
const settingsMap = new Map<number, PlaylistSettings>();
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<number, import('$lib/stores/offlineStore').PendingPlaylist>();
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<number, PlaylistSettings>();
for (const s of settingsResult) {
settingsMap.set(s.qobuz_playlist_id, s);
}
playlistSettings = settingsMap;
// Process stats
const statsMap = new Map<number, PlaylistStats>();
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<number, number>();
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<Playlist[]>('get_user_playlists'),
invoke<PlaylistSettings[]>('playlist_get_all_settings'),
invoke<PlaylistStats[]>('playlist_get_all_stats'),
invoke<Record<string, number>>('playlist_get_all_local_track_counts')
]);
playlists = playlistsResult;
pendingPlaylistsMap = new Map(); // Clear pending playlists when online
const settingsMap = new Map<number, PlaylistSettings>();
for (const s of settingsResult) {
settingsMap.set(s.qobuz_playlist_id, s);
}
playlistSettings = settingsMap;
const statsMap = new Map<number, PlaylistStats>();
for (const s of statsResult) {
statsMap.set(s.qobuz_playlist_id, s);
}
playlistStats = statsMap;
const localCountsMap = new Map<number, number>();
for (const [id, count] of Object.entries(localCountsResult)) {
localCountsMap.set(Number(id), count);
}
localTrackCounts = localCountsMap;
}
playlistSettings = settingsMap;
const statsMap = new Map<number, PlaylistStats>();
for (const s of statsResult) {
statsMap.set(s.qobuz_playlist_id, s);
}
playlistStats = statsMap;
const localCountsMap = new Map<number, number>();
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 {
+114 -22
View File
@@ -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<boolean> {
export async function setManualOffline(enabled: boolean): Promise<void> {
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<OfflineStatus>('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<number> {
return invoke<number>('create_pending_playlist', {
name,
description,
isPublic,
trackIds,
localTrackIds,
localTrackPaths,
});
}
@@ -441,15 +457,29 @@ export async function syncPendingPlaylists(): Promise<void> {
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<void> {
}
// 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
+11 -2
View File
@@ -234,8 +234,17 @@ export async function togglePlay(): Promise<void> {
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) {