Fix playlist track ordering to respect insertion order

- Add PlaylistLocalTrack struct with playlist_position field
- Add playlist_get_local_tracks_with_position command
- Update PlaylistDetailView to merge tracks by position instead of
  appending local tracks at the end
- Update PlaylistModal to calculate correct position when adding local
  tracks (total count = qobuz + existing local)
- Local tracks now appear in the order they were added, interleaved
  with Qobuz tracks
This commit is contained in:
vicrodh committed 2026-01-16 07:04:21 -06:00
1 parent 24c9df3bce
commit 16486e4591
6 files changed
+136 -14

No files matched your search

+1
View File
@@ -387,6 +387,7 @@ pub fn run() {
library::commands::playlist_add_local_track, library::commands::playlist_add_local_track,
library::commands::playlist_remove_local_track, library::commands::playlist_remove_local_track,
library::commands::playlist_get_local_tracks, library::commands::playlist_get_local_tracks,
library::commands::playlist_get_local_tracks_with_position,
library::commands::playlist_get_all_local_track_counts, library::commands::playlist_get_all_local_track_counts,
library::commands::playlist_clear_local_tracks, library::commands::playlist_clear_local_tracks,
// Playlist management commands // Playlist management commands
+13
View File
@@ -1031,6 +1031,19 @@ pub async fn playlist_get_local_tracks(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// Get all local tracks in a playlist with their positions (for mixed ordering)
#[tauri::command]
pub async fn playlist_get_local_tracks_with_position(
playlist_id: u64,
state: State<'_, LibraryState>,
) -> Result<Vec<crate::library::PlaylistLocalTrack>, String> {
log::info!("Command: playlist_get_local_tracks_with_position {}", playlist_id);
let db = state.db.lock().await;
db.get_playlist_local_tracks_with_position(playlist_id)
.map_err(|e| e.to_string())
}
/// Get local track counts for all playlists /// Get local track counts for all playlists
#[tauri::command] #[tauri::command]
pub async fn playlist_get_all_local_track_counts( pub async fn playlist_get_all_local_track_counts(
+50
View File
@@ -1550,6 +1550,56 @@ impl LibraryDatabase {
.map_err(|e| LibraryError::Database(format!("Failed to collect playlist local tracks: {}", e))) .map_err(|e| LibraryError::Database(format!("Failed to collect playlist local tracks: {}", e)))
} }
/// Get all local tracks in a playlist with their positions (for mixed ordering)
pub fn get_playlist_local_tracks_with_position(&self, qobuz_playlist_id: u64) -> Result<Vec<crate::library::PlaylistLocalTrack>, LibraryError> {
let mut stmt = self.conn.prepare(
"SELECT t.id, t.file_path, t.title, t.artist, t.album, t.album_artist,
t.album_group_key, t.album_group_title, t.track_number, t.disc_number,
t.year, t.genre, t.duration_secs, t.format, t.bit_depth, t.sample_rate,
t.channels, t.file_size_bytes, t.cue_file_path, t.cue_start_secs,
t.cue_end_secs, t.artwork_path, t.last_modified, t.indexed_at, plt.position
FROM playlist_local_tracks plt
JOIN local_tracks t ON plt.local_track_id = t.id
WHERE plt.qobuz_playlist_id = ?1
ORDER BY plt.position ASC"
).map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?;
let tracks = stmt.query_map(params![qobuz_playlist_id as i64], |row| {
Ok(crate::library::PlaylistLocalTrack {
track: LocalTrack {
id: row.get(0)?,
file_path: row.get(1)?,
title: row.get(2)?,
artist: row.get(3)?,
album: row.get(4)?,
album_artist: row.get(5)?,
album_group_key: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
album_group_title: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
track_number: row.get(8)?,
disc_number: row.get(9)?,
year: row.get(10)?,
genre: row.get(11)?,
duration_secs: row.get(12)?,
format: Self::parse_format(&row.get::<_, String>(13)?),
bit_depth: row.get(14)?,
sample_rate: row.get(15)?,
channels: row.get(16)?,
file_size_bytes: row.get(17)?,
cue_file_path: row.get(18)?,
cue_start_secs: row.get(19)?,
cue_end_secs: row.get(20)?,
artwork_path: row.get(21)?,
last_modified: row.get(22)?,
indexed_at: row.get(23)?,
},
playlist_position: row.get(24)?,
})
}).map_err(|e| LibraryError::Database(format!("Failed to query playlist local tracks with position: {}", e)))?;
tracks.collect::<Result<Vec<_>, _>>()
.map_err(|e| LibraryError::Database(format!("Failed to collect playlist local tracks with position: {}", e)))
}
/// Get count of local tracks in a playlist /// Get count of local tracks in a playlist
pub fn get_playlist_local_track_count(&self, qobuz_playlist_id: u64) -> Result<u32, LibraryError> { pub fn get_playlist_local_track_count(&self, qobuz_playlist_id: u64) -> Result<u32, LibraryError> {
let count: u32 = self.conn.query_row( let count: u32 = self.conn.query_row(
+9
View File
@@ -104,6 +104,15 @@ impl Default for LocalTrack {
} }
} }
/// A local track within a playlist, including its position
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaylistLocalTrack {
#[serde(flatten)]
pub track: LocalTrack,
/// Position in the combined playlist (Qobuz + local tracks)
pub playlist_position: i32,
}
/// An album aggregated from local tracks /// An album aggregated from local tracks
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalAlbum { pub struct LocalAlbum {
+8 -2
View File
@@ -181,12 +181,18 @@
try { try {
if (isLocalTracks) { if (isLocalTracks) {
// Add local tracks one by one // Get current total count (Qobuz + local) to append at correct position
const playlist = userPlaylists.find(p => p.id === selectedPlaylistId);
const qobuzCount = playlist?.tracks_count ?? 0;
const localCount = localTrackCounts.get(selectedPlaylistId!) ?? 0;
const startPosition = qobuzCount + localCount;
// Add local tracks at the end
for (let i = 0; i < trackIds.length; i++) { for (let i = 0; i < trackIds.length; i++) {
await invoke('playlist_add_local_track', { await invoke('playlist_add_local_track', {
playlistId: selectedPlaylistId, playlistId: selectedPlaylistId,
localTrackId: trackIds[i], localTrackId: trackIds[i],
position: i position: startPosition + i
}); });
} }
} else { } else {
@@ -77,6 +77,21 @@
artwork_path?: string; artwork_path?: string;
} }
// Local track with playlist position (for mixed ordering)
interface PlaylistLocalTrack {
id: number;
file_path: string;
title: string;
artist: string;
album: string;
duration_secs: number;
format: string;
bit_depth?: number;
sample_rate: number;
artwork_path?: string;
playlist_position: number;
}
interface PlaylistSettings { interface PlaylistSettings {
qobuz_playlist_id: number; qobuz_playlist_id: number;
custom_artwork_path?: string; custom_artwork_path?: string;
@@ -151,8 +166,8 @@
let playlist = $state<Playlist | null>(null); let playlist = $state<Playlist | null>(null);
let tracks = $state<DisplayTrack[]>([]); let tracks = $state<DisplayTrack[]>([]);
let localTracks = $state<LocalLibraryTrack[]>([]); let localTracks = $state<PlaylistLocalTrack[]>([]);
let localTracksMap = $state<Map<number, LocalLibraryTrack>>(new Map()); let localTracksMap = $state<Map<number, PlaylistLocalTrack>>(new Map());
let hasLocalTracks = $derived(localTracks.length > 0); let hasLocalTracks = $derived(localTracks.length > 0);
// Total counts including local tracks // Total counts including local tracks
@@ -240,7 +255,7 @@
async function loadLocalTracks() { async function loadLocalTracks() {
try { try {
const result = await invoke<LocalLibraryTrack[]>('playlist_get_local_tracks', { playlistId }); const result = await invoke<PlaylistLocalTrack[]>('playlist_get_local_tracks_with_position', { playlistId });
localTracks = result; localTracks = result;
// Create a map for quick lookup // Create a map for quick lookup
localTracksMap = new Map(result.map(t => [t.id, t])); localTracksMap = new Map(result.map(t => [t.id, t]));
@@ -350,7 +365,7 @@
} }
// Convert local tracks to DisplayTrack format // Convert local tracks to DisplayTrack format
function localTrackToDisplay(track: LocalLibraryTrack, index: number): DisplayTrack { function localTrackToDisplay(track: PlaylistLocalTrack, index: number): DisplayTrack {
return { return {
id: -track.id, // Negative ID to distinguish from Qobuz tracks id: -track.id, // Negative ID to distinguish from Qobuz tracks
number: index + 1, number: index + 1,
@@ -369,25 +384,53 @@
}; };
} }
// Filtered and sorted tracks (merged Qobuz + local) // Filtered and sorted tracks (merged Qobuz + local by position)
let displayTracks = $derived.by(() => { let displayTracks = $derived.by(() => {
// Combine Qobuz tracks with local tracks // Build merged list by interleaving based on position
const localDisplayTracks = localTracks.map((t, idx) => localTrackToDisplay(t, tracks.length + idx)); // Local tracks have explicit playlist_position
let result = [...tracks, ...localDisplayTracks]; // Qobuz tracks fill positions not occupied by local tracks
const result: DisplayTrack[] = [];
const totalCount = tracks.length + localTracks.length;
// Create a map of local track positions
const localByPosition = new Map<number, PlaylistLocalTrack>();
for (const lt of localTracks) {
localByPosition.set(lt.playlist_position, lt);
}
// Interleave: iterate through positions, use local if exists, else use next Qobuz track
let qobuzIdx = 0;
for (let pos = 0; pos < totalCount; pos++) {
const localTrack = localByPosition.get(pos);
if (localTrack) {
result.push(localTrackToDisplay(localTrack, pos));
} else if (qobuzIdx < tracks.length) {
// Use Qobuz track but update its number to match merged position
result.push({ ...tracks[qobuzIdx], number: pos + 1 });
qobuzIdx++;
}
}
// If any Qobuz tracks remain (shouldn't happen with correct positions), append them
while (qobuzIdx < tracks.length) {
result.push({ ...tracks[qobuzIdx], number: result.length + 1 });
qobuzIdx++;
}
// Filter by search query // Filter by search query
let filtered = result;
if (searchQuery.trim()) { if (searchQuery.trim()) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
result = result.filter(t => filtered = result.filter(t =>
t.title.toLowerCase().includes(query) || t.title.toLowerCase().includes(query) ||
(t.artist?.toLowerCase().includes(query)) || (t.artist?.toLowerCase().includes(query)) ||
(t.album?.toLowerCase().includes(query)) (t.album?.toLowerCase().includes(query))
); );
} }
// Sort // Sort (only if not default)
if (sortBy !== 'default') { if (sortBy !== 'default') {
result.sort((a, b) => { filtered.sort((a, b) => {
let cmp = 0; let cmp = 0;
switch (sortBy) { switch (sortBy) {
case 'title': case 'title':
@@ -407,7 +450,7 @@
}); });
} }
return result; return filtered;
}); });
const sortOptions: { field: SortField; label: string }[] = [ const sortOptions: { field: SortField; label: string }[] = [