Merge branch 'feature/internal/local-library-tab-order' into pre-release
Includes Phase 2 (rename Albums to Folders + per-user tab order preferences) and Phase 3 (new metadata-grouped Albums view + V2 commands + frontend wiring) of the Local Library design. cc
This commit is contained in:
21 files changed
+1169
-30
No files matched your search
Generated
+1
@@ -3007,6 +3007,7 @@ dependencies = [
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
|
||||
@@ -34,3 +34,6 @@ glob = "0.3"
|
||||
|
||||
# UUID generation
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,31 @@
|
||||
//! SQL fragments for metadata-based album grouping in the Local Library.
|
||||
//!
|
||||
//! The metadata-grouped Albums view groups tracks by `album` +
|
||||
//! `COALESCE(album_artist, artist)` when the album tag is usable, falls
|
||||
//! back to the existing folder-based `album_group_key` when it's not,
|
||||
//! and dumps anything else into a single `__unknown_album__` bucket.
|
||||
//!
|
||||
//! Both `get_albums_metadata_grouped` and `get_album_tracks_metadata`
|
||||
//! must produce the same group_key for the same row, so the expression
|
||||
//! is centralized here.
|
||||
|
||||
/// SQL expression that produces the metadata group key for a row of
|
||||
/// `local_tracks`. Insert wherever you would otherwise use a column.
|
||||
pub fn metadata_group_key_sql_expression() -> &'static str {
|
||||
r#"CASE
|
||||
WHEN album IS NOT NULL
|
||||
AND TRIM(album) != ''
|
||||
AND album != 'Unknown Album'
|
||||
THEN album || '|' || COALESCE(album_artist, artist, 'Unknown Artist')
|
||||
|
||||
WHEN album_group_key IS NOT NULL
|
||||
AND album_group_key != ''
|
||||
THEN album_group_key
|
||||
|
||||
ELSE '__unknown_album__'
|
||||
END"#
|
||||
}
|
||||
|
||||
/// Sentinel group_key value used for the orphan bucket. Frontend can
|
||||
/// special-case this if it needs a localized label.
|
||||
pub const UNKNOWN_ALBUM_GROUP_KEY: &str = "__unknown_album__";
|
||||
@@ -97,6 +97,8 @@ 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);
|
||||
CREATE INDEX IF NOT EXISTS idx_local_tracks_album_lookup
|
||||
ON local_tracks(album, album_artist, artist);
|
||||
|
||||
-- Playlist folders (local organization for Qobuz playlists)
|
||||
CREATE TABLE IF NOT EXISTS playlist_folders (
|
||||
@@ -1388,6 +1390,7 @@ impl LibraryDatabase {
|
||||
directory_path: row
|
||||
.get::<_, Option<String>>(12)?
|
||||
.unwrap_or_else(|| group_key.clone()),
|
||||
source_folders: None,
|
||||
source: row
|
||||
.get::<_, Option<String>>(13)?
|
||||
.unwrap_or_else(|| "user".to_string()),
|
||||
@@ -1426,6 +1429,174 @@ impl LibraryDatabase {
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
/// Get all albums grouped by metadata (album + album_artist OR
|
||||
/// artist), with fallback to folder grouping for tracks with no
|
||||
/// usable album tag, and a single 'Unknown Album' bucket for total
|
||||
/// orphans.
|
||||
///
|
||||
/// Mirrors the shape of [`Self::get_albums_with_full_filter`] but
|
||||
/// uses the metadata group key from
|
||||
/// [`crate::album_grouping::metadata_group_key_sql_expression`].
|
||||
/// Rows have `directory_path = ""` and `source_folders` populated
|
||||
/// with the comma-separated list of contributing folder keys (so
|
||||
/// the UI can show a tooltip when N folders > 1).
|
||||
///
|
||||
/// `include_hidden` is currently ignored: the `album_settings.hidden`
|
||||
/// flag targets the FOLDER key, which does not map cleanly onto
|
||||
/// metadata-grouped rows. Revisit if user feedback asks for it.
|
||||
pub fn get_albums_metadata_grouped(
|
||||
&self,
|
||||
include_hidden: bool,
|
||||
include_qobuz_downloads: bool,
|
||||
exclude_network_folders: bool,
|
||||
) -> Result<Vec<LocalAlbum>, LibraryError> {
|
||||
let _ = include_hidden;
|
||||
|
||||
let source_filter = if include_qobuz_downloads {
|
||||
""
|
||||
} else {
|
||||
"AND (source IS NULL OR source != 'qobuz_download')"
|
||||
};
|
||||
|
||||
let network_filter = if exclude_network_folders {
|
||||
"AND NOT EXISTS (
|
||||
SELECT 1 FROM library_folders nf
|
||||
WHERE nf.is_network = 1
|
||||
AND local_tracks.file_path LIKE nf.path || '%'
|
||||
)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let group_key_expr = crate::album_grouping::metadata_group_key_sql_expression();
|
||||
|
||||
let query = format!(
|
||||
r#"
|
||||
WITH grouped AS (
|
||||
SELECT
|
||||
{group_key} AS group_key,
|
||||
COALESCE(album_group_title, album, 'Unknown Album') AS title,
|
||||
COALESCE(album_artist, artist, 'Unknown Artist') AS artist,
|
||||
year,
|
||||
catalog_number,
|
||||
artwork_path,
|
||||
duration_secs,
|
||||
format,
|
||||
bit_depth,
|
||||
sample_rate,
|
||||
album_group_key AS source_folder,
|
||||
COALESCE(source, 'user') AS source
|
||||
FROM local_tracks
|
||||
WHERE 1=1 {source_filter} {network_filter}
|
||||
)
|
||||
SELECT
|
||||
group_key,
|
||||
CASE WHEN group_key = '__unknown_album__'
|
||||
THEN 'Unknown Album'
|
||||
ELSE MIN(title)
|
||||
END AS title,
|
||||
CASE WHEN COUNT(DISTINCT artist) > 1
|
||||
THEN 'Various Artists'
|
||||
ELSE MIN(artist)
|
||||
END AS artist,
|
||||
GROUP_CONCAT(DISTINCT artist) AS all_artists,
|
||||
MIN(year) AS year,
|
||||
MIN(catalog_number) AS catalog_number,
|
||||
MAX(CASE WHEN artwork_path IS NOT NULL THEN artwork_path END) AS artwork,
|
||||
COUNT(*) AS track_count,
|
||||
SUM(duration_secs) AS total_duration,
|
||||
MAX(format) AS format,
|
||||
MAX(bit_depth) AS bit_depth,
|
||||
MAX(sample_rate) AS sample_rate,
|
||||
GROUP_CONCAT(DISTINCT source_folder) AS source_folders,
|
||||
MAX(source) AS source
|
||||
FROM grouped
|
||||
GROUP BY group_key
|
||||
ORDER BY (group_key = '__unknown_album__'), artist, title
|
||||
"#,
|
||||
group_key = group_key_expr,
|
||||
source_filter = source_filter,
|
||||
network_filter = network_filter,
|
||||
);
|
||||
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(&query)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let group_key: String = row.get(0)?;
|
||||
let album: String = row.get(1)?;
|
||||
let artist: String = row.get(2)?;
|
||||
let all_artists: String =
|
||||
row.get::<_, Option<String>>(3)?.unwrap_or_default();
|
||||
let artwork_path: Option<String> = row.get(6)?;
|
||||
let source_folders: Option<String> = row.get(12)?;
|
||||
|
||||
Ok(LocalAlbum {
|
||||
id: group_key.clone(),
|
||||
title: album,
|
||||
artist,
|
||||
all_artists,
|
||||
year: row.get(4)?,
|
||||
catalog_number: row.get(5)?,
|
||||
artwork_path,
|
||||
track_count: row.get(7)?,
|
||||
total_duration_secs: row.get(8)?,
|
||||
format: Self::parse_format(
|
||||
&row.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
||||
),
|
||||
bit_depth: row.get(10)?,
|
||||
sample_rate: row.get::<_, Option<f64>>(11)?.unwrap_or(44100.0),
|
||||
directory_path: String::new(),
|
||||
source_folders,
|
||||
source: row
|
||||
.get::<_, Option<String>>(13)?
|
||||
.unwrap_or_else(|| "user".to_string()),
|
||||
})
|
||||
})
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
let mut albums = Vec::new();
|
||||
for album in rows {
|
||||
albums.push(album.map_err(|e| LibraryError::Database(e.to_string()))?);
|
||||
}
|
||||
Ok(albums)
|
||||
}
|
||||
|
||||
/// Get tracks for a metadata-grouped album. The `metadata_key`
|
||||
/// matches what [`Self::get_albums_metadata_grouped`] returns for
|
||||
/// the album's `id` field.
|
||||
pub fn get_album_tracks_metadata(
|
||||
&self,
|
||||
metadata_key: &str,
|
||||
) -> Result<Vec<LocalTrack>, LibraryError> {
|
||||
let group_key_expr = crate::album_grouping::metadata_group_key_sql_expression();
|
||||
let sql = format!(
|
||||
"SELECT {cols} FROM local_tracks
|
||||
WHERE {group_key} = ?
|
||||
ORDER BY disc_number, track_number, title",
|
||||
cols = Self::TRACK_COLUMNS,
|
||||
group_key = group_key_expr,
|
||||
);
|
||||
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![metadata_key], |row| Self::row_to_track(row))
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
for track in rows {
|
||||
tracks.push(track.map_err(|e| LibraryError::Database(e.to_string()))?);
|
||||
}
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
/// Get all artists
|
||||
pub fn get_artists(&self) -> Result<Vec<LocalArtist>, LibraryError> {
|
||||
self.get_artists_with_filter(true, false)
|
||||
@@ -4256,3 +4427,172 @@ impl LibraryDatabase {
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to collect formats: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod metadata_grouping_tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fresh_db() -> (TempDir, LibraryDatabase) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("library.db");
|
||||
let db = LibraryDatabase::open(&path).unwrap();
|
||||
(tmp, db)
|
||||
}
|
||||
|
||||
fn insert_track_for_test(
|
||||
db: &LibraryDatabase,
|
||||
file_path: &str,
|
||||
album: Option<&str>,
|
||||
album_artist: Option<&str>,
|
||||
artist: &str,
|
||||
album_group_key: &str,
|
||||
) {
|
||||
let mut t = LocalTrack::default();
|
||||
t.file_path = file_path.to_string();
|
||||
t.title = format!("Track at {}", file_path);
|
||||
t.album = album.unwrap_or("").to_string();
|
||||
t.album_artist = album_artist.map(String::from);
|
||||
t.artist = artist.to_string();
|
||||
t.album_group_key = album_group_key.to_string();
|
||||
t.album_group_title = album.unwrap_or("").to_string();
|
||||
db.insert_track(&t).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_group_merges_tracks_across_folders_with_same_album() {
|
||||
let (_tmp, db) = fresh_db();
|
||||
// Two folders, same album metadata -> one metadata group.
|
||||
insert_track_for_test(
|
||||
&db,
|
||||
"/m/Bjork/Vespertine/01.flac",
|
||||
Some("Vespertine"),
|
||||
Some("Bjork"),
|
||||
"Bjork",
|
||||
"/m/Bjork/Vespertine",
|
||||
);
|
||||
insert_track_for_test(
|
||||
&db,
|
||||
"/m/Bjork/Vespertine/02.flac",
|
||||
Some("Vespertine"),
|
||||
Some("Bjork"),
|
||||
"Bjork",
|
||||
"/m/Bjork/Vespertine",
|
||||
);
|
||||
insert_track_for_test(
|
||||
&db,
|
||||
"/m/mix/cd/track-from-vespertine.flac",
|
||||
Some("Vespertine"),
|
||||
Some("Bjork"),
|
||||
"Bjork",
|
||||
"/m/mix/cd",
|
||||
);
|
||||
|
||||
let albums = db.get_albums_metadata_grouped(false, true, false).unwrap();
|
||||
let vespertine = albums
|
||||
.iter()
|
||||
.find(|a| a.title == "Vespertine")
|
||||
.expect("Vespertine group");
|
||||
assert_eq!(vespertine.track_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_group_falls_back_to_folder_when_album_missing() {
|
||||
let (_tmp, db) = fresh_db();
|
||||
// Empty album tag -> use folder grouping.
|
||||
insert_track_for_test(&db, "/m/folder/01.flac", None, None, "A", "/m/folder");
|
||||
insert_track_for_test(&db, "/m/folder/02.flac", None, None, "B", "/m/folder");
|
||||
|
||||
let albums = db.get_albums_metadata_grouped(false, true, false).unwrap();
|
||||
assert_eq!(albums.len(), 1, "single folder fallback group");
|
||||
assert_eq!(albums[0].track_count, 2);
|
||||
assert_eq!(albums[0].artist, "Various Artists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_group_orphan_bucket_when_no_album_no_folder() {
|
||||
let (_tmp, db) = fresh_db();
|
||||
// No album tag AND no folder key -> orphan bucket.
|
||||
insert_track_for_test(&db, "/m/ghost/01.flac", None, None, "X", "");
|
||||
insert_track_for_test(&db, "/m/ghost/02.flac", None, None, "Y", "");
|
||||
|
||||
let albums = db.get_albums_metadata_grouped(false, true, false).unwrap();
|
||||
let unknown = albums
|
||||
.iter()
|
||||
.find(|a| a.title == "Unknown Album")
|
||||
.expect("Unknown Album bucket");
|
||||
assert_eq!(unknown.track_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_group_va_detection() {
|
||||
let (_tmp, db) = fresh_db();
|
||||
// Same album, different track artists, album_artist set to VA.
|
||||
insert_track_for_test(
|
||||
&db,
|
||||
"/m/comp/01.flac",
|
||||
Some("Comp"),
|
||||
Some("Various Artists"),
|
||||
"A",
|
||||
"/m/comp",
|
||||
);
|
||||
insert_track_for_test(
|
||||
&db,
|
||||
"/m/comp/02.flac",
|
||||
Some("Comp"),
|
||||
Some("Various Artists"),
|
||||
"B",
|
||||
"/m/comp",
|
||||
);
|
||||
|
||||
let albums = db.get_albums_metadata_grouped(false, true, false).unwrap();
|
||||
let comp = albums
|
||||
.iter()
|
||||
.find(|a| a.title == "Comp")
|
||||
.expect("Comp album");
|
||||
assert_eq!(comp.track_count, 2);
|
||||
assert_eq!(comp.artist, "Various Artists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_group_tracks_fetch_returns_all_in_group() {
|
||||
let (_tmp, db) = fresh_db();
|
||||
insert_track_for_test(
|
||||
&db,
|
||||
"/m/folderA/01.flac",
|
||||
Some("Album X"),
|
||||
Some("Artist Y"),
|
||||
"Artist Y",
|
||||
"/m/folderA",
|
||||
);
|
||||
insert_track_for_test(
|
||||
&db,
|
||||
"/m/folderB/02.flac",
|
||||
Some("Album X"),
|
||||
Some("Artist Y"),
|
||||
"Artist Y",
|
||||
"/m/folderB",
|
||||
);
|
||||
insert_track_for_test(
|
||||
&db,
|
||||
"/m/folderA/03.flac",
|
||||
Some("Album X"),
|
||||
Some("Artist Y"),
|
||||
"Artist Y",
|
||||
"/m/folderA",
|
||||
);
|
||||
// Different album in same folder set
|
||||
insert_track_for_test(
|
||||
&db,
|
||||
"/m/folderA/04.flac",
|
||||
Some("Album Z"),
|
||||
Some("Artist Y"),
|
||||
"Artist Y",
|
||||
"/m/folderA",
|
||||
);
|
||||
|
||||
let key = "Album X|Artist Y";
|
||||
let tracks = db.get_album_tracks_metadata(key).unwrap();
|
||||
assert_eq!(tracks.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
//! let db = LibraryDatabase::open(Path::new("library.db")).unwrap();
|
||||
//! ```
|
||||
|
||||
pub mod album_grouping;
|
||||
mod cue_parser;
|
||||
mod database;
|
||||
mod errors;
|
||||
|
||||
@@ -150,6 +150,13 @@ pub struct LocalAlbum {
|
||||
pub bit_depth: Option<u32>,
|
||||
pub sample_rate: f64, // Changed from u32 to f64 for decimal precision
|
||||
pub directory_path: String,
|
||||
/// Comma-separated list of distinct folder keys that contributed
|
||||
/// tracks to this album. Populated only by the metadata-grouped
|
||||
/// Albums query (`get_albums_metadata_grouped`); `None` for folder-
|
||||
/// grouped rows. The frontend uses this to render a tooltip when N
|
||||
/// folders > 1.
|
||||
#[serde(default)]
|
||||
pub source_folders: Option<String>,
|
||||
/// Source of the album: "user" for local files, "qobuz_download" for offline cached
|
||||
#[serde(default = "default_source")]
|
||||
pub source: String,
|
||||
|
||||
@@ -2509,6 +2509,35 @@ pub async fn v2_library_get_albums(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// V2 metadata-grouped albums for Local Library.
|
||||
#[tauri::command]
|
||||
pub async fn v2_library_get_albums_metadata(
|
||||
include_hidden: Option<bool>,
|
||||
exclude_network_folders: Option<bool>,
|
||||
state: State<'_, LibraryState>,
|
||||
download_settings_state: State<'_, DownloadSettingsState>,
|
||||
) -> Result<Vec<LocalAlbum>, String> {
|
||||
let include_qobuz = download_settings_state
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock download settings: {}", e))?
|
||||
.as_ref()
|
||||
.and_then(|s| s.get_settings().ok())
|
||||
.map(|s| s.show_in_library)
|
||||
.unwrap_or(false);
|
||||
|
||||
let guard__ = state.db.lock().await;
|
||||
let db = guard__
|
||||
.as_ref()
|
||||
.ok_or("No active session - please log in")?;
|
||||
|
||||
db.get_albums_metadata_grouped(
|
||||
include_hidden.unwrap_or(false),
|
||||
include_qobuz,
|
||||
exclude_network_folders.unwrap_or(false),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn v2_library_get_stats(
|
||||
state: State<'_, LibraryState>,
|
||||
@@ -2858,6 +2887,23 @@ pub async fn v2_library_get_album_tracks(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// V2 fetch of tracks for a metadata-grouped album.
|
||||
#[tauri::command]
|
||||
#[allow(non_snake_case)]
|
||||
pub async fn v2_library_get_album_tracks_metadata(
|
||||
metadataKey: String,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<Vec<crate::library::LocalTrack>, String> {
|
||||
log::info!("Command: v2_library_get_album_tracks_metadata {}", metadataKey);
|
||||
|
||||
let guard__ = state.db.lock().await;
|
||||
let db = guard__
|
||||
.as_ref()
|
||||
.ok_or("No active session - please log in")?;
|
||||
db.get_album_tracks_metadata(&metadataKey)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(non_snake_case)]
|
||||
pub async fn v2_plex_get_music_sections(
|
||||
|
||||
@@ -3,6 +3,7 @@ use tauri::State;
|
||||
use crate::audio::{AlsaPlugin, AudioBackendType, AudioDevice, BackendManager};
|
||||
use crate::cache::CacheStats;
|
||||
use crate::config::favorites_preferences::FavoritesPreferences;
|
||||
use crate::config::library_preferences::LibraryPreferences;
|
||||
use crate::config::playback_preferences::{
|
||||
AutoplayMode, PlaybackPreferences, PlaybackPreferencesState,
|
||||
};
|
||||
@@ -190,6 +191,26 @@ pub fn v2_save_favorites_preferences(
|
||||
crate::config::favorites_preferences::save_favorites_preferences(prefs, state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn v2_get_library_preferences(
|
||||
state: State<'_, crate::config::library_preferences::LibraryPreferencesState>,
|
||||
) -> Result<LibraryPreferences, String> {
|
||||
let guard = state
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock library preferences store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.get_preferences()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn v2_save_library_preferences(
|
||||
prefs: LibraryPreferences,
|
||||
state: State<'_, crate::config::library_preferences::LibraryPreferencesState>,
|
||||
) -> Result<LibraryPreferences, String> {
|
||||
crate::config::library_preferences::save_library_preferences(prefs, state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn v2_get_cache_stats(state: State<'_, AppState>) -> CacheStats {
|
||||
state.audio_cache.stats()
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
use rusqlite::{params, Connection, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LibraryPreferences {
|
||||
pub tab_order: Vec<String>,
|
||||
pub hidden_tabs: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for LibraryPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Default order matches the current LocalLibraryView tab list,
|
||||
// with the renamed 'folders' tab in place of the old 'albums'
|
||||
// tab. Phase 3 adds 'albums' (the new metadata-grouped view)
|
||||
// to this default.
|
||||
tab_order: vec![
|
||||
"tracks".to_string(),
|
||||
"folders".to_string(),
|
||||
"albums".to_string(),
|
||||
"artists".to_string(),
|
||||
],
|
||||
hidden_tabs: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LibraryPreferencesStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl LibraryPreferencesStore {
|
||||
fn open_at(dir: &Path, db_name: &str) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
let db_path = dir.join(db_name);
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open library preferences database: {}", e))?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to set WAL mode on library preferences: {}", e))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS library_preferences (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
tab_order TEXT NOT NULL,
|
||||
hidden_tabs TEXT NOT NULL DEFAULT '[]'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to create library preferences table: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz");
|
||||
Self::open_at(&data_dir, "library_preferences.db")
|
||||
}
|
||||
|
||||
pub fn new_at(base_dir: &Path) -> Result<Self, String> {
|
||||
Self::open_at(base_dir, "library_preferences.db")
|
||||
}
|
||||
|
||||
pub fn get_preferences(&self) -> Result<LibraryPreferences, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT tab_order, hidden_tabs FROM library_preferences WHERE id = 1")
|
||||
.map_err(|e| format!("Failed to prepare select: {}", e))?;
|
||||
|
||||
let result = stmt.query_row([], |row| {
|
||||
let tab_order_str: String = row.get(0)?;
|
||||
let hidden_tabs_str: String = row.get(1)?;
|
||||
|
||||
let tab_order: Vec<String> = serde_json::from_str(&tab_order_str)
|
||||
.unwrap_or_else(|_| LibraryPreferences::default().tab_order);
|
||||
let hidden_tabs: Vec<String> =
|
||||
serde_json::from_str(&hidden_tabs_str).unwrap_or_default();
|
||||
|
||||
Ok(LibraryPreferences {
|
||||
tab_order,
|
||||
hidden_tabs,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(prefs) => Ok(prefs),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(LibraryPreferences::default()),
|
||||
Err(e) => Err(format!("Failed to query library preferences: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_preferences(
|
||||
&self,
|
||||
prefs: LibraryPreferences,
|
||||
) -> Result<LibraryPreferences, String> {
|
||||
let tab_order_str = serde_json::to_string(&prefs.tab_order)
|
||||
.map_err(|e| format!("Failed to serialize tab_order: {}", e))?;
|
||||
let hidden_tabs_str = serde_json::to_string(&prefs.hidden_tabs)
|
||||
.map_err(|e| format!("Failed to serialize hidden_tabs: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO library_preferences (id, tab_order, hidden_tabs)
|
||||
VALUES (1, ?1, ?2)",
|
||||
params![tab_order_str, hidden_tabs_str],
|
||||
)
|
||||
.map_err(|e| format!("Failed to save library preferences: {}", e))?;
|
||||
Ok(prefs)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LibraryPreferencesState {
|
||||
pub store: Arc<Mutex<Option<LibraryPreferencesStore>>>,
|
||||
}
|
||||
|
||||
impl LibraryPreferencesState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let store = LibraryPreferencesStore::new()?;
|
||||
Ok(Self {
|
||||
store: Arc::new(Mutex::new(Some(store))),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||
let new_store = LibraryPreferencesStore::new_at(base_dir)?;
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock library preferences store".to_string())?;
|
||||
*guard = Some(new_store);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn teardown(&self) -> Result<(), String> {
|
||||
let mut guard = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock library preferences store".to_string())?;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_library_preferences(
|
||||
state: tauri::State<LibraryPreferencesState>,
|
||||
) -> Result<LibraryPreferences, String> {
|
||||
let guard = state
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock library preferences store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.get_preferences()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_library_preferences(
|
||||
prefs: LibraryPreferences,
|
||||
state: tauri::State<LibraryPreferencesState>,
|
||||
) -> Result<LibraryPreferences, String> {
|
||||
let guard = state
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock library preferences store".to_string())?;
|
||||
let store = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
store.save_preferences(prefs)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ pub mod favorites_preferences;
|
||||
pub mod graphics_settings;
|
||||
pub mod image_cache_settings;
|
||||
pub mod legal_settings;
|
||||
pub mod library_preferences;
|
||||
pub mod playback_preferences;
|
||||
pub mod remote_control_settings;
|
||||
pub mod subscription_state;
|
||||
@@ -43,6 +44,11 @@ pub use favorites_preferences::{
|
||||
FavoritesPreferencesState,
|
||||
};
|
||||
|
||||
pub use library_preferences::{
|
||||
get_library_preferences, save_library_preferences, LibraryPreferences,
|
||||
LibraryPreferencesState,
|
||||
};
|
||||
|
||||
pub use subscription_state::{
|
||||
create_empty_subscription_state, create_subscription_state, SubscriptionState,
|
||||
SubscriptionStateState, SubscriptionStateStore,
|
||||
|
||||
@@ -704,6 +704,8 @@ pub fn run(qconnect_cli_override: Option<bool>) {
|
||||
let playback_prefs_state = config::playback_preferences::PlaybackPreferencesState::new_empty();
|
||||
let favorites_prefs_state =
|
||||
config::favorites_preferences::FavoritesPreferencesState::new_empty();
|
||||
let library_prefs_state =
|
||||
config::library_preferences::LibraryPreferencesState::new_empty();
|
||||
let favorites_cache_state = config::favorites_cache::FavoritesCacheState::new_empty();
|
||||
let tray_settings_state = config::tray_settings::TraySettingsState::new_empty();
|
||||
let remote_control_settings_state =
|
||||
@@ -1352,6 +1354,7 @@ pub fn run(qconnect_cli_override: Option<bool>) {
|
||||
.manage(offline_state)
|
||||
.manage(playback_prefs_state)
|
||||
.manage(favorites_prefs_state)
|
||||
.manage(library_prefs_state)
|
||||
.manage(favorites_cache_state)
|
||||
.manage(tray_settings_state)
|
||||
.manage(remote_control_settings_state)
|
||||
@@ -1452,6 +1455,8 @@ pub fn run(qconnect_cli_override: Option<bool>) {
|
||||
commands_v2::v2_get_playback_preferences,
|
||||
commands_v2::v2_get_favorites_preferences,
|
||||
commands_v2::v2_save_favorites_preferences,
|
||||
commands_v2::v2_get_library_preferences,
|
||||
commands_v2::v2_save_library_preferences,
|
||||
commands_v2::v2_get_cache_stats,
|
||||
commands_v2::v2_get_available_backends,
|
||||
commands_v2::v2_get_devices_for_backend,
|
||||
@@ -1630,6 +1635,7 @@ pub fn run(qconnect_cli_override: Option<bool>) {
|
||||
commands_v2::v2_library_get_cache_stats,
|
||||
commands_v2::v2_library_get_stats,
|
||||
commands_v2::v2_library_get_albums,
|
||||
commands_v2::v2_library_get_albums_metadata,
|
||||
commands_v2::v2_library_get_folders,
|
||||
commands_v2::v2_library_get_folders_with_metadata,
|
||||
commands_v2::v2_library_add_folder,
|
||||
@@ -1638,6 +1644,7 @@ pub fn run(qconnect_cli_override: Option<bool>) {
|
||||
commands_v2::v2_library_get_artists,
|
||||
commands_v2::v2_library_search,
|
||||
commands_v2::v2_library_get_album_tracks,
|
||||
commands_v2::v2_library_get_album_tracks_metadata,
|
||||
commands_v2::v2_library_update_folder_path,
|
||||
commands_v2::v2_library_cache_artist_image,
|
||||
commands_v2::v2_library_set_custom_artist_image,
|
||||
|
||||
@@ -16,6 +16,7 @@ const DATA_DIR_DB_FILES: &[&str] = &[
|
||||
"subscription_state.db",
|
||||
"playback_preferences.db",
|
||||
"favorites_preferences.db",
|
||||
"library_preferences.db",
|
||||
"download_settings.db",
|
||||
"audio_settings.db",
|
||||
"tray_settings.db",
|
||||
|
||||
@@ -61,6 +61,8 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
|
||||
app.state::<crate::config::playback_preferences::PlaybackPreferencesState>();
|
||||
let favorites_prefs =
|
||||
app.state::<crate::config::favorites_preferences::FavoritesPreferencesState>();
|
||||
let library_prefs =
|
||||
app.state::<crate::config::library_preferences::LibraryPreferencesState>();
|
||||
let download_settings = app.state::<crate::config::download_settings::DownloadSettingsState>();
|
||||
let audio_settings = app.state::<crate::config::audio_settings::AudioSettingsState>();
|
||||
let tray_settings = app.state::<crate::config::tray_settings::TraySettingsState>();
|
||||
@@ -114,6 +116,7 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
|
||||
favorites_cache.init_at(&data_dir)?;
|
||||
playback_prefs.init_at(&data_dir)?;
|
||||
favorites_prefs.init_at(&data_dir)?;
|
||||
library_prefs.init_at(&data_dir)?;
|
||||
audio_settings.init_at(&data_dir)?;
|
||||
|
||||
// Sync per-user audio settings to CoreBridge player immediately.
|
||||
@@ -305,6 +308,8 @@ pub async fn deactivate_session(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
app.state::<crate::config::playback_preferences::PlaybackPreferencesState>();
|
||||
let favorites_prefs =
|
||||
app.state::<crate::config::favorites_preferences::FavoritesPreferencesState>();
|
||||
let library_prefs =
|
||||
app.state::<crate::config::library_preferences::LibraryPreferencesState>();
|
||||
let download_settings = app.state::<crate::config::download_settings::DownloadSettingsState>();
|
||||
let audio_settings = app.state::<crate::config::audio_settings::AudioSettingsState>();
|
||||
let tray_settings = app.state::<crate::config::tray_settings::TraySettingsState>();
|
||||
@@ -336,6 +341,7 @@ pub async fn deactivate_session(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
favorites_cache.teardown()?;
|
||||
playback_prefs.teardown()?;
|
||||
favorites_prefs.teardown()?;
|
||||
library_prefs.teardown()?;
|
||||
audio_settings.teardown()?;
|
||||
tray_settings.teardown()?;
|
||||
remote_control_settings.teardown()?;
|
||||
@@ -428,6 +434,8 @@ pub async fn activate_offline_session(app: &tauri::AppHandle) -> Result<(), Stri
|
||||
let tray_settings = app.state::<crate::config::tray_settings::TraySettingsState>();
|
||||
let favorites_prefs =
|
||||
app.state::<crate::config::favorites_preferences::FavoritesPreferencesState>();
|
||||
let library_prefs =
|
||||
app.state::<crate::config::library_preferences::LibraryPreferencesState>();
|
||||
|
||||
library.init_at(&data_dir).await?;
|
||||
|
||||
@@ -450,6 +458,7 @@ pub async fn activate_offline_session(app: &tauri::AppHandle) -> Result<(), Stri
|
||||
session_store.init_at(&data_dir)?;
|
||||
favorites_cache.init_at(&data_dir)?;
|
||||
favorites_prefs.init_at(&data_dir)?;
|
||||
library_prefs.init_at(&data_dir)?;
|
||||
tray_settings.init_at(&data_dir)?;
|
||||
|
||||
// Download settings — needed for "Show in Local Library" toggle
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import Modal from './Modal.svelte';
|
||||
import { GripVertical, Eye, EyeOff } from 'lucide-svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { LibraryPreferences } from '../types';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (prefs: LibraryPreferences) => void;
|
||||
initialPreferences: LibraryPreferences;
|
||||
}
|
||||
|
||||
let { isOpen, onClose, onSave, initialPreferences }: Props = $props();
|
||||
|
||||
// Tab list known to this modal. Keep in sync with LocalLibraryView.TabType.
|
||||
const availableTabs: string[] = ['tracks', 'folders', 'artists'];
|
||||
|
||||
// Default preferences used by the "Reset" button.
|
||||
const DEFAULT_TAB_ORDER: string[] = ['tracks', 'folders', 'artists'];
|
||||
|
||||
let tabOrder = $state<string[]>([]);
|
||||
let hiddenTabs = $state<string[]>([]);
|
||||
let saving = $state(false);
|
||||
|
||||
function getTabLabel(tab: string): string {
|
||||
return $t(`library.${tab}`);
|
||||
}
|
||||
|
||||
function syncFromPreferences() {
|
||||
// Sanitize: keep only known tabs, append any missing ones at the end so
|
||||
// older preferences still surface new tabs added in future releases.
|
||||
const validOrder = (initialPreferences?.tab_order ?? []).filter((tab) =>
|
||||
availableTabs.includes(tab),
|
||||
);
|
||||
for (const tab of availableTabs) {
|
||||
if (!validOrder.includes(tab)) validOrder.push(tab);
|
||||
}
|
||||
tabOrder = validOrder;
|
||||
|
||||
const validHidden = (initialPreferences?.hidden_tabs ?? []).filter((tab) =>
|
||||
availableTabs.includes(tab),
|
||||
);
|
||||
hiddenTabs = validHidden;
|
||||
}
|
||||
|
||||
function moveUp(index: number) {
|
||||
if (index === 0) return;
|
||||
const temp = tabOrder[index];
|
||||
tabOrder[index] = tabOrder[index - 1];
|
||||
tabOrder[index - 1] = temp;
|
||||
}
|
||||
|
||||
function moveDown(index: number) {
|
||||
if (index === tabOrder.length - 1) return;
|
||||
const temp = tabOrder[index];
|
||||
tabOrder[index] = tabOrder[index + 1];
|
||||
tabOrder[index + 1] = temp;
|
||||
}
|
||||
|
||||
function toggleHidden(tab: string) {
|
||||
if (hiddenTabs.includes(tab)) {
|
||||
hiddenTabs = hiddenTabs.filter((current) => current !== tab);
|
||||
} else {
|
||||
hiddenTabs = [...hiddenTabs, tab];
|
||||
}
|
||||
}
|
||||
|
||||
function isHidden(tab: string): boolean {
|
||||
return hiddenTabs.includes(tab);
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
tabOrder = [...DEFAULT_TAB_ORDER];
|
||||
hiddenTabs = [];
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
const prefs: LibraryPreferences = {
|
||||
tab_order: tabOrder,
|
||||
hidden_tabs: hiddenTabs,
|
||||
};
|
||||
const saved = await invoke<LibraryPreferences>('v2_save_library_preferences', { prefs });
|
||||
onSave(saved);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to save library preferences:', err);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
syncFromPreferences();
|
||||
onClose();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
syncFromPreferences();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} onClose={handleCancel} title={$t('library.editTabs.title')} maxWidth="400px">
|
||||
{#snippet children()}
|
||||
<div class="modal-body">
|
||||
<p class="modal-help">{$t('library.editTabs.help')}</p>
|
||||
|
||||
<div class="tab-order-list">
|
||||
{#each tabOrder as tab, index (tab)}
|
||||
<div class="tab-order-item" class:hidden-tab={isHidden(tab)}>
|
||||
<div class="tab-grip">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<div class="tab-label">{getTabLabel(tab)}</div>
|
||||
<div class="tab-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="tab-move-btn"
|
||||
onclick={() => toggleHidden(tab)}
|
||||
title={isHidden(tab) ? $t('library.editTabs.showTab') : $t('library.editTabs.hideTab')}
|
||||
aria-label={isHidden(tab) ? $t('library.editTabs.showTab') : $t('library.editTabs.hideTab')}
|
||||
>
|
||||
{#if isHidden(tab)}
|
||||
<EyeOff size={14} />
|
||||
{:else}
|
||||
<Eye size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab-move-btn"
|
||||
onclick={() => moveUp(index)}
|
||||
disabled={index === 0}
|
||||
title={$t('actions.moveUp')}
|
||||
aria-label={$t('actions.moveUp')}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab-move-btn"
|
||||
onclick={() => moveDown(index)}
|
||||
disabled={index === tabOrder.length - 1}
|
||||
title={$t('actions.moveDown')}
|
||||
aria-label={$t('actions.moveDown')}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-tertiary" onclick={handleReset} disabled={saving}>
|
||||
{$t('library.editTabs.reset')}
|
||||
</button>
|
||||
<div class="modal-actions-right">
|
||||
<button type="button" class="btn btn-secondary" onclick={handleCancel} disabled={saving}>
|
||||
{$t('library.editTabs.cancel')}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick={handleSave} disabled={saving}>
|
||||
{saving ? $t('actions.saving') : $t('library.editTabs.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.modal-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modal-help {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tab-order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-order-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.tab-order-item.hidden-tab {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tab-grip {
|
||||
color: var(--text-muted);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tab-move-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
|
||||
}
|
||||
|
||||
.tab-move-btn:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab-move-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.modal-actions-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,8 @@
|
||||
import { cmdAddTracksToQueue, cmdAddTracksToQueueNext } from '$lib/services/commandRouter';
|
||||
import FolderSettingsModal from '../FolderSettingsModal.svelte';
|
||||
import LocalLibraryTagEditorModal from '../LocalLibraryTagEditorModal.svelte';
|
||||
import LibraryEditModal from '../LibraryEditModal.svelte';
|
||||
import type { LibraryPreferences } from '$lib/types';
|
||||
import ViewTransition from '../ViewTransition.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getUserItem } from '$lib/utils/userStorage';
|
||||
@@ -98,6 +100,15 @@
|
||||
bit_depth?: number;
|
||||
sample_rate: number;
|
||||
directory_path: string;
|
||||
/**
|
||||
* Comma-separated list of folder keys that contributed tracks to this
|
||||
* album. Populated only by the metadata-grouped Albums query
|
||||
* (`v2_library_get_albums_metadata`); `null`/`undefined` for folder-
|
||||
* grouped rows from `v2_library_get_albums`. The Albums tab uses
|
||||
* presence of this field to detect a "metadata row" and renders a
|
||||
* tooltip when more than one folder contributed.
|
||||
*/
|
||||
source_folders?: string | null;
|
||||
source?: string; // 'user' | 'qobuz_download' | 'plex'
|
||||
likely_single_file_album?: boolean;
|
||||
}
|
||||
@@ -261,8 +272,67 @@
|
||||
}: Props = $props();
|
||||
|
||||
// View state
|
||||
type TabType = 'albums' | 'artists' | 'tracks';
|
||||
let activeTab = $state<TabType>('albums');
|
||||
type TabType = 'tracks' | 'folders' | 'albums' | 'artists';
|
||||
let activeTab = $state<TabType>('tracks');
|
||||
|
||||
// Library tab preferences (persisted per-user via v2_*_library_preferences)
|
||||
const DEFAULT_TAB_ORDER: TabType[] = ['tracks', 'folders', 'albums', 'artists'];
|
||||
let libraryPreferences = $state<LibraryPreferences>({
|
||||
tab_order: [...DEFAULT_TAB_ORDER],
|
||||
hidden_tabs: [],
|
||||
});
|
||||
let isEditTabsModalOpen = $state(false);
|
||||
|
||||
function isKnownTab(tab: string): tab is TabType {
|
||||
return (DEFAULT_TAB_ORDER as readonly string[]).includes(tab);
|
||||
}
|
||||
|
||||
function sanitizeLibraryPreferences(prefs: LibraryPreferences): LibraryPreferences {
|
||||
// Keep only known tabs; backfill any missing ones at the end so users
|
||||
// with older preferences still surface tabs added in future releases.
|
||||
const validOrder: TabType[] = (prefs.tab_order ?? []).filter(isKnownTab);
|
||||
for (const tab of DEFAULT_TAB_ORDER) {
|
||||
if (!validOrder.includes(tab)) validOrder.push(tab);
|
||||
}
|
||||
const validHidden: TabType[] = (prefs.hidden_tabs ?? []).filter(isKnownTab);
|
||||
return { tab_order: validOrder, hidden_tabs: validHidden };
|
||||
}
|
||||
|
||||
const visibleTabs = $derived(
|
||||
libraryPreferences.tab_order.filter(
|
||||
(tab): tab is TabType => isKnownTab(tab) && !libraryPreferences.hidden_tabs.includes(tab),
|
||||
),
|
||||
);
|
||||
|
||||
async function loadLibraryPreferences() {
|
||||
try {
|
||||
const prefs = await invoke<LibraryPreferences>('v2_get_library_preferences');
|
||||
libraryPreferences = sanitizeLibraryPreferences(prefs);
|
||||
// If the active tab is no longer visible (e.g. user hid it on another
|
||||
// device), fall back to the first visible tab.
|
||||
const currentVisible = libraryPreferences.tab_order.filter(
|
||||
(tab): tab is TabType =>
|
||||
isKnownTab(tab) && !libraryPreferences.hidden_tabs.includes(tab),
|
||||
);
|
||||
if (!currentVisible.includes(activeTab)) {
|
||||
activeTab = currentVisible[0] ?? 'tracks';
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[LocalLibrary] Failed to load preferences, using defaults:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLibraryPreferencesSaved(prefs: LibraryPreferences) {
|
||||
libraryPreferences = sanitizeLibraryPreferences(prefs);
|
||||
const currentVisible = libraryPreferences.tab_order.filter(
|
||||
(tab): tab is TabType =>
|
||||
isKnownTab(tab) && !libraryPreferences.hidden_tabs.includes(tab),
|
||||
);
|
||||
if (!currentVisible.includes(activeTab)) {
|
||||
activeTab = currentVisible[0] ?? 'tracks';
|
||||
}
|
||||
isEditTabsModalOpen = false;
|
||||
}
|
||||
let showSettings = $state(false);
|
||||
let showHiddenAlbums = $state(false);
|
||||
let albumSearch = $state('');
|
||||
@@ -787,8 +857,8 @@
|
||||
if (activeTab === 'tracks' && tracks.length > 0) {
|
||||
return tracks.length;
|
||||
}
|
||||
// When in albums view, calculate from filtered albums
|
||||
if (activeTab === 'albums') {
|
||||
// When in folders (album-by-folder) view, calculate from filtered albums
|
||||
if (activeTab === 'folders') {
|
||||
return albums.reduce((sum, album) => sum + album.track_count, 0);
|
||||
}
|
||||
// When in artists view, calculate from filtered artists
|
||||
@@ -1183,6 +1253,14 @@
|
||||
let selectedAlbum = $state<LocalAlbum | null>(null);
|
||||
let albumTracks = $state<LocalTrack[]>([]);
|
||||
|
||||
// Metadata-grouped Albums tab data — parallel to `albums` (folder-grouped).
|
||||
// The selected album carries its own discriminator via `source_folders`,
|
||||
// so `fetchAlbumTracks` knows which V2 command to call without needing a
|
||||
// separate viewMode state on this view.
|
||||
let metadataAlbums = $state<LocalAlbum[]>([]);
|
||||
let metadataAlbumsLoading = $state(false);
|
||||
let metadataAlbumsLoaded = $state(false);
|
||||
|
||||
// Album-detail multi-select helpers — shift-click anchor + select-all
|
||||
// state derived against the open album's track list. selectedTrackIds
|
||||
// is the same Set the tracks tab uses, so selections compose cleanly
|
||||
@@ -1313,6 +1391,8 @@
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
// Load tab preferences first so the initial render uses the saved order.
|
||||
await loadLibraryPreferences();
|
||||
await loadLibraryData();
|
||||
// Load folders (now safe in offline mode - uses library_get_folders instead)
|
||||
loadFolders(); // Load in background - doesn't block UI
|
||||
@@ -1641,6 +1721,9 @@
|
||||
const background = options.background === true;
|
||||
const startedAt = performance.now();
|
||||
console.log('[LocalLibrary] loadLibraryData START, isOffline:', isOffline, 'background:', background);
|
||||
// Library data was just refreshed (or is about to be) — drop the
|
||||
// cached metadata-grouped Albums list so the next visit re-fetches.
|
||||
metadataAlbumsLoaded = false;
|
||||
if (!background) {
|
||||
loading = true;
|
||||
error = null;
|
||||
@@ -1723,6 +1806,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMetadataAlbums() {
|
||||
if (metadataAlbumsLoaded || metadataAlbumsLoading) return;
|
||||
try {
|
||||
metadataAlbumsLoading = true;
|
||||
metadataAlbums = await invoke<LocalAlbum[]>('v2_library_get_albums_metadata', {
|
||||
includeHidden: false,
|
||||
excludeNetworkFolders: shouldExcludeNetworkFolders(),
|
||||
});
|
||||
metadataAlbumsLoaded = true;
|
||||
} catch (err) {
|
||||
console.error('[LocalLibrary] Failed to load metadata albums:', err);
|
||||
metadataAlbums = [];
|
||||
} finally {
|
||||
metadataAlbumsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFolders() {
|
||||
try {
|
||||
console.log('[LocalLibrary] loadFolders START, isOffline:', isOffline);
|
||||
@@ -1902,6 +2002,8 @@
|
||||
loadArtists();
|
||||
} else if (tab === 'tracks' && tracks.length === 0) {
|
||||
loadTracks(trackSearch.trim());
|
||||
} else if (tab === 'albums' && !metadataAlbumsLoaded) {
|
||||
loadMetadataAlbums();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2042,6 +2144,7 @@
|
||||
await loadLibraryData();
|
||||
if (activeTab === 'artists') await loadArtists();
|
||||
if (activeTab === 'tracks') await loadTracks();
|
||||
if (activeTab === 'albums') await loadMetadataAlbums();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get scan progress:', err);
|
||||
@@ -2148,6 +2251,8 @@
|
||||
albums = [];
|
||||
artists = [];
|
||||
tracks = [];
|
||||
metadataAlbums = [];
|
||||
metadataAlbumsLoaded = false;
|
||||
} catch (err) {
|
||||
console.error('Failed to clear library:', err);
|
||||
alert(`Failed to clear library: ${err}`);
|
||||
@@ -2353,6 +2458,17 @@
|
||||
return await hydratePlexTrackQuality(mappedTracks);
|
||||
}
|
||||
|
||||
// Detect a metadata-grouped album row: the metadata Albums query
|
||||
// emits an empty `directory_path` and populates `source_folders`.
|
||||
// Use `v2_library_get_album_tracks_metadata` for those; folder-
|
||||
// grouped rows keep the original `v2_library_get_album_tracks`.
|
||||
const isMetadataAlbum = album.source_folders != null;
|
||||
if (isMetadataAlbum) {
|
||||
return await invoke<LocalTrack[]>('v2_library_get_album_tracks_metadata', {
|
||||
metadataKey: album.id
|
||||
});
|
||||
}
|
||||
|
||||
return await invoke<LocalTrack[]>('v2_library_get_album_tracks', {
|
||||
albumGroupKey: album.id
|
||||
});
|
||||
@@ -3020,7 +3136,7 @@
|
||||
setTimeout(() => searchInputEl?.focus(), 50);
|
||||
} else {
|
||||
// Clear search when closing
|
||||
if (activeTab === 'albums') {
|
||||
if (activeTab === 'folders') {
|
||||
albumSearch = '';
|
||||
debouncedAlbumSearch = '';
|
||||
if (albumSearchTimer) clearTimeout(albumSearchTimer);
|
||||
@@ -3036,20 +3152,20 @@
|
||||
}
|
||||
|
||||
function getCurrentSearchValue(): string {
|
||||
if (activeTab === 'albums') return albumSearch;
|
||||
if (activeTab === 'folders') return albumSearch;
|
||||
if (activeTab === 'artists') return artistSearch;
|
||||
return trackSearch;
|
||||
}
|
||||
|
||||
function getCurrentSearchPlaceholder(): string {
|
||||
if (activeTab === 'albums') return 'Search albums or artists...';
|
||||
if (activeTab === 'folders') return 'Search albums or artists...';
|
||||
if (activeTab === 'artists') return 'Search artists...';
|
||||
return 'Search tracks, albums, artists...';
|
||||
}
|
||||
|
||||
function handleSearchInput(e: Event) {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
if (activeTab === 'albums') {
|
||||
if (activeTab === 'folders') {
|
||||
albumSearch = value;
|
||||
scheduleAlbumSearch();
|
||||
} else if (activeTab === 'artists') {
|
||||
@@ -3479,7 +3595,7 @@
|
||||
</script>
|
||||
|
||||
<ViewTransition duration={200} distance={12} direction="down">
|
||||
<div class="library-view" class:virtualized-active={!selectedAlbum && ((activeTab === 'albums' && !showHiddenAlbums && albums.length > 0) || (activeTab === 'artists' && artists.length > 0) || (activeTab === 'tracks' && tracks.length > 0))}>
|
||||
<div class="library-view" class:virtualized-active={!selectedAlbum && ((activeTab === 'folders' && !showHiddenAlbums && albums.length > 0) || (activeTab === 'albums' && metadataAlbums.length > 0) || (activeTab === 'artists' && artists.length > 0) || (activeTab === 'tracks' && tracks.length > 0))}>
|
||||
{#if selectedAlbum}
|
||||
{@const filteredAlbumTracks = albumTrackSearch.trim()
|
||||
? albumTracks.filter(track =>
|
||||
@@ -3757,7 +3873,7 @@
|
||||
{#if showSettings}
|
||||
<div class="settings-panel">
|
||||
<div class="settings-header">
|
||||
<h3>{$t('library.folders')}</h3>
|
||||
<h3>{$t('library.folderListTitle')}</h3>
|
||||
<div class="folder-actions">
|
||||
<div class="folder-search">
|
||||
<Search size={14} />
|
||||
@@ -3901,26 +4017,24 @@
|
||||
<div class="jump-nav">
|
||||
<div class="jump-nav-left">
|
||||
<div class="jump-links">
|
||||
{#each visibleTabs as tab (tab)}
|
||||
<button
|
||||
type="button"
|
||||
class="jump-link"
|
||||
class:active={activeTab === 'albums'}
|
||||
onclick={() => handleTabChange('albums')}
|
||||
class:active={activeTab === tab}
|
||||
onclick={() => handleTabChange(tab)}
|
||||
>
|
||||
{$t('library.albums')}
|
||||
{$t(`library.${tab}`)}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
class="jump-link"
|
||||
class:active={activeTab === 'artists'}
|
||||
onclick={() => handleTabChange('artists')}
|
||||
type="button"
|
||||
class="jump-link tab-edit-btn"
|
||||
onclick={() => (isEditTabsModalOpen = true)}
|
||||
aria-label={$t('library.editTabs.title')}
|
||||
title={$t('library.editTabs.title')}
|
||||
>
|
||||
{$t('library.artists')}
|
||||
</button>
|
||||
<button
|
||||
class="jump-link"
|
||||
class:active={activeTab === 'tracks'}
|
||||
onclick={() => handleTabChange('tracks')}
|
||||
>
|
||||
{$t('library.tracks')}
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3966,7 +4080,7 @@
|
||||
<p class="error-detail">{error}</p>
|
||||
<button class="retry-btn" onclick={() => loadLibraryData()}>{$t('actions.retry')}</button>
|
||||
</div>
|
||||
{:else if activeTab === 'albums'}
|
||||
{:else if activeTab === 'folders'}
|
||||
{#key activeTab}
|
||||
<ViewTransition duration={200} distance={12} direction="up">
|
||||
{#if showHiddenAlbums}
|
||||
@@ -4313,6 +4427,42 @@
|
||||
/>
|
||||
</ViewTransition>
|
||||
{/key}
|
||||
{:else if activeTab === 'albums'}
|
||||
{#key activeTab}
|
||||
<ViewTransition duration={200} distance={12} direction="up">
|
||||
{#if metadataAlbumsLoading && metadataAlbums.length === 0}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>{$t('library.loadingLibrary')}</p>
|
||||
</div>
|
||||
{:else if metadataAlbums.length === 0}
|
||||
<div class="empty">
|
||||
<Disc3 size={48} />
|
||||
<p>{$t('library.noAlbumsInLibrary')}</p>
|
||||
<p class="empty-hint">{$t('library.addFoldersHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="album-sections virtualized">
|
||||
<div class="virtualized-container">
|
||||
<VirtualizedAlbumList
|
||||
groups={[{ key: '', id: 'metadata-ungrouped', albums: metadataAlbums }]}
|
||||
viewMode={albumViewMode}
|
||||
showGroupHeaders={false}
|
||||
{getArtworkUrl}
|
||||
getQualityBadge={getAlbumQualityBadge}
|
||||
isHiRes={isAlbumHiRes}
|
||||
formatDuration={formatTotalDuration}
|
||||
onAlbumClick={handleAlbumClick}
|
||||
onAlbumPlay={handleAlbumPlayFromGrid}
|
||||
onAlbumQueueNext={handleAlbumQueueNextFromGrid}
|
||||
onAlbumQueueLater={handleAlbumQueueLaterFromGrid}
|
||||
showSourceBadge={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</ViewTransition>
|
||||
{/key}
|
||||
{:else if activeTab === 'artists'}
|
||||
{#key activeTab}
|
||||
<ViewTransition duration={200} distance={12} direction="up">
|
||||
@@ -4756,6 +4906,14 @@
|
||||
onScanFolder={handleScanSingleFolder}
|
||||
/>
|
||||
|
||||
<!-- Library Edit Tabs Modal -->
|
||||
<LibraryEditModal
|
||||
isOpen={isEditTabsModalOpen}
|
||||
initialPreferences={libraryPreferences}
|
||||
onClose={() => (isEditTabsModalOpen = false)}
|
||||
onSave={handleLibraryPreferencesSaved}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.library-view {
|
||||
padding: 8px 8px 100px 18px;
|
||||
@@ -5312,6 +5470,18 @@
|
||||
border-bottom-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.jump-link.tab-edit-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.jump-link.tab-edit-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Page Search in Nav */
|
||||
.page-search {
|
||||
display: flex;
|
||||
|
||||
@@ -890,7 +890,17 @@
|
||||
"albums": "Alben",
|
||||
"artists": "Künstler",
|
||||
"tracks": "Titel",
|
||||
"folders": "Bibliotheksordner",
|
||||
"folders": "Ordner",
|
||||
"folderListTitle": "Bibliotheksordner",
|
||||
"editTabs": {
|
||||
"title": "Library-Tabs bearbeiten",
|
||||
"help": "Zum Neuordnen ziehen. Sichtbarkeit umschalten.",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"reset": "Auf Standard zurücksetzen",
|
||||
"showTab": "Tab anzeigen",
|
||||
"hideTab": "Tab ausblenden"
|
||||
},
|
||||
"noAlbums": "Keine Alben in der Bibliothek",
|
||||
"noArtists": "Keine Künstler in der Bibliothek",
|
||||
"noTracks": "Keine Titel in der Bibliothek",
|
||||
|
||||
@@ -890,8 +890,18 @@
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"tracks": "Tracks",
|
||||
"folders": "Folders",
|
||||
"explicit": "Explicit",
|
||||
"folders": "Library Folders",
|
||||
"folderListTitle": "Library Folders",
|
||||
"editTabs": {
|
||||
"title": "Edit Library Tabs",
|
||||
"help": "Drag to reorder. Toggle visibility.",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"reset": "Reset to defaults",
|
||||
"showTab": "Show tab",
|
||||
"hideTab": "Hide tab"
|
||||
},
|
||||
"noAlbums": "No albums in library",
|
||||
"noArtists": "No artists in library",
|
||||
"noTracks": "No tracks in library",
|
||||
|
||||
@@ -890,7 +890,17 @@
|
||||
"albums": "Álbumes",
|
||||
"artists": "Artistas",
|
||||
"tracks": "Pistas",
|
||||
"folders": "Carpetas de la Biblioteca",
|
||||
"folders": "Carpetas",
|
||||
"folderListTitle": "Carpetas de la Biblioteca",
|
||||
"editTabs": {
|
||||
"title": "Editar pestañas de Library",
|
||||
"help": "Arrastra para reordenar. Activa o desactiva visibilidad.",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"reset": "Restaurar predeterminado",
|
||||
"showTab": "Mostrar pestaña",
|
||||
"hideTab": "Ocultar pestaña"
|
||||
},
|
||||
"noAlbums": "No hay álbumes en la biblioteca",
|
||||
"noArtists": "No hay artistas en la biblioteca",
|
||||
"noTracks": "No hay pistas en la biblioteca",
|
||||
|
||||
@@ -890,7 +890,17 @@
|
||||
"albums": "Albums",
|
||||
"artists": "Artistes",
|
||||
"tracks": "Titres",
|
||||
"folders": "Dossiers de la bibliothèque",
|
||||
"folders": "Dossiers",
|
||||
"folderListTitle": "Dossiers de la bibliothèque",
|
||||
"editTabs": {
|
||||
"title": "Modifier les onglets Library",
|
||||
"help": "Glissez pour réordonner. Basculez la visibilité.",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"reset": "Réinitialiser",
|
||||
"showTab": "Afficher l'onglet",
|
||||
"hideTab": "Masquer l'onglet"
|
||||
},
|
||||
"noAlbums": "Aucun album dans la bibliothèque",
|
||||
"noArtists": "Aucun artiste dans la bibliothèque",
|
||||
"noTracks": "Aucun titre dans la bibliothèque",
|
||||
|
||||
@@ -890,7 +890,17 @@
|
||||
"albums": "Álbuns",
|
||||
"artists": "Artistas",
|
||||
"tracks": "Faixas",
|
||||
"folders": "Pastas da Biblioteca",
|
||||
"folders": "Pastas",
|
||||
"folderListTitle": "Pastas da Biblioteca",
|
||||
"editTabs": {
|
||||
"title": "Editar abas da Library",
|
||||
"help": "Arraste para reordenar. Alterne a visibilidade.",
|
||||
"save": "Salvar",
|
||||
"cancel": "Cancelar",
|
||||
"reset": "Restaurar padrões",
|
||||
"showTab": "Mostrar aba",
|
||||
"hideTab": "Ocultar aba"
|
||||
},
|
||||
"noAlbums": "Nenhum álbum na biblioteca",
|
||||
"noArtists": "Nenhum artista na biblioteca",
|
||||
"noTracks": "Nenhuma faixa na biblioteca",
|
||||
|
||||
@@ -498,6 +498,11 @@ export interface FavoritesPreferences {
|
||||
tab_order: string[];
|
||||
}
|
||||
|
||||
export type LibraryPreferences = {
|
||||
tab_order: string[];
|
||||
hidden_tabs: string[];
|
||||
};
|
||||
|
||||
// ============ Discover API Types ============
|
||||
|
||||
export interface DiscoverResponse {
|
||||
|
||||
Reference in new issue
Block a user