feat(playlists): add local playlist enhancements
- Add playlist settings table for storing local-only features per playlist - Add playlist local tracks table for mixing local tracks into Qobuz playlists - Implement search within playlist (filters by title, artist, album) - Add custom sorting (by title, artist, album, duration, ascending/descending) - Add custom artwork selection with file picker - Create .env.example for Last.fm credentials setup - Update README with environment configuration instructions
This commit is contained in:
1 parent
24f551b571
commit
f7ca1a9557
7 files changed
+888
-6
No files matched your search
@@ -0,0 +1,8 @@
|
||||
# Last.fm API Configuration
|
||||
# Create your own Last.fm API application at: https://www.last.fm/api/account/create
|
||||
# These credentials are required for scrobbling functionality
|
||||
|
||||
LAST_FM_API_KEY=your_api_key_here
|
||||
LAST_FM_API_SHARED_SECRET=your_shared_secret_here
|
||||
LAST_FM_APP_NAME=your_app_name
|
||||
LAST_FM_APP_REGISTER=your_lastfm_username
|
||||
@@ -91,6 +91,32 @@ npm run tauri dev
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Copy the example environment file and configure it with your credentials:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
#### Last.fm Integration (Optional)
|
||||
|
||||
To enable Last.fm scrobbling, you need to create a Last.fm API application:
|
||||
|
||||
1. Go to [Last.fm API Account](https://www.last.fm/api/account/create)
|
||||
2. Create a new application (any name works)
|
||||
3. Copy your API Key and Shared Secret
|
||||
4. Update your `.env` file:
|
||||
|
||||
```env
|
||||
LAST_FM_API_KEY=your_api_key_here
|
||||
LAST_FM_API_SHARED_SECRET=your_shared_secret_here
|
||||
LAST_FM_APP_NAME=your_app_name
|
||||
LAST_FM_APP_REGISTER=your_lastfm_username
|
||||
```
|
||||
|
||||
The application will work without Last.fm credentials, but scrobbling will be disabled.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -183,6 +183,15 @@ pub fn run() {
|
||||
library::commands::library_clear,
|
||||
library::commands::library_get_track,
|
||||
library::commands::library_play_track,
|
||||
// Playlist local settings commands
|
||||
library::commands::playlist_get_settings,
|
||||
library::commands::playlist_save_settings,
|
||||
library::commands::playlist_set_sort,
|
||||
library::commands::playlist_set_artwork,
|
||||
library::commands::playlist_add_local_track,
|
||||
library::commands::playlist_remove_local_track,
|
||||
library::commands::playlist_get_local_tracks,
|
||||
library::commands::playlist_clear_local_tracks,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -367,3 +367,117 @@ pub async fn library_play_track(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Playlist Local Settings ===
|
||||
|
||||
use crate::library::PlaylistSettings;
|
||||
|
||||
/// Get playlist settings by Qobuz playlist ID
|
||||
#[tauri::command]
|
||||
pub async fn playlist_get_settings(
|
||||
playlist_id: u64,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<Option<PlaylistSettings>, String> {
|
||||
log::info!("Command: playlist_get_settings {}", playlist_id);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.get_playlist_settings(playlist_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Save or update playlist settings
|
||||
#[tauri::command]
|
||||
pub async fn playlist_save_settings(
|
||||
settings: PlaylistSettings,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: playlist_save_settings {}", settings.qobuz_playlist_id);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.save_playlist_settings(&settings)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Update playlist sort settings
|
||||
#[tauri::command]
|
||||
pub async fn playlist_set_sort(
|
||||
playlist_id: u64,
|
||||
sort_by: String,
|
||||
sort_order: String,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: playlist_set_sort {} {} {}", playlist_id, sort_by, sort_order);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.update_playlist_sort(playlist_id, &sort_by, &sort_order)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Update playlist custom artwork
|
||||
#[tauri::command]
|
||||
pub async fn playlist_set_artwork(
|
||||
playlist_id: u64,
|
||||
artwork_path: Option<String>,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: playlist_set_artwork {}", playlist_id);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.update_playlist_artwork(playlist_id, artwork_path.as_deref())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Add a local track to a playlist
|
||||
#[tauri::command]
|
||||
pub async fn playlist_add_local_track(
|
||||
playlist_id: u64,
|
||||
local_track_id: i64,
|
||||
position: i32,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: playlist_add_local_track {} track {}", playlist_id, local_track_id);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.add_local_track_to_playlist(playlist_id, local_track_id, position)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Remove a local track from a playlist
|
||||
#[tauri::command]
|
||||
pub async fn playlist_remove_local_track(
|
||||
playlist_id: u64,
|
||||
local_track_id: i64,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: playlist_remove_local_track {} track {}", playlist_id, local_track_id);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.remove_local_track_from_playlist(playlist_id, local_track_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get all local tracks in a playlist
|
||||
#[tauri::command]
|
||||
pub async fn playlist_get_local_tracks(
|
||||
playlist_id: u64,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<Vec<LocalTrack>, String> {
|
||||
log::info!("Command: playlist_get_local_tracks {}", playlist_id);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.get_playlist_local_tracks(playlist_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Clear all local tracks from a playlist
|
||||
#[tauri::command]
|
||||
pub async fn playlist_clear_local_tracks(
|
||||
playlist_id: u64,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: playlist_clear_local_tracks {}", playlist_id);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.clear_playlist_local_tracks(playlist_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -70,6 +70,32 @@ impl LibraryDatabase {
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_album_artist ON local_tracks(album_artist);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_file_path ON local_tracks(file_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_title ON local_tracks(title);
|
||||
|
||||
-- Playlist local settings (enhances remote Qobuz playlists)
|
||||
CREATE TABLE IF NOT EXISTS playlist_settings (
|
||||
qobuz_playlist_id INTEGER PRIMARY KEY,
|
||||
custom_artwork_path TEXT,
|
||||
sort_by TEXT DEFAULT 'default',
|
||||
sort_order TEXT DEFAULT 'asc',
|
||||
last_search_query TEXT,
|
||||
notes TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Local tracks added to playlists (mixed with remote Qobuz tracks)
|
||||
CREATE TABLE IF NOT EXISTS playlist_local_tracks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
qobuz_playlist_id INTEGER NOT NULL,
|
||||
local_track_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
added_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (local_track_id) REFERENCES local_tracks(id) ON DELETE CASCADE,
|
||||
UNIQUE(qobuz_playlist_id, local_track_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_playlist_local_tracks_playlist
|
||||
ON playlist_local_tracks(qobuz_playlist_id);
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to create schema: {}", e)))?;
|
||||
@@ -435,3 +461,316 @@ pub struct LibraryStats {
|
||||
pub total_duration_secs: u64,
|
||||
pub total_size_bytes: u64,
|
||||
}
|
||||
|
||||
/// Playlist local settings (enhances remote Qobuz playlists)
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PlaylistSettings {
|
||||
pub qobuz_playlist_id: u64,
|
||||
pub custom_artwork_path: Option<String>,
|
||||
pub sort_by: String,
|
||||
pub sort_order: String,
|
||||
pub last_search_query: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl Default for PlaylistSettings {
|
||||
fn default() -> Self {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
Self {
|
||||
qobuz_playlist_id: 0,
|
||||
custom_artwork_path: None,
|
||||
sort_by: "default".to_string(),
|
||||
sort_order: "asc".to_string(),
|
||||
last_search_query: None,
|
||||
notes: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LibraryDatabase {
|
||||
// === Playlist Settings ===
|
||||
|
||||
/// Get playlist settings by Qobuz playlist ID
|
||||
pub fn get_playlist_settings(&self, qobuz_playlist_id: u64) -> Result<Option<PlaylistSettings>, LibraryError> {
|
||||
let result = self.conn.query_row(
|
||||
"SELECT qobuz_playlist_id, custom_artwork_path, sort_by, sort_order,
|
||||
last_search_query, notes, created_at, updated_at
|
||||
FROM playlist_settings WHERE qobuz_playlist_id = ?1",
|
||||
params![qobuz_playlist_id as i64],
|
||||
|row| {
|
||||
Ok(PlaylistSettings {
|
||||
qobuz_playlist_id: row.get::<_, i64>(0)? as u64,
|
||||
custom_artwork_path: row.get(1)?,
|
||||
sort_by: row.get(2)?,
|
||||
sort_order: row.get(3)?,
|
||||
last_search_query: row.get(4)?,
|
||||
notes: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
).optional()
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to get playlist settings: {}", e)))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Save or update playlist settings
|
||||
pub fn save_playlist_settings(&self, settings: &PlaylistSettings) -> Result<(), LibraryError> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO playlist_settings
|
||||
(qobuz_playlist_id, custom_artwork_path, sort_by, sort_order,
|
||||
last_search_query, notes, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(qobuz_playlist_id) DO UPDATE SET
|
||||
custom_artwork_path = excluded.custom_artwork_path,
|
||||
sort_by = excluded.sort_by,
|
||||
sort_order = excluded.sort_order,
|
||||
last_search_query = excluded.last_search_query,
|
||||
notes = excluded.notes,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
settings.qobuz_playlist_id as i64,
|
||||
&settings.custom_artwork_path,
|
||||
&settings.sort_by,
|
||||
&settings.sort_order,
|
||||
&settings.last_search_query,
|
||||
&settings.notes,
|
||||
settings.created_at,
|
||||
now,
|
||||
],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to save playlist settings: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update just the sort settings for a playlist
|
||||
pub fn update_playlist_sort(&self, qobuz_playlist_id: u64, sort_by: &str, sort_order: &str) -> Result<(), LibraryError> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// First check if settings exist, if not create default
|
||||
let existing = self.get_playlist_settings(qobuz_playlist_id)?;
|
||||
if existing.is_none() {
|
||||
let mut settings = PlaylistSettings::default();
|
||||
settings.qobuz_playlist_id = qobuz_playlist_id;
|
||||
settings.sort_by = sort_by.to_string();
|
||||
settings.sort_order = sort_order.to_string();
|
||||
return self.save_playlist_settings(&settings);
|
||||
}
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE playlist_settings SET sort_by = ?1, sort_order = ?2, updated_at = ?3
|
||||
WHERE qobuz_playlist_id = ?4",
|
||||
params![sort_by, sort_order, now, qobuz_playlist_id as i64],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to update playlist sort: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update custom artwork path for a playlist
|
||||
pub fn update_playlist_artwork(&self, qobuz_playlist_id: u64, artwork_path: Option<&str>) -> Result<(), LibraryError> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// First check if settings exist, if not create default
|
||||
let existing = self.get_playlist_settings(qobuz_playlist_id)?;
|
||||
if existing.is_none() {
|
||||
let mut settings = PlaylistSettings::default();
|
||||
settings.qobuz_playlist_id = qobuz_playlist_id;
|
||||
settings.custom_artwork_path = artwork_path.map(|s| s.to_string());
|
||||
return self.save_playlist_settings(&settings);
|
||||
}
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE playlist_settings SET custom_artwork_path = ?1, updated_at = ?2
|
||||
WHERE qobuz_playlist_id = ?3",
|
||||
params![artwork_path, now, qobuz_playlist_id as i64],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to update playlist artwork: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update last search query for a playlist
|
||||
pub fn update_playlist_search_query(&self, qobuz_playlist_id: u64, query: Option<&str>) -> Result<(), LibraryError> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// First check if settings exist, if not create default
|
||||
let existing = self.get_playlist_settings(qobuz_playlist_id)?;
|
||||
if existing.is_none() {
|
||||
let mut settings = PlaylistSettings::default();
|
||||
settings.qobuz_playlist_id = qobuz_playlist_id;
|
||||
settings.last_search_query = query.map(|s| s.to_string());
|
||||
return self.save_playlist_settings(&settings);
|
||||
}
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE playlist_settings SET last_search_query = ?1, updated_at = ?2
|
||||
WHERE qobuz_playlist_id = ?3",
|
||||
params![query, now, qobuz_playlist_id as i64],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to update playlist search query: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete playlist settings
|
||||
pub fn delete_playlist_settings(&self, qobuz_playlist_id: u64) -> Result<(), LibraryError> {
|
||||
self.conn.execute(
|
||||
"DELETE FROM playlist_settings WHERE qobuz_playlist_id = ?1",
|
||||
params![qobuz_playlist_id as i64],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to delete playlist settings: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all playlist settings (for syncing/export)
|
||||
pub fn get_all_playlist_settings(&self) -> Result<Vec<PlaylistSettings>, LibraryError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT qobuz_playlist_id, custom_artwork_path, sort_by, sort_order,
|
||||
last_search_query, notes, created_at, updated_at
|
||||
FROM playlist_settings ORDER BY updated_at DESC"
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?;
|
||||
|
||||
let settings = stmt.query_map([], |row| {
|
||||
Ok(PlaylistSettings {
|
||||
qobuz_playlist_id: row.get::<_, i64>(0)? as u64,
|
||||
custom_artwork_path: row.get(1)?,
|
||||
sort_by: row.get(2)?,
|
||||
sort_order: row.get(3)?,
|
||||
last_search_query: row.get(4)?,
|
||||
notes: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
}).map_err(|e| LibraryError::Database(format!("Failed to query playlist settings: {}", e)))?;
|
||||
|
||||
settings.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to collect playlist settings: {}", e)))
|
||||
}
|
||||
|
||||
// === Playlist Local Tracks ===
|
||||
|
||||
/// Add a local track to a playlist
|
||||
pub fn add_local_track_to_playlist(&self, qobuz_playlist_id: u64, local_track_id: i64, position: i32) -> Result<(), LibraryError> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO playlist_local_tracks
|
||||
(qobuz_playlist_id, local_track_id, position, added_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![qobuz_playlist_id as i64, local_track_id, position, now],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to add local track to playlist: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a local track from a playlist
|
||||
pub fn remove_local_track_from_playlist(&self, qobuz_playlist_id: u64, local_track_id: i64) -> Result<(), LibraryError> {
|
||||
self.conn.execute(
|
||||
"DELETE FROM playlist_local_tracks
|
||||
WHERE qobuz_playlist_id = ?1 AND local_track_id = ?2",
|
||||
params![qobuz_playlist_id as i64, local_track_id],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to remove local track from playlist: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all local tracks in a playlist
|
||||
pub fn get_playlist_local_tracks(&self, qobuz_playlist_id: u64) -> Result<Vec<LocalTrack>, LibraryError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT t.id, t.file_path, t.title, t.artist, t.album, t.album_artist,
|
||||
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(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)?,
|
||||
track_number: row.get(6)?,
|
||||
disc_number: row.get(7)?,
|
||||
year: row.get(8)?,
|
||||
genre: row.get(9)?,
|
||||
duration_secs: row.get(10)?,
|
||||
format: Self::parse_format(&row.get::<_, String>(11)?),
|
||||
bit_depth: row.get(12)?,
|
||||
sample_rate: row.get(13)?,
|
||||
channels: row.get(14)?,
|
||||
file_size_bytes: row.get(15)?,
|
||||
cue_file_path: row.get(16)?,
|
||||
cue_start_secs: row.get(17)?,
|
||||
cue_end_secs: row.get(18)?,
|
||||
artwork_path: row.get(19)?,
|
||||
last_modified: row.get(20)?,
|
||||
indexed_at: row.get(21)?,
|
||||
})
|
||||
}).map_err(|e| LibraryError::Database(format!("Failed to query playlist local tracks: {}", e)))?;
|
||||
|
||||
tracks.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to collect playlist local tracks: {}", e)))
|
||||
}
|
||||
|
||||
/// Get count of local tracks in a playlist
|
||||
pub fn get_playlist_local_track_count(&self, qobuz_playlist_id: u64) -> Result<u32, LibraryError> {
|
||||
let count: u32 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM playlist_local_tracks WHERE qobuz_playlist_id = ?1",
|
||||
params![qobuz_playlist_id as i64],
|
||||
|row| row.get(0),
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to count playlist local tracks: {}", e)))?;
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Update position of a local track in a playlist
|
||||
pub fn update_local_track_position(&self, qobuz_playlist_id: u64, local_track_id: i64, new_position: i32) -> Result<(), LibraryError> {
|
||||
self.conn.execute(
|
||||
"UPDATE playlist_local_tracks SET position = ?1
|
||||
WHERE qobuz_playlist_id = ?2 AND local_track_id = ?3",
|
||||
params![new_position, qobuz_playlist_id as i64, local_track_id],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to update local track position: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear all local tracks from a playlist
|
||||
pub fn clear_playlist_local_tracks(&self, qobuz_playlist_id: u64) -> Result<(), LibraryError> {
|
||||
self.conn.execute(
|
||||
"DELETE FROM playlist_local_tracks WHERE qobuz_playlist_id = ?1",
|
||||
params![qobuz_playlist_id as i64],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to clear playlist local tracks: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ pub mod scanner;
|
||||
|
||||
pub use commands::LibraryState;
|
||||
pub use cue_parser::{cue_to_tracks, CueParser, CueSheet, CueTrack};
|
||||
pub use database::{LibraryDatabase, LibraryStats};
|
||||
pub use database::{LibraryDatabase, LibraryStats, PlaylistSettings};
|
||||
pub use errors::LibraryError;
|
||||
pub use metadata::MetadataExtractor;
|
||||
pub use models::*;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, Play, Shuffle, Plus, ListMusic } from 'lucide-svelte';
|
||||
import { ArrowLeft, Play, Shuffle, Plus, ListMusic, Search, X, ChevronDown, ImagePlus } from 'lucide-svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { onMount } from 'svelte';
|
||||
import TrackRow from '../TrackRow.svelte';
|
||||
|
||||
@@ -42,6 +43,18 @@
|
||||
samplingRate?: number;
|
||||
}
|
||||
|
||||
interface PlaylistSettings {
|
||||
qobuz_playlist_id: number;
|
||||
custom_artwork_path?: string;
|
||||
sort_by: string;
|
||||
sort_order: string;
|
||||
last_search_query?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
type SortField = 'default' | 'title' | 'artist' | 'album' | 'duration';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
interface Props {
|
||||
playlistId: number;
|
||||
onBack: () => void;
|
||||
@@ -56,8 +69,16 @@
|
||||
let error = $state<string | null>(null);
|
||||
let playBtnHovered = $state(false);
|
||||
|
||||
// Local settings state
|
||||
let searchQuery = $state('');
|
||||
let sortBy = $state<SortField>('default');
|
||||
let sortOrder = $state<SortOrder>('asc');
|
||||
let customArtworkPath = $state<string | null>(null);
|
||||
let showSortMenu = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
loadPlaylist();
|
||||
loadSettings();
|
||||
});
|
||||
|
||||
async function loadPlaylist() {
|
||||
@@ -90,6 +111,102 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const settings = await invoke<PlaylistSettings | null>('playlist_get_settings', { playlistId });
|
||||
if (settings) {
|
||||
sortBy = (settings.sort_by as SortField) || 'default';
|
||||
sortOrder = (settings.sort_order as SortOrder) || 'asc';
|
||||
customArtworkPath = settings.custom_artwork_path || null;
|
||||
searchQuery = settings.last_search_query || '';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load playlist settings:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSort(field: SortField, order: SortOrder) {
|
||||
sortBy = field;
|
||||
sortOrder = order;
|
||||
showSortMenu = false;
|
||||
try {
|
||||
await invoke('playlist_set_sort', { playlistId, sortBy: field, sortOrder: order });
|
||||
} catch (err) {
|
||||
console.error('Failed to save sort settings:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectCustomArtwork() {
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }]
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
customArtworkPath = selected;
|
||||
await invoke('playlist_set_artwork', { playlistId, artworkPath: selected });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to select artwork:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCustomArtwork() {
|
||||
customArtworkPath = null;
|
||||
try {
|
||||
await invoke('playlist_set_artwork', { playlistId, artworkPath: null });
|
||||
} catch (err) {
|
||||
console.error('Failed to clear artwork:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Filtered and sorted tracks
|
||||
let displayTracks = $derived.by(() => {
|
||||
let result = [...tracks];
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(t =>
|
||||
t.title.toLowerCase().includes(query) ||
|
||||
(t.artist?.toLowerCase().includes(query)) ||
|
||||
(t.album?.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (sortBy !== 'default') {
|
||||
result.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortBy) {
|
||||
case 'title':
|
||||
cmp = a.title.localeCompare(b.title);
|
||||
break;
|
||||
case 'artist':
|
||||
cmp = (a.artist || '').localeCompare(b.artist || '');
|
||||
break;
|
||||
case 'album':
|
||||
cmp = (a.album || '').localeCompare(b.album || '');
|
||||
break;
|
||||
case 'duration':
|
||||
cmp = a.durationSeconds - b.durationSeconds;
|
||||
break;
|
||||
}
|
||||
return sortOrder === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const sortOptions: { field: SortField; label: string }[] = [
|
||||
{ field: 'default', label: 'Default' },
|
||||
{ field: 'title', label: 'Title' },
|
||||
{ field: 'artist', label: 'Artist' },
|
||||
{ field: 'album', label: 'Album' },
|
||||
{ field: 'duration', label: 'Duration' },
|
||||
];
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
@@ -106,6 +223,10 @@
|
||||
}
|
||||
|
||||
function getPlaylistImage(): string {
|
||||
// Use custom artwork if set (convert to tauri asset URL)
|
||||
if (customArtworkPath) {
|
||||
return `asset://localhost/${encodeURIComponent(customArtworkPath)}`;
|
||||
}
|
||||
if (playlist?.images && playlist.images.length > 0) {
|
||||
return playlist.images[0];
|
||||
}
|
||||
@@ -195,7 +316,8 @@
|
||||
{:else if playlist}
|
||||
<!-- Playlist Header -->
|
||||
<div class="playlist-header">
|
||||
<!-- Playlist Artwork -->
|
||||
<!-- Playlist Artwork with customization -->
|
||||
<div class="artwork-container">
|
||||
<div class="artwork">
|
||||
{#if getPlaylistImage()}
|
||||
<img src={getPlaylistImage()} alt={playlist.name} />
|
||||
@@ -204,6 +326,17 @@
|
||||
<ListMusic size={64} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="artwork-overlay">
|
||||
<button class="artwork-btn" onclick={selectCustomArtwork} title="Set custom artwork">
|
||||
<ImagePlus size={24} />
|
||||
</button>
|
||||
{#if customArtworkPath}
|
||||
<button class="artwork-btn artwork-clear" onclick={clearCustomArtwork} title="Remove custom artwork">
|
||||
<X size={20} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Metadata -->
|
||||
@@ -244,6 +377,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Track List Controls -->
|
||||
<div class="track-controls">
|
||||
<!-- Search -->
|
||||
<div class="search-container">
|
||||
<Search size={16} class="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search in playlist..."
|
||||
bind:value={searchQuery}
|
||||
class="search-input"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button class="search-clear" onclick={() => searchQuery = ''}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sort dropdown -->
|
||||
<div class="sort-container">
|
||||
<button class="sort-btn" onclick={() => showSortMenu = !showSortMenu}>
|
||||
<span>Sort: {sortOptions.find(o => o.field === sortBy)?.label}</span>
|
||||
<ChevronDown size={14} class:rotated={showSortMenu} />
|
||||
</button>
|
||||
{#if showSortMenu}
|
||||
<div class="sort-menu">
|
||||
{#each sortOptions as option}
|
||||
<button
|
||||
class="sort-option"
|
||||
class:active={sortBy === option.field}
|
||||
onclick={() => saveSort(option.field, sortOrder)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="sort-divider"></div>
|
||||
<button
|
||||
class="sort-option"
|
||||
class:active={sortOrder === 'asc'}
|
||||
onclick={() => saveSort(sortBy, 'asc')}
|
||||
>
|
||||
Ascending
|
||||
</button>
|
||||
<button
|
||||
class="sort-option"
|
||||
class:active={sortOrder === 'desc'}
|
||||
onclick={() => saveSort(sortBy, 'desc')}
|
||||
>
|
||||
Descending
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="track-count">
|
||||
{displayTracks.length}{searchQuery ? ` / ${tracks.length}` : ''} tracks
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Track List -->
|
||||
<div class="track-list">
|
||||
<div class="track-list-header">
|
||||
@@ -253,9 +445,9 @@
|
||||
<span class="col-duration">Duration</span>
|
||||
</div>
|
||||
|
||||
{#each tracks as track (track.id)}
|
||||
{#each displayTracks as track, idx (track.id)}
|
||||
<TrackRow
|
||||
number={track.number}
|
||||
number={idx + 1}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
duration={track.duration}
|
||||
@@ -265,6 +457,12 @@
|
||||
onclick={() => handleTrackClick(track)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if displayTracks.length === 0 && searchQuery}
|
||||
<div class="no-results">
|
||||
<p>No tracks match "{searchQuery}"</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -339,10 +537,14 @@
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.artwork {
|
||||
width: 232px;
|
||||
height: 232px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-tertiary);
|
||||
@@ -364,6 +566,52 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.artwork-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.artwork:hover .artwork-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.artwork-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.artwork-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.artwork-btn.artwork-clear {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(255, 0, 0, 0.3);
|
||||
border-color: rgba(255, 100, 100, 0.5);
|
||||
}
|
||||
|
||||
.artwork-btn.artwork-clear:hover {
|
||||
background: rgba(255, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -495,4 +743,142 @@
|
||||
.col-duration {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Track Controls */
|
||||
.track-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.search-container :global(.search-icon) {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sort-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.sort-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sort-btn :global(.rotated) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.sort-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
min-width: 140px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.sort-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.sort-option:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sort-option.active {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.sort-divider {
|
||||
height: 1px;
|
||||
background-color: var(--bg-tertiary);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.track-count {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.no-results p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user