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:
vicrodh committed 2026-05-06 17:35:08 -06:00
commit 2b8e1a66e1
21 files changed
+1169 -30

No files matched your search

+1
View File
@@ -3007,6 +3007,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"uuid",
"walkdir",
+3
View File
@@ -34,3 +34,6 @@ glob = "0.3"
# UUID generation
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
tempfile = "3"
+31
View File
@@ -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__";
+340
View File
@@ -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);
}
}
+1
View File
@@ -28,6 +28,7 @@
//! let db = LibraryDatabase::open(Path::new("library.db")).unwrap();
//! ```
pub mod album_grouping;
mod cue_parser;
mod database;
mod errors;
+7
View File
@@ -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,
+46
View File
@@ -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(
+21
View File
@@ -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()
+178
View File
@@ -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)
}
+6
View File
@@ -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,
+7
View File
@@ -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,
+1
View File
@@ -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",
+9
View File
@@ -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
+263
View File
@@ -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>
+195 -25
View File
@@ -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;
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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",
+5
View File
@@ -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 {