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:
1 parent
24c9df3bce
commit
16486e4591
6 files changed
+136
-14
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }[] = [
|
||||||
|
|||||||
Reference in new issue
Block a user