From 6fd83c42df734719010777ca020cbd4b16e1a4cb Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:17:08 -0600 Subject: [PATCH] feat(library): page Artists from local catalog Materialize source-scoped artist aggregates and FTS, then expose exact keyset pages and bounded A-Z metadata for the existing Artists rail. Query the selected artist's albums through the indexed credit relationship with an independent bounded model, generation cancellation, artwork updates, and per-surface legacy fallback. cx --- crates/qbz-local-catalog/src/catalog.rs | 490 ++++- crates/qbz-local-catalog/src/lib.rs | 6 +- crates/qbz-local-catalog/src/model.rs | 27 + crates/qbz-local-catalog/src/schema.rs | 24 +- crates/qbz-local-catalog/src/tests.rs | 317 +++ crates/qbz-qt/build.rs | 1 + crates/qbz-qt/cxx/local_albums_model.cpp | 19 + crates/qbz-qt/cxx/local_albums_model.h | 2 + crates/qbz-qt/cxx/local_artists_model.cpp | 235 +++ crates/qbz-qt/cxx/local_artists_model.h | 79 + crates/qbz-qt/qml/views/LocalLibraryView.qml | 23 +- .../qml/views/local/LocalArtistsTab.qml | 141 +- .../qbz-qt/qml/views/local/LocalToolbar.qml | 17 +- crates/qbz-qt/src/local_artists_model_qt.rs | 1691 +++++++++++++++++ crates/qbz-qt/src/local_bridge.rs | 69 + crates/qbz-qt/src/local_bridge_ops.rs | 14 + crates/qbz-qt/src/local_catalog_qt.rs | 6 + crates/qbz-qt/src/main.rs | 3 + 18 files changed, 3128 insertions(+), 36 deletions(-) create mode 100644 crates/qbz-qt/cxx/local_artists_model.cpp create mode 100644 crates/qbz-qt/cxx/local_artists_model.h create mode 100644 crates/qbz-qt/src/local_artists_model_qt.rs diff --git a/crates/qbz-local-catalog/src/catalog.rs b/crates/qbz-local-catalog/src/catalog.rs index 61f55b979..293c2310b 100644 --- a/crates/qbz-local-catalog/src/catalog.rs +++ b/crates/qbz-local-catalog/src/catalog.rs @@ -10,8 +10,9 @@ use rusqlite::{ use crate::bootstrap::{BootstrapBatch, SourceCheckpoint, BOOTSTRAP_BATCH_ROWS}; use crate::model::{ - AlbumCursor, AlbumPage, AlbumRecord, ProjectedTrack, QueryDescriptor, QuerySurface, SourceKey, - SourceKind, TrackCursor, TrackGroup, TrackPage, TrackRecord, TrackRef, TrackSort, + AlbumCursor, AlbumPage, AlbumRecord, ArtistCursor, ArtistPage, ArtistRecord, ProjectedTrack, + QueryDescriptor, QuerySurface, SourceKey, SourceKind, TrackCursor, TrackGroup, TrackPage, + TrackRecord, TrackRef, TrackSort, }; use crate::projection::ReconciliationBatch; use crate::schema; @@ -540,7 +541,9 @@ impl Catalog { pub(crate) fn rebuild_materialized_views(&mut self) -> Result<()> { let tx = self.conn.transaction()?; tx.execute_batch( - "DELETE FROM edition_artists; + "DELETE FROM artists_fts; + DELETE FROM artist_source_stats; + DELETE FROM edition_artists; DELETE FROM albums_materialized; DELETE FROM artists_materialized; @@ -644,30 +647,95 @@ impl Catalog { GROUP BY e.edition_id; INSERT INTO artists_materialized( - artist_key,display_name,sort_name,album_count,track_count,available + artist_key,display_name,sort_name,album_count,track_count,available, + source_kind,artwork_source,artwork_token ) SELECT ac.artist_key,MIN(ac.display_name),ac.artist_key, - COUNT(DISTINCT sc.edition_id),COUNT(DISTINCT ac.catalog_id), + COUNT(DISTINCT CASE WHEN t.available=1 THEN sc.edition_id END), + COUNT(DISTINCT CASE WHEN t.available=1 THEN ac.catalog_id END), + COALESCE(MAX(t.available),0), + CASE WHEN COUNT(DISTINCT t.source_kind)>1 THEN 'mixed' + ELSE MIN(t.source_kind) END, + COALESCE(( + SELECT t2.source_kind + FROM artist_credits ac2 + JOIN tracks t2 ON t2.catalog_id=ac2.catalog_id + WHERE ac2.artist_key=ac.artist_key + AND t2.available=1 + AND COALESCE(t2.artwork_token,'') != '' + ORDER BY CASE ac2.role + WHEN 'track_artist' THEN 0 + WHEN 'album_artist' THEN 1 + WHEN 'featured' THEN 2 + WHEN 'performer' THEN 3 ELSE 4 END, + t2.catalog_id LIMIT 1 + ),''), + COALESCE(( + SELECT t2.artwork_token + FROM artist_credits ac2 + JOIN tracks t2 ON t2.catalog_id=ac2.catalog_id + WHERE ac2.artist_key=ac.artist_key + AND t2.available=1 + AND COALESCE(t2.artwork_token,'') != '' + ORDER BY CASE ac2.role + WHEN 'track_artist' THEN 0 + WHEN 'album_artist' THEN 1 + WHEN 'featured' THEN 2 + WHEN 'performer' THEN 3 ELSE 4 END, + t2.catalog_id LIMIT 1 + ),'') + FROM artist_credits ac + JOIN tracks t ON t.catalog_id=ac.catalog_id + LEFT JOIN source_copies sc ON sc.source_copy_id=t.source_copy_id + GROUP BY ac.artist_key; + + INSERT INTO artist_source_stats( + artist_key,source_kind,source_instance,album_count,track_count,available + ) + SELECT ac.artist_key,t.source_kind,t.source_instance, + COUNT(DISTINCT CASE WHEN t.available=1 THEN sc.edition_id END), + COUNT(DISTINCT CASE WHEN t.available=1 THEN ac.catalog_id END), COALESCE(MAX(t.available),0) FROM artist_credits ac JOIN tracks t ON t.catalog_id=ac.catalog_id LEFT JOIN source_copies sc ON sc.source_copy_id=t.source_copy_id - GROUP BY ac.artist_key;", + GROUP BY ac.artist_key,t.source_kind,t.source_instance; + + INSERT INTO artists_fts(artist_key,display_name) + SELECT artist_key,display_name FROM artists_materialized;", )?; tx.commit()?; Ok(()) } pub(crate) fn materialized_views_valid(&self) -> Result { - let (tracks, album_tracks, unresolved): (i64, i64, i64) = self.conn.query_row( + let (tracks, album_tracks, unresolved, credit_artists, materialized_artists, missing_stats): + (i64, i64, i64, i64, i64, i64) = self.conn.query_row( "SELECT (SELECT COUNT(*) FROM tracks), (SELECT COALESCE(SUM(track_count),0) FROM albums_materialized), - (SELECT COUNT(*) FROM tracks WHERE source_copy_id IS NULL)", + (SELECT COUNT(*) FROM tracks WHERE source_copy_id IS NULL), + (SELECT COUNT(DISTINCT artist_key) FROM artist_credits), + (SELECT COUNT(*) FROM artists_materialized), + (SELECT COUNT(*) FROM artists_materialized am + WHERE NOT EXISTS (SELECT 1 FROM artist_source_stats ast + WHERE ast.artist_key=am.artist_key))", [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }, )?; - Ok(tracks == album_tracks && unresolved == 0) + Ok(tracks == album_tracks + && unresolved == 0 + && credit_artists == materialized_artists + && missing_stats == 0) } pub fn resolve(&self, track_ref: &TrackRef) -> Result> { @@ -861,6 +929,186 @@ impl Catalog { )) } + pub fn count_artists(&self, descriptor: &QueryDescriptor) -> Result { + validate_artists_descriptor(descriptor)?; + let parts = artist_filter_parts(descriptor, None)?; + let sql = format!( + "SELECT COUNT(*) {} WHERE {}", + parts.from_sql, parts.where_sql + ); + let count: i64 = self + .conn + .query_row(&sql, params_from_iter(parts.params), |row| row.get(0))?; + Ok(count.max(0) as u64) + } + + /// Exact visual length of the Artists rail: one row per artist plus one + /// header for every non-empty A-Z/# bucket under the immutable query. + pub fn count_artist_entries(&self, descriptor: &QueryDescriptor) -> Result { + validate_artists_descriptor(descriptor)?; + let parts = artist_filter_parts(descriptor, None)?; + let sql = format!( + "SELECT COUNT(*) + COUNT(DISTINCT {}) {} WHERE {}", + artist_initial_expression("ar"), + parts.from_sql, + parts.where_sql + ); + let count: i64 = self + .conn + .query_row(&sql, params_from_iter(parts.params), |row| row.get(0))?; + Ok(count.max(0) as u64) + } + + pub fn query_artists( + &self, + descriptor: &QueryDescriptor, + cursor: Option<&ArtistCursor>, + page_size: usize, + ) -> Result { + self.query_artists_timed(descriptor, cursor, page_size) + .map(|(page, _)| page) + } + + pub fn query_artists_timed( + &self, + descriptor: &QueryDescriptor, + cursor: Option<&ArtistCursor>, + page_size: usize, + ) -> Result<(ArtistPage, QueryMetrics)> { + validate_artists_descriptor(descriptor)?; + let limit = page_size.clamp(1, MAX_PAGE_SIZE); + let parts = artist_filter_parts(descriptor, cursor)?; + let descending = artist_descending(descriptor); + let order = if descending { "DESC" } else { "ASC" }; + let sql = format!( + "SELECT ar.artist_key,ar.display_name,{},{},ar.artwork_source, + ar.artwork_token,{},ar.sort_name + {} WHERE {} + ORDER BY ar.sort_name {order},ar.artist_key {order} LIMIT {}", + parts.album_count_sql, + parts.track_count_sql, + parts.source_sql, + parts.from_sql, + parts.where_sql, + limit + 1 + ); + let key = descriptor_key(descriptor); + let started = Instant::now(); + let mut stmt = self.conn.prepare_cached(&sql)?; + let mapped = stmt.query_map(params_from_iter(parts.params), |row| { + Ok(( + ArtistRecord { + artist_key: row.get(0)?, + display_name: row.get(1)?, + album_count: row.get::<_, i64>(2)?.max(0) as u32, + track_count: row.get::<_, i64>(3)?.max(0) as u32, + artwork_source: row.get(4)?, + artwork_token: row.get(5)?, + source: row.get(6)?, + }, + row.get::<_, String>(7)?, + )) + })?; + let mut values = mapped.collect::>>()?; + let sql_time = started.elapsed(); + let has_more = values.len() > limit; + values.truncate(limit); + let next_cursor = has_more.then(|| { + let (record, sort_name) = values.last().expect("non-empty page"); + ArtistCursor { + descriptor_key: key, + sort_name: sort_name.clone(), + artist_key: record.artist_key.clone(), + } + }); + let rows = values + .into_iter() + .map(|(record, _)| record) + .collect::>(); + let metrics = QueryMetrics { + sql_time, + rows: rows.len(), + }; + Ok(( + ArtistPage { + rows, + next_cursor, + has_more, + }, + metrics, + )) + } + + /// Count the selected artist's albums through the normalized, + /// source-aware `edition_artists` relationship. No album collection is + /// materialized in the frontend to answer this question. + pub fn count_artist_albums(&self, artist_key: &str, sources: &[SourceKey]) -> Result { + validate_artist_key(artist_key)?; + let (where_sql, values) = artist_album_filter(artist_key, sources, None)?; + let sql = format!("SELECT COUNT(*) FROM albums_materialized am WHERE {where_sql}"); + let count: i64 = self + .conn + .query_row(&sql, params_from_iter(values), |row| row.get(0))?; + Ok(count.max(0) as u64) + } + + pub fn query_artist_albums( + &self, + artist_key: &str, + sources: &[SourceKey], + cursor: Option<&AlbumCursor>, + page_size: usize, + ) -> Result { + self.query_artist_albums_timed(artist_key, sources, cursor, page_size) + .map(|(page, _)| page) + } + + pub fn query_artist_albums_timed( + &self, + artist_key: &str, + sources: &[SourceKey], + cursor: Option<&AlbumCursor>, + page_size: usize, + ) -> Result<(AlbumPage, QueryMetrics)> { + validate_artist_key(artist_key)?; + let limit = page_size.clamp(1, MAX_PAGE_SIZE); + let (where_sql, values) = artist_album_filter(artist_key, sources, cursor)?; + let sql = format!( + "SELECT {ALBUM_COLUMNS} FROM albums_materialized am + WHERE {where_sql} + ORDER BY am.sort_title,am.edition_id LIMIT {}", + limit + 1 + ); + let key = artist_relation_key(artist_key, sources); + let started = Instant::now(); + let mut stmt = self.conn.prepare_cached(&sql)?; + let mapped = stmt.query_map(params_from_iter(values), map_album_row)?; + let mut values = mapped.collect::>>()?; + for value in &mut values { + value.cursor.descriptor_key = key.clone(); + } + let sql_time = started.elapsed(); + let has_more = values.len() > limit; + values.truncate(limit); + let next_cursor = has_more.then(|| values.last().expect("non-empty page").cursor.clone()); + let rows = values + .into_iter() + .map(|value| value.record) + .collect::>(); + let metrics = QueryMetrics { + sql_time, + rows: rows.len(), + }; + Ok(( + AlbumPage { + rows, + next_cursor, + has_more, + }, + metrics, + )) + } + pub fn stats(&self) -> Result { let track_count: i64 = self .conn @@ -921,10 +1169,17 @@ impl Catalog { [], ) .is_ok(); + let artists_fts_ok = self + .conn + .execute( + "INSERT INTO artists_fts(artists_fts) VALUES ('integrity-check')", + [], + ) + .is_ok(); Ok(IntegrityReport { sqlite_ok: result == "ok", foreign_key_violations: foreign_key_violations as u64, - fts_ok: tracks_fts_ok && albums_fts_ok, + fts_ok: tracks_fts_ok && albums_fts_ok && artists_fts_ok, }) } @@ -1872,6 +2127,219 @@ const ALBUM_COLUMNS: &str = " am.sort_title,am.sort_artist, CASE WHEN am.year IS NULL THEN 1 ELSE 0 END,COALESCE(am.year,0)"; +struct ArtistQueryParts { + from_sql: String, + where_sql: String, + params: Vec, + album_count_sql: String, + track_count_sql: String, + source_sql: String, +} + +fn validate_artists_descriptor(descriptor: &QueryDescriptor) -> Result<()> { + if descriptor.surface() != QuerySurface::Artists { + return Err(CatalogError::InvalidInput( + "a non-Artists descriptor was passed to an Artists query".to_string(), + )); + } + if !descriptor.formats().is_empty() + || descriptor.other_formats() + || !descriptor.quality_tiers().is_empty() + || !descriptor.source_buckets().is_empty() + || descriptor.group() != TrackGroup::Off + { + return Err(CatalogError::InvalidInput( + "Artists supports source instances and name search only".to_string(), + )); + } + match descriptor.sort() { + TrackSort::Default | TrackSort::ArtistAsc | TrackSort::ArtistDesc => Ok(()), + _ => Err(CatalogError::InvalidInput( + "Artists supports name ordering only".to_string(), + )), + } +} + +fn artist_descending(descriptor: &QueryDescriptor) -> bool { + descriptor.sort() == TrackSort::ArtistDesc +} + +fn artist_initial_expression(alias: &str) -> String { + format!( + "CASE WHEN UPPER(SUBSTR({alias}.sort_name,1,1)) BETWEEN 'A' AND 'Z' \ + THEN UPPER(SUBSTR({alias}.sort_name,1,1)) ELSE '#' END" + ) +} + +fn artist_filter_parts( + descriptor: &QueryDescriptor, + cursor: Option<&ArtistCursor>, +) -> Result { + if let Some(cursor) = cursor { + if cursor.descriptor_key != descriptor_key(descriptor) { + return Err(CatalogError::CursorDescriptorMismatch); + } + } + let mut params = Vec::new(); + let (source_join, album_count_sql, track_count_sql, source_sql, source_filter) = + if descriptor.sources().is_empty() { + ( + "".to_string(), + "ar.album_count".to_string(), + "ar.track_count".to_string(), + "ar.source_kind".to_string(), + None, + ) + } else { + let mut source = Vec::new(); + for key in descriptor.sources() { + if key.source_instance.trim().is_empty() { + return Err(CatalogError::InvalidInput( + "source filter instance must not be empty".to_string(), + )); + } + let first = params.len() + 1; + source.push(format!( + "(ass.source_kind=?{first} AND ass.source_instance=?{})", + first + 1 + )); + params.push(Value::Text(key.source.as_str().to_string())); + params.push(Value::Text(key.source_instance.clone())); + } + let clause = source.join(" OR "); + ( + "".to_string(), + format!( + "(SELECT COALESCE(SUM(ass.album_count),0) FROM artist_source_stats ass \ + WHERE ass.artist_key=ar.artist_key AND ass.available=1 AND ({clause}))" + ), + format!( + "(SELECT COALESCE(SUM(ass.track_count),0) FROM artist_source_stats ass \ + WHERE ass.artist_key=ar.artist_key AND ass.available=1 AND ({clause}))" + ), + format!( + "(SELECT CASE WHEN COUNT(DISTINCT ass.source_kind)>1 THEN 'mixed' \ + ELSE MIN(ass.source_kind) END FROM artist_source_stats ass \ + WHERE ass.artist_key=ar.artist_key AND ass.available=1 AND ({clause}))" + ), + Some(clause), + ) + }; + + let mut predicates = Vec::new(); + let mut search_join = String::new(); + if !descriptor.search().is_empty() { + let search_parameter = params.len() + 1; + if descriptor.search().chars().count() < 3 { + predicates.push(format!("INSTR(ar.sort_name,?{search_parameter})>0")); + params.push(Value::Text(normalize_artist_key(descriptor.search()))); + } else { + search_join = "JOIN artists_fts ON artists_fts.artist_key=ar.artist_key".to_string(); + predicates.push(format!("artists_fts MATCH ?{search_parameter}")); + params.push(fts_match_value(descriptor.search())?); + } + } + if descriptor.available_only() { + predicates.push("ar.available=1".to_string()); + } + if let Some(source) = source_filter { + predicates.push(format!( + "EXISTS (SELECT 1 FROM artist_source_stats ass \ + WHERE ass.artist_key=ar.artist_key AND ass.available=1 AND ({source}))" + )); + } + if let Some(cursor) = cursor { + let comparison = if artist_descending(descriptor) { + "<" + } else { + ">" + }; + predicates.push(format!( + "(ar.sort_name{comparison}? OR (ar.sort_name=? AND ar.artist_key{comparison}?))" + )); + params.push(Value::Text(cursor.sort_name.clone())); + params.push(Value::Text(cursor.sort_name.clone())); + params.push(Value::Text(cursor.artist_key.clone())); + } + if predicates.is_empty() { + predicates.push("1".to_string()); + } + Ok(ArtistQueryParts { + from_sql: format!("FROM artists_materialized ar {source_join} {search_join}"), + where_sql: predicates.join(" AND "), + params, + album_count_sql, + track_count_sql, + source_sql, + }) +} + +fn validate_artist_key(artist_key: &str) -> Result<()> { + if artist_key.trim().is_empty() || normalize_artist_key(artist_key) != artist_key { + return Err(CatalogError::InvalidInput( + "artist relationship requires a normalized artist key".to_string(), + )); + } + Ok(()) +} + +fn artist_relation_key(artist_key: &str, sources: &[SourceKey]) -> String { + let mut sources = sources.to_vec(); + sources.sort(); + sources.dedup(); + let mut key = String::from("artist-albums|"); + push_key_part(&mut key, artist_key); + for source in sources { + push_key_part(&mut key, source.source.as_str()); + push_key_part(&mut key, &source.source_instance); + } + key +} + +fn artist_album_filter( + artist_key: &str, + sources: &[SourceKey], + cursor: Option<&AlbumCursor>, +) -> Result<(String, Vec)> { + let relation_key = artist_relation_key(artist_key, sources); + if let Some(cursor) = cursor { + if cursor.descriptor_key != relation_key { + return Err(CatalogError::CursorDescriptorMismatch); + } + } + let mut predicates = vec![ + "am.available=1".to_string(), + "EXISTS (SELECT 1 FROM edition_artists ea \ + WHERE ea.edition_id=am.edition_id AND ea.artist_key=?)" + .to_string(), + ]; + let mut values = vec![Value::Text(artist_key.to_string())]; + if !sources.is_empty() { + let mut source = Vec::new(); + for key in sources { + if key.source_instance.trim().is_empty() { + return Err(CatalogError::InvalidInput( + "source filter instance must not be empty".to_string(), + )); + } + source.push("(scf.source_kind=? AND scf.source_instance=?)"); + values.push(Value::Text(key.source.as_str().to_string())); + values.push(Value::Text(key.source_instance.clone())); + } + predicates.push(format!( + "EXISTS (SELECT 1 FROM source_copies scf WHERE scf.edition_id=am.edition_id AND ({}))", + source.join(" OR ") + )); + } + if let Some(cursor) = cursor { + predicates.push("(am.sort_title>? OR (am.sort_title=? AND am.edition_id>?))".to_string()); + values.push(Value::Text(cursor.sort_title.clone())); + values.push(Value::Text(cursor.sort_title.clone())); + values.push(Value::Integer(cursor.edition_id)); + } + Ok((predicates.join(" AND "), values)) +} + /// Unicode-aware display sort key shared by ingest and query indices. pub fn normalize_sort_key(value: &str) -> String { value diff --git a/crates/qbz-local-catalog/src/lib.rs b/crates/qbz-local-catalog/src/lib.rs index 672778f88..ca7e5ac42 100644 --- a/crates/qbz-local-catalog/src/lib.rs +++ b/crates/qbz-local-catalog/src/lib.rs @@ -26,9 +26,9 @@ pub use legacy::{ reconcile_legacy_caches_with_progress, LegacyLocations, LegacySourceSpec, }; pub use model::{ - AlbumCursor, AlbumPage, AlbumRecord, ArtistCredit, CreditRole, ProjectedTrack, QueryDescriptor, - QuerySurface, SourceKey, SourceKind, TrackCursor, TrackGroup, TrackPage, TrackRecord, TrackRef, - TrackSort, + AlbumCursor, AlbumPage, AlbumRecord, ArtistCredit, ArtistCursor, ArtistPage, ArtistRecord, + CreditRole, ProjectedTrack, QueryDescriptor, QuerySurface, SourceKey, SourceKind, TrackCursor, + TrackGroup, TrackPage, TrackRecord, TrackRef, TrackSort, }; pub use projection::{ ProjectionOutcome, ProjectionProgress, ProjectionSession, ReconciliationBatch, diff --git a/crates/qbz-local-catalog/src/model.rs b/crates/qbz-local-catalog/src/model.rs index eb1f935e1..9dcb98974 100644 --- a/crates/qbz-local-catalog/src/model.rs +++ b/crates/qbz-local-catalog/src/model.rs @@ -412,3 +412,30 @@ pub struct AlbumPage { pub next_cursor: Option, pub has_more: bool, } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtistRecord { + pub artist_key: String, + pub display_name: String, + pub album_count: u32, + pub track_count: u32, + pub artwork_source: String, + pub artwork_token: String, + /// `local`, a remote source word, or `mixed` when several source kinds + /// contribute to the aggregate visible under the query descriptor. + pub source: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtistCursor { + pub(crate) descriptor_key: String, + pub(crate) sort_name: String, + pub(crate) artist_key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtistPage { + pub rows: Vec, + pub next_cursor: Option, + pub has_more: bool, +} diff --git a/crates/qbz-local-catalog/src/schema.rs b/crates/qbz-local-catalog/src/schema.rs index b6f83a1fc..88ae0ca60 100644 --- a/crates/qbz-local-catalog/src/schema.rs +++ b/crates/qbz-local-catalog/src/schema.rs @@ -2,7 +2,7 @@ use rusqlite::{params, Connection}; use crate::{CatalogError, Result}; -pub const SCHEMA_VERSION: u32 = 3; +pub const SCHEMA_VERSION: u32 = 4; pub const APPLICATION_ID: i64 = 0x5142_5A43; // "QBZC" pub(crate) fn configure(conn: &Connection) -> Result<()> { @@ -314,6 +314,7 @@ CREATE TABLE IF NOT EXISTS artists_materialized ( album_count INTEGER NOT NULL DEFAULT 0, track_count INTEGER NOT NULL DEFAULT 0, available INTEGER NOT NULL DEFAULT 1 CHECK (available IN (0,1)), + source_kind TEXT NOT NULL DEFAULT 'local', artwork_source TEXT NOT NULL DEFAULT '', artwork_token TEXT NOT NULL DEFAULT '' ) STRICT; @@ -321,6 +322,21 @@ CREATE TABLE IF NOT EXISTS artists_materialized ( CREATE INDEX IF NOT EXISTS idx_artists_materialized_name ON artists_materialized(available, sort_name, artist_key); +CREATE TABLE IF NOT EXISTS artist_source_stats ( + artist_key TEXT NOT NULL REFERENCES artists_materialized(artist_key) ON DELETE CASCADE, + source_kind TEXT NOT NULL CHECK ( + source_kind IN ('local','offline','plex','jellyfin','subsonic') + ), + source_instance TEXT NOT NULL, + album_count INTEGER NOT NULL DEFAULT 0, + track_count INTEGER NOT NULL DEFAULT 0, + available INTEGER NOT NULL DEFAULT 1 CHECK (available IN (0,1)), + PRIMARY KEY (artist_key, source_kind, source_instance) +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_artist_source_stats_source + ON artist_source_stats(source_kind, source_instance, available, artist_key); + CREATE TABLE IF NOT EXISTS edition_artists ( edition_id INTEGER NOT NULL REFERENCES editions(edition_id) ON DELETE CASCADE, artist_key TEXT NOT NULL, @@ -383,4 +399,10 @@ CREATE TRIGGER IF NOT EXISTS albums_fts_update AFTER UPDATE ON albums_materializ INSERT INTO albums_fts(rowid, title, artist, all_artists) VALUES (new.edition_id, new.title, new.artist, new.all_artists); END; + +CREATE VIRTUAL TABLE IF NOT EXISTS artists_fts USING fts5( + artist_key UNINDEXED, + display_name, + tokenize='trigram case_sensitive 0' +); "#; diff --git a/crates/qbz-local-catalog/src/tests.rs b/crates/qbz-local-catalog/src/tests.rs index 68e0268a6..477024c92 100644 --- a/crates/qbz-local-catalog/src/tests.rs +++ b/crates/qbz-local-catalog/src/tests.rs @@ -117,6 +117,26 @@ fn collect_all_albums( rows } +fn collect_all_artists( + catalog: &Catalog, + descriptor: &QueryDescriptor, + page_size: usize, +) -> Vec { + let mut cursor = None; + let mut rows = Vec::new(); + loop { + let page = catalog + .query_artists(descriptor, cursor.as_ref(), page_size) + .unwrap(); + rows.extend(page.rows); + if !page.has_more { + break; + } + cursor = page.next_cursor; + } + rows +} + fn insert_album_fixture(catalog: &mut Catalog, count: usize) { let mut by_source = std::collections::BTreeMap::>::new(); for index in 0..count { @@ -185,14 +205,236 @@ fn schema_is_versioned_frontend_agnostic_and_fts5_enabled() { "artist_credits", "albums_materialized", "artists_materialized", + "artist_source_stats", "edition_artists", "tracks_fts", "albums_fts", + "artists_fts", ] { assert!(tables.contains(required), "missing {required}"); } } +#[test] +fn artist_keyset_search_and_source_scope_cover_every_remote_only_artist_once() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + insert_album_fixture(&mut catalog, 1_231); + + let all = QueryDescriptor::artists(); + let rows = collect_all_artists(&catalog, &all, 17); + let keys = rows + .iter() + .map(|row| row.artist_key.clone()) + .collect::>(); + let expected_available = (0..1_231).filter(|index| index % 97 != 0).count(); + assert_eq!(rows.len(), expected_available); + assert_eq!(keys.len(), rows.len()); + assert_eq!(catalog.count_artists(&all).unwrap(), rows.len() as u64); + assert_eq!( + catalog.count_artist_entries(&all).unwrap(), + rows.len() as u64 + 1 + ); + + let jellyfin_source = SourceKey { + source: SourceKind::Jellyfin, + source_instance: source_instance(SourceKind::Jellyfin).to_string(), + }; + let jellyfin = QueryDescriptor::artists().with_sources(vec![jellyfin_source.clone()]); + let remote = collect_all_artists(&catalog, &jellyfin, 11); + assert!(!remote.is_empty()); + assert!(remote.iter().all(|row| row.source == "jellyfin")); + assert_eq!( + catalog.count_artists(&jellyfin).unwrap(), + remote.len() as u64 + ); + assert!(remote + .iter() + .all(|row| row.album_count == 1 && row.track_count == 1)); + + let needle = remote[0] + .display_name + .chars() + .skip(7) + .take(3) + .collect::(); + let searched = collect_all_artists( + &catalog, + &QueryDescriptor::artists() + .with_sources(vec![jellyfin_source]) + .with_search(needle), + 7, + ); + assert!(!searched.is_empty()); + assert!(searched + .iter() + .all(|row| row.source == "jellyfin" && keys.contains(&row.artist_key))); + let searched_descriptor = QueryDescriptor::artists() + .with_sources(vec![SourceKey { + source: SourceKind::Jellyfin, + source_instance: source_instance(SourceKind::Jellyfin).to_string(), + }]) + .with_search( + remote[0] + .display_name + .chars() + .skip(7) + .take(3) + .collect::(), + ); + assert_eq!( + catalog.count_artists(&searched_descriptor).unwrap(), + searched.len() as u64 + ); + assert_eq!( + catalog.count_artist_entries(&searched_descriptor).unwrap(), + searched.len() as u64 + 1 + ); +} + +#[test] +fn artist_credit_counts_and_album_relationship_span_every_source() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + let mut local = projected(10, SourceKind::Local); + local.native_album_id = Some("local-edition".to_string()); + local.credits.push(ArtistCredit { + display_name: "Beyoncé".to_string(), + role: CreditRole::Featured, + ordinal: 1, + }); + let mut remote = projected(80, SourceKind::Jellyfin); + remote.native_album_id = Some("remote-edition".to_string()); + remote.credits.push(ArtistCredit { + display_name: "Beyonce".to_string(), + role: CreditRole::Performer, + ordinal: 1, + }); + for track in [local, remote] { + let source = SourceKey { + source: track.track_ref.source, + source_instance: track.track_ref.source_instance.clone(), + }; + catalog + .apply_bootstrap_batch(&BootstrapBatch { + source, + snapshot_version: "artist-credit-v1".to_string(), + expected_cursor: String::new(), + next_cursor: "1".to_string(), + tracks: vec![track], + complete: true, + }) + .unwrap(); + } + catalog.rebuild_materialized_views().unwrap(); + + let key = normalize_artist_key("Beyoncé"); + let row = collect_all_artists(&catalog, &QueryDescriptor::artists(), 5) + .into_iter() + .find(|row| row.artist_key == key) + .expect("credit-only artist is materialized"); + assert_eq!(row.album_count, 2); + assert_eq!(row.track_count, 2); + assert_eq!(row.source, "mixed"); + + let sources = vec![ + SourceKey { + source: SourceKind::Local, + source_instance: source_instance(SourceKind::Local).to_string(), + }, + SourceKey { + source: SourceKind::Jellyfin, + source_instance: source_instance(SourceKind::Jellyfin).to_string(), + }, + ]; + assert_eq!(catalog.count_artist_albums(&key, &sources).unwrap(), 2); + let page = catalog + .query_artist_albums(&key, &sources, None, 1) + .unwrap(); + assert_eq!(page.rows.len(), 1); + assert!(page.has_more); + let second = catalog + .query_artist_albums(&key, &sources, page.next_cursor.as_ref(), 1) + .unwrap(); + assert_eq!(second.rows.len(), 1); + assert!(!second.has_more); + assert_ne!(page.rows[0].edition_id, second.rows[0].edition_id); + + let jellyfin = &sources[1..]; + assert_eq!(catalog.count_artist_albums(&key, jellyfin).unwrap(), 1); + let remote_row = collect_all_artists( + &catalog, + &QueryDescriptor::artists().with_sources(jellyfin.to_vec()), + 5, + ) + .into_iter() + .find(|row| row.artist_key == key) + .unwrap(); + assert_eq!((remote_row.album_count, remote_row.track_count), (1, 1)); + assert_eq!(remote_row.source, "jellyfin"); +} + +#[test] +fn artist_album_keysets_cross_pages_without_omissions_or_duplicates() { + const ALBUMS: usize = 731; + let mut catalog = Catalog::open_in_memory(1).unwrap(); + let source = SourceKey { + source: SourceKind::Jellyfin, + source_instance: source_instance(SourceKind::Jellyfin).to_string(), + }; + let mut rows = (0..ALBUMS) + .map(|index| { + let mut track = projected(80_000 + index, SourceKind::Jellyfin); + track.native_album_id = Some(format!("guest-album-{index:04}")); + track.album = format!("Guest Album {index:04}"); + track.credits.push(ArtistCredit { + display_name: "Remote Guest".to_string(), + role: CreditRole::Featured, + ordinal: 1, + }); + track + }) + .collect::>(); + let expected_available = rows.iter().filter(|track| track.available).count(); + let mut cursor = String::new(); + for (batch_index, batch) in rows.chunks_mut(BOOTSTRAP_BATCH_ROWS).enumerate() { + let committed = (batch_index * BOOTSTRAP_BATCH_ROWS + batch.len()).min(ALBUMS); + let saved = catalog + .apply_bootstrap_batch(&BootstrapBatch { + source: source.clone(), + snapshot_version: "artist-albums-731".to_string(), + expected_cursor: cursor, + next_cursor: committed.to_string(), + tracks: batch.to_vec(), + complete: committed == ALBUMS, + }) + .unwrap(); + cursor = saved.checkpoint_cursor; + } + catalog.rebuild_materialized_views().unwrap(); + + let key = normalize_artist_key("Remote Guest"); + assert_eq!( + catalog.count_artist_albums(&key, &[source.clone()]).unwrap(), + expected_available as u64 + ); + let mut cursor = None; + let mut ids = Vec::new(); + loop { + let page = catalog + .query_artist_albums(&key, std::slice::from_ref(&source), cursor.as_ref(), 37) + .unwrap(); + ids.extend(page.rows.into_iter().map(|row| row.edition_id)); + if !page.has_more { + break; + } + cursor = page.next_cursor; + } + assert_eq!(ids.len(), expected_available); + assert_eq!( + ids.iter().copied().collect::>().len(), + expected_available + ); +} + #[test] fn album_keyset_pages_cover_every_id_once_for_all_orders_and_groups() { let mut catalog = Catalog::open_in_memory(1).unwrap(); @@ -355,6 +597,81 @@ fn album_query_and_broad_search_metric_uses_a_mixed_deterministic_fixture() { ); } +#[test] +fn artist_query_and_broad_search_metric_uses_a_mixed_deterministic_fixture() { + const ARTISTS: usize = 20_000; + let mut catalog = Catalog::open_in_memory(10).unwrap(); + insert_album_fixture(&mut catalog, ARTISTS); + + let descriptor = QueryDescriptor::artists(); + let expected = (0..ARTISTS).filter(|index| index % 97 != 0).count() as u64; + let count_started = Instant::now(); + let count = catalog.count_artists(&descriptor).unwrap(); + let count_time = count_started.elapsed(); + let (page, query) = catalog.query_artists_timed(&descriptor, None, 100).unwrap(); + let broad = descriptor.clone().with_search("Artist"); + let broad_count_started = Instant::now(); + let broad_count = catalog.count_artists(&broad).unwrap(); + let broad_count_time = broad_count_started.elapsed(); + let (broad_page, broad_query) = catalog.query_artists_timed(&broad, None, 100).unwrap(); + let source_counts = [ + SourceKind::Local, + SourceKind::Offline, + SourceKind::Plex, + SourceKind::Jellyfin, + SourceKind::Subsonic, + ] + .into_iter() + .map(|source| { + let scoped = QueryDescriptor::artists().with_sources(vec![SourceKey { + source, + source_instance: source_instance(source).to_string(), + }]); + format!( + "{}={}", + source.as_str(), + catalog.count_artists(&scoped).unwrap() + ) + }) + .collect::>() + .join(","); + + assert_eq!(count, expected); + assert_eq!(page.rows.len(), 100); + assert!(page.has_more); + assert_eq!(broad_count, expected); + assert_eq!(broad_page.rows.len(), 100); + assert_eq!( + catalog.count_artist_entries(&descriptor).unwrap(), + expected + 1 + ); + + let plan = catalog + .connection() + .prepare( + "EXPLAIN QUERY PLAN SELECT artist_key FROM artists_materialized + WHERE available=1 ORDER BY sort_name,artist_key LIMIT 101", + ) + .unwrap() + .query_map([], |row| row.get::<_, String>(3)) + .unwrap() + .collect::>>() + .unwrap(); + assert!( + plan.iter().all(|step| !step.contains("TEMP B-TREE")), + "{plan:?}" + ); + + println!( + "F2_ARTISTS_QUERY total={count} first_artists={} count_ms={:.3} query_ms={:.3} broad_count_ms={:.3} broad_query_ms={:.3} source_counts={source_counts}", + page.rows.len(), + count_time.as_secs_f64() * 1_000.0, + query.sql_time.as_secs_f64() * 1_000.0, + broad_count_time.as_secs_f64() * 1_000.0, + broad_query.sql_time.as_secs_f64() * 1_000.0, + ); +} + #[test] fn opening_an_unrelated_sqlite_file_is_refused_without_mutating_it() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 6ae1c9dc9..7efa10981 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -304,6 +304,7 @@ fn build_rhi_items() { "tunnelflow_item", "local_tracks_model", "local_albums_model", + "local_artists_model", ] { let header = format!("cxx/{item}.h"); let source = format!("cxx/{item}.cpp"); diff --git a/crates/qbz-qt/cxx/local_albums_model.cpp b/crates/qbz-qt/cxx/local_albums_model.cpp index 2deeba535..72b327cbd 100644 --- a/crates/qbz-qt/cxx/local_albums_model.cpp +++ b/crates/qbz-qt/cxx/local_albums_model.cpp @@ -13,6 +13,7 @@ namespace { QPointer g_model; +QPointer g_artist_model; void registerLocalAlbumsModel() { @@ -21,6 +22,10 @@ void registerLocalAlbumsModel() g_model = model; qmlRegisterSingletonInstance( "com.blitzfc.qbz", 1, 0, "QbzLocalAlbums", model); + auto *artistModel = new LocalAlbumsModel(application); + g_artist_model = artistModel; + qmlRegisterSingletonInstance( + "com.blitzfc.qbz", 1, 0, "QbzLocalArtistAlbums", artistModel); } Q_COREAPP_STARTUP_FUNCTION(registerLocalAlbumsModel) @@ -303,3 +308,17 @@ extern "C" void qbz_local_albums_set_selection( g_model->setSelection( generation, QByteArray(json ? json : "{}"), selectedCount); } + +extern "C" void qbz_local_artist_albums_reset( + int generation, qint64 totalCount, qint64 albumTotal) +{ + if (g_artist_model) + g_artist_model->resetQuery(generation, totalCount, albumTotal); +} + +extern "C" bool qbz_local_artist_albums_apply_page( + int generation, int page, const char *json) +{ + return g_artist_model && g_artist_model->applyPage( + generation, page, QByteArray(json ? json : "[]")); +} diff --git a/crates/qbz-qt/cxx/local_albums_model.h b/crates/qbz-qt/cxx/local_albums_model.h index a9cd5c753..89219d429 100644 --- a/crates/qbz-qt/cxx/local_albums_model.h +++ b/crates/qbz-qt/cxx/local_albums_model.h @@ -83,6 +83,8 @@ void qbz_local_albums_register_qml_type(); void qbz_local_albums_reset(int generation, qint64 totalCount, qint64 albumTotal); bool qbz_local_albums_apply_page(int generation, int page, const char *json); void qbz_local_albums_set_selection(int generation, const char *json, qint64 selectedCount); +void qbz_local_artist_albums_reset(int generation, qint64 totalCount, qint64 albumTotal); +bool qbz_local_artist_albums_apply_page(int generation, int page, const char *json); } #endif // QBZ_LOCAL_ALBUMS_MODEL_H diff --git a/crates/qbz-qt/cxx/local_artists_model.cpp b/crates/qbz-qt/cxx/local_artists_model.cpp new file mode 100644 index 000000000..b287419e8 --- /dev/null +++ b/crates/qbz-qt/cxx/local_artists_model.cpp @@ -0,0 +1,235 @@ +#include "local_artists_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +QPointer g_model; + +void registerLocalArtistsModel() +{ + auto *model = new LocalArtistsModel(QCoreApplication::instance()); + g_model = model; + qmlRegisterSingletonInstance( + "com.blitzfc.qbz", 1, 0, "QbzLocalArtists", model); +} + +Q_COREAPP_STARTUP_FUNCTION(registerLocalArtistsModel) +} + +LocalArtistsModel::LocalArtistsModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int LocalArtistsModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return int(qMin(m_totalCount, std::numeric_limits::max())); +} + +QVariant LocalArtistsModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || role != ModelDataRole) + return {}; + return modelData(index.row()); +} + +QHash LocalArtistsModel::roleNames() const +{ + return {{ ModelDataRole, QByteArrayLiteral("modelData") }}; +} + +QVariantMap LocalArtistsModel::rowAt(qint64 row) const +{ + if (row < 0 || row >= m_totalCount) + return {}; + return modelData(row); +} + +QVariantMap LocalArtistsModel::modelData(qint64 row) const +{ + const int page = int(row / PageEntries); + const int offset = int(row % PageEntries); + const auto found = m_pages.constFind(page); + if (found == m_pages.cend() || offset >= found->size()) { + requestPage(page, false); + return { + { QStringLiteral("t"), 1 }, + { QStringLiteral("loading"), true }, + { QStringLiteral("base"), 0 }, + { QStringLiteral("item"), QVariantMap() }, + }; + } + touch(page); + QVariantMap values = found->at(offset).values; + values.insert(QStringLiteral("loading"), false); + return values; +} + +void LocalArtistsModel::requestPage(int page, bool prefetch) const +{ + if (page < 0) + return; + const QMetaMethod missSignal = QMetaMethod::fromSignal(&LocalArtistsModel::pageMiss); + if (!isSignalConnected(missSignal)) + return; + if (m_pending.contains(page)) { + if (!prefetch) + m_pending.insert(page, false); + return; + } + m_pending.insert(page, prefetch); + const int generation = m_generation; + auto *self = const_cast(this); + QMetaObject::invokeMethod( + self, + [self, page, generation]() { + if (generation != self->m_generation) + return; + if (self->isSignalConnected( + QMetaMethod::fromSignal(&LocalArtistsModel::pageMiss))) { + emit self->pageMiss(page, generation); + } else { + self->m_pending.remove(page); + } + }, + Qt::QueuedConnection); +} + +void LocalArtistsModel::touch(int page) const +{ + m_lru.removeAll(page); + m_lru.append(page); +} + +void LocalArtistsModel::resetQuery(int generation, qint64 totalCount, qint64 artistTotal) +{ + beginResetModel(); + m_generation = generation; + m_totalCount = qMax(0, totalCount); + m_artistTotal = qMax(0, artistTotal); + m_pages.clear(); + m_lru.clear(); + m_pending.clear(); + m_residentArtists = 0; + m_evictions = 0; + endResetModel(); + emit generationChanged(); + emit totalCountChanged(); + emit artistTotalChanged(); + emit residentArtistsChanged(); + emit evictionsChanged(); +} + +bool LocalArtistsModel::applyPage(int generation, int page, const QByteArray &json) +{ + if (generation != m_generation || page < 0) + return false; + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(json, &error); + if (error.error != QJsonParseError::NoError || !document.isArray()) + return false; + + Page entries; + const QJsonArray array = document.array(); + entries.reserve(array.size()); + for (const QJsonValue &value : array) { + if (!value.isObject()) + return false; + Entry entry; + entry.values = value.toObject().toVariantMap(); + entry.isArtist = entry.values.value(QStringLiteral("t")).toInt() == 1; + entry.artKey = entry.values.value(QStringLiteral("item")) + .toMap() + .value(QStringLiteral("artKey")) + .toString(); + entries.append(std::move(entry)); + } + + const auto previous = m_pages.constFind(page); + if (previous != m_pages.cend()) { + for (const Entry &entry : *previous) + m_residentArtists -= int(entry.isArtist); + } + m_pages.insert(page, std::move(entries)); + for (const Entry &entry : m_pages.value(page)) + m_residentArtists += int(entry.isArtist); + const bool wasPrefetch = m_pending.take(page); + touch(page); + evictIfNeeded(); + + const int first = page * PageEntries; + const int last = qMin(rowCount() - 1, first + PageEntries - 1); + if (first <= last) + emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole }); + emit residentArtistsChanged(); + const int next = page + 1; + if (!wasPrefetch && qint64(next) * PageEntries < m_totalCount && !m_pages.contains(next)) + requestPage(next, true); + return true; +} + +void LocalArtistsModel::evictIfNeeded() +{ + while (m_residentArtists > MaxResidentArtists && m_pages.size() > 1 && !m_lru.isEmpty()) { + const int page = m_lru.takeFirst(); + const auto found = m_pages.find(page); + if (found == m_pages.end()) + continue; + for (const Entry &entry : *found) + m_residentArtists -= int(entry.isArtist); + m_pages.erase(found); + ++m_evictions; + const int first = page * PageEntries; + const int last = qMin(rowCount() - 1, first + PageEntries - 1); + if (first <= last) + emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole }); + } + emit evictionsChanged(); +} + +void LocalArtistsModel::setArtwork(const QString &artKey, const QString &path) +{ + if (artKey.isEmpty() || path.isEmpty()) + return; + for (auto page = m_pages.begin(); page != m_pages.end(); ++page) { + for (int offset = 0; offset < page->size(); ++offset) { + Entry &entry = (*page)[offset]; + if (entry.artKey != artKey) + continue; + QVariantMap item = entry.values.value(QStringLiteral("item")).toMap(); + item.insert(QStringLiteral("artPath"), path); + entry.values.insert(QStringLiteral("item"), item); + const int row = page.key() * PageEntries + offset; + emit dataChanged(index(row, 0), index(row, 0), { ModelDataRole }); + } + } +} + +extern "C" void qbz_local_artists_register_qml_type() +{ + // Link anchor. Registration is performed by Q_COREAPP_STARTUP_FUNCTION. +} + +extern "C" void qbz_local_artists_reset( + int generation, qint64 totalCount, qint64 artistTotal) +{ + if (g_model) + g_model->resetQuery(generation, totalCount, artistTotal); +} + +extern "C" bool qbz_local_artists_apply_page(int generation, int page, const char *json) +{ + return g_model && g_model->applyPage( + generation, page, QByteArray(json ? json : "[]")); +} diff --git a/crates/qbz-qt/cxx/local_artists_model.h b/crates/qbz-qt/cxx/local_artists_model.h new file mode 100644 index 000000000..e99597005 --- /dev/null +++ b/crates/qbz-qt/cxx/local_artists_model.h @@ -0,0 +1,79 @@ +#ifndef QBZ_LOCAL_ARTISTS_MODEL_H +#define QBZ_LOCAL_ARTISTS_MODEL_H + +#include +#include +#include +#include + +class LocalArtistsModel final : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int generation READ generation NOTIFY generationChanged) + Q_PROPERTY(qint64 totalCount READ totalCount NOTIFY totalCountChanged) + Q_PROPERTY(qint64 artistTotal READ artistTotal NOTIFY artistTotalChanged) + Q_PROPERTY(int residentArtists READ residentArtists NOTIFY residentArtistsChanged) + Q_PROPERTY(qint64 evictions READ evictions NOTIFY evictionsChanged) + +public: + enum Role { ModelDataRole = Qt::UserRole + 1 }; + + explicit LocalArtistsModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + int generation() const { return m_generation; } + qint64 totalCount() const { return m_totalCount; } + qint64 artistTotal() const { return m_artistTotal; } + int residentArtists() const { return m_residentArtists; } + qint64 evictions() const { return m_evictions; } + + Q_INVOKABLE QVariantMap rowAt(qint64 row) const; + Q_INVOKABLE void setArtwork(const QString &artKey, const QString &path); + + void resetQuery(int generation, qint64 totalCount, qint64 artistTotal); + bool applyPage(int generation, int page, const QByteArray &json); + +signals: + void generationChanged(); + void totalCountChanged(); + void artistTotalChanged(); + void residentArtistsChanged(); + void evictionsChanged(); + void pageMiss(int page, int generation); + +private: + struct Entry { + QVariantMap values; + QString artKey; + bool isArtist = false; + }; + using Page = QList; + + static constexpr int PageEntries = 100; + static constexpr int MaxResidentArtists = 2000; + + QVariantMap modelData(qint64 row) const; + void requestPage(int page, bool prefetch = false) const; + void touch(int page) const; + void evictIfNeeded(); + + int m_generation = 0; + qint64 m_totalCount = 0; + qint64 m_artistTotal = 0; + QHash m_pages; + mutable QList m_lru; + mutable QHash m_pending; + int m_residentArtists = 0; + qint64 m_evictions = 0; +}; + +extern "C" { +void qbz_local_artists_register_qml_type(); +void qbz_local_artists_reset(int generation, qint64 totalCount, qint64 artistTotal); +bool qbz_local_artists_apply_page(int generation, int page, const char *json); +} + +#endif // QBZ_LOCAL_ARTISTS_MODEL_H diff --git a/crates/qbz-qt/qml/views/LocalLibraryView.qml b/crates/qbz-qt/qml/views/LocalLibraryView.qml index 97c34d9a6..f36c166a5 100644 --- a/crates/qbz-qt/qml/views/LocalLibraryView.qml +++ b/crates/qbz-qt/qml/views/LocalLibraryView.qml @@ -63,6 +63,8 @@ Rectangle { // the composition root also gives every Tracks delegate one stable model. readonly property var nativeTracksModel: QbzLocalTracks readonly property var nativeAlbumsModel: QbzLocalAlbums + readonly property var nativeArtistsModel: QbzLocalArtists + readonly property var nativeArtistAlbumsModel: QbzLocalArtistAlbums // ============================ state ================================== // Slint defaults, verbatim (state.slint LocalLibraryState). @@ -335,6 +337,8 @@ Rectangle { function onLocalArtworkReady(key, path) { root.nativeTracksModel.setArtwork(key, path) root.nativeAlbumsModel.setArtwork(key, path) + root.nativeArtistsModel.setArtwork(key, path) + root.nativeArtistAlbumsModel.setArtwork(key, path) root._artInbox[key] = path if (!artFlush.running) artFlush.start() // An arrival is live evidence that the pass is still running, so @@ -357,6 +361,18 @@ Rectangle { QbzLocal.albumsNativePageMiss(page, generation) } } + Connections { + target: root.nativeArtistsModel + function onPageMiss(page, generation) { + QbzLocal.artistsNativePageMiss(page, generation) + } + } + Connections { + target: root.nativeArtistAlbumsModel + function onPageMiss(page, generation) { + QbzLocal.artistAlbumsNativePageMiss(page, generation) + } + } // A name route from LocalAlbumView ("go to artist" on a local album: // local/Plex artists have no catalog id) lands the view on the Artists @@ -409,10 +425,6 @@ Rectangle { } onActiveTabChanged: { QbzLocal.loadTab(activeTab) - // The Artists detail derives from the album set (the DB aggregates - // the contributor list per album), so make sure it is loaded. - if (activeTab === "artists" && albums.length === 0) - QbzLocal.loadTab("albums-legacy") // The tab that just appeared has covers to ask for and the one that // left has covers to let go of. Each surface answers for itself. artworkRefresh() @@ -615,6 +627,7 @@ Rectangle { } readonly property var artistsVisible: { + if (QbzLocal.localArtistsNativeActive && activeTab === "artists") return [] var q = artistsSearch.trim().toLowerCase() if (q === "") return artists var out = [] @@ -625,6 +638,7 @@ Rectangle { } readonly property int artistsVisibleCount: artistsVisible.length readonly property var artistsGrouped: { + if (QbzLocal.localArtistsNativeActive && activeTab === "artists") return [] var rows = artistsVisible var buckets = {} var order = [] @@ -661,6 +675,7 @@ Rectangle { // objects this view already parsed, in the published order. readonly property var artistAlbums: { if (selectedArtist === "") return [] + if (QbzLocal.localArtistsNativeActive) return [] var rows = albums // binding dependency: re-derive on every republish var ids = {} try { diff --git a/crates/qbz-qt/qml/views/local/LocalArtistsTab.qml b/crates/qbz-qt/qml/views/local/LocalArtistsTab.qml index 522b50dd8..8b036f90c 100644 --- a/crates/qbz-qt/qml/views/local/LocalArtistsTab.qml +++ b/crates/qbz-qt/qml/views/local/LocalArtistsTab.qml @@ -20,6 +20,11 @@ Item { readonly property int headerH: 22 readonly property int rowH: 62 + readonly property bool nativeActive: QbzLocal.localArtistsNativeActive + readonly property var nativeModel: QbzLocalArtists + readonly property var nativeAlbumModel: QbzLocalArtistAlbums + readonly property int artistTotal: nativeActive + ? QbzLocal.localArtistsNativeTotal : (view ? view.artists.length : 0) // --------------------------- entry model ------------------------------ property var entries: [] @@ -32,7 +37,19 @@ Item { property var flat: [] /// entry index -> index into `flat`, -1 for a letter header. property var flatIndex: [] + function parseNativeJumps() { + try { return JSON.parse(QbzLocal.localArtistsNativeJumpsJson || "[]") } + catch (e) { return [] } + } function rebuild() { + if (nativeActive) { + entries = [] + alphaJumps = parseNativeJumps() + flat = [] + flatIndex = [] + report() + return + } var groups = view ? view.artistsGrouped : [] var out = [] var jumps = [] @@ -56,13 +73,51 @@ Item { flatIndex = idx report() } - Component.onCompleted: { rebuild(); reportSoon() } + Component.onCompleted: { + rebuild() + reportSoon() + nativeQueryCoalescer.restart() + detailQueryCoalescer.restart() + } Component.onDestruction: if (view) view.releaseWindow("artists-rail") Connections { target: root.view function onArtistsGroupedChanged() { root.rebuild() } + function onArtistsSearchChanged() { root.nativeQueryCoalescer.restart() } + function onSelectedArtistChanged() { root.detailQueryCoalescer.restart() } function onArtworkRefresh() { root.reportSoon() } } + Connections { + target: QbzLocal + function onLocalArtistsNativeActiveChanged() { + root.rebuild() + if (root.nativeActive) root.detailQueryCoalescer.restart() + } + function onLocalArtistsNativeJumpsJsonChanged() { + if (root.nativeActive) root.alphaJumps = root.parseNativeJumps() + } + function onLocalArtistsLoadingChanged() { + if (!QbzLocal.localArtistsLoading) { + root.reportSoon() + root.detailQueryCoalescer.restart() + } + } + } + Timer { + id: nativeQueryCoalescer + interval: 0 + onTriggered: if (root.view) + QbzLocal.artistsNativeReset(root.view.artistsSearch) + } + Timer { + id: detailQueryCoalescer + interval: 0 + onTriggered: root.resetNativeDetail() + } + function resetNativeDetail() { + if (!nativeActive || !view || !artistCollection || artistCollection.cols <= 0) return + QbzLocal.artistsNativeSelect(view.selectedArtist, artistCollection.cols) + } // Loading = the shape of the master/detail pair that is coming: 60px // rail rows with a ROUND 48px avatar on the left, the album grid on the @@ -97,7 +152,8 @@ Item { } // Empty library — icon + line (Slint :2222). Column { - visible: !QbzLocal.localArtistsLoading && root.view.artists.length === 0 + visible: !QbzLocal.localArtistsLoading && root.artistTotal === 0 + && (!root.view || root.view.artistsSearch === "") anchors.centerIn: parent spacing: 10 QbzIcon { @@ -117,7 +173,8 @@ Item { Row { anchors.fill: parent - visible: !QbzLocal.localArtistsLoading && root.view.artists.length > 0 + visible: !QbzLocal.localArtistsLoading + && (root.artistTotal > 0 || (root.view && root.view.artistsSearch !== "")) spacing: 0 // ---------------------- master rail ------------------------------ @@ -133,20 +190,23 @@ Item { spacing: 8 Text { - text: root.view.artistsVisibleCount + " " + text: (root.nativeActive ? QbzLocal.localArtistsNativeTotal + : root.view.artistsVisibleCount) + " " + QbzSession.tr("artists", QbzSession.trRev) color: theme.textMuted font.pixelSize: theme.fontLegal font.weight: theme.weightSemibold } Text { - visible: root.view.artistsVisibleCount === 0 + visible: (root.nativeActive ? QbzLocal.localArtistsNativeTotal + : root.view.artistsVisibleCount) === 0 text: QbzSession.tr("No artists match your search.", QbzSession.trRev) color: theme.textMuted font.pixelSize: theme.fontLegal } Item { - visible: root.view.artistsVisibleCount > 0 + visible: (root.nativeActive ? QbzLocal.localArtistsNativeTotal + : root.view.artistsVisibleCount) > 0 width: parent.width height: parent.height - 30 @@ -159,7 +219,7 @@ Item { clip: true cacheBuffer: 62 * 8 boundsBehavior: Flickable.StopAtBounds - model: root.entries + model: root.nativeActive ? root.nativeModel : root.entries onContentYChanged: root.report() onModelChanged: root.report() onHeightChanged: root.report() @@ -172,7 +232,21 @@ Item { required property var modelData width: rail.width height: modelData.t === 0 ? root.headerH : root.rowH - sourceComponent: modelData.t === 0 ? letterComp : artistComp + sourceComponent: modelData.loading ? loadingComp + : modelData.t === 0 ? letterComp : artistComp + onModelDataChanged: root.reportSoon() + + Component { + id: loadingComp + QbzSkeleton { + variant: "rowList" + rowH: 60 + rowGap: 2 + rowArtSize: 48 + rowArtRound: true + phase: root.view ? root.view.skelPhase : false + } + } Component { id: letterComp @@ -192,7 +266,8 @@ Item { width: rail.width view: root.view item: modelData.item - artSource: root.view.artMap[modelData.item.artKey] || "" + artSource: modelData.item.artPath + || root.view.artMap[modelData.item.artKey] || "" selected: root.view.selectedArtist === modelData.item.name onPicked: function (name) { root.view.selectArtist(name) } } @@ -264,14 +339,19 @@ Item { elide: Text.ElideRight } Text { - text: root.view.artistAlbums.length + " " + text: (root.nativeActive + ? QbzLocal.localArtistAlbumsNativeTotal + : root.view.artistAlbums.length) + " " + QbzSession.tr("albums", QbzSession.trRev) color: theme.textMuted font.pixelSize: theme.fontLegal } } Text { - visible: root.view.artistAlbums.length === 0 + visible: !QbzLocal.localArtistAlbumsLoading + && (root.nativeActive + ? QbzLocal.localArtistAlbumsNativeTotal === 0 + : root.view.artistAlbums.length === 0) width: parent.width topPadding: 24 text: QbzSession.tr("No albums found", QbzSession.trRev) @@ -279,16 +359,33 @@ Item { font.pixelSize: theme.fontBody horizontalAlignment: Text.AlignHCenter } + QbzSkeleton { + visible: root.nativeActive && QbzLocal.localArtistAlbumsLoading + width: parent.width + height: parent.height - 60 + variant: "cardGrid" + cellW: 220 + cellH: 266 + phase: root.view.skelPhase + } LocalAlbumCollection { - visible: root.view.artistAlbums.length > 0 + id: artistCollection + visible: !QbzLocal.localArtistAlbumsLoading + && (root.nativeActive + ? QbzLocal.localArtistAlbumsNativeTotal > 0 + : root.view.artistAlbums.length > 0) width: parent.width height: parent.height - 60 view: root.view surface: "artist-albums" - rows: root.view.artistAlbums + rows: root.nativeActive ? [] : root.view.artistAlbums grouped: false viewMode: "grid" showSource: true + nativeActive: root.nativeActive + nativeModel: root.nativeAlbumModel + nativeJumpsJson: "[]" + onColsChanged: root.detailQueryCoalescer.restart() onOpenRequested: function (id) { root.view.openAlbum(id) } onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) } onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) } @@ -308,14 +405,28 @@ Item { // when the viewport resizes. function report() { if (!view || !rail) return - if (!rail.visible || root.entries.length === 0) { + var count = root.nativeActive ? root.nativeModel.totalCount : root.entries.length + if (!rail.visible || count === 0) { view.releaseWindow("artists-rail") return } var first = rail.indexAt(4, rail.contentY + 1) var last = rail.indexAt(4, rail.contentY + Math.max(1, rail.height) - 1) if (first < 0) first = 0 - if (last < 0) last = Math.min(root.entries.length - 1, first + 12) + if (last < 0) last = Math.min(count - 1, first + 12) + if (root.nativeActive) { + var resident = [] + for (var entryIndex = first; entryIndex <= last; entryIndex++) { + var entry = root.nativeModel.rowAt(entryIndex) + if (!entry || entry.loading || entry.t !== 1 || !entry.item) continue + resident.push(entry.item) + } + if (resident.length > 0) + view.queueWindowReport(resident, 0, resident.length - 1, "artists-rail") + else + view.releaseWindow("artists-rail") + return + } // Entry band -> rail-row band (letter headers do not carry a cover). var lo = -1 var hi = -1 diff --git a/crates/qbz-qt/qml/views/local/LocalToolbar.qml b/crates/qbz-qt/qml/views/local/LocalToolbar.qml index 173eaaf0c..477ef27b9 100644 --- a/crates/qbz-qt/qml/views/local/LocalToolbar.qml +++ b/crates/qbz-qt/qml/views/local/LocalToolbar.qml @@ -355,9 +355,22 @@ Row { // ============================ ARTISTS ================================ LocalSearchBox { visible: root.view && root.view.activeTab === "artists" - && root.view.artists.length > 0 + && ((QbzLocal.localArtistsNativeActive + ? QbzLocal.localArtistsNativeTotal > 0 + : root.view.artists.length > 0) + || root.view.artistsSearch !== "" + || QbzLocal.localArtistsLoading) anchors.verticalCenter: parent.verticalCenter placeholder: QbzSession.tr("Search artists", QbzSession.trRev) - onEdited: function (v) { root.view.artistsSearch = v } + property string pending: "" + onEdited: function (v) { + pending = v + artistsSearchDebounce.restart() + } + Timer { + id: artistsSearchDebounce + interval: 200 + onTriggered: root.view.artistsSearch = parent.pending + } } } diff --git a/crates/qbz-qt/src/local_artists_model_qt.rs b/crates/qbz-qt/src/local_artists_model_qt.rs new file mode 100644 index 000000000..624169ab8 --- /dev/null +++ b/crates/qbz-qt/src/local_artists_model_qt.rs @@ -0,0 +1,1691 @@ +//! Native paged Artists rail and indexed artist-album pane (phase F2). + +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::ffi::{c_char, CString}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use cxx_qt_lib::QString; +use qbz_library::AudioFormat; +use qbz_local_catalog::{ + ActiveCatalog, AlbumCursor, AlbumRecord, ArtistCursor, ArtistRecord, BootstrapLayout, + QueryDescriptor, SourceKey, SourceKind, +}; +use qbz_source::SourceId; +use serde::Serialize; + +use crate::local_bridge::ui; +use crate::local_rows::{ + badge_source, badge_source_raw, detail_of, total_duration, AlbumRow, ArtistRow, +}; + +const PAGE_ENTRIES: usize = 100; +const QUERY_ROWS: usize = 500; +const MAX_RESIDENT_ARTISTS: usize = 2_000; +const MAX_RESIDENT_ALBUMS: usize = 2_000; +const MAX_UNANCHORED_ENTRIES: usize = 500; +const MAX_COLUMNS: usize = 20; + +static QUERY_GENERATION: AtomicU64 = AtomicU64::new(0); +static DETAIL_GENERATION: AtomicU64 = AtomicU64::new(0); +static SESSION_FAILED: AtomicBool = AtomicBool::new(false); +static SESSION: Mutex> = Mutex::new(None); +static DETAIL_SESSION: Mutex> = Mutex::new(None); +static LAST_SEARCH: Mutex> = Mutex::new(None); + +unsafe extern "C" { + fn qbz_local_artists_register_qml_type(); + fn qbz_local_artists_reset(generation: i32, total_count: i64, artist_total: i64); + fn qbz_local_artists_apply_page(generation: i32, page: i32, json: *const c_char) -> bool; + fn qbz_local_artist_albums_reset(generation: i32, total_count: i64, album_total: i64); + fn qbz_local_artist_albums_apply_page(generation: i32, page: i32, json: *const c_char) -> bool; +} + +#[derive(Clone, Default)] +struct ArtistAnchor { + entry_index: usize, + artist_index: usize, + cursor: Option, + previous_group: Option, + done: bool, +} + +#[derive(Clone)] +struct CachedArtistPage { + wire: Arc>, + artists: usize, +} + +struct ArtistSession { + generation: u64, + catalog_generation: u64, + descriptor: QueryDescriptor, + entry_total: u64, + anchors: BTreeMap, + anchors_ready: bool, + anchor_building: bool, + pending_pages: HashSet, + waiting_pages: HashSet, + cache: ArtistPageLru, +} + +#[derive(Clone, Serialize)] +struct ArtistEntry { + t: u8, + label: String, + base: usize, + #[serde(skip_serializing_if = "Option::is_none")] + item: Option, +} + +#[derive(Clone, Serialize)] +struct NativeArtist { + #[serde(flatten)] + row: ArtistRow, + #[serde(rename = "artistKey")] + artist_key: String, + #[serde(rename = "artPath")] + art_path: String, +} + +#[derive(Clone, Serialize)] +struct NativeJump { + letter: String, + index: usize, +} + +#[derive(Default)] +struct ArtistPageLru { + entries: HashMap, + order: VecDeque, + resident_artists: usize, + evictions: u64, +} + +impl ArtistPageLru { + fn get(&mut self, page: usize) -> Option<&CachedArtistPage> { + if !self.entries.contains_key(&page) { + return None; + } + self.touch(page); + self.entries.get(&page) + } + + fn insert(&mut self, page: usize, value: CachedArtistPage) { + if let Some(previous) = self.entries.insert(page, value) { + self.resident_artists = self.resident_artists.saturating_sub(previous.artists); + } + self.resident_artists += self.entries[&page].artists; + self.touch(page); + while self.resident_artists > MAX_RESIDENT_ARTISTS && self.entries.len() > 1 { + let Some(oldest) = self.order.pop_front() else { + break; + }; + if let Some(removed) = self.entries.remove(&oldest) { + self.resident_artists = self.resident_artists.saturating_sub(removed.artists); + self.evictions += 1; + } + } + } + + fn touch(&mut self, page: usize) { + self.order.retain(|candidate| *candidate != page); + self.order.push_back(page); + } +} + +struct OpenedArtists { + generation: u64, + catalog_generation: u64, + descriptor: QueryDescriptor, + artist_total: u64, + entry_total: u64, + loaded: LoadedArtists, + count_time: std::time::Duration, +} + +struct LoadedArtists { + generation: u64, + page: usize, + wire: Vec, + anchors: Vec, + jumps: Vec, + query_time: std::time::Duration, + map_time: std::time::Duration, +} + +#[derive(Clone, Default)] +struct DetailAnchor { + entry_index: usize, + album_index: usize, + cursor: Option, + pending: Vec, + done: bool, +} + +#[derive(Clone)] +struct IndexedAlbum { + index: usize, + record: AlbumRecord, +} + +#[derive(Clone)] +struct CachedDetailPage { + wire: Arc>, + albums: usize, +} + +struct DetailSession { + generation: u64, + catalog_generation: u64, + artist_key: String, + sources: Vec, + columns: usize, + entry_total: u64, + anchors: BTreeMap, + anchors_ready: bool, + anchor_building: bool, + pending_pages: HashSet, + waiting_pages: HashSet, + cache: DetailPageLru, +} + +#[derive(Clone, Serialize)] +struct DetailEntry { + t: u8, + label: String, + base: usize, + items: Vec, +} + +#[derive(Clone, Serialize)] +struct NativeAlbum { + #[serde(flatten)] + row: AlbumRow, + #[serde(rename = "nativeIndex")] + native_index: usize, + #[serde(rename = "artPath")] + art_path: String, +} + +#[derive(Default)] +struct DetailPageLru { + entries: HashMap, + order: VecDeque, + resident_albums: usize, + evictions: u64, +} + +impl DetailPageLru { + fn get(&mut self, page: usize) -> Option<&CachedDetailPage> { + if !self.entries.contains_key(&page) { + return None; + } + self.touch(page); + self.entries.get(&page) + } + + fn insert(&mut self, page: usize, value: CachedDetailPage) { + if let Some(previous) = self.entries.insert(page, value) { + self.resident_albums = self.resident_albums.saturating_sub(previous.albums); + } + self.resident_albums += self.entries[&page].albums; + self.touch(page); + while self.resident_albums > MAX_RESIDENT_ALBUMS && self.entries.len() > 1 { + let Some(oldest) = self.order.pop_front() else { + break; + }; + if let Some(removed) = self.entries.remove(&oldest) { + self.resident_albums = self.resident_albums.saturating_sub(removed.albums); + self.evictions += 1; + } + } + } + + fn touch(&mut self, page: usize) { + self.order.retain(|candidate| *candidate != page); + self.order.push_back(page); + } +} + +struct OpenedDetail { + generation: u64, + catalog_generation: u64, + artist_key: String, + sources: Vec, + columns: usize, + album_total: u64, + entry_total: u64, + loaded: LoadedDetail, + count_time: std::time::Duration, +} + +struct LoadedDetail { + generation: u64, + page: usize, + wire: Vec, + anchors: Vec, + query_time: std::time::Duration, + map_time: std::time::Duration, +} + +pub(crate) fn register_qml_model() { + // SAFETY: link anchor only; registration is a QCoreApplication startup hook. + unsafe { qbz_local_artists_register_qml_type() }; +} + +pub(crate) fn requested() -> bool { + if SESSION_FAILED.load(Ordering::Acquire) { + return false; + } + std::env::var("QBZ_LOCAL_CATALOG_ARTISTS") + .ok() + .is_some_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on")) +} + +pub(crate) fn reset(search: String) -> bool { + if !requested() { + return false; + } + *LAST_SEARCH + .lock() + .unwrap_or_else(|error| error.into_inner()) = Some(search.clone()); + if crate::local_library_qt::album_mode() != "folder" { + deactivate_for_legacy("metadata-mode"); + return false; + } + let generation = next_generation(&QUERY_GENERATION); + *SESSION.lock().unwrap_or_else(|error| error.into_inner()) = None; + let detail_generation = next_generation(&DETAIL_GENERATION); + *DETAIL_SESSION + .lock() + .unwrap_or_else(|error| error.into_inner()) = None; + set_loading(); + ui(move |mut bridge| { + cpp_detail_reset(detail_generation, 0, 0); + bridge.as_mut().set_local_artist_albums_native_total(0); + bridge.as_mut().set_local_artist_albums_loading(false); + }); + let descriptor = QueryDescriptor::artists().with_search(search); + crate::spawn(async move { + let result = + tokio::task::spawn_blocking(move || open_artists(generation, descriptor)).await; + match result { + Ok(Ok(opened)) => activate_artists(opened), + Ok(Err(reason)) => fallback(generation, reason), + Err(_) => fallback(generation, "worker-join"), + } + }); + true +} + +pub(crate) fn retry_last() -> bool { + let search = LAST_SEARCH + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + search.is_some_and(reset) +} + +pub(crate) fn request_page(page: i32, generation: i32) { + if page < 0 || generation <= 0 { + return; + } + let page = page as usize; + let generation = generation as u64; + let mut session = SESSION.lock().unwrap_or_else(|error| error.into_inner()); + let Some(current) = session + .as_mut() + .filter(|state| state.generation == generation) + else { + return; + }; + if let Some(cached) = current.cache.get(page).cloned() { + let resident = current.cache.resident_artists; + let evictions = current.cache.evictions; + drop(session); + publish_artist_page(generation, page, cached.wire, true, resident, evictions); + return; + } + if !current.pending_pages.insert(page) { + return; + } + let target = page.saturating_mul(PAGE_ENTRIES); + let anchor = nearest_artist_anchor(¤t.anchors, target); + if target.saturating_sub(anchor.entry_index) > MAX_UNANCHORED_ENTRIES && !current.anchors_ready + { + current.pending_pages.remove(&page); + current.waiting_pages.insert(page); + let build = !current.anchor_building; + drop(session); + if build { + build_artist_anchors(generation); + } + return; + } + let descriptor = current.descriptor.clone(); + let catalog_generation = current.catalog_generation; + drop(session); + crate::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + load_artist_page(generation, catalog_generation, descriptor, page, anchor) + }) + .await; + match result { + Ok(Ok(loaded)) => commit_artist_page(loaded), + Ok(Err("superseded")) => {} + Ok(Err(reason)) => fallback(generation, reason), + Err(_) => fallback(generation, "page-worker-join"), + } + }); +} + +pub(crate) fn select_artist(name: String, columns: i32) { + if !requested() || crate::local_library_qt::album_mode() != "folder" { + return; + } + let generation = next_generation(&DETAIL_GENERATION); + let columns = usize::try_from(columns.max(1)) + .unwrap_or(1) + .clamp(1, MAX_COLUMNS); + let artist_key = qbz_local_catalog::normalize_artist_key(&name); + *DETAIL_SESSION + .lock() + .unwrap_or_else(|error| error.into_inner()) = None; + ui(move |mut bridge| { + cpp_detail_reset(generation, 0, 0); + bridge.as_mut().set_local_artist_albums_native_total(0); + bridge.as_mut().set_local_artist_albums_loading(true); + }); + if artist_key.is_empty() { + ui(move |mut bridge| { + cpp_detail_reset(generation, 0, 0); + bridge.as_mut().set_local_artist_albums_native_total(0); + bridge.as_mut().set_local_artist_albums_loading(false); + }); + return; + } + crate::spawn(async move { + let result = + tokio::task::spawn_blocking(move || open_detail(generation, artist_key, columns)).await; + match result { + Ok(Ok(opened)) => activate_detail(opened), + Ok(Err("superseded")) => {} + Ok(Err(reason)) => fallback_detail(generation, reason), + Err(_) => fallback_detail(generation, "detail-worker-join"), + } + }); +} + +pub(crate) fn request_detail_page(page: i32, generation: i32) { + if page < 0 || generation <= 0 { + return; + } + let page = page as usize; + let generation = generation as u64; + let mut session = DETAIL_SESSION + .lock() + .unwrap_or_else(|error| error.into_inner()); + let Some(current) = session + .as_mut() + .filter(|state| state.generation == generation) + else { + return; + }; + if let Some(cached) = current.cache.get(page).cloned() { + let resident = current.cache.resident_albums; + let evictions = current.cache.evictions; + drop(session); + publish_detail_page(generation, page, cached.wire, true, resident, evictions); + return; + } + if !current.pending_pages.insert(page) { + return; + } + let target = page.saturating_mul(PAGE_ENTRIES); + let anchor = nearest_detail_anchor(¤t.anchors, target); + if target.saturating_sub(anchor.entry_index) > MAX_UNANCHORED_ENTRIES && !current.anchors_ready + { + current.pending_pages.remove(&page); + current.waiting_pages.insert(page); + let build = !current.anchor_building; + drop(session); + if build { + build_detail_anchors(generation); + } + return; + } + let catalog_generation = current.catalog_generation; + let artist_key = current.artist_key.clone(); + let sources = current.sources.clone(); + let columns = current.columns; + drop(session); + crate::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + load_detail_page( + generation, + catalog_generation, + artist_key, + sources, + columns, + page, + anchor, + ) + }) + .await; + match result { + Ok(Ok(loaded)) => commit_detail_page(loaded), + Ok(Err("superseded")) => {} + Ok(Err(reason)) => fallback_detail(generation, reason), + Err(_) => fallback_detail(generation, "detail-page-worker-join"), + } + }); +} + +fn open_artists( + generation: u64, + mut descriptor: QueryDescriptor, +) -> Result { + let (catalog, catalog_generation) = open_active()?; + let (sources, none_enabled) = enabled_sources(&catalog)?; + if none_enabled { + return Ok(OpenedArtists { + generation, + catalog_generation, + descriptor, + artist_total: 0, + entry_total: 0, + loaded: LoadedArtists { + generation, + page: 0, + wire: Vec::new(), + anchors: Vec::new(), + jumps: Vec::new(), + query_time: std::time::Duration::ZERO, + map_time: std::time::Duration::ZERO, + }, + count_time: std::time::Duration::ZERO, + }); + } + descriptor = descriptor.with_sources(sources); + let started = Instant::now(); + let artist_total = catalog + .count_artists(&descriptor) + .map_err(|_| "artist-count")?; + let entry_total = catalog + .count_artist_entries(&descriptor) + .map_err(|_| "artist-entry-count")?; + let count_time = started.elapsed(); + let loaded = load_artist_page_from_catalog( + &catalog, + generation, + &descriptor, + 0, + ArtistAnchor::default(), + )?; + Ok(OpenedArtists { + generation, + catalog_generation, + descriptor, + artist_total, + entry_total, + loaded, + count_time, + }) +} + +fn activate_artists(opened: OpenedArtists) { + if QUERY_GENERATION.load(Ordering::Acquire) != opened.generation { + return; + } + let wire = Arc::new(opened.loaded.wire.clone()); + let artists = artist_count(&wire); + let unfiltered = opened.descriptor.search().is_empty(); + let mut cache = ArtistPageLru::default(); + cache.insert( + 0, + CachedArtistPage { + wire: Arc::clone(&wire), + artists, + }, + ); + let mut anchors = BTreeMap::new(); + anchors.insert(0, ArtistAnchor::default()); + for anchor in opened.loaded.anchors { + anchors.insert(anchor.entry_index, anchor); + } + *SESSION.lock().unwrap_or_else(|error| error.into_inner()) = Some(ArtistSession { + generation: opened.generation, + catalog_generation: opened.catalog_generation, + descriptor: opened.descriptor, + entry_total: opened.entry_total, + anchors, + anchors_ready: opened.entry_total <= MAX_UNANCHORED_ENTRIES as u64, + anchor_building: false, + pending_pages: HashSet::new(), + waiting_pages: HashSet::new(), + cache, + }); + let generation = opened.generation; + let total = opened.entry_total; + let artist_total = opened.artist_total; + let jumps = jumps_json(opened.loaded.jumps); + let count_time = opened.count_time; + let query_time = opened.loaded.query_time; + let map_time = opened.loaded.map_time; + crate::local_state::state(|state| state.artists.clear()); + if unfiltered { + crate::local_state::state(|state| { + state.counts.artists = artist_total.min(i64::MAX as u64) as i64; + }); + crate::local_bridge_ops::publish_counts(); + } + ui(move |mut bridge| { + cpp_artist_reset(generation, total, artist_total); + let bytes = publish_artist_page_now(generation, 0, &wire); + bridge.as_mut().set_local_artists_native_active(true); + bridge + .as_mut() + .set_local_artists_json(QString::from("[]")); + bridge + .as_mut() + .set_local_artists_native_total(artist_total.min(i64::MAX as u64) as i64); + bridge + .as_mut() + .set_local_artists_native_jumps_json(QString::from(jumps.as_str())); + bridge + .as_mut() + .set_local_artists_native_error(QString::default()); + bridge.as_mut().set_local_artists_loading(false); + log::info!( + "[local-catalog] phase=artists-native generation={generation} artists={artist_total} entries={total} first_entries={} first_artists={artists} json_bytes={bytes} count={count_time:?} query={query_time:?} map={map_time:?}", + wire.len() + ); + }); + build_artist_anchors(generation); +} + +fn load_artist_page( + generation: u64, + catalog_generation: u64, + descriptor: QueryDescriptor, + page: usize, + anchor: ArtistAnchor, +) -> Result { + let (catalog, active_generation) = open_active()?; + if active_generation != catalog_generation { + return Err("catalog-generation-changed"); + } + load_artist_page_from_catalog(&catalog, generation, &descriptor, page, anchor) +} + +fn load_artist_page_from_catalog( + catalog: &qbz_local_catalog::Catalog, + generation: u64, + descriptor: &QueryDescriptor, + page: usize, + anchor: ArtistAnchor, +) -> Result { + let target = page.saturating_mul(PAGE_ENTRIES); + if anchor.entry_index > target { + return Err("anchor-after-target"); + } + let started = Instant::now(); + let scanned = scan_artists( + catalog, + generation, + descriptor, + anchor, + target, + target.saturating_add(PAGE_ENTRIES), + true, + )?; + Ok(LoadedArtists { + generation, + page, + wire: scanned.wire, + anchors: scanned.anchors, + jumps: scanned.jumps, + query_time: started.elapsed(), + map_time: scanned.map_time, + }) +} + +struct ArtistScan { + wire: Vec, + anchors: Vec, + jumps: Vec, + final_state: ArtistAnchor, + map_time: std::time::Duration, +} + +fn scan_artists( + catalog: &qbz_local_catalog::Catalog, + generation: u64, + descriptor: &QueryDescriptor, + mut state: ArtistAnchor, + collect_start: usize, + stop_entry: usize, + collect: bool, +) -> Result { + let mut wire = Vec::new(); + let mut anchors = Vec::new(); + let mut jumps = Vec::new(); + let mut map_time = std::time::Duration::ZERO; + while state.entry_index < stop_entry && !state.done { + if QUERY_GENERATION.load(Ordering::Acquire) != generation { + return Err("superseded"); + } + let page = catalog + .query_artists(descriptor, state.cursor.as_ref(), QUERY_ROWS) + .map_err(|_| "artist-query")?; + for record in page.rows { + let group = artist_initial(&record.artist_key); + if state.previous_group.as_deref() != Some(group.as_str()) { + state.previous_group = Some(group.clone()); + jumps.push(NativeJump { + letter: group.clone(), + index: state.entry_index, + }); + if collect && (collect_start..stop_entry).contains(&state.entry_index) { + wire.push(ArtistEntry { + t: 0, + label: group, + base: state.artist_index, + item: None, + }); + } + state.entry_index = state.entry_index.saturating_add(1); + } + let index = state.artist_index; + if collect && (collect_start..stop_entry).contains(&state.entry_index) { + let started = Instant::now(); + let item = map_artist(&record); + map_time += started.elapsed(); + wire.push(ArtistEntry { + t: 1, + label: String::new(), + base: index, + item: Some(item), + }); + } + state.artist_index = state.artist_index.saturating_add(1); + state.entry_index = state.entry_index.saturating_add(1); + } + if let Some(cursor) = page.next_cursor { + state.cursor = Some(cursor); + anchors.push(state.clone()); + } else { + state.cursor = None; + state.done = true; + } + } + Ok(ArtistScan { + wire, + anchors, + jumps, + final_state: state, + map_time, + }) +} + +fn commit_artist_page(loaded: LoadedArtists) { + let mut session = SESSION.lock().unwrap_or_else(|error| error.into_inner()); + let Some(current) = session + .as_mut() + .filter(|state| state.generation == loaded.generation) + else { + return; + }; + current.pending_pages.remove(&loaded.page); + for anchor in loaded.anchors { + current.anchors.insert(anchor.entry_index, anchor); + } + let wire = Arc::new(loaded.wire); + let artists = artist_count(&wire); + current.cache.insert( + loaded.page, + CachedArtistPage { + wire: Arc::clone(&wire), + artists, + }, + ); + let resident = current.cache.resident_artists; + let evictions = current.cache.evictions; + drop(session); + publish_artist_page( + loaded.generation, + loaded.page, + wire, + false, + resident, + evictions, + ); +} + +fn publish_artist_page( + generation: u64, + page: usize, + wire: Arc>, + cache_hit: bool, + resident: usize, + evictions: u64, +) { + ui(move |_| { + let bytes = publish_artist_page_now(generation, page, &wire); + log::info!( + "[local-catalog] phase=artists-publish generation={generation} page={page} entries={} artists={} json_bytes={bytes} cache_hit={cache_hit} resident_artists={resident} evictions={evictions}", + wire.len(), + artist_count(&wire) + ); + }); +} + +fn publish_artist_page_now(generation: u64, page: usize, wire: &[ArtistEntry]) -> usize { + let json = serde_json::to_string(wire).unwrap_or_else(|_| "[]".to_string()); + let bytes = json.len(); + let Ok(json) = CString::new(json) else { + return 0; + }; + // SAFETY: C++ copies the bounded page synchronously. + unsafe { qbz_local_artists_apply_page(generation as i32, page as i32, json.as_ptr()) }; + bytes +} + +fn build_artist_anchors(generation: u64) { + let (descriptor, catalog_generation, entry_total) = { + let mut session = SESSION.lock().unwrap_or_else(|error| error.into_inner()); + let Some(current) = session + .as_mut() + .filter(|state| state.generation == generation) + else { + return; + }; + if current.anchor_building || current.anchors_ready { + return; + } + current.anchor_building = true; + ( + current.descriptor.clone(), + current.catalog_generation, + current.entry_total, + ) + }; + crate::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + let (catalog, active_generation) = open_active()?; + if active_generation != catalog_generation { + return Err("catalog-generation-changed"); + } + let started = Instant::now(); + let scan = scan_artists( + &catalog, + generation, + &descriptor, + ArtistAnchor::default(), + usize::MAX, + entry_total.min(usize::MAX as u64) as usize, + false, + )?; + Ok(( + scan.anchors, + scan.jumps, + scan.final_state, + started.elapsed(), + )) + }) + .await; + match result { + Ok(Ok((anchors, jumps, final_state, elapsed))) => { + finish_artist_anchors(generation, anchors, jumps, final_state, elapsed) + } + Ok(Err("superseded")) => {} + Ok(Err(reason)) => fallback(generation, reason), + Err(_) => fallback(generation, "anchor-worker-join"), + } + }); +} + +fn finish_artist_anchors( + generation: u64, + anchors: Vec, + jumps: Vec, + final_state: ArtistAnchor, + elapsed: std::time::Duration, +) { + let waiting = { + let mut session = SESSION.lock().unwrap_or_else(|error| error.into_inner()); + let Some(current) = session + .as_mut() + .filter(|state| state.generation == generation) + else { + return; + }; + let count = anchors.len(); + for anchor in anchors { + current.anchors.insert(anchor.entry_index, anchor); + } + current.anchors.insert(final_state.entry_index, final_state); + current.anchors_ready = true; + current.anchor_building = false; + log::info!( + "[local-catalog] phase=artists-anchors generation={generation} anchors={count} elapsed={elapsed:?}" + ); + current.waiting_pages.drain().collect::>() + }; + let jumps = jumps_json(jumps); + ui(move |mut bridge| { + bridge + .as_mut() + .set_local_artists_native_jumps_json(QString::from(jumps.as_str())); + }); + for page in waiting { + request_page(page as i32, generation as i32); + } +} + +fn open_detail( + generation: u64, + artist_key: String, + columns: usize, +) -> Result { + if DETAIL_GENERATION.load(Ordering::Acquire) != generation { + return Err("superseded"); + } + let (catalog, catalog_generation) = open_active()?; + let (sources, none_enabled) = enabled_sources(&catalog)?; + if none_enabled { + return Ok(OpenedDetail { + generation, + catalog_generation, + artist_key, + sources, + columns, + album_total: 0, + entry_total: 0, + loaded: LoadedDetail { + generation, + page: 0, + wire: Vec::new(), + anchors: Vec::new(), + query_time: std::time::Duration::ZERO, + map_time: std::time::Duration::ZERO, + }, + count_time: std::time::Duration::ZERO, + }); + } + let started = Instant::now(); + let album_total = catalog + .count_artist_albums(&artist_key, &sources) + .map_err(|_| "artist-album-count")?; + let entry_total = album_total.div_ceil(columns as u64); + let count_time = started.elapsed(); + let loaded = load_detail_page_from_catalog( + &catalog, + generation, + &artist_key, + &sources, + columns, + 0, + DetailAnchor::default(), + )?; + Ok(OpenedDetail { + generation, + catalog_generation, + artist_key, + sources, + columns, + album_total, + entry_total, + loaded, + count_time, + }) +} + +fn activate_detail(opened: OpenedDetail) { + if DETAIL_GENERATION.load(Ordering::Acquire) != opened.generation { + return; + } + let wire = Arc::new(opened.loaded.wire.clone()); + let albums = detail_album_count(&wire); + let mut cache = DetailPageLru::default(); + cache.insert( + 0, + CachedDetailPage { + wire: Arc::clone(&wire), + albums, + }, + ); + let mut anchors = BTreeMap::new(); + anchors.insert(0, DetailAnchor::default()); + for anchor in opened.loaded.anchors { + anchors.insert(anchor.entry_index, anchor); + } + *DETAIL_SESSION + .lock() + .unwrap_or_else(|error| error.into_inner()) = Some(DetailSession { + generation: opened.generation, + catalog_generation: opened.catalog_generation, + artist_key: opened.artist_key, + sources: opened.sources, + columns: opened.columns, + entry_total: opened.entry_total, + anchors, + anchors_ready: opened.entry_total <= MAX_UNANCHORED_ENTRIES as u64, + anchor_building: false, + pending_pages: HashSet::new(), + waiting_pages: HashSet::new(), + cache, + }); + let generation = opened.generation; + let entries = opened.entry_total; + let total = opened.album_total; + let count_time = opened.count_time; + let query_time = opened.loaded.query_time; + let map_time = opened.loaded.map_time; + ui(move |mut bridge| { + cpp_detail_reset(generation, entries, total); + let bytes = publish_detail_page_now(generation, 0, &wire); + bridge + .as_mut() + .set_local_artist_albums_native_total(total.min(i64::MAX as u64) as i64); + bridge.as_mut().set_local_artist_albums_loading(false); + log::info!( + "[local-catalog] phase=artist-albums-native generation={generation} albums={total} entries={entries} first_entries={} first_albums={albums} json_bytes={bytes} count={count_time:?} query={query_time:?} map={map_time:?}", + wire.len() + ); + }); + if opened.entry_total > MAX_UNANCHORED_ENTRIES as u64 { + build_detail_anchors(generation); + } +} + +fn load_detail_page( + generation: u64, + catalog_generation: u64, + artist_key: String, + sources: Vec, + columns: usize, + page: usize, + anchor: DetailAnchor, +) -> Result { + let (catalog, active_generation) = open_active()?; + if active_generation != catalog_generation { + return Err("catalog-generation-changed"); + } + load_detail_page_from_catalog( + &catalog, + generation, + &artist_key, + &sources, + columns, + page, + anchor, + ) +} + +fn load_detail_page_from_catalog( + catalog: &qbz_local_catalog::Catalog, + generation: u64, + artist_key: &str, + sources: &[SourceKey], + columns: usize, + page: usize, + anchor: DetailAnchor, +) -> Result { + let target = page.saturating_mul(PAGE_ENTRIES); + if anchor.entry_index > target { + return Err("detail-anchor-after-target"); + } + let started = Instant::now(); + let scan = scan_detail( + catalog, + generation, + artist_key, + sources, + columns, + anchor, + target, + target.saturating_add(PAGE_ENTRIES), + true, + )?; + Ok(LoadedDetail { + generation, + page, + wire: scan.wire, + anchors: scan.anchors, + query_time: started.elapsed(), + map_time: scan.map_time, + }) +} + +struct DetailScan { + wire: Vec, + anchors: Vec, + final_state: DetailAnchor, + map_time: std::time::Duration, +} + +#[allow(clippy::too_many_arguments)] +fn scan_detail( + catalog: &qbz_local_catalog::Catalog, + generation: u64, + artist_key: &str, + sources: &[SourceKey], + columns: usize, + mut state: DetailAnchor, + collect_start: usize, + stop_entry: usize, + collect: bool, +) -> Result { + let mut wire = Vec::new(); + let mut anchors = Vec::new(); + let mut map_time = std::time::Duration::ZERO; + while state.entry_index < stop_entry && !state.done { + if DETAIL_GENERATION.load(Ordering::Acquire) != generation { + return Err("superseded"); + } + let page = catalog + .query_artist_albums(artist_key, sources, state.cursor.as_ref(), QUERY_ROWS) + .map_err(|_| "artist-album-query")?; + for record in page.rows { + let index = state.album_index; + state.album_index = state.album_index.saturating_add(1); + state.pending.push(IndexedAlbum { index, record }); + if state.pending.len() == columns { + emit_detail_chunk( + &mut state, + collect_start, + stop_entry, + collect, + &mut wire, + &mut map_time, + ); + } + } + if let Some(cursor) = page.next_cursor { + state.cursor = Some(cursor); + anchors.push(state.clone()); + } else { + emit_detail_chunk( + &mut state, + collect_start, + stop_entry, + collect, + &mut wire, + &mut map_time, + ); + state.cursor = None; + state.done = true; + } + } + Ok(DetailScan { + wire, + anchors, + final_state: state, + map_time, + }) +} + +fn emit_detail_chunk( + state: &mut DetailAnchor, + collect_start: usize, + collect_end: usize, + collect: bool, + wire: &mut Vec, + map_time: &mut std::time::Duration, +) { + if state.pending.is_empty() { + return; + } + if collect && (collect_start..collect_end).contains(&state.entry_index) { + let started = Instant::now(); + let items = state + .pending + .iter() + .map(|album| map_album(&album.record, album.index)) + .collect(); + *map_time += started.elapsed(); + wire.push(DetailEntry { + t: 1, + label: String::new(), + base: state.pending[0].index, + items, + }); + } + state.pending.clear(); + state.entry_index = state.entry_index.saturating_add(1); +} + +fn commit_detail_page(loaded: LoadedDetail) { + let mut session = DETAIL_SESSION + .lock() + .unwrap_or_else(|error| error.into_inner()); + let Some(current) = session + .as_mut() + .filter(|state| state.generation == loaded.generation) + else { + return; + }; + current.pending_pages.remove(&loaded.page); + for anchor in loaded.anchors { + current.anchors.insert(anchor.entry_index, anchor); + } + let wire = Arc::new(loaded.wire); + let albums = detail_album_count(&wire); + current.cache.insert( + loaded.page, + CachedDetailPage { + wire: Arc::clone(&wire), + albums, + }, + ); + let resident = current.cache.resident_albums; + let evictions = current.cache.evictions; + drop(session); + publish_detail_page( + loaded.generation, + loaded.page, + wire, + false, + resident, + evictions, + ); +} + +fn publish_detail_page( + generation: u64, + page: usize, + wire: Arc>, + cache_hit: bool, + resident: usize, + evictions: u64, +) { + ui(move |_| { + let bytes = publish_detail_page_now(generation, page, &wire); + log::info!( + "[local-catalog] phase=artist-albums-publish generation={generation} page={page} entries={} albums={} json_bytes={bytes} cache_hit={cache_hit} resident_albums={resident} evictions={evictions}", + wire.len(), + detail_album_count(&wire) + ); + }); +} + +fn publish_detail_page_now(generation: u64, page: usize, wire: &[DetailEntry]) -> usize { + let json = serde_json::to_string(wire).unwrap_or_else(|_| "[]".to_string()); + let bytes = json.len(); + let Ok(json) = CString::new(json) else { + return 0; + }; + // SAFETY: C++ copies the bounded page synchronously. + unsafe { qbz_local_artist_albums_apply_page(generation as i32, page as i32, json.as_ptr()) }; + bytes +} + +fn build_detail_anchors(generation: u64) { + let (catalog_generation, artist_key, sources, columns, entry_total) = { + let mut session = DETAIL_SESSION + .lock() + .unwrap_or_else(|error| error.into_inner()); + let Some(current) = session + .as_mut() + .filter(|state| state.generation == generation) + else { + return; + }; + if current.anchor_building || current.anchors_ready { + return; + } + current.anchor_building = true; + ( + current.catalog_generation, + current.artist_key.clone(), + current.sources.clone(), + current.columns, + current.entry_total, + ) + }; + crate::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + let (catalog, active_generation) = open_active()?; + if active_generation != catalog_generation { + return Err("catalog-generation-changed"); + } + let started = Instant::now(); + let scan = scan_detail( + &catalog, + generation, + &artist_key, + &sources, + columns, + DetailAnchor::default(), + usize::MAX, + entry_total.min(usize::MAX as u64) as usize, + false, + )?; + Ok((scan.anchors, scan.final_state, started.elapsed())) + }) + .await; + match result { + Ok(Ok((anchors, final_state, elapsed))) => { + finish_detail_anchors(generation, anchors, final_state, elapsed) + } + Ok(Err("superseded")) => {} + Ok(Err(reason)) => fallback_detail(generation, reason), + Err(_) => fallback_detail(generation, "detail-anchor-worker-join"), + } + }); +} + +fn finish_detail_anchors( + generation: u64, + anchors: Vec, + final_state: DetailAnchor, + elapsed: std::time::Duration, +) { + let waiting = { + let mut session = DETAIL_SESSION + .lock() + .unwrap_or_else(|error| error.into_inner()); + let Some(current) = session + .as_mut() + .filter(|state| state.generation == generation) + else { + return; + }; + let count = anchors.len(); + for anchor in anchors { + current.anchors.insert(anchor.entry_index, anchor); + } + current.anchors.insert(final_state.entry_index, final_state); + current.anchors_ready = true; + current.anchor_building = false; + log::info!( + "[local-catalog] phase=artist-album-anchors generation={generation} anchors={count} elapsed={elapsed:?}" + ); + current.waiting_pages.drain().collect::>() + }; + for page in waiting { + request_detail_page(page as i32, generation as i32); + } +} + +fn map_artist(record: &ArtistRecord) -> NativeArtist { + let art_key = format!("catalog-artist:{}", record.artist_key); + if !record.artwork_token.is_empty() { + if let Some(source) = SourceId::from_word(&record.artwork_source) { + crate::local_state::with_art(|art| { + art.insert(art_key.clone(), (source, record.artwork_token.clone())); + }); + } + } + NativeArtist { + row: ArtistRow { + name: record.display_name.clone(), + album_count: record.album_count, + track_count: record.track_count, + art_key, + source: record.source.clone(), + }, + artist_key: record.artist_key.clone(), + art_path: String::new(), + } +} + +fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum { + let id = album_action_id(record); + let art_key = format!("catalog-album:{}", record.edition_id); + if !record.artwork_token.is_empty() { + if let Some(source) = SourceId::from_word(&record.artwork_source) { + crate::local_state::with_art(|art| { + art.insert(art_key.clone(), (source, record.artwork_token.clone())); + }); + } + } + let format = audio_format(&record.format); + NativeAlbum { + row: AlbumRow { + id, + title: record.title.clone(), + artist: record.artist.clone(), + all_artists: record.all_artists.clone(), + year: record.year.map(|year| year.to_string()).unwrap_or_default(), + track_count: record.track_count, + duration: total_duration(record.total_duration_ms / 1_000), + quality_tier: record.quality_tier.clone(), + quality_detail: detail_of( + &format, + record.bit_depth, + record.sample_rate_hz.unwrap_or(0) as f64, + ), + format: record.format.to_ascii_uppercase(), + art_key, + source: badge_source(Some(source_word(record))), + source_raw: badge_source_raw(Some(source_word(record))), + directory_path: record.directory_path.clone(), + folder_count: record.folder_count, + }, + native_index: index, + art_path: String::new(), + } +} + +fn source_word(record: &AlbumRecord) -> &str { + if record.source_raw.trim().is_empty() { + record.source.as_str() + } else { + &record.source_raw + } +} + +fn album_action_id(record: &AlbumRecord) -> String { + let native = record.native_album_id.trim(); + match record.source { + SourceKind::Plex if native.starts_with("plex:") => native.to_string(), + SourceKind::Plex => format!("plex:album:{native}"), + SourceKind::Jellyfin if native.starts_with("jellyfin:") => native.to_string(), + SourceKind::Jellyfin => format!("jellyfin:{native}"), + SourceKind::Subsonic if native.starts_with("subsonic:") => native.to_string(), + SourceKind::Subsonic => format!("subsonic:{native}"), + _ => native.to_string(), + } +} + +fn audio_format(value: &str) -> AudioFormat { + match value.to_ascii_lowercase().as_str() { + "flac" => AudioFormat::Flac, + "alac" | "m4a" => AudioFormat::Alac, + "wav" | "wave" => AudioFormat::Wav, + "aiff" | "aif" => AudioFormat::Aiff, + "ape" => AudioFormat::Ape, + "mp3" => AudioFormat::Mp3, + "dsd" | "dsf" | "dff" => AudioFormat::Dsd, + _ => AudioFormat::Unknown, + } +} + +fn artist_initial(key: &str) -> String { + let initial = key.chars().next().unwrap_or('#').to_ascii_uppercase(); + if initial.is_ascii_uppercase() { + initial.to_string() + } else { + "#".to_string() + } +} + +/// Empty query sources means every catalog source, so return a separate +/// `none_enabled` bit to distinguish "all enabled" from "nothing enabled". +/// The common all-enabled path intentionally leaves the descriptor unscoped; +/// that keeps the materialized name/count index as the direct query plan. +fn enabled_sources( + catalog: &qbz_local_catalog::Catalog, +) -> Result<(Vec, bool), &'static str> { + let stats = catalog.stats().map_err(|_| "source-counts")?; + let plex = crate::local_plex::is_enabled(); + let remote = crate::media_servers_qt::configured_words(); + let total_sources = stats.source_counts.len(); + let enabled = stats + .source_counts + .into_iter() + .map(|(source, _)| source) + .filter(|source| match source.source { + SourceKind::Local | SourceKind::Offline => true, + SourceKind::Plex => plex, + SourceKind::Jellyfin => remote.iter().any(|word| *word == "jellyfin"), + SourceKind::Subsonic => remote.iter().any(|word| *word == "subsonic"), + }) + .collect::>(); + let none_enabled = enabled.is_empty() && stats.track_count > 0; + let query_sources = if enabled.len() == total_sources { + Vec::new() + } else { + enabled + }; + Ok((query_sources, none_enabled)) +} + +fn open_active() -> Result<(qbz_local_catalog::Catalog, u64), &'static str> { + let locations = crate::local_catalog_qt::locations().ok_or("missing-data-directory")?; + match BootstrapLayout::new(locations.catalog_dir).open_active() { + ActiveCatalog::Ready { catalog, manifest } => Ok((catalog, manifest.active_generation)), + ActiveCatalog::Fallback(_) => Err("catalog-not-ready"), + } +} + +fn nearest_artist_anchor(anchors: &BTreeMap, target: usize) -> ArtistAnchor { + anchors + .range(..=target) + .next_back() + .map(|(_, anchor)| anchor.clone()) + .unwrap_or_default() +} + +fn nearest_detail_anchor(anchors: &BTreeMap, target: usize) -> DetailAnchor { + anchors + .range(..=target) + .next_back() + .map(|(_, anchor)| anchor.clone()) + .unwrap_or_default() +} + +fn set_loading() { + ui(|mut bridge| { + bridge.as_mut().set_local_artists_loading(true); + bridge + .as_mut() + .set_local_artists_native_error(QString::default()); + }); +} + +fn fallback(generation: u64, reason: &'static str) { + if QUERY_GENERATION.load(Ordering::Acquire) != generation { + return; + } + if reason != "catalog-not-ready" { + SESSION_FAILED.store(true, Ordering::Release); + } + *SESSION.lock().unwrap_or_else(|error| error.into_inner()) = None; + let detail_generation = next_generation(&DETAIL_GENERATION); + *DETAIL_SESSION + .lock() + .unwrap_or_else(|error| error.into_inner()) = None; + log::warn!("[local-catalog] phase=artists-fallback generation={generation} reason={reason}"); + ui(move |mut bridge| { + cpp_detail_reset(detail_generation, 0, 0); + bridge.as_mut().set_local_artists_native_active(false); + bridge.as_mut().set_local_artists_native_total(0); + bridge.as_mut().set_local_artist_albums_native_total(0); + bridge.as_mut().set_local_artist_albums_loading(false); + bridge + .as_mut() + .set_local_artists_native_jumps_json(QString::from("[]")); + bridge + .as_mut() + .set_local_artists_native_error(QString::from(reason)); + crate::local_bridge_ops::load_artists_legacy(); + crate::local_bridge_ops::load_albums_legacy(); + }); +} + +fn fallback_detail(generation: u64, reason: &'static str) { + if DETAIL_GENERATION.load(Ordering::Acquire) != generation { + return; + } + let rail_generation = QUERY_GENERATION.load(Ordering::Acquire); + fallback(rail_generation, reason); +} + +fn deactivate_for_legacy(reason: &'static str) { + *SESSION.lock().unwrap_or_else(|error| error.into_inner()) = None; + let detail_generation = next_generation(&DETAIL_GENERATION); + *DETAIL_SESSION + .lock() + .unwrap_or_else(|error| error.into_inner()) = None; + ui(move |mut bridge| { + cpp_detail_reset(detail_generation, 0, 0); + bridge.as_mut().set_local_artists_native_active(false); + bridge.as_mut().set_local_artists_native_total(0); + bridge.as_mut().set_local_artist_albums_native_total(0); + bridge.as_mut().set_local_artist_albums_loading(false); + bridge + .as_mut() + .set_local_artists_native_jumps_json(QString::from("[]")); + bridge + .as_mut() + .set_local_artists_native_error(QString::from(reason)); + }); +} + +fn artist_count(wire: &[ArtistEntry]) -> usize { + wire.iter().filter(|entry| entry.t == 1).count() +} + +fn detail_album_count(wire: &[DetailEntry]) -> usize { + wire.iter().map(|entry| entry.items.len()).sum() +} + +fn jumps_json(jumps: Vec) -> String { + serde_json::to_string(&jumps).unwrap_or_else(|_| "[]".to_string()) +} + +fn cpp_artist_reset(generation: u64, total: u64, artists: u64) { + // SAFETY: primitive values only; C++ applies synchronously on the Qt thread. + unsafe { + qbz_local_artists_reset( + generation as i32, + total.min(i64::MAX as u64) as i64, + artists.min(i64::MAX as u64) as i64, + ) + }; +} + +fn cpp_detail_reset(generation: u64, total: u64, albums: u64) { + // SAFETY: primitive values only; C++ applies synchronously on the Qt thread. + unsafe { + qbz_local_artist_albums_reset( + generation as i32, + total.min(i64::MAX as u64) as i64, + albums.min(i64::MAX as u64) as i64, + ) + }; +} + +fn next_generation(counter: &AtomicU64) -> u64 { + let mut generation = counter.fetch_add(1, Ordering::AcqRel).wrapping_add(1); + if generation == 0 { + counter.store(1, Ordering::Release); + generation = 1; + } + generation +} + +#[cfg(test)] +mod tests { + use super::*; + + fn artist_entry(index: usize) -> ArtistEntry { + ArtistEntry { + t: 1, + label: String::new(), + base: index, + item: Some(NativeArtist { + row: ArtistRow { + name: format!("Artist {index}"), + album_count: 1, + track_count: 1, + art_key: format!("artist:{index}"), + source: "local".to_string(), + }, + artist_key: format!("artist {index}"), + art_path: String::new(), + }), + } + } + + #[test] + fn artist_page_cache_is_bounded() { + let mut cache = ArtistPageLru::default(); + for page in 0..25 { + let wire = Arc::new((0..100).map(|row| artist_entry(page * 100 + row)).collect()); + cache.insert(page, CachedArtistPage { wire, artists: 100 }); + } + assert!(cache.resident_artists <= MAX_RESIDENT_ARTISTS); + assert!(cache.evictions > 0); + } + + #[test] + fn stale_artist_generation_is_not_current() { + let old = next_generation(&QUERY_GENERATION); + let new = next_generation(&QUERY_GENERATION); + assert_ne!(old, new); + assert_ne!(QUERY_GENERATION.load(Ordering::Acquire), old); + assert_eq!(QUERY_GENERATION.load(Ordering::Acquire), new); + } + + #[test] + fn artist_alpha_bucket_matches_the_existing_rail() { + assert_eq!(artist_initial("air"), "A"); + assert_eq!(artist_initial("123 go"), "#"); + assert_eq!(artist_initial("émilie"), "#"); + } + + #[test] + fn artist_transport_is_bounded_to_one_visual_page() { + const TOTAL: usize = 20_000; + let legacy = (0..TOTAL) + .map(|index| ArtistRow { + name: format!("Artist {index:05}"), + album_count: 1, + track_count: 1, + art_key: format!("artist:Artist {index:05}"), + source: match index % 4 { + 0 => "local", + 1 => "plex", + 2 => "jellyfin", + _ => "subsonic", + } + .to_string(), + }) + .collect::>(); + let legacy_bytes = serde_json::to_vec(&legacy).unwrap().len(); + let map_started = Instant::now(); + let mut page = vec![ArtistEntry { + t: 0, + label: "A".to_string(), + base: 0, + item: None, + }]; + page.extend( + legacy + .iter() + .take(99) + .enumerate() + .map(|(index, row)| ArtistEntry { + t: 1, + label: String::new(), + base: index, + item: Some(NativeArtist { + row: row.clone(), + artist_key: qbz_local_catalog::normalize_artist_key(&row.name), + art_path: String::new(), + }), + }), + ); + let map_time = map_started.elapsed(); + let serialize_started = Instant::now(); + let native_bytes = serde_json::to_vec(&page).unwrap().len(); + let serialize_time = serialize_started.elapsed(); + + assert_eq!(page.len(), PAGE_ENTRIES); + assert_eq!(artist_count(&page), 99); + assert!(native_bytes < legacy_bytes / 100); + println!( + "F2_ARTISTS_TRANSPORT total={TOTAL} first_entries={} first_artists={} json_bytes={native_bytes} legacy_json_bytes={legacy_bytes} map_ms={:.3} serialize_ms={:.3} qml_artist_json_bytes=0", + page.len(), + artist_count(&page), + map_time.as_secs_f64() * 1_000.0, + serialize_time.as_secs_f64() * 1_000.0, + ); + } +} diff --git a/crates/qbz-qt/src/local_bridge.rs b/crates/qbz-qt/src/local_bridge.rs index 3886a9bf5..9ad2cdc26 100644 --- a/crates/qbz-qt/src/local_bridge.rs +++ b/crates/qbz-qt/src/local_bridge.rs @@ -68,6 +68,13 @@ pub mod qbz_local { // --- Artists tab -------------------------------------------------- #[qproperty(bool, local_artists_loading)] #[qproperty(QString, local_artists_json)] + /// Phase-F2 surface switch. False keeps the legacy JSON rail/pane. + #[qproperty(bool, local_artists_native_active)] + #[qproperty(i64, local_artists_native_total)] + #[qproperty(i64, local_artist_albums_native_total)] + #[qproperty(bool, local_artist_albums_loading)] + #[qproperty(QString, local_artists_native_jumps_json)] + #[qproperty(QString, local_artists_native_error)] // --- Folders tab, FLAT mode --------------------------------------- #[qproperty(bool, local_folders_loading)] @@ -283,6 +290,28 @@ pub mod qbz_local { #[qinvokable] fn albums_native_bulk_action(self: Pin<&mut QbzLocal>, action: QString); + // --- Artists tab (native paged models) --------------------------- + #[qinvokable] + fn artists_native_reset(self: Pin<&mut QbzLocal>, search: QString); + #[qinvokable] + fn artists_native_page_miss( + self: Pin<&mut QbzLocal>, + page: i32, + generation: i32, + ); + #[qinvokable] + fn artists_native_select( + self: Pin<&mut QbzLocal>, + artist: QString, + columns: i32, + ); + #[qinvokable] + fn artist_albums_native_page_miss( + self: Pin<&mut QbzLocal>, + page: i32, + generation: i32, + ); + // --- Tracks tab ---------------------------------------------------- /// Toolbar search (server-side; resets to page 1). #[qinvokable] @@ -609,6 +638,12 @@ pub struct QbzLocalRust { local_albums_native_error: QString, local_artists_loading: bool, local_artists_json: QString, + local_artists_native_active: bool, + local_artists_native_total: i64, + local_artist_albums_native_total: i64, + local_artist_albums_loading: bool, + local_artists_native_jumps_json: QString, + local_artists_native_error: QString, local_folders_loading: bool, local_folders_json: QString, local_tree_loading: bool, @@ -676,6 +711,12 @@ impl Default for QbzLocalRust { local_albums_native_error: QString::default(), local_artists_loading: false, local_artists_json: QString::from("[]"), + local_artists_native_active: false, + local_artists_native_total: 0, + local_artist_albums_native_total: 0, + local_artist_albums_loading: false, + local_artists_native_jumps_json: QString::from("[]"), + local_artists_native_error: QString::default(), local_folders_loading: false, local_folders_json: QString::from("[]"), local_tree_loading: false, @@ -850,6 +891,34 @@ impl qbz_local::QbzLocal { crate::local_albums_model_qt::bulk_action(action.to_string()); } + pub fn artists_native_reset(self: Pin<&mut Self>, search: QString) { + crate::local_artists_model_qt::reset(search.to_string()); + } + + pub fn artists_native_page_miss( + self: Pin<&mut Self>, + page: i32, + generation: i32, + ) { + crate::local_artists_model_qt::request_page(page, generation); + } + + pub fn artists_native_select( + self: Pin<&mut Self>, + artist: QString, + columns: i32, + ) { + crate::local_artists_model_qt::select_artist(artist.to_string(), columns); + } + + pub fn artist_albums_native_page_miss( + self: Pin<&mut Self>, + page: i32, + generation: i32, + ) { + crate::local_artists_model_qt::request_detail_page(page, generation); + } + pub fn tracks_search(self: Pin<&mut Self>, query: QString) { lib::set_tracks_query(&query.to_string()); load_tracks(true); diff --git a/crates/qbz-qt/src/local_bridge_ops.rs b/crates/qbz-qt/src/local_bridge_ops.rs index 187f2fa3f..40696e1d6 100644 --- a/crates/qbz-qt/src/local_bridge_ops.rs +++ b/crates/qbz-qt/src/local_bridge_ops.rs @@ -301,6 +301,20 @@ pub(crate) fn invalidate_artists() { } pub(crate) fn load_artists() { + ui(|mut b| b.as_mut().set_local_artists_loading(true)); + if crate::local_artists_model_qt::requested() + && crate::local_library_qt::album_mode() == "folder" + { + crate::local_artists_model_qt::retry_last(); + return; + } + load_artists_legacy(); + if lib::state(|state| state.albums.is_empty()) { + load_albums_legacy(); + } +} + +pub(crate) fn load_artists_legacy() { ui(|mut b| b.as_mut().set_local_artists_loading(true)); crate::spawn(async move { let rows = tokio::task::spawn_blocking(lib::load_artists_blocking) diff --git a/crates/qbz-qt/src/local_catalog_qt.rs b/crates/qbz-qt/src/local_catalog_qt.rs index 46b2f9476..5d29b169d 100644 --- a/crates/qbz-qt/src/local_catalog_qt.rs +++ b/crates/qbz-qt/src/local_catalog_qt.rs @@ -147,6 +147,9 @@ pub(crate) fn start() { if ready && crate::local_albums_model_qt::requested() { crate::local_albums_model_qt::retry_last(); } + if ready && crate::local_artists_model_qt::requested() { + crate::local_artists_model_qt::retry_last(); + } } Ok(Err(CatalogError::InsufficientSpace { required_bytes, @@ -258,6 +261,9 @@ pub(crate) fn request_catch_up() { if ready && crate::local_albums_model_qt::requested() { crate::local_albums_model_qt::retry_last(); } + if ready && crate::local_artists_model_qt::requested() { + crate::local_artists_model_qt::retry_last(); + } } Ok(Err(CatalogError::InsufficientSpace { required_bytes, diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 5bae1403a..f7aeefffb 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -186,6 +186,8 @@ mod local_catalog_qt; mod local_tracks_model_qt; // Native bounded Albums entry model (phase F1); grid/list geometry stays QML. mod local_albums_model_qt; +// Native paged Artists rail + indexed artist-album pane (phase F2). +mod local_artists_model_qt; mod local_plex; // Plex PIN sign-in (the "Authorize" half of the Plex settings) + the // Check-connection ping. Over `qbz_plex`'s existing pin/start, pin/check and @@ -3040,6 +3042,7 @@ fn main() { // registration itself runs at QCoreApplication startup. local_tracks_model_qt::register_qml_model(); local_albums_model_qt::register_qml_model(); + local_artists_model_qt::register_qml_model(); // BEFORE any restore reads a pref: read + increment the crash chain, so a // boot that dies from a restored view or queue degrades the NEXT boot // instead of trapping the user in a loop it cannot escape from inside the