fix(library): restore offline visibility and unify filters and sorting

Recover missing offline library entries, recognize cached-only libraries, and refresh availability after cache changes. Unify per-tab filters, compact the toolbar, and add reversible sort criteria with complete available purchase metadata.

cx
This commit is contained in:
vicrodh committed 2026-09-09 17:08:33 -06:00
1 parent 9504139dec
commit 17e08e93de
33 files changed
+1938 -547

No files matched your search

+1
View File
@@ -3895,6 +3895,7 @@ mod tests {
streamable: None, streamable: None,
streamable_at: None, streamable_at: None,
release_date_stream: None, release_date_stream: None,
release_date_original: None,
}); });
t t
} }
@@ -6858,3 +6858,80 @@ msgstr "Gruppe: Jahrzehnt"
msgid "Popularity" msgid "Popularity"
msgstr "Beliebtheit" msgstr "Beliebtheit"
#: library toolbar
msgid "Purchases only"
msgstr "Nur Käufe"
#: library toolbar
msgid "Following only"
msgstr "Nur gefolgte Inhalte"
#: library toolbar
msgid "Hi-Res only"
msgstr "Nur Hi-Res"
#: library toolbar
msgid "Hide local albums from library"
msgstr "Lokale Alben in der Bibliothek ausblenden"
#: library toolbar
msgid "By you"
msgstr "Von dir"
#: library toolbar
msgid "By Qobuz"
msgstr "Von Qobuz"
#: library toolbar
msgid "By others"
msgstr "Von anderen"
#: library toolbar
msgid "Search your tracks"
msgstr "Deine Titel durchsuchen"
#: library toolbar
msgid "Search your releases"
msgstr "Deine Veröffentlichungen durchsuchen"
#: library toolbar
msgid "Search your playlists"
msgstr "Deine Playlists durchsuchen"
#: library toolbar
msgid "Search your artists"
msgstr "Deine Künstler durchsuchen"
#: library toolbar
msgid "Search your labels"
msgstr "Deine Labels durchsuchen"
#: library toolbar
msgid "Show only purchased items. Combines with the other enabled filters."
msgstr "Nur gekaufte Inhalte anzeigen. Wird mit den anderen aktiven Filtern kombiniert."
#: library toolbar
msgid "Show only favorites. Combines with the other enabled filters."
msgstr "Nur Favoriten anzeigen. Wird mit den anderen aktiven Filtern kombiniert."
#: library toolbar
msgid "Show only followed items. Combines with the other enabled filters."
msgstr "Nur gefolgte Inhalte anzeigen. Wird mit den anderen aktiven Filtern kombiniert."
#: library toolbar
msgid "Show only Hi-Res items. Combines with the other enabled filters."
msgstr "Nur Hi-Res-Inhalte anzeigen. Wird mit den anderen aktiven Filtern kombiniert."
#: library toolbar
msgid "Hide local albums here. They remain available in Local Library."
msgstr "Lokale Alben hier ausblenden. Sie bleiben in der lokalen Bibliothek verfügbar."
msgid "Alphabetical"
msgstr "Alphabetisch"
msgid "Date updated"
msgstr "Aktualisierungsdatum"
msgid "Track count"
msgstr "Titelanzahl"
@@ -2442,3 +2442,96 @@ msgstr "Group: Decade"
msgid "Popularity" msgid "Popularity"
msgstr "Popularity" msgstr "Popularity"
#: library toolbar
msgid "Purchases only"
msgstr "Purchases only"
#: library toolbar
msgid "Following only"
msgstr "Following only"
#: library toolbar
msgid "Hi-Res only"
msgstr "Hi-Res only"
#: library toolbar
msgid "Hide local albums from library"
msgstr "Hide local albums from library"
#: library toolbar
msgid "By you"
msgstr "By you"
#: library toolbar
msgid "By Qobuz"
msgstr "By Qobuz"
#: library toolbar
msgid "By others"
msgstr "By others"
#: library toolbar
msgid "Search your tracks"
msgstr "Search your tracks"
#: library toolbar
msgid "Search your releases"
msgstr "Search your releases"
#: library toolbar
msgid "Search your playlists"
msgstr "Search your playlists"
#: library toolbar
msgid "Search your artists"
msgstr "Search your artists"
#: library toolbar
msgid "Search your labels"
msgstr "Search your labels"
#: library toolbar
msgid "Releases"
msgstr "Releases"
#: library toolbar
msgid "Show only purchased items. Combines with the other enabled filters."
msgstr "Show only purchased items. Combines with the other enabled filters."
#: library toolbar
msgid "Show only favorites. Combines with the other enabled filters."
msgstr "Show only favorites. Combines with the other enabled filters."
#: library toolbar
msgid "Show only followed items. Combines with the other enabled filters."
msgstr "Show only followed items. Combines with the other enabled filters."
#: library toolbar
msgid "Show only Hi-Res items. Combines with the other enabled filters."
msgstr "Show only Hi-Res items. Combines with the other enabled filters."
#: library toolbar
msgid "Hide local albums here. They remain available in Local Library."
msgstr "Hide local albums here. They remain available in Local Library."
msgid "Filters"
msgstr "Filters"
msgid "Alphabetical"
msgstr "Alphabetical"
msgid "Date updated"
msgstr "Date updated"
msgid "Track count"
msgstr "Track count"
msgid "Release"
msgstr "Release"
msgid "Release date"
msgstr "Release date"
msgid "Label"
msgstr "Label"
@@ -6864,3 +6864,80 @@ msgstr "Agrupar: Década"
msgid "Popularity" msgid "Popularity"
msgstr "Popularidad" msgstr "Popularidad"
#: library toolbar
msgid "Purchases only"
msgstr "Solo compras"
#: library toolbar
msgid "Following only"
msgstr "Solo seguidos"
#: library toolbar
msgid "Hi-Res only"
msgstr "Solo Hi-Res"
#: library toolbar
msgid "Hide local albums from library"
msgstr "Ocultar álbumes locales de la biblioteca"
#: library toolbar
msgid "By you"
msgstr "Por ti"
#: library toolbar
msgid "By Qobuz"
msgstr "Por Qobuz"
#: library toolbar
msgid "By others"
msgstr "Por otros"
#: library toolbar
msgid "Search your tracks"
msgstr "Buscar en tus pistas"
#: library toolbar
msgid "Search your releases"
msgstr "Buscar en tus lanzamientos"
#: library toolbar
msgid "Search your playlists"
msgstr "Buscar en tus playlists"
#: library toolbar
msgid "Search your artists"
msgstr "Buscar en tus artistas"
#: library toolbar
msgid "Search your labels"
msgstr "Buscar en tus sellos"
#: library toolbar
msgid "Show only purchased items. Combines with the other enabled filters."
msgstr "Mostrar solo compras. Se combina con los demás filtros activos."
#: library toolbar
msgid "Show only favorites. Combines with the other enabled filters."
msgstr "Mostrar solo favoritos. Se combina con los demás filtros activos."
#: library toolbar
msgid "Show only followed items. Combines with the other enabled filters."
msgstr "Mostrar solo elementos seguidos. Se combina con los demás filtros activos."
#: library toolbar
msgid "Show only Hi-Res items. Combines with the other enabled filters."
msgstr "Mostrar solo contenido Hi-Res. Se combina con los demás filtros activos."
#: library toolbar
msgid "Hide local albums here. They remain available in Local Library."
msgstr "Ocultar aquí los álbumes locales. Seguirán disponibles en Biblioteca local."
msgid "Alphabetical"
msgstr "Alfabético"
msgid "Date updated"
msgstr "Fecha de actualización"
msgid "Track count"
msgstr "Número de pistas"
@@ -6856,3 +6856,80 @@ msgstr "Grouper : Décennie"
msgid "Popularity" msgid "Popularity"
msgstr "Popularité" msgstr "Popularité"
#: library toolbar
msgid "Purchases only"
msgstr "Achats uniquement"
#: library toolbar
msgid "Following only"
msgstr "Éléments suivis uniquement"
#: library toolbar
msgid "Hi-Res only"
msgstr "Hi-Res uniquement"
#: library toolbar
msgid "Hide local albums from library"
msgstr "Masquer les albums locaux de la bibliothèque"
#: library toolbar
msgid "By you"
msgstr "Par vous"
#: library toolbar
msgid "By Qobuz"
msgstr "Par Qobuz"
#: library toolbar
msgid "By others"
msgstr "Par les autres"
#: library toolbar
msgid "Search your tracks"
msgstr "Rechercher dans vos titres"
#: library toolbar
msgid "Search your releases"
msgstr "Rechercher dans vos sorties"
#: library toolbar
msgid "Search your playlists"
msgstr "Rechercher dans vos playlists"
#: library toolbar
msgid "Search your artists"
msgstr "Rechercher dans vos artistes"
#: library toolbar
msgid "Search your labels"
msgstr "Rechercher dans vos labels"
#: library toolbar
msgid "Show only purchased items. Combines with the other enabled filters."
msgstr "Afficher uniquement les achats. Se combine avec les autres filtres actifs."
#: library toolbar
msgid "Show only favorites. Combines with the other enabled filters."
msgstr "Afficher uniquement les favoris. Se combine avec les autres filtres actifs."
#: library toolbar
msgid "Show only followed items. Combines with the other enabled filters."
msgstr "Afficher uniquement les éléments suivis. Se combine avec les autres filtres actifs."
#: library toolbar
msgid "Show only Hi-Res items. Combines with the other enabled filters."
msgstr "Afficher uniquement le contenu Hi-Res. Se combine avec les autres filtres actifs."
#: library toolbar
msgid "Hide local albums here. They remain available in Local Library."
msgstr "Masquer les albums locaux ici. Ils restent disponibles dans la bibliothèque locale."
msgid "Alphabetical"
msgstr "Alphabétique"
msgid "Date updated"
msgstr "Date de modification"
msgid "Track count"
msgstr "Nombre de pistes"
@@ -6839,3 +6839,80 @@ msgstr "グループ: 年代"
msgid "Popularity" msgid "Popularity"
msgstr "人気順" msgstr "人気順"
#: library toolbar
msgid "Purchases only"
msgstr "購入済みのみ"
#: library toolbar
msgid "Following only"
msgstr "フォロー中のみ"
#: library toolbar
msgid "Hi-Res only"
msgstr "Hi-Resのみ"
#: library toolbar
msgid "Hide local albums from library"
msgstr "ライブラリでローカルアルバムを非表示"
#: library toolbar
msgid "By you"
msgstr "自分が作成"
#: library toolbar
msgid "By Qobuz"
msgstr "Qobuzが作成"
#: library toolbar
msgid "By others"
msgstr "他のユーザーが作成"
#: library toolbar
msgid "Search your tracks"
msgstr "トラックを検索"
#: library toolbar
msgid "Search your releases"
msgstr "リリースを検索"
#: library toolbar
msgid "Search your playlists"
msgstr "プレイリストを検索"
#: library toolbar
msgid "Search your artists"
msgstr "アーティストを検索"
#: library toolbar
msgid "Search your labels"
msgstr "レーベルを検索"
#: library toolbar
msgid "Show only purchased items. Combines with the other enabled filters."
msgstr "購入済みの項目のみ表示します。他の有効なフィルターと組み合わせて適用します。"
#: library toolbar
msgid "Show only favorites. Combines with the other enabled filters."
msgstr "お気に入りのみ表示します。他の有効なフィルターと組み合わせて適用します。"
#: library toolbar
msgid "Show only followed items. Combines with the other enabled filters."
msgstr "フォロー中の項目のみ表示します。他の有効なフィルターと組み合わせて適用します。"
#: library toolbar
msgid "Show only Hi-Res items. Combines with the other enabled filters."
msgstr "Hi-Resの項目のみ表示します。他の有効なフィルターと組み合わせて適用します。"
#: library toolbar
msgid "Hide local albums here. They remain available in Local Library."
msgstr "ここではローカルアルバムを非表示にします。ローカルライブラリでは引き続き利用できます。"
msgid "Alphabetical"
msgstr "アルファベット順"
msgid "Date updated"
msgstr "更新日"
msgid "Track count"
msgstr "曲数"
@@ -6846,3 +6846,80 @@ msgstr "Groeperen: Decennium"
msgid "Popularity" msgid "Popularity"
msgstr "Populariteit" msgstr "Populariteit"
#: library toolbar
msgid "Purchases only"
msgstr "Alleen aankopen"
#: library toolbar
msgid "Following only"
msgstr "Alleen gevolgd"
#: library toolbar
msgid "Hi-Res only"
msgstr "Alleen Hi-Res"
#: library toolbar
msgid "Hide local albums from library"
msgstr "Lokale albums verbergen in bibliotheek"
#: library toolbar
msgid "By you"
msgstr "Door jou"
#: library toolbar
msgid "By Qobuz"
msgstr "Door Qobuz"
#: library toolbar
msgid "By others"
msgstr "Door anderen"
#: library toolbar
msgid "Search your tracks"
msgstr "Zoek in je nummers"
#: library toolbar
msgid "Search your releases"
msgstr "Zoek in je uitgaven"
#: library toolbar
msgid "Search your playlists"
msgstr "Zoek in je afspeellijsten"
#: library toolbar
msgid "Search your artists"
msgstr "Zoek in je artiesten"
#: library toolbar
msgid "Search your labels"
msgstr "Zoek in je labels"
#: library toolbar
msgid "Show only purchased items. Combines with the other enabled filters."
msgstr "Toon alleen aankopen. Wordt gecombineerd met de andere actieve filters."
#: library toolbar
msgid "Show only favorites. Combines with the other enabled filters."
msgstr "Toon alleen favorieten. Wordt gecombineerd met de andere actieve filters."
#: library toolbar
msgid "Show only followed items. Combines with the other enabled filters."
msgstr "Toon alleen gevolgde items. Wordt gecombineerd met de andere actieve filters."
#: library toolbar
msgid "Show only Hi-Res items. Combines with the other enabled filters."
msgstr "Toon alleen Hi-Res-items. Wordt gecombineerd met de andere actieve filters."
#: library toolbar
msgid "Hide local albums here. They remain available in Local Library."
msgstr "Verberg lokale albums hier. Ze blijven beschikbaar in de lokale bibliotheek."
msgid "Alphabetical"
msgstr "Alfabetisch"
msgid "Date updated"
msgstr "Datum bijgewerkt"
msgid "Track count"
msgstr "Aantal nummers"
@@ -6856,3 +6856,80 @@ msgstr "Agrupar: Década"
msgid "Popularity" msgid "Popularity"
msgstr "Popularidade" msgstr "Popularidade"
#: library toolbar
msgid "Purchases only"
msgstr "Somente compras"
#: library toolbar
msgid "Following only"
msgstr "Somente seguidos"
#: library toolbar
msgid "Hi-Res only"
msgstr "Somente Hi-Res"
#: library toolbar
msgid "Hide local albums from library"
msgstr "Ocultar álbuns locais da biblioteca"
#: library toolbar
msgid "By you"
msgstr "Por você"
#: library toolbar
msgid "By Qobuz"
msgstr "Por Qobuz"
#: library toolbar
msgid "By others"
msgstr "Por outros"
#: library toolbar
msgid "Search your tracks"
msgstr "Buscar nas suas faixas"
#: library toolbar
msgid "Search your releases"
msgstr "Buscar nos seus lançamentos"
#: library toolbar
msgid "Search your playlists"
msgstr "Buscar nas suas playlists"
#: library toolbar
msgid "Search your artists"
msgstr "Buscar nos seus artistas"
#: library toolbar
msgid "Search your labels"
msgstr "Buscar nas suas gravadoras"
#: library toolbar
msgid "Show only purchased items. Combines with the other enabled filters."
msgstr "Mostrar somente compras. Combina com os outros filtros ativos."
#: library toolbar
msgid "Show only favorites. Combines with the other enabled filters."
msgstr "Mostrar somente favoritos. Combina com os outros filtros ativos."
#: library toolbar
msgid "Show only followed items. Combines with the other enabled filters."
msgstr "Mostrar somente itens seguidos. Combina com os outros filtros ativos."
#: library toolbar
msgid "Show only Hi-Res items. Combines with the other enabled filters."
msgstr "Mostrar somente conteúdo Hi-Res. Combina com os outros filtros ativos."
#: library toolbar
msgid "Hide local albums here. They remain available in Local Library."
msgstr "Ocultar álbuns locais aqui. Eles continuam disponíveis na Biblioteca local."
msgid "Alphabetical"
msgstr "Alfabético"
msgid "Date updated"
msgstr "Data de atualização"
msgid "Track count"
msgstr "Número de faixas"
@@ -6877,3 +6877,80 @@ msgstr "Группа: Десятилетие"
msgid "Popularity" msgid "Popularity"
msgstr "Популярность" msgstr "Популярность"
#: library toolbar
msgid "Purchases only"
msgstr "Только покупки"
#: library toolbar
msgid "Following only"
msgstr "Только подписки"
#: library toolbar
msgid "Hi-Res only"
msgstr "Только Hi-Res"
#: library toolbar
msgid "Hide local albums from library"
msgstr "Скрыть локальные альбомы в библиотеке"
#: library toolbar
msgid "By you"
msgstr "Ваши"
#: library toolbar
msgid "By Qobuz"
msgstr "От Qobuz"
#: library toolbar
msgid "By others"
msgstr "От других"
#: library toolbar
msgid "Search your tracks"
msgstr "Поиск по вашим трекам"
#: library toolbar
msgid "Search your releases"
msgstr "Поиск по вашим релизам"
#: library toolbar
msgid "Search your playlists"
msgstr "Поиск по вашим плейлистам"
#: library toolbar
msgid "Search your artists"
msgstr "Поиск по вашим исполнителям"
#: library toolbar
msgid "Search your labels"
msgstr "Поиск по вашим лейблам"
#: library toolbar
msgid "Show only purchased items. Combines with the other enabled filters."
msgstr "Показывать только покупки. Применяется вместе с другими включёнными фильтрами."
#: library toolbar
msgid "Show only favorites. Combines with the other enabled filters."
msgstr "Показывать только избранное. Применяется вместе с другими включёнными фильтрами."
#: library toolbar
msgid "Show only followed items. Combines with the other enabled filters."
msgstr "Показывать только подписки. Применяется вместе с другими включёнными фильтрами."
#: library toolbar
msgid "Show only Hi-Res items. Combines with the other enabled filters."
msgstr "Показывать только Hi-Res. Применяется вместе с другими включёнными фильтрами."
#: library toolbar
msgid "Hide local albums here. They remain available in Local Library."
msgstr "Скрыть здесь локальные альбомы. Они останутся доступны в локальной библиотеке."
msgid "Alphabetical"
msgstr "По алфавиту"
msgid "Date updated"
msgstr "Дата обновления"
msgid "Track count"
msgstr "Количество треков"
+3
View File
@@ -695,6 +695,9 @@ pub struct AlbumSummary {
/// ISO streaming release date, used as the fallback upcoming signal. /// ISO streaming release date, used as the fallback upcoming signal.
#[serde(default)] #[serde(default)]
pub release_date_stream: Option<String>, pub release_date_stream: Option<String>,
/// Original release date, when included by the embedded album endpoint.
#[serde(default)]
pub release_date_original: Option<String>,
} }
impl AlbumSummary { impl AlbumSummary {
+10 -2
View File
@@ -408,7 +408,7 @@ pub(crate) async fn try_cmaf_offline_download(
let album_group_key = format!("{}|{}", metadata.album, album_artist); let album_group_key = format!("{}|{}", metadata.album, album_artist);
let lib_opt = library_db.lock().await; let lib_opt = library_db.lock().await;
if let Some(lib_guard) = lib_opt.as_ref() { if let Some(lib_guard) = lib_opt.as_ref() {
let _ = lib_guard.insert_qobuz_cached_track_with_grouping( let result = lib_guard.insert_qobuz_cached_track_with_grouping(
track_id, track_id,
&metadata.title, &metadata.title,
&metadata.artist, &metadata.artist,
@@ -428,6 +428,9 @@ pub(crate) async fn try_cmaf_offline_download(
bundle.sampling_rate.map(|r| r as f64), bundle.sampling_rate.map(|r| r as f64),
artwork_path.as_deref(), artwork_path.as_deref(),
); );
if let Err(error) = result {
log::warn!("[offline-library] track={track_id} metadata insert failed: {error}");
}
} }
} else if let Err(e) = metadata { } else if let Err(e) = metadata {
log::warn!( log::warn!(
@@ -666,7 +669,7 @@ pub fn spawn_track_cache_download(
let album_group_key = format!("{}|{}", metadata.album, album_artist); let album_group_key = format!("{}|{}", metadata.album, album_artist);
let lib_opt = library_db.lock().await; let lib_opt = library_db.lock().await;
if let Some(lib_guard) = lib_opt.as_ref() { if let Some(lib_guard) = lib_opt.as_ref() {
let _ = lib_guard.insert_qobuz_cached_track_with_grouping( let result = lib_guard.insert_qobuz_cached_track_with_grouping(
track_id, track_id,
&metadata.title, &metadata.title,
&metadata.artist, &metadata.artist,
@@ -683,6 +686,11 @@ pub fn spawn_track_cache_download(
sample_rate_detected, sample_rate_detected,
artwork_path_v1.as_deref(), artwork_path_v1.as_deref(),
); );
if let Err(error) = result {
log::warn!(
"[offline-library] track={track_id} metadata insert failed: {error}"
);
}
} }
if let Some(db_guard) = db.lock().await.as_ref() { if let Some(db_guard) = db.lock().await.as_ref() {
+1
View File
@@ -19,6 +19,7 @@ pub mod db;
pub mod downloader; pub mod downloader;
pub mod event; pub mod event;
pub mod maintenance; pub mod maintenance;
pub mod library_sync;
pub mod metadata; pub mod metadata;
pub mod migration; pub mod migration;
pub mod path_validator; pub mod path_validator;
@@ -0,0 +1,272 @@
//! Repair missing derived library rows using the ready download index only.
//! Never download, delete audio, or replace an existing library row.
use std::collections::{HashMap, HashSet};
use std::path::Path;
use crate::{OfflineCacheDb, OfflineCacheStatus};
use qbz_library::LibraryDatabase;
#[derive(Debug, Default)]
pub struct LibrarySyncReport {
pub ready: usize,
pub restored: usize,
pub unavailable: usize,
pub failed: usize,
}
pub fn restore_missing_library_rows(
cache: &OfflineCacheDb,
library: &LibraryDatabase,
cache_root: &str,
track_id: Option<u64>,
) -> Result<LibrarySyncReport, String> {
let tracks = match track_id {
Some(id) => cache.get_track(id)?.into_iter().collect(),
None => cache.get_all_tracks()?,
};
let existing = library
.get_qobuz_download_tracks()
.map_err(|e| e.to_string())?;
let ids: HashSet<u64> = existing
.iter()
.filter_map(|t| t.qobuz_track_id.map(|id| id as u64))
.collect();
// Reuse an album's established grouping when another cached occurrence
// supplies its album ID. This preserves compilation album artists.
let by_id: HashMap<u64, _> = existing
.iter()
.filter_map(|t| t.qobuz_track_id.map(|id| (id as u64, t)))
.collect();
let mut groups = HashMap::new();
for row in cache.get_all_tracks()? {
if let (Some(album_id), Some(local)) = (row.album_id, by_id.get(&row.track_id)) {
groups.insert(
album_id,
(
local.album_group_key.clone(),
local.album_group_title.clone(),
local.album_artist.clone(),
),
);
}
}
let mut report = LibrarySyncReport::default();
for row in tracks {
if row.status != OfflineCacheStatus::Ready {
continue;
}
report.ready += 1;
if ids.contains(&row.track_id) {
continue;
}
let bundle = cache
.get_cmaf_bundle(row.track_id)?
.filter(|b| b.cache_format == 2);
let path = Path::new(&row.file_path);
let playable = if let Some(bundle) = bundle.as_ref() {
if !bundle
.init_path
.as_deref()
.is_some_and(|p| Path::new(p).is_file())
|| !path.is_file()
{
report.unavailable += 1;
continue;
}
path.parent().unwrap_or(path)
} else {
if !path.is_file() {
report.unavailable += 1;
continue;
}
path
};
let album = row.album.as_deref().unwrap_or("Unknown Album");
let fallback = (format!("{}|{}", album, row.artist), album.to_owned(), None);
let (group, title, album_artist) = row
.album_id
.as_ref()
.and_then(|id| groups.get(id))
.unwrap_or(&fallback);
let artwork = row.resolve_cover_path(cache_root);
// The Qobuz index stores sampling rates in kHz; local_tracks uses Hz.
let rate = row
.sample_rate
.filter(|v| v.is_finite() && *v > 0.0)
.map(|v| if v < 1000.0 { v * 1000.0 } else { v });
let result = library.insert_qobuz_cached_track_with_grouping(
row.track_id,
&row.title,
&row.artist,
row.album.as_deref(),
album_artist.as_deref(),
None,
None,
None,
row.duration_secs,
&playable.to_string_lossy(),
group,
title,
row.bit_depth,
rate,
artwork.as_deref(),
);
match result {
Ok(()) => report.restored += 1,
Err(error) => {
report.failed += 1;
log::warn!(
"[offline-library] track={} repair failed: {error}",
row.track_id
);
}
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::TrackCacheInfo;
#[test]
fn restores_ready_files_without_replacing_metadata_or_reviving_missing_downloads() {
let dir = tempfile::tempdir().unwrap();
let cache = OfflineCacheDb::new(&dir.path().join("index.db")).unwrap();
let library = LibraryDatabase::open(&dir.path().join("library.db")).unwrap();
for id in 1..=5 {
let path_id = if id == 5 { 1 } else { id };
let path = dir.path().join(format!("{path_id}.flac"));
if id != 3 {
std::fs::write(&path, b"fixture").unwrap();
}
cache
.insert_track(
&TrackCacheInfo {
track_id: id,
title: format!("Track {id}"),
artist: "Artist".into(),
album: Some("Album".into()),
album_id: Some("album-id".into()),
duration_secs: 90,
quality: "HiRes".into(),
bit_depth: Some(24),
sample_rate: Some(96.0),
},
&path.to_string_lossy(),
)
.unwrap();
if id != 4 {
cache.mark_complete(id, 7).unwrap();
}
}
library
.insert_qobuz_cached_track_with_grouping(
1,
"Existing title",
"Artist",
Some("Album"),
Some("Compilation"),
Some(8),
Some(2),
Some(1990),
91,
&dir.path().join("1.flac").to_string_lossy(),
"existing-group",
"Album",
Some(24),
Some(96000.0),
None,
)
.unwrap();
let before = library.get_qobuz_download_tracks().unwrap()[0].clone();
let report =
restore_missing_library_rows(&cache, &library, &dir.path().to_string_lossy(), None)
.unwrap();
assert_eq!(
(report.ready, report.restored, report.unavailable),
(4, 1, 1)
);
assert_eq!(report.failed, 1);
let rows = library.get_qobuz_download_tracks().unwrap();
assert_eq!(rows.len(), 2);
let kept = rows.iter().find(|t| t.qobuz_track_id == Some(1)).unwrap();
assert_eq!(kept.id, before.id);
assert_eq!(kept.title, "Existing title");
assert_eq!(kept.track_number, Some(8));
let added = rows.iter().find(|t| t.qobuz_track_id == Some(2)).unwrap();
assert_eq!(added.album_group_key, "existing-group");
assert_eq!(added.sample_rate, 96000.0);
assert_eq!(
restore_missing_library_rows(&cache, &library, "", None)
.unwrap()
.restored,
0
);
cache.delete_track(2).unwrap();
library.remove_qobuz_cached_track(2).unwrap();
assert_eq!(
restore_missing_library_rows(&cache, &library, "", Some(2))
.unwrap()
.restored,
0
);
}
#[test]
fn cmaf_requires_init_and_segments_and_uses_bundle_directory() {
let dir = tempfile::tempdir().unwrap();
let cache = OfflineCacheDb::new(&dir.path().join("index.db")).unwrap();
let library = LibraryDatabase::open(&dir.path().join("library.db")).unwrap();
let segments = dir.path().join("segments.bin");
let init = dir.path().join("init.mp4");
std::fs::write(&segments, b"encrypted-fixture").unwrap();
cache
.insert_track(
&TrackCacheInfo {
track_id: 8,
title: "CMAF".into(),
artist: "Artist".into(),
album: Some("Album".into()),
album_id: Some("a".into()),
duration_secs: 90,
quality: "HiRes".into(),
bit_depth: Some(24),
sample_rate: Some(192.0),
},
&segments.to_string_lossy(),
)
.unwrap();
cache
.set_cmaf_bundle(
8,
&segments.to_string_lossy(),
&init.to_string_lossy(),
b"wrapped",
b"wrapped",
27,
1,
17,
)
.unwrap();
cache.mark_complete(8, 17).unwrap();
assert_eq!(
restore_missing_library_rows(&cache, &library, "", None)
.unwrap()
.unavailable,
1
);
std::fs::write(&init, b"init-fixture").unwrap();
assert_eq!(
restore_missing_library_rows(&cache, &library, "", None)
.unwrap()
.restored,
1
);
let rows = library.get_qobuz_download_tracks().unwrap();
assert_eq!(rows[0].file_path, dir.path().to_string_lossy());
assert_eq!(std::fs::read(segments).unwrap(), b"encrypted-fixture");
}
}
@@ -1606,6 +1606,7 @@ mod tests {
streamable: None, streamable: None,
streamable_at: None, streamable_at: None,
release_date_stream: None, release_date_stream: None,
release_date_original: None,
}), }),
..Default::default() ..Default::default()
} }
@@ -2037,6 +2038,7 @@ mod tests {
streamable: None, streamable: None,
streamable_at: None, streamable_at: None,
release_date_stream: None, release_date_stream: None,
release_date_original: None,
}), }),
..Default::default() ..Default::default()
} }
@@ -2979,6 +2981,7 @@ mod entitlement_index_tests {
streamable: None, streamable: None,
streamable_at: None, streamable_at: None,
release_date_stream: None, release_date_stream: None,
release_date_original: None,
}), }),
..Default::default() ..Default::default()
}], }],
+24
View File
@@ -111,6 +111,30 @@ impl OfflineCacheState {
Ok(()) Ok(())
} }
/// SQLite and filesystem work stays off the async executor. Hold the
/// source index through insertion so removal cannot race a stale snapshot.
pub async fn restore_library_rows(
&self,
track_id: Option<u64>,
) -> Result<crate::library_sync::LibrarySyncReport, String> {
let cache = self.db.clone();
let library = self.library_db.clone();
let root = self.get_cache_path();
tokio::task::spawn_blocking(move || {
// Download post-processing takes library before cache.
let library = library.blocking_lock();
let cache = cache.blocking_lock();
crate::library_sync::restore_missing_library_rows(
cache.as_ref().ok_or("Offline index is closed")?,
library.as_ref().ok_or("Offline library is closed")?,
&root,
track_id,
)
})
.await
.map_err(|e| e.to_string())?
}
pub async fn teardown(&self) { pub async fn teardown(&self) {
// Close library connection first (before main teardown) // Close library connection first (before main teardown)
{ {
@@ -433,6 +433,7 @@ mod tests {
streamable: None, streamable: None,
streamable_at: None, streamable_at: None,
release_date_stream: None, release_date_stream: None,
release_date_original: None,
} }
} }
+47 -8
View File
@@ -29,27 +29,66 @@ function textCompare(a, b) {
} }
function sortAlbums(rows, sort, group) { function sortAlbums(rows, sort, group) {
sort = normalizedMode(sort)
if (group === "off") return sortRows(rows, sort)
return rows.slice().sort(function (a, b) { return rows.slice().sort(function (a, b) {
var grouped = 0 var grouped = 0
if (group === "year" || group === "decade") { if (group === "year" || group === "decade") {
var x = groupNumber(a, group), y = groupNumber(b, group) var x = groupNumber(a, group), y = groupNumber(b, group)
if (!x || !y) grouped = !x && !y ? 0 : (!x ? 1 : -1) if (!x || !y) grouped = !x && !y ? 0 : (!x ? 1 : -1)
else grouped = sort === "newest" ? y - x : x - y else grouped = sort === "release-date-desc" ? y - x : x - y
} else if (group === "artist") { } else if (group === "artist") {
grouped = textCompare(a.artist, b.artist) grouped = textCompare(a.artist, b.artist)
} else if (group === "alpha") { } else if (group === "alpha") {
grouped = textCompare(String(a.title || "").charAt(0), String(b.title || "").charAt(0)) grouped = textCompare(String(a.title || "").charAt(0), String(b.title || "").charAt(0))
} }
if (grouped) return grouped if (grouped) return grouped
if (sort === "oldest" || sort === "newest") if (sort !== "default") return compareItems(a, b, sort)
return compareDates(a, b, sort === "newest")
if (sort === "artist-asc") return textCompare(a.artist, b.artist)
if (sort === "title-asc" || sort === "title-desc") {
var title = textCompare(a.title, b.title)
return sort === "title-desc" ? -title : title
}
// Default retains server/feed order; alphabetical groups keep their // Default retains server/feed order; alphabetical groups keep their
// established alphabetical order within each group. // established alphabetical order within each group.
return group === "artist" || group === "alpha" ? textCompare(a.title, b.title) : 0 return group === "artist" || group === "alpha" ? textCompare(a.title, b.title) : 0
}) })
} }
function normalizedMode(mode) {
if (mode === "oldest") return "release-date-asc"
if (mode === "newest") return "release-date-desc"
return mode || "default"
}
function fieldOf(mode) {
return normalizedMode(mode).replace(/-(asc|desc|reverse)$/, "")
}
function ascending(mode) {
return mode === "default-reverse" || /-asc$/.test(normalizedMode(mode))
}
function compareItems(a, b, mode) {
mode = normalizedMode(mode)
if (mode === "default") return 0
if (mode === "default-reverse") return Number(b._feedOrder || 0) - Number(a._feedOrder || 0)
var field = fieldOf(mode), asc = ascending(mode)
if (field === "release-date") return compareDates(a, b, !asc)
if (field === "date") {
// Existing library feed recency proxy: lower rank is more recent.
var ar = a.added_rank === undefined ? a._feedOrder : a.added_rank
var br = b.added_rank === undefined ? b._feedOrder : b.added_rank
return (Number(ar || 0) - Number(br || 0)) * (asc ? -1 : 1)
}
var key = field === "release" ? "album" : field === "duration" ? "durationSecs"
: field === "track-count" ? "trackCount" : field === "updated" ? "updatedAt" : field
var x = a[key], y = b[key]
// An empty playlist has a known zero duration even though zero-valued
// durationSecs is omitted by the compact feed serializer.
var emptyA = field === "duration" && a.kind === "playlist" && a.trackCount === 0
var emptyB = field === "duration" && b.kind === "playlist" && b.trackCount === 0
if (emptyA) x = 0
if (emptyB) y = 0
var numeric = field === "duration" || field === "track-count" || field === "updated"
var missingX = x === undefined || x === null || x === "" || (field === "duration" && !x && !emptyA)
var missingY = y === undefined || y === null || y === "" || (field === "duration" && !y && !emptyB)
if (missingX || missingY) return missingX === missingY ? 0 : (missingX ? 1 : -1)
return (numeric ? Number(x) - Number(y) : textCompare(x, y)) * (asc ? 1 : -1)
}
function sortRows(rows, mode) {
if (mode === "default-reverse") return rows.slice().reverse()
return rows.slice().sort(function (a, b) { return compareItems(a, b, mode) })
}
+9 -2
View File
@@ -33,6 +33,8 @@ Rectangle {
// NO outline (it matches the favorites toolbar buttons), 12px label and a // NO outline (it matches the favorites toolbar buttons), 12px label and a
// 14px chevron, and 40px narrower because toolbar selects were needlessly // 14px chevron, and 40px narrower because toolbar selects were needlessly
// wide (QbzSelect.slint:86-88,102-105,118-119,126,147-148,299). // wide (QbzSelect.slint:86-88,102-105,118-119,126,147-148,299).
// Optional sort selector: reselecting an option still emits selected.
property string sortDirection: ""
property bool sm: false property bool sm: false
signal selected(int index) signal selected(int index)
@@ -205,7 +207,9 @@ Rectangle {
} }
} }
QbzIcon { QbzIcon {
name: selectRoot.popupPlacement === "left" ? "chevron-left" : "chevron-down" name: selectRoot.sortDirection !== ""
? (selectRoot.sortDirection === "asc" ? "chevron-up" : "chevron-down")
: (selectRoot.popupPlacement === "left" ? "chevron-left" : "chevron-down")
width: selectRoot.sm ? 14 : 16 width: selectRoot.sm ? 14 : 16
height: selectRoot.sm ? 14 : 16 height: selectRoot.sm ? 14 : 16
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -409,6 +413,7 @@ Rectangle {
Item { Item {
id: rowBadge id: rowBadge
visible: selectRoot.optHasBadges() visible: selectRoot.optHasBadges()
|| (selectRoot.sortDirection !== "" && optRow.index === selectRoot.currentIndex)
width: visible ? 20 : 0 width: visible ? 20 : 0
height: parent.height height: parent.height
Text { Text {
@@ -422,7 +427,9 @@ Rectangle {
} }
QbzIcon { QbzIcon {
visible: !selectRoot.optBp(optRow.index) visible: !selectRoot.optBp(optRow.index)
name: "volume-2" name: selectRoot.sortDirection !== ""
? (selectRoot.sortDirection === "asc" ? "chevron-up" : "chevron-down")
: "volume-2"
width: 14 width: 14
height: 14 height: 14
anchors.centerIn: parent anchors.centerIn: parent
+1 -1
View File
@@ -69,7 +69,7 @@ Rectangle {
property string activeTab: "albums" property string activeTab: "albums"
function tabLabel(id) { function tabLabel(id) {
return id === "albums" ? root.t("Albums") return id === "albums" ? root.t("Releases")
: id === "tracks" ? root.t("Tracks") : id === "tracks" ? root.t("Tracks")
: id === "artists" ? root.t("Artists") : id === "artists" ? root.t("Artists")
: id === "playlists" ? root.t("Playlists") : id === "playlists" ? root.t("Playlists")
+1 -1
View File
@@ -191,7 +191,7 @@ Item {
"entries": [ "entries": [
{ "label": nav.trs("All"), "icon": "layout-grid", "view": "library", "tab": "all", "enabled": true }, { "label": nav.trs("All"), "icon": "layout-grid", "view": "library", "tab": "all", "enabled": true },
{ "label": nav.trs("Tracks"), "icon": "music", "view": "library", "tab": "tracks", "enabled": true }, { "label": nav.trs("Tracks"), "icon": "music", "view": "library", "tab": "tracks", "enabled": true },
{ "label": nav.trs("Albums"), "icon": "disc", "view": "library", "tab": "albums", "enabled": true }, { "label": nav.trs("Releases"), "icon": "disc", "view": "library", "tab": "albums", "enabled": true },
{ "label": nav.trs("Artists"), "icon": "user", "view": "library", "tab": "artists", "enabled": true }, { "label": nav.trs("Artists"), "icon": "user", "view": "library", "tab": "artists", "enabled": true },
{ "label": nav.trs("Playlists"), "icon": "list-music", "view": "library", "tab": "playlists", "enabled": true }, { "label": nav.trs("Playlists"), "icon": "list-music", "view": "library", "tab": "playlists", "enabled": true },
{ "label": nav.trs("Labels"), "icon": "disc-3", "view": "library", "tab": "labels", "enabled": true } { "label": nav.trs("Labels"), "icon": "disc-3", "view": "library", "tab": "labels", "enabled": true }
+155 -65
View File
@@ -11,7 +11,7 @@
// //
// THIS FILE IS THE STATE + ORCHESTRATION HALF. The bodies live next door // THIS FILE IS THE STATE + ORCHESTRATION HALF. The bodies live next door
// (track rule 2 — it used to be 1,881 lines): // (track rule 2 — it used to be 1,881 lines):
// library/LibraryToolbar.qml the whole 56px chrome band // library/LibraryToolbar.qml the two-row Library header
// library/FeedListRow.qml the All feed's mixed LIST row // library/FeedListRow.qml the All feed's mixed LIST row
// library/LibraryAlbumsList.qml Albums in LIST mode + its bulk bar // library/LibraryAlbumsList.qml Albums in LIST mode + its bulk bar
// library/LibraryArtistsPanel.qml Artists in SIDEPANEL mode // library/LibraryArtistsPanel.qml Artists in SIDEPANEL mode
@@ -92,6 +92,28 @@ Rectangle {
readonly property var counts: JSON.parse(QbzLibrary.libraryCountsJson) readonly property var counts: JSON.parse(QbzLibrary.libraryCountsJson)
// Full merged feed (parsed once per publish — timed for the report). // Full merged feed (parsed once per publish — timed for the report).
readonly property var feed: parseFeed(QbzLibrary.libraryJson) readonly property var feed: parseFeed(QbzLibrary.libraryJson)
// The local catalog includes disk, download cache and self-hosted servers.
function isLocalFeedItem(item) {
return item.group === "local" || ["local", "plex", "jellyfin", "subsonic",
"navidrome", "gonic", "airsonic", "astiga"].indexOf(item.source) >= 0
}
function hideableLocalAlbum(item) {
// Matched Qobuz/download entries stay in the library when local is hidden.
var sources = item.sources || []
var isDownload = item.source === "offline" || item.source === "qobuz_download"
|| item.source === "qobuz_purchase" || sources.indexOf("offline") >= 0
|| sources.indexOf("qobuz_purchase") >= 0
return item.kind === "album" && item.source !== "qobuz"
&& isLocalFeedItem(item) && !isDownload
}
readonly property bool showSourceBadges: visibleRows.some(function (item) {
return isLocalFeedItem(item) || (item.sources || []).some(function (source) {
return source !== "qobuz"
})
})
function parseFeed(json) { function parseFeed(json) {
var t = Date.now() var t = Date.now()
var f = JSON.parse(json) var f = JSON.parse(json)
@@ -103,6 +125,7 @@ Rectangle {
var tabSeen = ({}) var tabSeen = ({})
var tabPurchased = ({}) var tabPurchased = ({})
var tabFavorites = ({}) var tabFavorites = ({})
var tabFollowing = ({})
// Normalize the strings once per Rust publish. Library > All can hold // Normalize the strings once per Rust publish. Library > All can hold
// the complete local library, so repeating toLowerCase() in every // the complete local library, so repeating toLowerCase() in every
// search pass and every sort comparison turns one key press into a // search pass and every sort comparison turns one key press into a
@@ -110,6 +133,16 @@ Rectangle {
// rows; they do not enlarge the Rust JSON transport. // rows; they do not enlarge the Rust JSON transport.
for (var i = 0; i < f.length; i++) { for (var i = 0; i < f.length; i++) {
var it = f[i] var it = f[i]
it._feedOrder = i
// Local database IDs can coincide with Qobuz IDs.
var membershipKey = (isLocalFeedItem(it)
? it.source + ":" : "") + it.kind + ":" + it.id
it._membershipKey = membershipKey
if (it.group === "purchases") tabPurchased[membershipKey] = true
if (it.kind === "playlist" ? it.isFavorite === true
: it.group === "favorites" || (isLocalFeedItem(it) && it.isFavorite === true))
tabFavorites[membershipKey] = true
if (it.group === "following") tabFollowing[membershipKey] = true
it._titleLc = String(it.title || "").toLowerCase() it._titleLc = String(it.title || "").toLowerCase()
it._artistLc = String(it.artist || "").toLowerCase() it._artistLc = String(it.artist || "").toLowerCase()
it._albumLc = String(it.album || "").toLowerCase() it._albumLc = String(it.album || "").toLowerCase()
@@ -125,7 +158,10 @@ Rectangle {
} }
} }
} else if (it.kind === "artist") totals.artists++ } else if (it.kind === "artist") totals.artists++
else if (it.kind === "playlist") totals.playlists++ else if (it.kind === "playlist" && !tabSeen[membershipKey]) {
totals.playlists++
tabSeen[membershipKey] = true
}
else if (it.kind === "label") totals.labels++ else if (it.kind === "label") totals.labels++
} }
// The toolbar emptiness gate used to make a second full pass over the // The toolbar emptiness gate used to make a second full pass over the
@@ -134,6 +170,7 @@ Rectangle {
f._tabTotals = totals f._tabTotals = totals
f._tabPurchased = tabPurchased f._tabPurchased = tabPurchased
f._tabFavorites = tabFavorites f._tabFavorites = tabFavorites
f._tabFollowing = tabFollowing
// Was a bare console.log, i.e. printed on every publish for every user // Was a bare console.log, i.e. printed on every publish for every user
// forever. It belongs to the same investigation as the derive timing // forever. It belongs to the same investigation as the derive timing
// right below it, so it now rides the same category and is silent // right below it, so it now rides the same category and is silent
@@ -208,7 +245,14 @@ Rectangle {
? QbzLibrary.sessionAlbumsShowFavorites : activeTab === "tracks" ? QbzLibrary.sessionAlbumsShowFavorites : activeTab === "tracks"
? QbzLibrary.sessionTracksShowFavorites : QbzLibrary.sessionShowFavorites ? QbzLibrary.sessionTracksShowFavorites : QbzLibrary.sessionShowFavorites
readonly property bool showFollowing: QbzLibrary.sessionShowFollowing readonly property bool showFollowing: QbzLibrary.sessionShowFollowing
property bool showLocal: true readonly property bool showLocal: !QbzLibrary.sessionHideLocalAlbums
readonly property bool hiresOnly: activeTab === "albums"
? QbzLibrary.sessionAlbumsHiresOnly : activeTab === "tracks"
? QbzLibrary.sessionTracksHiresOnly : false
function setHiresOnly(on) {
if (activeTab === "albums") QbzLibrary.sessionAlbumsHiresOnly = on
else if (activeTab === "tracks") QbzLibrary.sessionTracksHiresOnly = on
}
// Only bumped when a live heart change can alter membership in the // Only bumped when a live heart change can alter membership in the
// current Tracks / Albums source filter. Kept separate from `feed` so the // current Tracks / Albums source filter. Kept separate from `feed` so the
// normal card-heart path still avoids rebuilding a large scrolled model. // normal card-heart path still avoids rebuilding a large scrolled model.
@@ -245,12 +289,15 @@ Rectangle {
if (!p || p.albumsView === undefined) return if (!p || p.albumsView === undefined) return
root.albumsView = p.albumsView root.albumsView = p.albumsView
root.albumsSort = p.albumsSort root.albumsSort = p.albumsSort
root.labelsSort = p.labelsSort || "default"
root.artistsSort = p.artistsSort || "default"
root.playlistsSort = p.playlistsSort || "default"
root.tracksSort = p.tracksSort || "default"
root.albumsGroup = p.albumsGroup root.albumsGroup = p.albumsGroup
root.tracksGroup = p.tracksGroup root.tracksGroup = p.tracksGroup
root.playlistsView = p.playlistsView root.playlistsView = p.playlistsView
root.artistsGroup = p.artistsGroup root.artistsGroup = p.artistsGroup
root.artistsView = p.artistsView root.artistsView = p.artistsView
root.showLocal = p.allShowLocal === true
} }
Component.onCompleted: root.applyPrefs() Component.onCompleted: root.applyPrefs()
Connections { Connections {
@@ -267,6 +314,10 @@ Rectangle {
// list mode with a live selection would strand the bulk bar. // list mode with a live selection would strand the bulk bar.
if (value !== "list") root.setAlbumsMultiSelect(false) if (value !== "list") root.setAlbumsMultiSelect(false)
} else if (key === "albumsSort") root.albumsSort = value } else if (key === "albumsSort") root.albumsSort = value
else if (key === "tracksSort") root.tracksSort = value
else if (key === "playlistsSort") root.playlistsSort = value
else if (key === "artistsSort") root.artistsSort = value
else if (key === "labelsSort") root.labelsSort = value
else if (key === "albumsGroup") root.albumsGroup = value else if (key === "albumsGroup") root.albumsGroup = value
else if (key === "tracksGroup") root.tracksGroup = value else if (key === "tracksGroup") root.tracksGroup = value
else if (key === "playlistsView") root.playlistsView = value else if (key === "playlistsView") root.playlistsView = value
@@ -278,11 +329,9 @@ Rectangle {
} }
QbzLibrary.setLibraryPref(key, String(value)) QbzLibrary.setLibraryPref(key, String(value))
} }
/// The one SOURCE switch the reference persists (favorites_prefs.rs /// Match the other session filters: false means no restriction.
/// `all_show_local`); the other three are session-local there too.
function setShowLocal(on) { function setShowLocal(on) {
root.showLocal = on QbzLibrary.sessionHideLocalAlbums = !on
QbzLibrary.setLibraryPref("allShowLocal", on ? "true" : "false")
} }
// Genre selections are independent for each Library tab, too. // Genre selections are independent for each Library tab, too.
@@ -316,36 +365,45 @@ Rectangle {
/// declared LAST in this file and cannot be named from a child component. /// declared LAST in this file and cannot be named from a child component.
function toggleGenrePopup() { libGenrePopup.toggle() } function toggleGenrePopup() { libGenrePopup.toggle() }
/// The All tab's filters as the applied-filters tooltip wants them: the /// Active restrictions for the genre tooltip.
/// selected genres, plus the four SOURCE switches — but only when one is
/// OFF, because all four on is the default and "everything is shown" is not
/// a filter worth naming. Genres come back in display casing here (the
/// lowercased copy above exists for matching, not for reading).
readonly property var filterSummaryGroups: { readonly property var filterSummaryGroups: {
var tr = QbzSession.trRev var tr = QbzSession.trRev
var hidden = [] var active = []
if (!root.showFavorites) if (root.showPurchases) active.push(QbzSession.tr("Purchases only", tr))
hidden.push(QbzSession.tr("Favorites", tr)) if (root.showFavorites) active.push(QbzSession.tr("Favorites only", tr))
if (!root.showPurchases) if (root.activeTab === "all" && root.showFollowing)
hidden.push(QbzSession.tr("Purchases", tr)) active.push(QbzSession.tr("Following only", tr))
// Following / Local are All-only sources. Do not claim they filter if (root.hiresOnly) active.push(QbzSession.tr("Hi-Res only", tr))
// the Albums or Tracks toolbar when those switches are off on All. if (root.activeTab === "all" && !root.showLocal)
if (root.activeTab === "all") { active.push(QbzSession.tr("Hide local albums from library", tr))
if (!root.showFollowing) return active.length ? [{ group: QbzSession.tr("Filters", tr), values: active }] : []
hidden.push(QbzSession.tr("Following", tr))
if (!root.showLocal)
hidden.push(QbzSession.tr("Local", tr))
}
var out = []
if (hidden.length > 0)
out.push({ group: QbzSession.tr("Hidden", tr), values: hidden })
return out
} }
// Other-tab state. // Other-tab state.
property string tabSearch: "" property string tabSearch: ""
property string tracksSort: "default"
property string playlistsSort: "default"
property string artistsSort: "default"
property string labelsSort: "default"
readonly property string activeSort: activeTab === "all" ? sortBy + (sortAsc ? "-asc" : "-desc")
: activeTab === "albums" ? albumsSort : activeTab === "tracks" ? tracksSort
: activeTab === "playlists" ? playlistsSort : activeTab === "artists" ? artistsSort : labelsSort
function pickSort(field) {
var same = ReleaseSort.fieldOf(activeSort) === field
var asc = same ? !ReleaseSort.ascending(activeSort)
: ["default", "date", "updated", "duration", "track-count", "release-date"].indexOf(field) < 0
if (activeTab === "all") {
sortBy = field
sortAsc = asc
} else {
setPref(activeTab + "Sort", field === "default"
? (asc ? "default-reverse" : "default") : field + (asc ? "-asc" : "-desc"))
}
}
property string albumsSort: "default" // default|title-asc|title-desc|artist-asc|oldest|newest property string albumsSort: "default" // default|title-asc|title-desc|artist-asc|oldest|newest
property string playlistsSubTab: "favorites" property string playlistsSubTab: "all"
/// The All tab's search field debounces; the toolbar routes through here /// The All tab's search field debounces; the toolbar routes through here
/// so the timer stays next to the window report it feeds. /// so the timer stays next to the window report it feeds.
@@ -456,6 +514,21 @@ Rectangle {
return _rows return _rows
} }
// Emit counts and geometry, never search text or library contents. This
// distinguishes an empty derive from a mounted view that cannot paint.
function reportSurfaceState() {
if (!root.visible || !content.ready) return
console.info("[library-view] tab=" + root.activeTab
+ " feed=" + root.feed.length + " rows=" + root.visibleRows.length
+ " searchActive=" + (root.tabSearch.length > 0)
+ " artistsView=" + root.artistsView
+ " grid=" + content.showGrid + " gridCount=" + grid.count
+ " viewport=" + grid.width + "x" + grid.height
+ " contentY=" + grid.contentY)
}
onVisibleRowsChanged: Qt.callLater(root.reportSurfaceState)
onVisibleChanged: Qt.callLater(root.reportSurfaceState)
/// A-Z bucket for a title: its first letter uppercased, "#" for anything /// A-Z bucket for a title: its first letter uppercased, "#" for anything
/// that is not a letter (a leading digit, "*", punctuation). Matches the /// that is not a letter (a leading digit, "*", punctuation). Matches the
/// jump strip's own buckets, so a letter in the strip always has a /// jump strip's own buckets, so a letter in the strip always has a
@@ -501,10 +574,9 @@ Rectangle {
} }
function matchesSources(isPurchased, isFavorite, isFollowing) { function matchesSources(isPurchased, isFavorite, isFollowing) {
var following = activeTab === "all" && showFollowing return (!showPurchases || isPurchased)
var any = showPurchases || showFavorites || following && (!showFavorites || isFavorite)
return !any || (showPurchases && isPurchased) && (activeTab !== "all" || !showFollowing || isFollowing)
|| (showFavorites && isFavorite) || (following && isFollowing)
} }
function visibleItems() { function visibleItems() {
@@ -520,17 +592,17 @@ Rectangle {
// already merged every source in that order, so do not copy and // already merged every source in that order, so do not copy and
// rescan a potentially 10k-row array merely to return it intact. // rescan a potentially 10k-row array merely to return it intact.
if (needle === "" && genres.length === 0 if (needle === "" && genres.length === 0
&& showPurchases && showFavorites && showFollowing && showLocal && !showPurchases && !showFavorites && !showFollowing && showLocal
&& sortBy === "date" && !sortAsc) && sortBy === "date" && !sortAsc)
return feed return feed
var allSeen = ({})
for (i = 0; i < feed.length; i++) { for (i = 0; i < feed.length; i++) {
var it = feed[i] var it = feed[i]
var isLocal = it.source === "local" || it.source === "plex" if (!showLocal && hideableLocalAlbum(it)) continue
if (isLocal) { var memberKey = it._membershipKey
if (!showLocal) continue if (!matchesSources(feed._tabPurchased[memberKey] === true,
} else if (!matchesSources(it.group === "purchases", feed._tabFavorites[memberKey] === true,
it.group === "favorites", feed._tabFollowing[memberKey] === true)) continue
it.group === "following")) continue
if (needle !== "" if (needle !== ""
&& it._titleLc.indexOf(needle) < 0 && it._titleLc.indexOf(needle) < 0
&& it._artistLc.indexOf(needle) < 0) continue && it._artistLc.indexOf(needle) < 0) continue
@@ -543,20 +615,13 @@ Rectangle {
} }
if (!genreHit) continue if (!genreHit) continue
} }
if (showPurchases || showFavorites || showFollowing) {
if (allSeen[memberKey]) continue
allSeen[memberKey] = true
}
items.push(it) items.push(it)
} }
// Canonical ascending order per field, then reverse for the items = ReleaseSort.sortRows(items, root.activeSort)
// other direction; "date" keeps model order (newest-first),
// reversed for oldest (library_all.rs derive).
if (sortBy === "title") {
items.sort(function (a, b) { return a._titleLc < b._titleLc ? -1 : 1 })
if (!sortAsc) items.reverse()
} else if (sortBy === "artist") {
items.sort(function (a, b) { return a._artistLc < b._artistLc ? -1 : 1 })
if (!sortAsc) items.reverse()
} else if (sortAsc) {
items = items.slice().reverse()
}
return items return items
} }
var tabNeedle = tabSearch.toLowerCase() var tabNeedle = tabSearch.toLowerCase()
@@ -568,9 +633,9 @@ Rectangle {
var sourceTab = activeTab === "tracks" || activeTab === "albums" var sourceTab = activeTab === "tracks" || activeTab === "albums"
var sourceKind = activeTab === "tracks" ? "track" : "album" var sourceKind = activeTab === "tracks" ? "track" : "album"
var tabGenres = sourceTab ? root.genreNames : [] var tabGenres = sourceTab ? root.genreNames : []
// Enabled sources form a union in every tab. With none selected, // Every enabled "only" filter restricts the result. With none enabled,
// retain All's unconstrained behavior. Prefer purchase metadata for // show all entities. Prefer purchase metadata for an entity in both
// entities belonging to both sources, and render each entity once. // sources, and render it once.
var purchased = feed._tabPurchased || ({}) var purchased = feed._tabPurchased || ({})
var favorites = feed._tabFavorites || ({}) var favorites = feed._tabFavorites || ({})
var tabSeen = ({}) var tabSeen = ({})
@@ -592,8 +657,14 @@ Rectangle {
} }
} else if (activeTab === "artists") keep = x.kind === "artist" } else if (activeTab === "artists") keep = x.kind === "artist"
else if (activeTab === "labels") keep = x.kind === "label" else if (activeTab === "labels") keep = x.kind === "label"
else if (activeTab === "playlists") keep = x.kind === "playlist" else if (activeTab === "playlists") {
&& (playlistsSubTab === "following" ? x.group === "following" : x.group === "favorites") keep = x.kind === "playlist"
&& (playlistsSubTab === "all" || x.playlistCreator === playlistsSubTab)
if (keep) {
if (tabSeen["playlist:" + x.id]) keep = false
else tabSeen["playlist:" + x.id] = true
}
}
if (keep && tabGenres.length > 0) { if (keep && tabGenres.length > 0) {
var tg = x._genreLc var tg = x._genreLc
keep = false keep = false
@@ -601,10 +672,12 @@ Rectangle {
if (tg !== "" && tg.indexOf(tabGenres[tgi]) >= 0) { keep = true; break } if (tg !== "" && tg.indexOf(tabGenres[tgi]) >= 0) { keep = true; break }
} }
} }
if (keep && sourceTab && hiresOnly && x.qualityTier !== "hires") keep = false
if (keep && hit(x)) items.push(x) if (keep && hit(x)) items.push(x)
} }
if (activeTab === "albums") if (activeTab === "albums")
items = ReleaseSort.sortAlbums(items, albumsSort, albumsGroup) items = ReleaseSort.sortAlbums(items, albumsSort, albumsGroup)
else items = ReleaseSort.sortRows(items, root.activeSort)
// --- Group-by (favorites.rs::derive_tracks / derive_albums) ------ // --- Group-by (favorites.rs::derive_tracks / derive_albums) ------
// Grouping REORDERS the rows so a group's entries sit together. The // Grouping REORDERS the rows so a group's entries sit together. The
// TRACKS list also gets separator rows (see withGroupHeaders); the // TRACKS list also gets separator rows (see withGroupHeaders); the
@@ -628,12 +701,24 @@ Rectangle {
}) })
} }
if (activeTab === "tracks" && tracksGroup !== "off") { if (activeTab === "tracks" && tracksGroup !== "off") {
if (tracksGroup === "album") by(["album", "title"]) if (tracksSort !== "default") {
var groupKey = tracksGroup === "name" ? "title" : tracksGroup
items.sort(function (a, b) {
var av = cachedKey(a, groupKey), bv = cachedKey(b, groupKey)
if (tracksGroup === "name") { av = av.charAt(0); bv = bv.charAt(0) }
return av !== bv ? (av < bv ? -1 : 1) : ReleaseSort.compareItems(a, b, tracksSort)
})
} else if (tracksGroup === "album") by(["album", "title"])
else if (tracksGroup === "artist") by(["artist", "album", "title"]) else if (tracksGroup === "artist") by(["artist", "album", "title"])
else if (tracksGroup === "name") by(["title"]) else if (tracksGroup === "name") by(["title"])
items = withGroupHeaders(items, tracksGroup) items = withGroupHeaders(items, tracksGroup)
} else if (activeTab === "artists" && artistsGroup === "alpha") { } else if (activeTab === "artists" && artistsGroup === "alpha" && artistsView === "grid") {
by(["title"]) items.sort(function (a, b) {
var av = root.alphaKey(a.title), bv = root.alphaKey(b.title)
return av !== bv ? (av < bv ? -1 : 1)
: artistsSort === "default" ? ReleaseSort.textCompare(a.title, b.title)
: ReleaseSort.compareItems(a, b, artistsSort)
})
} }
return items return items
} }
@@ -904,7 +989,11 @@ Rectangle {
var f = root.feed var f = root.feed
for (var i = 0; i < f.length; i++) { for (var i = 0; i < f.length; i++) {
// A purchased favourite has one row per source; patch both. // A purchased favourite has one row per source; patch both.
if (f[i].artKey === key) f[i].isFavorite = value if (f[i].artKey === key) {
f[i].isFavorite = value
if (value) f._tabFavorites[f[i]._membershipKey] = true
else delete f._tabFavorites[f[i]._membershipKey]
}
} }
// Keep the entity-level source set live even when a newly-added // Keep the entity-level source set live even when a newly-added
// favourite has no `favorites` row until the next API refresh. // favourite has no `favorites` row until the next API refresh.
@@ -918,7 +1007,7 @@ Rectangle {
// except Purchases-only. Re-derive only in those cases; elsewhere // except Purchases-only. Re-derive only in those cases; elsewhere
// the mounted delegates consume this same signal directly and // the mounted delegates consume this same signal directly and
// retain the existing no-scroll-reset path. // retain the existing no-scroll-reset path.
if (sourceTab && (root.showFavorites || !root.showPurchases)) if ((sourceTab || root.activeTab === "all") && root.showFavorites)
root.favoriteFilterRev++ root.favoriteFilterRev++
} }
// Pin state settled in the store. The row is patched IN PLACE and the // Pin state settled in the store. The row is patched IN PLACE and the
@@ -1097,7 +1186,7 @@ Rectangle {
Item { Item {
id: content id: content
width: parent.width width: parent.width
height: parent.height - 57 height: parent.height - toolbar.height - 1
clip: true clip: true
// The tracks bulk bar is pinned above the list (the Slint scrolls // The tracks bulk bar is pinned above the list (the Slint scrolls
@@ -1216,6 +1305,7 @@ Rectangle {
readonly property bool ready: readonly property bool ready:
!QbzLibrary.libraryLoading && QbzLibrary.libraryError === "" !QbzLibrary.libraryLoading && QbzLibrary.libraryError === ""
onReadyChanged: Qt.callLater(root.reportSurfaceState)
// Which surface owns the tab right now — one predicate per body, // Which surface owns the tab right now — one predicate per body,
// so no two can mount at once. // so no two can mount at once.
readonly property bool showAlbumsList: readonly property bool showAlbumsList:
@@ -114,7 +114,7 @@ Item {
// may additionally show the catalog mark in the mixed feed. // may additionally show the catalog mark in the mixed feed.
source: cell.item.source source: cell.item.source
sources: cell.item.sources || [] sources: cell.item.sources || []
showSourceBadge: cell.view.showLocal showSourceBadge: cell.view.showSourceBadges
} }
} }
// Group separator. These are pseudo-rows injected into the SAME flat model // Group separator. These are pseudo-rows injected into the SAME flat model
@@ -142,7 +142,7 @@ Item {
TrackCard { TrackCard {
item: cell.item item: cell.item
artSource: cell.view.artMap[cell.item.artKey] || "" artSource: cell.view.artMap[cell.item.artKey] || ""
showSourceBadge: cell.view.showLocal showSourceBadge: cell.view.showSourceBadges
confirmReleaseRemoval: function (item) { confirmReleaseRemoval: function (item) {
cell.view.askRemoveReleaseFavorites(item) cell.view.askRemoveReleaseFavorites(item)
} }
@@ -340,7 +340,7 @@ Rectangle {
// Col — source glyph (only when local/Plex can appear). // Col — source glyph (only when local/Plex can appear).
Rectangle { Rectangle {
id: srcCol id: srcCol
visible: feedRow.view.showLocal visible: feedRow.view.showSourceBadges
width: 44 width: 44
height: parent.height height: parent.height
color: "transparent" color: "transparent"
@@ -63,20 +63,23 @@ Item {
var artists = [] var artists = []
for (var i = 0; i < rows.length; i++) for (var i = 0; i < rows.length; i++)
if (rows[i].kind === "artist") artists.push(rows[i]) if (rows[i].kind === "artist") artists.push(rows[i])
// The sidepanel rail is ALWAYS A-Z grouped (the reference reads // Preserve the chosen sort; only alphabetical modes need letter headers.
// `artists-grouped` unconditionally at :2038, which is why its var mode = root.view.artistsSort
// Group select is hidden in this mode). var grouped = mode === "default" || mode === "default-reverse"
artists.sort(function (a, b) { || mode.indexOf("title-") === 0
var av = (a.title || "").toLowerCase(), bv = (b.title || "").toLowerCase() if (mode === "default" || mode === "default-reverse") {
return av < bv ? -1 : av > bv ? 1 : 0 artists.sort(function(a, b) {
}) var result = (a.title || "").localeCompare(b.title || "")
return mode === "default-reverse" ? -result : result
})
}
var out = [] var out = []
var flat = [] var flat = []
var jumps = [] var jumps = []
var last = null var last = null
for (i = 0; i < artists.length; i++) { for (i = 0; i < artists.length; i++) {
var key = root.view.alphaKey(artists[i].title) var key = root.view.alphaKey(artists[i].title)
if (key !== last) { if (grouped && key !== last) {
jumps.push({ "letter": key, "index": out.length }) jumps.push({ "letter": key, "index": out.length })
out.push({ "t": 0, "label": key }) out.push({ "t": 0, "label": key })
flat.push(root.headerRow) flat.push(root.headerRow)
+363 -428
View File
@@ -1,7 +1,6 @@
// LibraryToolbar — the Library view's fixed chrome row: the six-tab menu on // LibraryToolbar — two rows: navigation/actions/selects above, an always-open
// the left, the per-tab ACTION cluster beside it, and the per-tab controls on // search and independent opt-in filter switches below. Narrow panes scroll
// the right (FavoritesView.slint:444-942, rows 1 and 2 folded into one 56px // each control row horizontally instead of covering neighbouring controls.
// band because this port's header carries no "Library" title).
// //
// EXTRACTED from views/LibraryView.qml (track rule 2 — the file was 1,881 // EXTRACTED from views/LibraryView.qml (track rule 2 — the file was 1,881
// lines). Everything it reads and writes goes through `view`, the LibraryView // lines). Everything it reads and writes goes through `view`, the LibraryView
@@ -14,20 +13,45 @@ import QtQuick
import com.blitzfc.qbz import com.blitzfc.qbz
import "../../controls" import "../../controls"
import "../../theme" import "../../theme"
import "../../assets/release-sort.js" as ReleaseSort
Item { Item {
id: root id: root
readonly property var sortOptions: {
var tab = view.activeTab
var options = []
function add(value, label) { options.push({value: value, label: label}) }
if (tab !== "all") add("default", QbzSession.tr("Default", QbzSession.trRev))
add("date", QbzSession.tr("Date added", QbzSession.trRev))
if (tab === "playlists") add("updated", QbzSession.tr("Date updated", QbzSession.trRev))
add("title", QbzSession.tr("Alphabetical", QbzSession.trRev))
if (tab === "tracks") add("release", QbzSession.tr("Release", QbzSession.trRev))
if (["all", "tracks", "albums"].indexOf(tab) >= 0)
add("artist", QbzSession.tr("Artist", QbzSession.trRev))
if (tab === "tracks" || tab === "albums") {
add("label", QbzSession.tr("Label", QbzSession.trRev))
add("genre", QbzSession.tr("Genre", QbzSession.trRev))
add("release-date", QbzSession.tr("Release date", QbzSession.trRev))
}
if (tab === "playlists") add("track-count", QbzSession.tr("Track count", QbzSession.trRev))
if (["tracks", "albums", "playlists"].indexOf(tab) >= 0)
add("duration", QbzSession.tr("Duration", QbzSession.trRev))
return options
}
/// The LibraryView root. /// The LibraryView root.
property var view: null property var view: null
QbzTheme { id: theme } QbzTheme { id: theme }
height: 56 readonly property bool singleRow: view.activeTab === "labels" || view.activeTab === "playlists"
// The width here is the REAL content pane after the navigation and || view.activeTab === "artists"
// Queue/Lyrics columns have taken their space. At this breakpoint the readonly property real rowGap: 8
// six-tab strip and the All controls fit in one row without either side readonly property real secondRowY: 25 + Math.max(tabRow.height, 30) / 2 + rowGap
// eating the other. readonly property real fullSearchWidth: Math.min(240, Math.max(140, root.width * 0.22))
height: singleRow ? 50 : secondRowY + searchBox.height + rowGap
// Compact only the count badges; genre and sort retain their labels.
readonly property bool compactChrome: width < 1040 readonly property bool compactChrome: width < 1040
// Small toolbar toggle button (ToggleButton sm): 30px, active = accent. // Small toolbar toggle button (ToggleButton sm): 30px, active = accent.
@@ -62,7 +86,7 @@ Item {
component GenreToolButton: Rectangle { component GenreToolButton: Rectangle {
id: gtb id: gtb
readonly property bool active: root.view.genreCount > 0 readonly property bool active: root.view.genreCount > 0
width: root.compactChrome ? 30 : gtbRow.implicitWidth width: gtbRow.implicitWidth
height: 30 height: 30
radius: 6 radius: 6
color: gtb.active ? theme.accent color: gtb.active ? theme.accent
@@ -71,9 +95,9 @@ Item {
id: gtbRow id: gtbRow
anchors.centerIn: parent anchors.centerIn: parent
height: parent.height height: parent.height
leftPadding: root.compactChrome ? 0 : 10 leftPadding: 10
rightPadding: root.compactChrome ? 0 : 12 rightPadding: 12
spacing: root.compactChrome ? 0 : 7 spacing: 7
QbzIcon { QbzIcon {
name: "list-filter" name: "list-filter"
width: 13 width: 13
@@ -91,7 +115,7 @@ Item {
tintName: gtb.active ? theme.accentGlyphTint : "secondary" tintName: gtb.active ? theme.accentGlyphTint : "secondary"
} }
Text { Text {
visible: !root.compactChrome visible: true
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: root.view.genreCount === 0 text: root.view.genreCount === 0
? QbzSession.tr("Filter by genre", QbzSession.trRev) ? QbzSession.tr("Filter by genre", QbzSession.trRev)
@@ -127,7 +151,7 @@ Item {
anchor: gtb anchor: gtb
groups: { groups: {
var out = [] var out = []
var names = (root.view.genreDoc.names || {})["library-all"] || [] var names = (root.view.genreDoc.names || {})[root.view.genreContext] || []
if (names.length > 0) if (names.length > 0)
out.push({ group: QbzSession.tr("Genre", QbzSession.trRev), out.push({ group: QbzSession.tr("Genre", QbzSession.trRev),
values: names }) values: names })
@@ -220,7 +244,17 @@ Item {
} }
} }
// FavTabMenu (SegmentedTabBar with count badges). // Navigation and labelled selects share the first row.
Flickable {
id: topChrome
width: parent.width
height: 52
contentWidth: Math.max(width, topControls.x + topControls.width + 32)
contentHeight: height
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
clip: true
Rectangle { Rectangle {
x: 32 x: 32
y: 25 - height / 2 y: 25 - height / 2
@@ -239,9 +273,9 @@ Item {
tabs: [ tabs: [
{ "id": "all", "label": QbzSession.tr("All", QbzSession.trRev), "count": root.view.counts.all || 0 }, { "id": "all", "label": QbzSession.tr("All", QbzSession.trRev), "count": root.view.counts.all || 0 },
{ "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.tabTotals.tracks || 0 }, { "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.tabTotals.tracks || 0 },
{ "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": root.view.tabTotals.albums || 0 }, { "id": "albums", "label": QbzSession.tr("Releases", QbzSession.trRev), "count": root.view.tabTotals.albums || 0 },
{ "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": root.view.counts.artists || 0 }, { "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": root.view.counts.artists || 0 },
{ "id": "playlists", "label": QbzSession.tr("Playlists", QbzSession.trRev), "count": root.view.counts.playlists || 0 }, { "id": "playlists", "label": QbzSession.tr("Playlists", QbzSession.trRev), "count": root.view.tabTotals.playlists || 0 },
{ "id": "labels", "label": QbzSession.tr("Labels", QbzSession.trRev), "count": root.view.counts.labels || 0 }, { "id": "labels", "label": QbzSession.tr("Labels", QbzSession.trRev), "count": root.view.counts.labels || 0 },
] ]
onSelected: function (id) { root.view.activeTab = id } onSelected: function (id) { root.view.activeTab = id }
@@ -249,292 +283,23 @@ Item {
} }
} }
// --- Per-tab ACTION cluster (FavoritesView.slint:462-565) -------------
// The reference draws these in its own row, to the right of a "Library"
// title this port's header does not have; they sit beside the tab bar
// instead. FUNCTION is 1:1 — this is issue #554 ("users reported the
// feature as removed"), so what matters is that Play all / Shuffle / the
// per-tab random / the multi-select toggles exist and work.
//
// Each gates on its tab having something to act on, exactly as the
// reference's `*-visible.length > 0` guards do: an action that would play
// nothing is not drawn.
Row {
id: actionRow
x: 32 + tabRow.width + 12
y: 25 - height / 2
height: 30
spacing: 8
function randomVisibleId(kind) {
var pool = []
var rows = root.view.visibleRows
for (var i = 0; i < rows.length; i++)
if (rows[i].kind === kind) pool.push(rows[i])
if (pool.length === 0) return null
return pool[Math.floor(Math.random() * pool.length)]
}
// Tracks — play all / shuffle all / select multiple.
QbzIconButton {
visible: root.view.activeTab === "tracks" && root.view.tabHasItems
btnSize: 30
name: "play-fill"
onClicked: QbzLibrary.libraryPlayAll(
JSON.stringify(root.view.visibleTrackIds()), false)
}
QbzIconButton {
visible: root.view.activeTab === "tracks" && root.view.tabHasItems
btnSize: 30
name: "shuffle"
onClicked: QbzLibrary.libraryPlayAll(
JSON.stringify(root.view.visibleTrackIds()), true)
}
ToolToggle {
visible: root.view.activeTab === "tracks" && root.view.tabHasItems
name: "square-check-big"
active: root.view.tracksMultiSelect
onClicked: root.view.setTracksMultiSelect(!root.view.tracksMultiSelect)
}
// Albums — play a RANDOM album (the reference's `albums_shuffle`,
// which picks one visible album, not a shuffled play of every album).
QbzIconButton {
visible: root.view.activeTab === "albums" && root.view.tabHasItems
btnSize: 30
name: "shuffle"
onClicked: {
var pick = actionRow.randomVisibleId("album")
if (pick) QbzPlayer.playAlbum(pick.id)
}
}
// Albums multi-select — LIST mode ONLY (owner 2026-07-24,
// FavoritesView.slint:512-523): the grid card has no checkbox slot.
ToolToggle {
visible: root.view.activeTab === "albums" && root.view.tabHasItems
&& root.view.albumsView === "list"
name: "square-check-big"
active: root.view.albumsMultiSelect
onClicked: root.view.setAlbumsMultiSelect(!root.view.albumsMultiSelect)
}
// Artists — open a random artist.
QbzIconButton {
visible: root.view.activeTab === "artists" && root.view.tabHasItems
btnSize: 30
name: "shuffle"
onClicked: {
var pick = actionRow.randomVisibleId("artist")
if (pick) QbzArtist.openArtist(pick.id)
}
}
// Playlists — play a random playlist.
QbzIconButton {
visible: root.view.activeTab === "playlists" && root.view.tabHasItems
btnSize: 30
name: "shuffle"
onClicked: {
var pick = actionRow.randomVisibleId("playlist")
if (pick) QbzBridge.openPlaylist(pick.id)
}
}
// Labels — open a random label's landing.
QbzIconButton {
visible: root.view.activeTab === "labels" && root.view.tabHasItems
btnSize: 30
name: "shuffle"
onClicked: {
var pick = actionRow.randomVisibleId("label")
if (pick) QbzHome.openLabel(pick.id)
}
}
}
// Per-tab controls. // Per-tab controls.
Row { Row {
x: parent.width - width - 32 id: topControls
x: Math.max(32 + tabRow.width + 16, topChrome.width - width - 32)
y: 25 - height / 2 y: 25 - height / 2
height: 30 height: 30
spacing: 8 spacing: 8
// ===== All toolbar ===== Item {
Row { id: firstRowSearchSlot
visible: root.view.activeTab === "all" visible: root.singleRow
spacing: 8 width: searchBox.width
height: parent.height height: 30
// FavoritesView.slint:801-813 — collapsed magnifier that
// opens LEFT over the tab bar; the All tab is the only
// one that debounces after the keystroke.
QbzLineEdit {
searchMode: true
expandable: true
sm: true
elevated: false // ExpandableSearch fill = surface-card
openWidth: 196 // = max-open-width
placeholder: QbzSession.tr("Search your library", QbzSession.trRev)
onEdited: function (v) { root.view.setAllSearch(v) }
}
// Source switches (tooltips are the Slint copies).
ToolToggle { name: "shopping-bag"; active: root.view.showPurchases; onClicked: root.view.setShowPurchases(!root.view.showPurchases) }
ToolToggle { name: "heart"; active: root.view.showFavorites; onClicked: root.view.setShowFavorites(!root.view.showFavorites) }
ToolToggle { name: "user-plus"; active: root.view.showFollowing; onClicked: root.view.setShowFollowing(!root.view.showFollowing) }
// The ONE source switch the reference persists (favorites_prefs.rs
// `all_show_local`) — the other three are session-local there too.
ToolToggle {
name: "hard-drive"
active: root.view.showLocal
onClicked: root.view.setShowLocal(!root.view.showLocal)
}
// Filter by genre — shared popup, own "library-all"
// context (FavoritesView.slint:864).
GenreToolButton { }
// Grid / list toggle.
ToolToggle {
name: root.view.viewMode === "list" ? "layout-grid" : "list"
active: false
onClicked: root.view.viewMode = root.view.viewMode === "list" ? "grid" : "list"
}
// Sort menu (PlaylistView-style: field + direction caret).
Rectangle {
width: root.compactChrome ? 40 : allSortRow.implicitWidth
height: 30
radius: 6
color: allSortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
Row {
id: allSortRow
anchors.centerIn: parent
height: parent.height
leftPadding: root.compactChrome ? 8 : 10
rightPadding: root.compactChrome ? 8 : 10
spacing: root.compactChrome ? 0 : 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.compactChrome ? "A-Z"
: QbzSession.tr("Sort", QbzSession.trRev) + ": " + (
root.view.sortBy === "title" ? QbzSession.tr("Title", QbzSession.trRev)
: root.view.sortBy === "artist" ? QbzSession.tr("Artist", QbzSession.trRev)
: QbzSession.tr("Date added", QbzSession.trRev))
color: theme.textSecondary
font.pixelSize: 12
}
QbzIcon {
visible: !root.compactChrome
name: root.view.sortAsc ? "chevron-up" : "chevron-down"
width: 12
height: 12
anchors.verticalCenter: parent.verticalCenter
tintName: "secondary"
}
}
MouseArea {
id: allSortArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: allSortMenu.openBelowRight(allSortArea)
}
QbzContextMenu {
id: allSortMenu
menuWidth: 172
Repeater {
model: [
{ "field": "date", "label": QbzSession.tr("Date added", QbzSession.trRev) },
{ "field": "title", "label": QbzSession.tr("Title", QbzSession.trRev) },
{ "field": "artist", "label": QbzSession.tr("Artist", QbzSession.trRev) },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: sortOptArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 6
Text {
width: parent.width - 26
height: parent.height
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
font.weight: root.view.sortBy === modelData.field ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
}
QbzIcon {
visible: root.view.sortBy === modelData.field
name: root.view.sortAsc ? "chevron-up" : "chevron-down"
width: 12
height: 12
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
}
MouseArea {
id: sortOptArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
// Re-pick flips direction; a new field
// resets to its natural default.
if (root.view.sortBy === modelData.field) {
root.view.sortAsc = !root.view.sortAsc
} else {
root.view.sortBy = modelData.field
root.view.sortAsc = modelData.field !== "date"
}
allSortMenu.close()
}
}
}
}
}
}
} }
// ===== Other-tab toolbars =====
// ONE search instance for the five tabs: the reference clears the
// query on every tab entry (see LibraryView.onActiveTabChanged), so
// per-tab instances would only implement a persistence that does not
// exist. A Row skips `visible: false` children, so the 30px slot
// appears/disappears cleanly and, because the ROOT width never leaves
// collapsedSize while the field opens, nothing to its right ever moves.
QbzLineEdit {
id: tabSearchBox
objectName: "libraryTabSearch"
visible: root.view.activeTab !== "all" && root.view.tabHasItems
searchMode: true
expandable: true
sm: true
elevated: false
openWidth: 196
placeholder: QbzSession.tr("Search", QbzSession.trRev)
onEdited: function (v) { root.view.tabSearch = v }
}
// Tracks / Albums source switches. They deliberately sit between the
// fixed 30px search slot and Filter by genre: ExpandableSearch grows
// left, so opening it can never cover or push either toggle.
ToolToggle {
visible: (root.view.activeTab === "tracks" || root.view.activeTab === "albums")
&& root.view.tabHasItems
name: "shopping-bag"
active: root.view.showPurchases
onClicked: root.view.setShowPurchases(!root.view.showPurchases)
}
ToolToggle {
visible: (root.view.activeTab === "tracks" || root.view.activeTab === "albums")
&& root.view.tabHasItems
name: "heart"
active: root.view.showFavorites
onClicked: root.view.setShowFavorites(!root.view.showFavorites)
}
// Filter by genre — the SAME shared popup the All toolbar uses. The
// reference draws it on Tracks and Albums too (FavoritesView.slint:
// 609, :652); this port had it on All only, so the two biggest tabs
// could not be filtered at all.
GenreToolButton { GenreToolButton {
visible: (root.view.activeTab === "tracks" || root.view.activeTab === "albums") visible: ["all", "tracks", "albums"].indexOf(root.view.activeTab) >= 0
&& root.view.tabHasItems && root.view.tabHasItems
} }
GroupSelect { GroupSelect {
@@ -560,9 +325,7 @@ Item {
] ]
onPicked: function (v) { root.view.setPref("albumsGroup", v) } onPicked: function (v) { root.view.setPref("albumsGroup", v) }
} }
// Artists group (A-Z) — GRID mode only, like the reference // Grid grouping is independent of the chosen sort.
// (FavoritesView.slint:763: "Tauri hides it in sidepanel"; the
// sidepanel's left rail is ALWAYS A-Z grouped).
GroupSelect { GroupSelect {
visible: root.view.activeTab === "artists" && root.view.tabHasItems visible: root.view.activeTab === "artists" && root.view.tabHasItems
&& root.view.artistsView === "grid" && root.view.artistsView === "grid"
@@ -574,152 +337,324 @@ Item {
onPicked: function (v) { root.view.setPref("artistsGroup", v) } onPicked: function (v) { root.view.setPref("artistsGroup", v) }
} }
// Albums sort popup (real, JS-side). QbzSelect {
id: sortSelect
sm: true
menuWidth: 190
popupWidth: 200
options: root.sortOptions
searchable: options.length > 8
currentIndex: {
var field = ReleaseSort.fieldOf(root.view.activeSort)
for (var i = 0; i < options.length; ++i)
if (options[i].value === field) return i
return 0
}
sortDirection: ReleaseSort.ascending(root.view.activeSort) ? "asc" : "desc"
onSelected: function(index) { root.view.pickSort(options[index].value) }
}
QbzSelect {
visible: root.view.activeTab === "playlists"
sm: true
menuWidth: 180
options: [QbzSession.tr("All", QbzSession.trRev),
QbzSession.tr("By you", QbzSession.trRev),
QbzSession.tr("By Qobuz", QbzSession.trRev),
QbzSession.tr("By others", QbzSession.trRev)]
currentIndex: Math.max(0, ["all", "you", "qobuz", "others"].indexOf(root.view.playlistsSubTab))
onSelected: function (index) {
root.view.playlistsSubTab = ["all", "you", "qobuz", "others"][index]
}
}
Rectangle { Rectangle {
visible: root.view.activeTab === "albums" && root.view.tabHasItems visible: rightButtons.width > 0
width: albumsSortRow.width width: 1
height: 20
anchors.verticalCenter: parent.verticalCenter
color: theme.borderSubtle
}
Row {
id: rightButtons
height: 30 height: 30
radius: 6 spacing: 8
color: albumsSortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
Row { Row {
id: albumsSortRow id: actionRow
height: parent.height height: 30
leftPadding: 10 spacing: 8
rightPadding: 10
spacing: 6 function randomVisibleId(kind) {
Text { var pool = []
anchors.verticalCenter: parent.verticalCenter var rows = root.view.visibleRows
text: QbzSession.tr("Sort", QbzSession.trRev) + ": " + ( for (var i = 0; i < rows.length; i++)
root.view.albumsSort === "title-asc" ? QbzSession.tr("Title A-Z", QbzSession.trRev) if (rows[i].kind === kind) pool.push(rows[i])
: root.view.albumsSort === "title-desc" ? QbzSession.tr("Title Z-A", QbzSession.trRev) if (pool.length === 0) return null
: root.view.albumsSort === "artist-asc" ? QbzSession.tr("Artist A-Z", QbzSession.trRev) return pool[Math.floor(Math.random() * pool.length)]
: root.view.albumsSort === "newest" ? QbzSession.tr("Newest", QbzSession.trRev)
: root.view.albumsSort === "oldest" ? QbzSession.tr("Oldest", QbzSession.trRev)
: QbzSession.tr("Default", QbzSession.trRev))
color: theme.textSecondary
font.pixelSize: 12
} }
QbzIcon { name: "chevron-down"; width: 12; height: 12; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
} // Tracks — play all / shuffle all / select multiple.
MouseArea { QbzIconButton {
id: albumsSortArea visible: root.view.activeTab === "tracks" && root.view.tabHasItems
anchors.fill: parent btnSize: 30
hoverEnabled: true name: "play-fill"
cursorShape: Qt.PointingHandCursor onClicked: QbzLibrary.libraryPlayAll(
onClicked: albumsSortMenu.openBelowRight(albumsSortArea) JSON.stringify(root.view.visibleTrackIds()), false)
} }
QbzContextMenu { QbzIconButton {
id: albumsSortMenu visible: root.view.activeTab === "tracks" && root.view.tabHasItems
menuWidth: 172 btnSize: 30
Repeater { name: "shuffle"
model: [ onClicked: QbzLibrary.libraryPlayAll(
{ "value": "default", "label": QbzSession.tr("Default", QbzSession.trRev) }, JSON.stringify(root.view.visibleTrackIds()), true)
{ "value": "newest", "label": QbzSession.tr("Newest", QbzSession.trRev) }, }
{ "value": "oldest", "label": QbzSession.tr("Oldest", QbzSession.trRev) }, ToolToggle {
{ "value": "title-asc", "label": QbzSession.tr("Title A-Z", QbzSession.trRev) }, visible: root.view.activeTab === "tracks" && root.view.tabHasItems
{ "value": "title-desc", "label": QbzSession.tr("Title Z-A", QbzSession.trRev) }, name: "square-check-big"
{ "value": "artist-asc", "label": QbzSession.tr("Artist A-Z", QbzSession.trRev) }, active: root.view.tracksMultiSelect
] onClicked: root.view.setTracksMultiSelect(!root.view.tracksMultiSelect)
delegate: Rectangle { }
required property var modelData // Albums — play a RANDOM album (the reference's `albums_shuffle`,
width: parent ? parent.width : 0 // which picks one visible album, not a shuffled play of every album).
height: 33 QbzIconButton {
radius: 5 visible: root.view.activeTab === "albums" && root.view.tabHasItems
color: abSortOptArea.containsMouse ? theme.surfaceHover : "transparent" btnSize: 30
Text { name: "shuffle"
anchors.fill: parent onClicked: {
anchors.leftMargin: 8 var pick = actionRow.randomVisibleId("album")
text: modelData.label if (pick) QbzPlayer.playAlbum(pick.id)
color: theme.textSecondary }
font.pixelSize: 13 }
font.weight: root.view.albumsSort === modelData.value ? theme.weightSemibold : theme.weightRegular // Albums multi-select — LIST mode ONLY (owner 2026-07-24,
verticalAlignment: Text.AlignVCenter // FavoritesView.slint:512-523): the grid card has no checkbox slot.
} ToolToggle {
MouseArea { visible: root.view.activeTab === "albums" && root.view.tabHasItems
id: abSortOptArea && root.view.albumsView === "list"
anchors.fill: parent name: "square-check-big"
hoverEnabled: true active: root.view.albumsMultiSelect
cursorShape: Qt.PointingHandCursor onClicked: root.view.setAlbumsMultiSelect(!root.view.albumsMultiSelect)
onClicked: { }
root.view.setPref("albumsSort", modelData.value) // Artists — open a random artist.
albumsSortMenu.close() QbzIconButton {
} visible: root.view.activeTab === "artists" && root.view.tabHasItems
} btnSize: 30
name: "shuffle"
onClicked: {
var pick = actionRow.randomVisibleId("artist")
if (pick) QbzArtist.openArtist(pick.id)
}
}
// Playlists — play a random playlist.
QbzIconButton {
visible: root.view.activeTab === "playlists" && root.view.tabHasItems
btnSize: 30
name: "shuffle"
onClicked: {
var pick = actionRow.randomVisibleId("playlist")
if (pick) QbzBridge.openPlaylist(pick.id)
}
}
// Labels — open a random label's landing.
QbzIconButton {
visible: root.view.activeTab === "labels" && root.view.tabHasItems
btnSize: 30
name: "shuffle"
onClicked: {
var pick = actionRow.randomVisibleId("label")
if (pick) QbzHome.openLabel(pick.id)
} }
} }
} }
ToolToggle {
visible: root.view.activeTab === "all"
name: root.view.viewMode === "list" ? "layout-grid" : "list"
active: false
onClicked: root.view.viewMode = root.view.viewMode === "list" ? "grid" : "list"
}
// Albums grid / list toggle (FavoritesView.slint:697-705 ViewToggle).
ToolToggle {
visible: root.view.activeTab === "albums" && root.view.tabHasItems
name: root.view.albumsView === "list" ? "layout-grid" : "list"
active: false
onClicked: root.view.setPref("albumsView",
root.view.albumsView === "list" ? "grid" : "list")
}
// Playlists grid / list toggle (FavoritesView.slint:737-745).
ToolToggle {
visible: root.view.activeTab === "playlists" && root.view.tabHasItems
name: root.view.playlistsView === "list" ? "layout-grid" : "list"
active: false
onClicked: root.view.setPref("playlistsView",
root.view.playlistsView === "list" ? "grid" : "list")
}
// Artists grid / sidepanel toggle (FavoritesView.slint:780-793 —
// `active: false` there too, "keeps the two icon states visually
// identical").
ToolToggle {
visible: root.view.activeTab === "artists" && root.view.tabHasItems
name: root.view.artistsView === "sidepanel" ? "layout-grid" : "list"
active: false
onClicked: root.view.setPref("artistsView",
root.view.artistsView === "sidepanel" ? "grid" : "sidepanel")
}
} }
// Albums grid / list toggle (FavoritesView.slint:697-705 ViewToggle). }
ToolToggle {
visible: root.view.activeTab === "albums" && root.view.tabHasItems } // topChrome
name: root.view.albumsView === "list" ? "layout-grid" : "list"
active: false // Search remains visible, including when a restriction produces no rows.
onClicked: root.view.setPref("albumsView", QbzLineEdit {
root.view.albumsView === "list" ? "grid" : "list") id: searchBox
objectName: "librarySearch"
parent: root.singleRow ? firstRowSearchSlot : root
x: root.singleRow ? 0 : 34
y: root.singleRow ? 0 : root.secondRowY
width: root.fullSearchWidth * (root.singleRow ? 0.6 : 1)
searchMode: true
expandable: false
sm: true
text: root.view.activeTab === "all" ? root.view.search : root.view.tabSearch
placeholder: root.view.activeTab === "tracks" ? QbzSession.tr("Search your tracks", QbzSession.trRev)
: root.view.activeTab === "albums" ? QbzSession.tr("Search your releases", QbzSession.trRev)
: root.view.activeTab === "playlists" ? QbzSession.tr("Search your playlists", QbzSession.trRev)
: root.view.activeTab === "artists" ? QbzSession.tr("Search your artists", QbzSession.trRev)
: root.view.activeTab === "labels" ? QbzSession.tr("Search your labels", QbzSession.trRev)
: QbzSession.tr("Search your library", QbzSession.trRev)
onEdited: function (value) {
if (root.view.activeTab === "all") root.view.setAllSearch(value)
else root.view.tabSearch = value
} }
// Playlists grid / list toggle (FavoritesView.slint:737-745). }
ToolToggle {
visible: root.view.activeTab === "playlists" && root.view.tabHasItems // Compact Library-only switch: glyph travels inside the thumb.
name: root.view.playlistsView === "list" ? "layout-grid" : "list" component SourceSwitch: Rectangle {
active: false id: sourceSwitch
onClicked: root.view.setPref("playlistsView", property string label: ""
root.view.playlistsView === "list" ? "grid" : "list") property string explanation: ""
property string glyph: ""
property bool checked: false
signal toggled(bool value)
width: 46
height: 26
anchors.verticalCenter: parent.verticalCenter
radius: 13
color: checked ? theme.accent : theme.surfaceHover
activeFocusOnTab: true
border.width: activeFocus ? 2 : 0
border.color: theme.accent
Accessible.role: Accessible.CheckBox
Accessible.name: label
Accessible.description: explanation
Accessible.checked: checked
Accessible.onToggleAction: sourceSwitch.toggled(!checked)
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Space || event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (!event.isAutoRepeat) sourceSwitch.toggled(!checked)
event.accepted = true
}
} }
// Artists grid / sidepanel toggle (FavoritesView.slint:780-793 — Rectangle {
// `active: false` there too, "keeps the two icon states visually width: 20
// identical"). height: 20
ToolToggle { radius: 10
visible: root.view.activeTab === "artists" && root.view.tabHasItems x: sourceSwitch.checked ? sourceSwitch.width - width - 3 : 3
name: root.view.artistsView === "sidepanel" ? "layout-grid" : "list" y: 3
active: false color: theme.textPrimary
onClicked: root.view.setPref("artistsView", QbzIcon {
root.view.artistsView === "sidepanel" ? "grid" : "sidepanel") anchors.centerIn: parent
width: 15
height: 15
visible: sourceSwitch.glyph !== ""
name: sourceSwitch.glyph
tintName: theme.isDark ? "black" : "white"
}
Text {
anchors.centerIn: parent
visible: sourceSwitch.glyph === ""
text: "Hi-Res"
font.pixelSize: 7
font.weight: Font.DemiBold
color: theme.surfaceMain
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: sourceSwitch.forceActiveFocus()
onClicked: sourceSwitch.toggled(!sourceSwitch.checked)
} }
// Playlists sub-tab (Library / Following). HoverHandler { onHoveredChanged: hovered ? switchTip.enter() : switchTip.exit() }
Rectangle { QbzFilterTip {
visible: root.view.activeTab === "playlists" && root.view.tabHasItems id: switchTip
width: plSubRow.width ownerKey: "library-switch-" + sourceSwitch.label
anchor: sourceSwitch
groups: [{group: sourceSwitch.label, values: [sourceSwitch.explanation]}]
}
}
Flickable {
id: filterChrome
x: 34 + searchBox.width + 20
width: Math.max(0, root.width - x - 32)
y: root.secondRowY
visible: !root.singleRow
height: 30
clip: true
contentWidth: Math.max(width, switches.width)
contentHeight: height
flickableDirection: Flickable.HorizontalFlick
boundsBehavior: Flickable.StopAtBounds
Row {
id: switches
x: Math.max(0, filterChrome.width - width)
height: 30 height: 30
radius: 6 spacing: 12
color: theme.surfaceElevated readonly property bool sourceTab: root.view.activeTab === "all"
Row { || root.view.activeTab === "tracks" || root.view.activeTab === "albums"
id: plSubRow SourceSwitch {
padding: 3 visible: switches.sourceTab
spacing: 4 glyph: "shopping-bag"
Repeater { label: QbzSession.tr("Purchases only", QbzSession.trRev)
model: [ explanation: QbzSession.tr("Show only purchased items. Combines with the other enabled filters.", QbzSession.trRev)
{ "id": "favorites", "label": QbzSession.tr("Library", QbzSession.trRev) }, checked: root.view.showPurchases
{ "id": "following", "label": QbzSession.tr("Following", QbzSession.trRev) }, onToggled: function (value) { root.view.setShowPurchases(value) }
] }
delegate: Rectangle { SourceSwitch {
required property var modelData visible: switches.sourceTab
property bool active: root.view.playlistsSubTab === modelData.id glyph: "heart"
width: plSubText.implicitWidth + 20 label: QbzSession.tr("Favorites only", QbzSession.trRev)
height: 24 explanation: QbzSession.tr("Show only favorites. Combines with the other enabled filters.", QbzSession.trRev)
radius: 4 checked: root.view.showFavorites
color: active ? theme.surfaceMain : "transparent" onToggled: function (value) { root.view.setShowFavorites(value) }
Text { }
id: plSubText SourceSwitch {
anchors.centerIn: parent visible: root.view.activeTab === "all"
text: modelData.label glyph: "user-plus"
color: parent.active ? theme.textPrimary : theme.textMuted label: QbzSession.tr("Following only", QbzSession.trRev)
font.pixelSize: 12 explanation: QbzSession.tr("Show only followed items. Combines with the other enabled filters.", QbzSession.trRev)
font.weight: theme.weightMedium checked: root.view.showFollowing
} onToggled: function (value) { root.view.setShowFollowing(value) }
MouseArea { }
anchors.fill: parent SourceSwitch {
cursorShape: Qt.PointingHandCursor visible: root.view.activeTab === "tracks" || root.view.activeTab === "albums"
onClicked: root.view.playlistsSubTab = modelData.id label: QbzSession.tr("Hi-Res only", QbzSession.trRev)
} explanation: QbzSession.tr("Show only Hi-Res items. Combines with the other enabled filters.", QbzSession.trRev)
} checked: root.view.hiresOnly
} onToggled: function (value) { root.view.setHiresOnly(value) }
}
SourceSwitch {
visible: root.view.activeTab === "all"
glyph: "hard-drive"
label: QbzSession.tr("Hide local albums from library", QbzSession.trRev)
explanation: QbzSession.tr("Hide local albums here. They remain available in Local Library.", QbzSession.trRev)
checked: !root.view.showLocal
onToggled: function (value) { root.view.setShowLocal(!value) }
} }
} }
} }
/// Collapse the shared per-tab search field (LibraryView calls this on
/// every tab change — the reference destroys and re-creates it collapsed).
function closeTabSearch() { function closeTabSearch() {
if (tabSearchBox) tabSearchBox.closeSearch() searchBox.text = Qt.binding(function () {
return root.view.activeTab === "all" ? root.view.search : root.view.tabSearch
})
} }
} }
+36 -7
View File
@@ -45,6 +45,9 @@ pub mod qbz_library_bridge {
// Process/session-only source filters, stored separately per tab. They do not // Process/session-only source filters, stored separately per tab. They do not
// enter favorites_ui.json, but the singleton outlives LibraryView so // enter favorites_ui.json, but the singleton outlives LibraryView so
// navigation cannot reset them with the component. // navigation cannot reset them with the component.
#[qproperty(bool, session_albums_hires_only)]
#[qproperty(bool, session_tracks_hires_only)]
#[qproperty(bool, session_hide_local_albums)]
#[qproperty(bool, session_show_purchases)] #[qproperty(bool, session_show_purchases)]
#[qproperty(bool, session_show_favorites)] #[qproperty(bool, session_show_favorites)]
#[qproperty(bool, session_show_following)] #[qproperty(bool, session_show_following)]
@@ -174,6 +177,9 @@ pub struct QbzLibraryRust {
library_json: QString, library_json: QString,
library_counts_json: QString, library_counts_json: QString,
library_prefs_json: QString, library_prefs_json: QString,
session_albums_hires_only: bool,
session_tracks_hires_only: bool,
session_hide_local_albums: bool,
session_show_purchases: bool, session_show_purchases: bool,
session_show_favorites: bool, session_show_favorites: bool,
session_show_following: bool, session_show_following: bool,
@@ -194,13 +200,16 @@ impl Default for QbzLibraryRust {
// Parseable defaults so QML's JSON.parse never throws on frame 1 // Parseable defaults so QML's JSON.parse never throws on frame 1
// (the real values arrive in `boot`). // (the real values arrive in `boot`).
library_prefs_json: QString::from("{}"), library_prefs_json: QString::from("{}"),
session_show_purchases: true, session_albums_hires_only: false,
session_show_favorites: true, session_tracks_hires_only: false,
session_show_following: true, session_hide_local_albums: false,
session_albums_show_purchases: true, session_show_purchases: false,
session_albums_show_favorites: true, session_show_favorites: false,
session_tracks_show_purchases: true, session_show_following: false,
session_tracks_show_favorites: true, session_albums_show_purchases: false,
session_albums_show_favorites: false,
session_tracks_show_purchases: false,
session_tracks_show_favorites: false,
sidepanel_json: QString::from("{}"), sidepanel_json: QString::from("{}"),
} }
} }
@@ -324,3 +333,23 @@ impl qbz_library_bridge::QbzLibrary {
crate::sidebar_qt::is_pinned(&kind.to_string(), &id.to_string()) crate::sidebar_qt::is_pinned(&kind.to_string(), &id.to_string())
} }
} }
#[cfg(test)]
mod filter_defaults_tests {
use super::QbzLibraryRust;
#[test]
fn every_only_filter_starts_unrestricted() {
let state = QbzLibraryRust::default();
assert!(!state.session_show_purchases);
assert!(!state.session_show_favorites);
assert!(!state.session_show_following);
assert!(!state.session_albums_show_purchases);
assert!(!state.session_albums_show_favorites);
assert!(!state.session_tracks_show_purchases);
assert!(!state.session_tracks_show_favorites);
assert!(!state.session_albums_hires_only);
assert!(!state.session_tracks_hires_only);
assert!(!state.session_hide_local_albums);
}
}
+20
View File
@@ -36,6 +36,14 @@ pub struct Prefs {
pub albums_view: String, pub albums_view: String,
#[serde(default = "d_default")] #[serde(default = "d_default")]
pub albums_sort: String, pub albums_sort: String,
#[serde(default = "d_default")]
pub labels_sort: String,
#[serde(default = "d_default")]
pub artists_sort: String,
#[serde(default = "d_default")]
pub playlists_sort: String,
#[serde(default = "d_default")]
pub tracks_sort: String,
#[serde(default = "d_off")] #[serde(default = "d_off")]
pub albums_group: String, pub albums_group: String,
#[serde(default = "d_off")] #[serde(default = "d_off")]
@@ -76,6 +84,10 @@ impl Default for Prefs {
Self { Self {
albums_view: d_grid(), albums_view: d_grid(),
albums_sort: d_default(), albums_sort: d_default(),
labels_sort: d_default(),
artists_sort: d_default(),
playlists_sort: d_default(),
tracks_sort: d_default(),
albums_group: d_off(), albums_group: d_off(),
tracks_group: d_off(), tracks_group: d_off(),
playlists_view: d_grid(), playlists_view: d_grid(),
@@ -112,6 +124,10 @@ pub fn to_json() -> String {
serde_json::json!({ serde_json::json!({
"albumsView": p.albums_view, "albumsView": p.albums_view,
"albumsSort": p.albums_sort, "albumsSort": p.albums_sort,
"labelsSort": p.labels_sort,
"artistsSort": p.artists_sort,
"playlistsSort": p.playlists_sort,
"tracksSort": p.tracks_sort,
"albumsGroup": p.albums_group, "albumsGroup": p.albums_group,
"tracksGroup": p.tracks_group, "tracksGroup": p.tracks_group,
"playlistsView": p.playlists_view, "playlistsView": p.playlists_view,
@@ -179,6 +195,10 @@ pub fn set(key: &str, value: &str) {
match key { match key {
"albumsView" => update(|p| p.albums_view = v), "albumsView" => update(|p| p.albums_view = v),
"albumsSort" => update(|p| p.albums_sort = v), "albumsSort" => update(|p| p.albums_sort = v),
"labelsSort" => update(|p| p.labels_sort = v),
"artistsSort" => update(|p| p.artists_sort = v),
"playlistsSort" => update(|p| p.playlists_sort = v),
"tracksSort" => update(|p| p.tracks_sort = v),
"albumsGroup" => update(|p| p.albums_group = v), "albumsGroup" => update(|p| p.albums_group = v),
"tracksGroup" => update(|p| p.tracks_group = v), "tracksGroup" => update(|p| p.tracks_group = v),
"playlistsView" => update(|p| p.playlists_view = v), "playlistsView" => update(|p| p.playlists_view = v),
+120 -4
View File
@@ -149,6 +149,12 @@ pub struct FeedItem {
#[serde(default, rename = "cacheStatus", skip_serializing_if = "is_zero_i32")] #[serde(default, rename = "cacheStatus", skip_serializing_if = "is_zero_i32")]
pub cache_status: i32, pub cache_status: i32,
pub genre: String, pub genre: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub label: String,
#[serde(default, rename = "updatedAt", skip_serializing_if = "Option::is_none")]
pub updated_at: Option<i64>,
#[serde(default, rename = "trackCount", skip_serializing_if = "Option::is_none")]
pub track_count: Option<u32>,
pub year: String, pub year: String,
#[serde( #[serde(
default, default,
@@ -167,6 +173,12 @@ pub struct FeedItem {
pub explicit: bool, pub explicit: bool,
#[serde(rename = "playlistOwned")] #[serde(rename = "playlistOwned")]
pub playlist_owned: bool, pub playlist_owned: bool,
#[serde(
default,
rename = "playlistCreator",
skip_serializing_if = "String::is_empty"
)]
pub playlist_creator: String,
#[serde(rename = "playlistFollowing")] #[serde(rename = "playlistFollowing")]
pub playlist_following: bool, pub playlist_following: bool,
#[serde(rename = "isPinned", default)] #[serde(rename = "isPinned", default)]
@@ -551,6 +563,15 @@ fn map_track(track: Track) -> FeedItem {
.performer .performer
.map(|p| (p.name, p.id.to_string())) .map(|p| (p.name, p.id.to_string()))
.unwrap_or_default(); .unwrap_or_default();
let label = track
.album
.as_ref()
.and_then(|a| a.label.as_ref())
.map(|label| label.name.clone())
.unwrap_or_default();
let release_sort_key = qbz_text_utils::dates::release_sort_key(
track.album.as_ref().and_then(|a| a.release_date_original.as_deref()),
);
FeedItem { FeedItem {
kind: "track".into(), kind: "track".into(),
group: "favorites".into(), group: "favorites".into(),
@@ -565,6 +586,8 @@ fn map_track(track: Track) -> FeedItem {
album_artist, album_artist,
album_artist_id, album_artist_id,
genre, genre,
label,
release_sort_key,
duration: mmss(duration_secs), duration: mmss(duration_secs),
isrc, isrc,
duration_secs, duration_secs,
@@ -625,6 +648,9 @@ fn map_album(album: Album, ready_offline_tracks: usize) -> FeedItem {
subtitle: artist.clone(), subtitle: artist.clone(),
artist, artist,
artist_id: album.artist.id.to_string(), artist_id: album.artist.id.to_string(),
label: album.label.map(|label| label.name).unwrap_or_default(),
duration_secs: album.duration.unwrap_or(0),
track_count: Some(track_count as u32),
genre: album.genre.map(|g| g.name).unwrap_or_default(), genre: album.genre.map(|g| g.name).unwrap_or_default(),
year: qbz_text_utils::dates::release_label(date.as_deref()), year: qbz_text_utils::dates::release_label(date.as_deref()),
release_sort_key: qbz_text_utils::dates::release_sort_key(date.as_deref()), release_sort_key: qbz_text_utils::dates::release_sort_key(date.as_deref()),
@@ -739,6 +765,22 @@ pub(crate) fn playlist_subtitle(owner: &str, tracks_count: u32) -> String {
subtitle subtitle
} }
fn playlist_creator(owned: bool, owner_name: &str) -> &'static str {
if owned {
"you"
} else if owner_name
.split_whitespace()
.next()
.is_some_and(|name| name.eq_ignore_ascii_case("qobuz"))
{
"qobuz"
} else if owner_name.trim().is_empty() {
"unknown"
} else {
"others"
}
}
fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem { fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem {
// The card's single image is the playlist's own Qobuz artwork ONLY. // The card's single image is the playlist's own Qobuz artwork ONLY.
// Falling back to `images300[0]` here is what put a member ALBUM cover // Falling back to `images300[0]` here is what put a member ALBUM cover
@@ -762,6 +804,9 @@ fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem {
id: playlist.id.to_string(), id: playlist.id.to_string(),
title: playlist.name.clone(), title: playlist.name.clone(),
subtitle, subtitle,
track_count: Some(playlist.tracks_count),
duration_secs: playlist.duration,
updated_at: playlist.updated_at,
playlist_own_image: !cover_url.is_empty(), playlist_own_image: !cover_url.is_empty(),
image_url: cover_url, image_url: cover_url,
covers, covers,
@@ -775,6 +820,7 @@ fn map_playlist_row(playlist: &Playlist, is_following: bool) -> FeedItem {
// still available to callers as `group` / `playlist_following`. // still available to callers as `group` / `playlist_following`.
is_favorite: crate::fav_cache_qt::is_favorite("playlist", &playlist.id.to_string()), is_favorite: crate::fav_cache_qt::is_favorite("playlist", &playlist.id.to_string()),
playlist_owned: owned, playlist_owned: owned,
playlist_creator: playlist_creator(owned, &playlist.owner.name).into(),
playlist_following: is_following, playlist_following: is_following,
..Default::default() ..Default::default()
} }
@@ -1148,11 +1194,11 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
// A failed availability query must not turn every favorite into a // A failed availability query must not turn every favorite into a
// tombstone. `None` means "unknown" and leaves rows interactive; // tombstone. `None` means "unknown" and leaves rows interactive;
// the next successful Library load will settle them. // the next successful Library load will settle them.
let existing_albums: Option<HashSet<String>> = if album_candidates.is_empty() { let existing_albums: Option<HashMap<String, Vec<String>>> = if album_candidates.is_empty() {
Some(HashSet::new()) Some(HashMap::new())
} else { } else {
match tokio::task::spawn_blocking(move || { match tokio::task::spawn_blocking(move || {
crate::local_albums::existing_favorite_album_ids_blocking(album_candidates) crate::local_albums::existing_favorite_album_sources_blocking(album_candidates)
}) })
.await .await
{ {
@@ -1172,12 +1218,18 @@ pub async fn load_library(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Result<u
let source_unavailable = lf.kind == "album" let source_unavailable = lf.kind == "album"
&& existing_albums && existing_albums
.as_ref() .as_ref()
.is_some_and(|ids| !ids.contains(&lf.id)); .is_some_and(|ids| !ids.contains_key(&lf.id));
let sources = existing_albums
.as_ref()
.and_then(|albums| albums.get(&lf.id))
.cloned()
.unwrap_or_default();
feed.push( feed.push(
FeedItem { FeedItem {
kind: lf.kind, kind: lf.kind,
group: "local".into(), group: "local".into(),
source: lf.source, source: lf.source,
sources,
subtitle: lf.subtitle, subtitle: lf.subtitle,
artist: lf.artist.clone(), artist: lf.artist.clone(),
image_url: lf.artwork_url, image_url: lf.artwork_url,
@@ -1509,6 +1561,9 @@ fn map_purchased_album(a: qbz_models::PurchaseAlbum) -> FeedItem {
release_sort_key: qbz_text_utils::dates::release_sort_key( release_sort_key: qbz_text_utils::dates::release_sort_key(
a.release_date_original.as_deref(), a.release_date_original.as_deref(),
), ),
label: a.label.as_ref().map(|label| label.name.clone()).unwrap_or_default(),
duration_secs: a.duration.unwrap_or(0),
track_count: a.tracks_count,
genre: a.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(), genre: a.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(),
id: a.id, id: a.id,
title: a.title, title: a.title,
@@ -1572,6 +1627,14 @@ async fn fetch_purchases(
subtitle: t.performer.name.clone(), subtitle: t.performer.name.clone(),
artist: t.performer.name, artist: t.performer.name,
artist_id: t.performer.id.to_string(), artist_id: t.performer.id.to_string(),
duration: mmss(t.duration),
duration_secs: t.duration,
label: t.album.as_ref().and_then(|a| a.label.as_ref())
.map(|label| label.name.clone()).unwrap_or_default(),
genre: t.album.as_ref().and_then(|a| a.genre.as_ref())
.map(|genre| genre.name.clone()).unwrap_or_default(),
release_sort_key: qbz_text_utils::dates::release_sort_key(
t.album.as_ref().and_then(|a| a.release_date_original.as_deref())),
album: alb, album: alb,
album_id: aid, album_id: aid,
album_artist_id, album_artist_id,
@@ -2311,11 +2374,13 @@ pub(crate) fn insert_playlist_row(
id: id.to_string(), id: id.to_string(),
title: title.to_string(), title: title.to_string(),
subtitle: playlist_subtitle(owner, tracks_count), subtitle: playlist_subtitle(owner, tracks_count),
track_count: Some(tracks_count),
playlist_own_image: !cover_url.is_empty(), playlist_own_image: !cover_url.is_empty(),
image_url: cover_url.to_string(), image_url: cover_url.to_string(),
covers, covers,
is_favorite: crate::fav_cache_qt::is_favorite("playlist", id), is_favorite: crate::fav_cache_qt::is_favorite("playlist", id),
playlist_owned: !following, playlist_owned: !following,
playlist_creator: playlist_creator(!following, owner).into(),
playlist_following: following, playlist_following: following,
// 0.0 = most-recently added, which is what this just made it. The // 0.0 = most-recently added, which is what this just made it. The
// feed is kept sorted by this proxy, so the row lands at the head // feed is kept sorted by this proxy, so the row lands at the head
@@ -2601,6 +2666,57 @@ pub fn play_from_visible(visible_ids_json: String, clicked_id: String) {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn purchased_album_sort_metadata_is_preserved() {
let album = serde_json::from_value(serde_json::json!({
"id": "a", "title": "A", "release_date_original": "1980-04-01",
"duration": 2500, "tracks_count": 11, "label": {"id": 1, "name": "Label"}
})).unwrap();
let row = map_purchased_album(album);
assert_eq!(row.release_sort_key, 19800401);
assert_eq!(row.duration_secs, 2500);
assert_eq!(row.track_count, Some(11));
assert_eq!(row.label, "Label");
}
#[test]
fn track_sort_metadata_uses_original_date_and_keeps_missing_dates_unknown() {
let track: Track = serde_json::from_value(serde_json::json!({
"id": 42, "duration": 234,
"album": {"id": "a", "title": "A", "release_date_original": "1980-04-01",
"release_date_stream": "2015-06-01", "label": {"id": 1, "name": "Label"}}
})).unwrap();
let row = map_track(track);
assert_eq!(row.release_sort_key, 19800401);
assert_eq!(row.label, "Label");
assert_eq!(row.duration_secs, 234);
let track: Track = serde_json::from_value(serde_json::json!({
"id": 43, "album": {"release_date_stream": "2015-06-01"}
})).unwrap();
assert_eq!(map_track(track).release_sort_key, 0);
}
#[test]
fn playlist_sort_metadata_retains_numeric_values_and_empty_track_count() {
let playlist: Playlist = serde_json::from_value(serde_json::json!({
"id": 42, "name": "List", "tracks_count": 0, "duration": 0,
"updated_at": 1700000000
})).unwrap();
let row = serde_json::to_value(map_playlist_row(&playlist, false)).unwrap();
assert_eq!(row["trackCount"], 0);
assert_eq!(row["updatedAt"], 1700000000);
}
#[test]
fn playlist_creator_filters_distinguish_ownership_and_editorial_names() {
assert_eq!(playlist_creator(true, "Qobuz fan"), "you");
assert_eq!(playlist_creator(false, "Qobuz"), "qobuz");
assert_eq!(playlist_creator(false, "Qobuz Latinoamérica"), "qobuz");
assert_eq!(playlist_creator(false, "My Qobuz playlist"), "others");
assert_eq!(playlist_creator(false, "Listener"), "others");
assert_eq!(playlist_creator(false, ""), "unknown");
}
#[tokio::test] #[tokio::test]
async fn favorites_paging_passes_ten_thousand_and_preserves_every_track() { async fn favorites_paging_passes_ten_thousand_and_preserves_every_track() {
for reported_total in [0, 10_751] { for reported_total in [0, 10_751] {
+21 -8
View File
@@ -559,16 +559,18 @@ pub fn counts() -> LocalCounts {
state(|s| s.counts.clone()) state(|s| s.counts.clone())
} }
/// Whether the local library is usable at all (a per-user db with at least /// Whether any registered folder or indexed track makes the library usable.
/// one registered folder). Drives the "nothing indexed yet" empty state. /// Offline downloads are indexed without registering a music folder.
/// A server-only setup is ALSO usable — the browse union has content even /// A server-only setup is ALSO usable — the browse union has content even
/// with no registered on-disk folder. /// with no registered on-disk folder.
pub fn has_library() -> bool { pub fn has_library() -> bool {
let has_folders = with_db(|db| db.get_folders()) let has_folders = with_db(|db| db.get_folders())
.map(|f| !f.is_empty()) .map(|f| !f.is_empty())
.unwrap_or(false); .unwrap_or(false);
let indexed_tracks = with_db(|db| db.count_all_local_tracks()).unwrap_or(0);
library_sources_available( library_sources_available(
has_folders, has_folders,
indexed_tracks,
crate::local_plex::is_configured(), crate::local_plex::is_configured(),
crate::local_plex::cached_track_count(), crate::local_plex::cached_track_count(),
crate::media_servers_qt::cached_track_count(), crate::media_servers_qt::cached_track_count(),
@@ -577,11 +579,12 @@ pub fn has_library() -> bool {
fn library_sources_available( fn library_sources_available(
has_folders: bool, has_folders: bool,
indexed_tracks: u64,
plex_enabled: bool, plex_enabled: bool,
plex_tracks: i64, plex_tracks: i64,
remote_tracks: i64, remote_tracks: i64,
) -> bool { ) -> bool {
has_folders || (plex_enabled && plex_tracks > 0) || remote_tracks > 0 has_folders || indexed_tracks > 0 || (plex_enabled && plex_tracks > 0) || remote_tracks > 0
} }
#[cfg(test)] #[cfg(test)]
@@ -646,11 +649,21 @@ mod phase_a_tests {
#[test] #[test]
fn remote_only_installation_has_a_library() { fn remote_only_installation_has_a_library() {
assert!(library_sources_available(true, false, 0, 0)); assert!(library_sources_available(true, 0, false, 0, 0));
assert!(library_sources_available(false, true, 5_137, 0)); assert!(library_sources_available(false, 0, true, 5_137, 0));
assert!(library_sources_available(false, false, 0, 4_924)); assert!(library_sources_available(false, 0, false, 0, 4_924));
assert!(library_sources_available(false, false, 0, 6_678)); assert!(library_sources_available(false, 0, false, 0, 6_678));
assert!(!library_sources_available(false, false, 0, 0)); assert!(!library_sources_available(false, 0, false, 0, 0));
}
#[test]
fn offline_only_installation_has_a_library_without_folders_or_servers() {
// #758: 7 albums / 97 indexed downloaded tracks, no music folders.
assert!(library_sources_available(false, 97, false, 0, 0));
assert!(library_sources_available(false, 1, false, 0, 0));
// Removing the last download restores the genuine empty state.
assert!(!library_sources_available(false, 0, false, 0, 0));
assert!(!library_sources_available(false, 0, false, 20, 0));
} }
#[test] #[test]
+48 -1
View File
@@ -122,6 +122,47 @@ fn track_cache_info(
fn spawn_download(off: &Arc<OfflineCacheState>, track_id: u64) { fn spawn_download(off: &Arc<OfflineCacheState>, track_id: u64) {
let file_path = off.track_file_path(track_id, "flac"); let file_path = off.track_file_path(track_id, "flac");
let state = Arc::downgrade(off);
let rows = row_sink();
let sink = Arc::new(move |event: CacheEvent| {
let refresh = matches!(
event,
CacheEvent::Completed { .. } | CacheEvent::Processed { .. }
);
rows(event);
if refresh {
let state = state.clone();
crate::spawn(async move {
let Some(state) = state.upgrade() else {
return;
};
let Some(current) = offline_qt::get().await else {
return;
};
if !Arc::ptr_eq(&state, &current) {
return;
}
match state.restore_library_rows(Some(track_id)).await {
Ok(report) => log::info!(
"[offline-library] track={track_id} restored={} unavailable={} failed={}",
report.restored,
report.unavailable,
report.failed
),
Err(error) => {
log::warn!("[offline-library] track={track_id} repair failed: {error}")
}
}
if offline_qt::get()
.await
.is_some_and(|current| Arc::ptr_eq(&state, &current))
{
crate::local_catalog_qt::request_catch_up();
crate::local_bridge_ops::publish_availability();
}
});
}
});
qbz_offline_cache::spawn_track_cache_download( qbz_offline_cache::spawn_track_cache_download(
track_id, track_id,
file_path, file_path,
@@ -130,7 +171,7 @@ fn spawn_download(off: &Arc<OfflineCacheState>, track_id: u64) {
off.db.clone(), off.db.clone(),
off.get_cache_path(), off.get_cache_path(),
off.library_db.clone(), off.library_db.clone(),
row_sink(), sink,
off.cache_semaphore.clone(), off.cache_semaphore.clone(),
); );
} }
@@ -723,6 +764,8 @@ pub fn remove_album(album_id: String) {
offline_qt::mark_cached(*id, false); offline_qt::mark_cached(*id, false);
push_status(*id, 0, 0.0); push_status(*id, 0, 0.0);
} }
crate::local_catalog_qt::request_catch_up();
crate::local_bridge_ops::publish_availability();
crate::toast_qt::success(qbz_i18n::t("Removed album from offline")); crate::toast_qt::success(qbz_i18n::t("Removed album from offline"));
crate::offline_manager_qt::refresh_if_open().await; crate::offline_manager_qt::refresh_if_open().await;
}); });
@@ -765,6 +808,8 @@ pub fn clear_all() {
return; return;
} }
offline_qt::clear_cached_ids(); offline_qt::clear_cached_ids();
crate::local_catalog_qt::request_catch_up();
crate::local_bridge_ops::publish_availability();
crate::toast_qt::success(qbz_i18n::t("Cache cleared")); crate::toast_qt::success(qbz_i18n::t("Cache cleared"));
crate::offline_manager_qt::refresh_if_open().await; crate::offline_manager_qt::refresh_if_open().await;
}); });
@@ -825,6 +870,8 @@ async fn remove_cached_inner(id: u64, toast: bool) {
} }
} }
offline_qt::mark_cached(id, false); offline_qt::mark_cached(id, false);
crate::local_catalog_qt::request_catch_up();
crate::local_bridge_ops::publish_availability();
push_status(id, 0, 0.0); push_status(id, 0, 0.0);
if toast { if toast {
crate::toast_qt::success(qbz_i18n::t("Removed from offline")); crate::toast_qt::success(qbz_i18n::t("Removed from offline"));
+12
View File
@@ -78,7 +78,19 @@ pub async fn activate(user_id: u64) {
log::warn!("[qbz-qt] offline: library connection failed: {e}"); log::warn!("[qbz-qt] offline: library connection failed: {e}");
} }
match state.restore_library_rows(None).await {
Ok(report) => log::info!(
"[offline-library] startup ready={} restored={} unavailable={} failed={}",
report.ready,
report.restored,
report.unavailable,
report.failed
),
Err(error) => log::warn!("[offline-library] startup repair failed: {error}"),
}
*slot().lock().await = Some(Arc::new(state)); *slot().lock().await = Some(Arc::new(state));
crate::local_catalog_qt::request_catch_up();
crate::local_bridge_ops::publish_availability();
log::info!("[qbz-qt] offline cache activated for user {user_id}"); log::info!("[qbz-qt] offline cache activated for user {user_id}");
load_cached_ids().await; load_cached_ids().await;
} }
+1
View File
@@ -778,6 +778,7 @@ mod tests {
streamable: None, streamable: None,
streamable_at: None, streamable_at: None,
release_date_stream: None, release_date_stream: None,
release_date_original: None,
}); });
let qt = track_to_queue_track(&track); let qt = track_to_queue_track(&track);
+143 -9
View File
@@ -79,22 +79,24 @@ function qmlFunction(name) {
return libraryQml.slice(start, stop); return libraryQml.slice(start, stop);
} }
const bridge = { const bridge = {
sessionShowPurchases: true, sessionShowFavorites: true, sessionShowFollowing: true, sessionShowPurchases: false, sessionShowFavorites: false, sessionShowFollowing: false,
sessionAlbumsShowPurchases: true, sessionAlbumsShowFavorites: true, sessionAlbumsShowPurchases: false, sessionAlbumsShowFavorites: false,
sessionTracksShowPurchases: true, sessionTracksShowFavorites: true, sessionTracksShowPurchases: false, sessionTracksShowFavorites: false,
sessionAlbumsHiresOnly:false, sessionTracksHiresOnly:false, sessionHideLocalAlbums:false,
}; };
const lib = vm.createContext({ const lib = vm.createContext({
QbzLibrary: bridge, ReleaseSort: sort, Qt: {callLater() {}}, QbzLibrary: bridge, ReleaseSort: sort, Qt: {callLater() {}},
activeTab: 'all', search: '', tabSearch: '', showLocal: true, activeTab: 'all', search: '', tabSearch: '', showLocal: true,
sortBy: 'date', sortAsc: false, albumsSort: 'oldest', albumsGroup: 'off', sortBy: 'date', sortAsc: false, albumsSort: 'oldest', albumsGroup: 'off',
tracksGroup: 'off', artistsGroup: 'off', genreNames: [], tracksGroup: 'off', artistsGroup: 'off', artistsView:'grid', genreNames: [],
tracksSort:'default', playlistsSort:'default', artistsSort:'default', labelsSort:'default',
}); });
lib.root = lib; lib.root = lib;
for (const name of ['showPurchases', 'showFavorites', 'showFollowing', 'genreContext']) { for (const name of ['showPurchases', 'showFavorites', 'showFollowing', 'genreContext', 'hiresOnly', 'activeSort']) {
const expr = libraryQml.match(new RegExp(`readonly property (?:bool|string) ${name}:([^\\n]*(?:\\n [^\\n]*)*)`))[1]; const expr = libraryQml.match(new RegExp(`readonly property (?:bool|string) ${name}:([^\\n]*(?:\\n [^\\n]*)*)`))[1];
Object.defineProperty(lib, name, {get() { return vm.runInContext(expr, lib); }}); Object.defineProperty(lib, name, {get() { return vm.runInContext(expr, lib); }});
} }
for (const name of ['parseFeed', 'visibleItems', 'matchesSources', 'setShowPurchases', 'setShowFavorites', 'setShowFollowing']) { for (const name of ['alphaKey', 'pickSort', 'isLocalFeedItem', 'hideableLocalAlbum', 'parseFeed', 'visibleItems', 'matchesSources', 'setShowPurchases', 'setShowFavorites', 'setShowFollowing', 'setHiresOnly']) {
vm.runInContext(qmlFunction(name), lib); vm.runInContext(qmlFunction(name), lib);
} }
const libraryRows = []; const libraryRows = [];
@@ -114,7 +116,7 @@ for (const tab of ['all', 'albums', 'tracks']) {
lib.activeTab = tab; lib.activeTab = tab;
if (tab === 'all') lib.setShowFollowing(false); if (tab === 'all') lib.setShowFollowing(false);
for (const [purchases, favorites, expected] of [ for (const [purchases, favorites, expected] of [
[true, true, ['both','favorite','purchased']], [true, true, ['both']],
[true, false, ['both','purchased']], [true, false, ['both','purchased']],
[false, true, ['both','favorite']], [false, true, ['both','favorite']],
[false, false, ['both','favorite','purchased']], [false, false, ['both','favorite','purchased']],
@@ -128,7 +130,7 @@ for (const tab of ['all', 'albums', 'tracks']) {
} }
} }
} }
lib.activeTab = 'albums'; lib.setShowPurchases(true); lib.setShowFavorites(true); lib.activeTab = 'albums'; lib.setShowPurchases(false); lib.setShowFavorites(false);
assert.deepEqual(ids(lib.visibleItems()), ['favorite', 'both', 'purchased']); assert.deepEqual(ids(lib.visibleItems()), ['favorite', 'both', 'purchased']);
lib.albumsSort = 'newest'; lib.albumsSort = 'newest';
assert.deepEqual(ids(lib.visibleItems()), ['purchased', 'both', 'favorite']); assert.deepEqual(ids(lib.visibleItems()), ['purchased', 'both', 'favorite']);
@@ -146,7 +148,7 @@ lib.activeTab = 'all'; lib.setShowPurchases(false); lib.setShowFavorites(false);
assert.deepEqual(ids(lib.visibleItems()), ['followed']); assert.deepEqual(ids(lib.visibleItems()), ['followed']);
lib.activeTab = 'albums'; lib.activeTab = 'albums';
assert.deepEqual(ids(lib.visibleItems()), ['purchased', 'both'], 'Following cannot gate Albums'); assert.deepEqual(ids(lib.visibleItems()), ['purchased', 'both'], 'Following cannot gate Albums');
console.log('Library source union, per-tab state, purchase-row dates, deduplication and chronology: passed'); console.log('Library only-filter intersections, per-tab state, purchase-row dates, deduplication and chronology: passed');
// Popularity leaves server order intact, and changing its paging order must // Popularity leaves server order intact, and changing its paging order must
// discard the old overlay/cursor while a local date/title sort keeps them. // discard the old overlay/cursor while a local date/title sort keeps them.
@@ -173,3 +175,135 @@ for (const [from,to,reset] of [['default','relevant',true],['relevant','oldest',
if (reset) assert.equal(artistState.releasePending.album, undefined); if (reset) assert.equal(artistState.releasePending.album, undefined);
} }
console.log('Artist popularity preserves server rank and resets pagination across server-order changes: passed'); console.log('Artist popularity preserves server rank and resets pagination across server-order changes: passed');
// Artists must remain independent of album/track source and genre switches.
// The #758 log contains 19 artists; it does not establish a rendering cause.
const artistRows = Array.from({length:19}, (_, i) => ({kind:'artist', id:`artist-${i}`,
title:`Artist ${i}`, group:'favorites', source:'qobuz'}));
lib.feed = lib.parseFeed(JSON.stringify([...libraryRows, ...artistRows]));
lib.activeTab = 'artists';
lib.genreNames = ['genre absent from every artist'];
for (const group of ['off', 'alpha']) {
lib.artistsGroup = group;
for (const enabled of [false, true]) {
bridge.sessionShowPurchases = enabled;
bridge.sessionShowFavorites = enabled;
bridge.sessionAlbumsShowPurchases = enabled;
bridge.sessionTracksShowFavorites = enabled;
assert.equal(lib.visibleItems().length, 19);
}
}
lib.tabSearch = 'Artist 18';
assert.deepEqual(ids(lib.visibleItems()), ['artist-18']);
lib.tabSearch = '';
assert.equal(lib.visibleItems().length, 19);
console.log('Library Artists preserves all 19 rows independently of source/genre switches: passed');
// Each Hi-Res restriction combines with source restrictions in its own tab.
lib.genreNames = [];
lib.feed = lib.parseFeed(JSON.stringify(libraryRows.map(row => ({...row,
qualityTier: row.id === 'both' ? 'hires' : 'cd'}))));
lib.activeTab = 'albums'; lib.setShowPurchases(false); lib.setShowFavorites(false);
lib.setHiresOnly(true);
assert.deepEqual(ids(lib.visibleItems()), ['both']);
lib.activeTab = 'tracks'; lib.setShowPurchases(false); lib.setShowFavorites(false);
assert.equal(lib.visibleItems().length, 3);
lib.setHiresOnly(true); lib.setShowFavorites(true);
assert.deepEqual(ids(lib.visibleItems()), ['both']);
lib.activeTab = 'all'; lib.setShowFollowing(false);
lib.setShowPurchases(false); lib.setShowFavorites(false);
lib.feed = lib.parseFeed(JSON.stringify([
...['local','plex','jellyfin','subsonic'].map(source => ({
kind:'album',id:source+':album',source,group:'local'})),
{kind:'track',id:'local-track',source:'local',group:'local'},
{kind:'album',id:'online',source:'qobuz',group:'favorites'},
{kind:'album',id:'matched-cache',source:'qobuz',group:'favorites',sources:['offline']},
{kind:'album',id:'cache',source:'local',group:'local',sources:['offline']},
]));
lib.showLocal = false;
assert.deepEqual(ids(lib.visibleItems()).sort(), ['cache','local-track','matched-cache','online']);
lib.showLocal = true;
assert.equal(lib.visibleItems().length, 8);
const playlists = ['you','qobuz','others','unknown'].map((creator, i) => ({
id:String(i), kind:'playlist', source:'qobuz', group:i ? 'following':'favorites',
title:creator, playlistCreator:creator, isFavorite:i===2,
}));
lib.feed = lib.parseFeed(JSON.stringify([...playlists, {...playlists[2],group:'favorites'}]));
lib.activeTab = 'playlists';
for (const creator of ['all','you','qobuz','others']) {
lib.playlistsSubTab = creator;
assert.equal(lib.visibleItems().length, creator === 'all' ? 4 : 1);
if (creator !== 'all') assert.equal(lib.visibleItems()[0].playlistCreator, creator);
}
assert.equal(lib.feed._tabTotals.playlists, 4);
console.log('Hi-Res intersections, per-tab isolation, local-album exclusion and playlist author select: passed');
// Catalog IDs and local row IDs live in different namespaces.
lib.feed = lib.parseFeed(JSON.stringify([
{kind:'track',id:'7',source:'local',group:'local'},
{kind:'track',id:'7',source:'qobuz',group:'purchases'},
]));
lib.activeTab = 'all'; lib.setShowPurchases(true); lib.setShowFavorites(false); lib.setShowFollowing(false);
assert.equal(lib.visibleItems().length, 1);
assert.equal(lib.visibleItems()[0].source, 'qobuz');
// Re-selecting one criterion flips it; other tabs retain their own choices.
lib.setPref = (key, value) => { lib[key] = value; };
for (const tab of ['all','tracks','albums','artists','labels','playlists']) {
lib.activeTab = tab;
lib.pickSort('title');
assert.equal(lib.activeSort, 'title-asc');
lib.pickSort('title');
assert.equal(lib.activeSort, 'title-desc');
lib.pickSort('date');
assert.equal(lib.activeSort, 'date-desc');
lib.pickSort('date');
assert.equal(lib.activeSort, 'date-asc');
}
lib.activeTab='albums'; lib.albumsSort='oldest'; lib.pickSort('release-date');
assert.equal(lib.albumsSort,'release-date-desc');
lib.activeTab='tracks'; assert.equal(lib.tracksSort,'date-asc');
const sortRows = [
{id:'b',title:'Bravo',artist:'B',album:'B',label:'B',genre:'Rock',releaseSortKey:20000101,durationSecs:200,trackCount:10,updatedAt:200,added_rank:0},
{id:'a',title:'Alpha',artist:'A',album:'A',label:'A',genre:'Jazz',releaseSortKey:19800101,durationSecs:100,trackCount:0,updatedAt:100,added_rank:1},
{id:'missing'}
];
for (const field of ['title','artist','release','label','genre','release-date','duration','track-count','updated']) {
assert.deepEqual(ids(sort.sortRows(sortRows,field+'-asc')),['a','b','missing'],field);
assert.deepEqual(ids(sort.sortRows(sortRows,field+'-desc')),['b','a','missing'],field);
}
assert.deepEqual(ids(sort.sortRows(sortRows.slice(0,2),'date-desc')),['b','a']);
assert.deepEqual(ids(sort.sortRows(sortRows.slice(0,2),'date-asc')),['a','b']);
assert.deepEqual(ids(sort.sortRows(sortRows,'default-reverse')),['missing','a','b']);
assert.deepEqual(ids(sortRows),['b','a','missing']);
console.log('All six tabs: one criterion, repeat-click reversal, independent state, numeric metadata, unknowns last: passed');
// Exercise tab derivation, so a grouping or old forced sort cannot override it.
lib.genreNames=[]; lib.tabSearch=''; lib.tracksGroup='off'; lib.artistsGroup='off';
bridge.sessionTracksHiresOnly=false; bridge.sessionAlbumsHiresOnly=false;
bridge.sessionTracksShowFavorites=false; bridge.sessionTracksShowPurchases=false;
bridge.sessionAlbumsShowFavorites=false; bridge.sessionAlbumsShowPurchases=false;
for (const [tab,kind] of [['tracks','track'],['albums','album'],['artists','artist'],['labels','label'],['playlists','playlist']]) {
lib.activeTab=tab; lib.playlistsSubTab='all';
lib.feed=lib.parseFeed(JSON.stringify(sortRows.map(row=>({...row,kind,source:'qobuz',group:'favorites'}))));
lib[tab+'Sort']='title-asc'; assert.deepEqual(ids(lib.visibleItems()),['a','b','missing'],tab);
lib[tab+'Sort']='title-desc'; assert.deepEqual(ids(lib.visibleItems()),['b','a','missing'],tab);
}
lib.activeTab='artists'; lib.artistsGroup='alpha'; lib.artistsView='sidepanel';
lib.artistsSort='date-desc';
lib.feed=lib.parseFeed(JSON.stringify(sortRows.slice(0,2).map(row=>({...row,kind:'artist',source:'qobuz',group:'favorites'}))));
assert.deepEqual(ids(lib.visibleItems()),['b','a'],'Sidepanel must not inherit grid grouping');
console.log('Every Library tab applies chosen order through the real visible-items pipeline: passed');
assert.deepEqual(ids(sort.sortRows([
{id:'full',kind:'playlist',trackCount:2,durationSecs:200},
{id:'empty',kind:'playlist',trackCount:0},
{id:'unknown',kind:'playlist',trackCount:2}
], 'duration-asc')), ['empty','full','unknown']);
lib.activeTab='albums'; lib.albumsSort='title-asc';
lib.pickSort('default'); assert.equal(lib.albumsSort,'default');
assert.equal(sort.ascending(lib.activeSort),false);
lib.pickSort('default'); assert.equal(lib.albumsSort,'default-reverse');
assert.equal(sort.ascending(lib.activeSort),true);
lib.pickSort('default'); assert.equal(lib.albumsSort,'default');