From 299dcde5b47377068acd8d027ec5d265b9b55b16 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:54:31 -0600 Subject: [PATCH] feat(library): page Albums from local catalog Materialize album browse fields and FTS in the derived catalog, then expose exact counts and stable keyset queries for the current filters, sort, grouping, and enabled sources. Bind the existing Albums layout to a bounded native Qt model with asynchronous page misses, anchors, selection ranges, generation cancellation, artwork updates, and per-surface fallback. Resolve Plex native edition keys directly and retain the metadata-mode legacy reader. cx --- crates/qbz-local-catalog/src/catalog.rs | 497 +++++- crates/qbz-local-catalog/src/lib.rs | 5 +- crates/qbz-local-catalog/src/model.rs | 86 + crates/qbz-local-catalog/src/schema.rs | 53 +- crates/qbz-local-catalog/src/tests.rs | 269 ++++ crates/qbz-plex/src/lib.rs | 69 +- crates/qbz-qt/build.rs | 1 + crates/qbz-qt/cxx/local_albums_model.cpp | 305 ++++ crates/qbz-qt/cxx/local_albums_model.h | 88 + crates/qbz-qt/qml/views/LocalLibraryView.qml | 48 +- .../qml/views/local/LocalAlbumCollection.qml | 122 +- .../qbz-qt/qml/views/local/LocalAlbumsTab.qml | 20 +- .../qbz-qt/qml/views/local/LocalToolbar.qml | 4 +- crates/qbz-qt/src/local_albums.rs | 7 +- crates/qbz-qt/src/local_albums_model_qt.rs | 1434 +++++++++++++++++ crates/qbz-qt/src/local_bridge.rs | 93 ++ crates/qbz-qt/src/local_bridge_ops.rs | 20 + crates/qbz-qt/src/local_bulk.rs | 6 + crates/qbz-qt/src/local_catalog_qt.rs | 6 + crates/qbz-qt/src/local_plex.rs | 3 +- crates/qbz-qt/src/main.rs | 3 + 21 files changed, 3092 insertions(+), 47 deletions(-) create mode 100644 crates/qbz-qt/cxx/local_albums_model.cpp create mode 100644 crates/qbz-qt/cxx/local_albums_model.h create mode 100644 crates/qbz-qt/src/local_albums_model_qt.rs diff --git a/crates/qbz-local-catalog/src/catalog.rs b/crates/qbz-local-catalog/src/catalog.rs index d632af9e3..61f55b979 100644 --- a/crates/qbz-local-catalog/src/catalog.rs +++ b/crates/qbz-local-catalog/src/catalog.rs @@ -10,8 +10,8 @@ use rusqlite::{ use crate::bootstrap::{BootstrapBatch, SourceCheckpoint, BOOTSTRAP_BATCH_ROWS}; use crate::model::{ - ProjectedTrack, QueryDescriptor, SourceKey, SourceKind, TrackCursor, TrackGroup, TrackPage, - TrackRecord, TrackRef, TrackSort, + AlbumCursor, AlbumPage, AlbumRecord, ProjectedTrack, QueryDescriptor, QuerySurface, SourceKey, + SourceKind, TrackCursor, TrackGroup, TrackPage, TrackRecord, TrackRef, TrackSort, }; use crate::projection::ReconciliationBatch; use crate::schema; @@ -552,14 +552,75 @@ impl Catalog { INSERT INTO albums_materialized( edition_id,logical_album_id,title,sort_title,artist,sort_artist, - year,track_count,total_duration_ms,source_count,available, + year,year_missing,year_value,track_count,total_duration_ms, + source_count,available, + source_kind,native_album_id,source_raw,all_artists,format, + bit_depth,sample_rate_hz,quality_tier,directory_path,folder_count, + added_at, artwork_source,artwork_token ) SELECT e.edition_id,e.logical_album_id,e.display_title,la.sort_title, - e.display_artist,la.sort_artist,e.release_year,COUNT(t.catalog_id), + e.display_artist,la.sort_artist,e.release_year, + CASE WHEN e.release_year IS NULL THEN 1 ELSE 0 END, + COALESCE(e.release_year,0),COUNT(t.catalog_id), COALESCE(SUM(t.duration_ms),0), COUNT(DISTINCT sc.source_kind || char(31) || sc.source_instance), COALESCE(MAX(t.available),0), + COALESCE(( + SELECT scp.source_kind FROM source_copies scp + WHERE scp.edition_id=e.edition_id + ORDER BY scp.available DESC, + CASE scp.source_kind + WHEN 'local' THEN 0 WHEN 'offline' THEN 1 + WHEN 'plex' THEN 2 WHEN 'jellyfin' THEN 3 ELSE 4 END, + scp.source_copy_id + LIMIT 1 + ),'local'), + COALESCE(( + SELECT scp.native_album_id FROM source_copies scp + WHERE scp.edition_id=e.edition_id + ORDER BY scp.available DESC, + CASE scp.source_kind + WHEN 'local' THEN 0 WHEN 'offline' THEN 1 + WHEN 'plex' THEN 2 WHEN 'jellyfin' THEN 3 ELSE 4 END, + scp.source_copy_id + LIMIT 1 + ),''), + COALESCE(( + SELECT t2.source_raw FROM tracks t2 + JOIN source_copies sc2 ON sc2.source_copy_id=t2.source_copy_id + WHERE sc2.edition_id=e.edition_id + ORDER BY t2.available DESC,t2.catalog_id LIMIT 1 + ),''), + COALESCE(( + SELECT group_concat(DISTINCT ac.display_name) + FROM artist_credits ac + JOIN tracks ta ON ta.catalog_id=ac.catalog_id + JOIN source_copies sca ON sca.source_copy_id=ta.source_copy_id + WHERE sca.edition_id=e.edition_id + ),''), + COALESCE(( + SELECT LOWER(t2.format) FROM tracks t2 + JOIN source_copies sc2 ON sc2.source_copy_id=t2.source_copy_id + WHERE sc2.edition_id=e.edition_id + ORDER BY COALESCE(t2.bit_depth,0) DESC, + COALESCE(t2.sample_rate_hz,0) DESC,t2.catalog_id + LIMIT 1 + ),''), + MAX(t.bit_depth),MAX(t.sample_rate_hz), + CASE + WHEN MAX(CASE WHEN LOWER(t.format)='mp3' THEN 0 ELSE 1 END)=0 + THEN 'mp3' + WHEN MAX(CASE WHEN LOWER(t.format) IN ('dsd','dsf','dff') THEN 1 ELSE 0 END)=1 + THEN 'hires' + WHEN MAX(t.bit_depth)>=24 AND MAX(t.sample_rate_hz)>96000 THEN 'max' + WHEN MAX(t.bit_depth)>=24 THEN 'hires' + WHEN MAX(t.bit_depth) IS NOT NULL THEN 'cd' + WHEN MAX(t.sample_rate_hz)>=44100 THEN 'cd' + ELSE '' + END, + COALESCE(MIN(NULLIF(sc.local_directory,'')),''), + COUNT(DISTINCT NULLIF(sc.local_directory,'')),MAX(t.added_at), COALESCE(( SELECT t2.source_kind FROM tracks t2 WHERE t2.source_copy_id IN ( @@ -692,6 +753,114 @@ impl Catalog { )) } + pub fn count_albums(&self, descriptor: &QueryDescriptor) -> Result { + validate_albums_descriptor(descriptor)?; + let parts = album_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 number of visual rows for the current grid/list layout. Group + /// headers count as one row and album chunks never cross a global group. + pub fn count_album_entries(&self, descriptor: &QueryDescriptor, columns: usize) -> Result { + validate_albums_descriptor(descriptor)?; + let columns = columns.clamp(1, 32) as i64; + let parts = album_filter_parts(descriptor, None)?; + let sql = if descriptor.group() == TrackGroup::Off { + format!( + "SELECT (COUNT(*) + ? - 1) / ? {} WHERE {}", + parts.from_sql, parts.where_sql + ) + } else { + let group = album_group_expression(descriptor.group()); + format!( + "SELECT COALESCE(SUM(1 + (n + ? - 1) / ?),0) + FROM (SELECT {group} AS group_key,COUNT(*) AS n + {} WHERE {} GROUP BY group_key)", + parts.from_sql, parts.where_sql + ) + }; + let mut params = vec![Value::Integer(columns), Value::Integer(columns)]; + params.extend(parts.params); + let count: i64 = self + .conn + .query_row(&sql, params_from_iter(params), |row| row.get(0))?; + Ok(count.max(0) as u64) + } + + pub fn query_albums( + &self, + descriptor: &QueryDescriptor, + cursor: Option<&AlbumCursor>, + page_size: usize, + ) -> Result { + self.query_albums_timed(descriptor, cursor, page_size) + .map(|(page, _)| page) + } + + pub fn query_albums_timed( + &self, + descriptor: &QueryDescriptor, + cursor: Option<&AlbumCursor>, + page_size: usize, + ) -> Result<(AlbumPage, QueryMetrics)> { + validate_albums_descriptor(descriptor)?; + let limit = page_size.clamp(1, MAX_PAGE_SIZE); + let parts = album_filter_parts(descriptor, cursor)?; + let order = album_order_fields(descriptor, "am") + .into_iter() + .map(|field| { + format!( + "{} {}", + field.expression, + if field.descending { "DESC" } else { "ASC" } + ) + }) + .chain(std::iter::once("am.edition_id ASC".to_string())) + .collect::>() + .join(", "); + let sql = format!( + "SELECT {ALBUM_COLUMNS} {} WHERE {} ORDER BY {order} LIMIT {}", + 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), 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 @@ -738,17 +907,24 @@ impl Catalog { .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { row.get(0) })?; - let fts_ok = self + let tracks_fts_ok = self .conn .execute( "INSERT INTO tracks_fts(tracks_fts) VALUES ('integrity-check')", [], ) .is_ok(); + let albums_fts_ok = self + .conn + .execute( + "INSERT INTO albums_fts(albums_fts) VALUES ('integrity-check')", + [], + ) + .is_ok(); Ok(IntegrityReport { sqlite_ok: result == "ok", foreign_key_violations: foreign_key_violations as u64, - fts_ok, + fts_ok: tracks_fts_ok && albums_fts_ok, }) } @@ -1394,6 +1570,308 @@ const TRACK_COLUMNS: &str = " t.sort_title, t.sort_artist, t.sort_track_artist, t.sort_album, t.year_missing, t.year_value, t.disc_sort, t.track_sort, t.added_at, t.catalog_id"; +struct AlbumRowWithCursor { + record: AlbumRecord, + cursor: AlbumCursor, +} + +struct AlbumQueryParts { + from_sql: String, + where_sql: String, + params: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum AlbumOrderKind { + TitleInitial, + SortTitle, + SortArtist, + YearMissing, + YearValue, + AddedAt, +} + +struct AlbumOrderField { + kind: AlbumOrderKind, + expression: String, + descending: bool, +} + +impl AlbumOrderField { + fn cursor_value(&self, cursor: &AlbumCursor) -> Value { + match self.kind { + AlbumOrderKind::TitleInitial => { + Value::Text(cursor.sort_title.chars().next().unwrap_or('#').to_string()) + } + AlbumOrderKind::SortTitle => Value::Text(cursor.sort_title.clone()), + AlbumOrderKind::SortArtist => Value::Text(cursor.sort_artist.clone()), + AlbumOrderKind::YearMissing => Value::Integer(cursor.year_missing), + AlbumOrderKind::YearValue => Value::Integer(cursor.year_value), + AlbumOrderKind::AddedAt => Value::Integer(cursor.added_at), + } + } +} + +fn validate_albums_descriptor(descriptor: &QueryDescriptor) -> Result<()> { + if descriptor.surface() != QuerySurface::Albums { + return Err(CatalogError::InvalidInput( + "a non-Albums descriptor was passed to an Albums query".to_string(), + )); + } + Ok(()) +} + +fn album_filter_parts( + descriptor: &QueryDescriptor, + cursor: Option<&AlbumCursor>, +) -> Result { + if let Some(cursor) = cursor { + if cursor.descriptor_key != descriptor_key(descriptor) { + return Err(CatalogError::CursorDescriptorMismatch); + } + } + let mut predicates = Vec::new(); + let mut params = Vec::new(); + let from_sql = if descriptor.search().is_empty() { + "FROM albums_materialized am".to_string() + } else { + params.push(fts_match_value(descriptor.search())?); + predicates.push("albums_fts MATCH ?".to_string()); + predicates.push("am.edition_id=albums_fts.rowid".to_string()); + "FROM albums_fts CROSS JOIN albums_materialized am NOT INDEXED".to_string() + }; + if descriptor.available_only() { + predicates.push("am.available=1".to_string()); + } + if !descriptor.sources().is_empty() { + 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(), + )); + } + source.push("(scf.source_kind=? AND scf.source_instance=?)"); + params.push(Value::Text(key.source.as_str().to_string())); + params.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 !descriptor.formats().is_empty() || descriptor.other_formats() { + let mut format_arms = Vec::new(); + if !descriptor.formats().is_empty() { + format_arms.push(format!( + "am.format IN ({})", + std::iter::repeat("?") + .take(descriptor.formats().len()) + .collect::>() + .join(",") + )); + } + if descriptor.other_formats() { + format_arms.push( + "am.format NOT IN ('flac','alac','ape','wav','wave','mp3','aac')".to_string(), + ); + } + predicates.push(format!("({})", format_arms.join(" OR "))); + params.extend(descriptor.formats().iter().cloned().map(Value::Text)); + } + if !descriptor.source_buckets().is_empty() { + let mut source_arms = Vec::new(); + for bucket in descriptor.source_buckets() { + source_arms.push(match bucket.as_str() { + "local" => "(am.source_kind='local' AND am.source_raw NOT IN ('qobuz_download','qobuz_purchase'))", + "offline" => "(am.source_kind='offline' OR am.source_raw IN ('qobuz_download','qobuz_purchase'))", + "plex" => "am.source_kind='plex'", + "jellyfin" => "am.source_kind='jellyfin'", + "subsonic" => "am.source_kind='subsonic'", + _ => { + return Err(CatalogError::InvalidInput(format!( + "unknown album source bucket {bucket}" + ))) + } + }); + } + predicates.push(format!("({})", source_arms.join(" OR "))); + } + if !descriptor.quality_tiers().is_empty() { + let mut quality = Vec::new(); + for tier in descriptor.quality_tiers() { + match tier.as_str() { + "hires" => quality.push("am.quality_tier IN ('hires','max')"), + "cd" => quality.push("am.quality_tier='cd'"), + "lossy" => quality.push("am.quality_tier IN ('mp3','lossy')"), + _ => { + return Err(CatalogError::InvalidInput(format!( + "unknown album quality tier {tier}" + ))) + } + } + } + predicates.push(format!("({})", quality.join(" OR "))); + } + if let Some(cursor) = cursor { + let (predicate, values) = album_cursor_predicate(descriptor, cursor); + predicates.push(predicate); + params.extend(values); + } + if predicates.is_empty() { + predicates.push("1".to_string()); + } + Ok(AlbumQueryParts { + from_sql, + where_sql: predicates.join(" AND "), + params, + }) +} + +fn album_group_expression(group: TrackGroup) -> &'static str { + match group { + TrackGroup::Artist => "am.sort_artist", + TrackGroup::Name | TrackGroup::Album => "substr(am.sort_title,1,1)", + TrackGroup::Off => "''", + } +} + +fn album_order_fields(descriptor: &QueryDescriptor, alias: &str) -> Vec { + let expression = |name: &str| format!("{alias}.{name}"); + let mut fields = Vec::new(); + match descriptor.group() { + TrackGroup::Artist => fields.push(AlbumOrderField { + kind: AlbumOrderKind::SortArtist, + expression: expression("sort_artist"), + descending: false, + }), + TrackGroup::Name | TrackGroup::Album => fields.push(AlbumOrderField { + kind: AlbumOrderKind::TitleInitial, + expression: format!("substr({alias}.sort_title,1,1)"), + descending: false, + }), + TrackGroup::Off => {} + } + let mut push = |kind: AlbumOrderKind, name: &str, descending: bool| { + if fields.iter().any(|field| field.kind == kind) { + return; + } + fields.push(AlbumOrderField { + kind, + expression: expression(name), + descending, + }); + }; + match descriptor.sort() { + TrackSort::Default | TrackSort::ArtistAsc => { + push(AlbumOrderKind::SortArtist, "sort_artist", false); + push(AlbumOrderKind::SortTitle, "sort_title", false); + } + TrackSort::ArtistDesc => { + push(AlbumOrderKind::SortArtist, "sort_artist", true); + push(AlbumOrderKind::SortTitle, "sort_title", false); + } + TrackSort::TitleAsc => { + push(AlbumOrderKind::SortTitle, "sort_title", false); + push(AlbumOrderKind::SortArtist, "sort_artist", false); + } + TrackSort::TitleDesc => { + push(AlbumOrderKind::SortTitle, "sort_title", true); + push(AlbumOrderKind::SortArtist, "sort_artist", false); + } + TrackSort::YearAsc => { + push(AlbumOrderKind::YearMissing, "year_missing", false); + push(AlbumOrderKind::YearValue, "year_value", false); + push(AlbumOrderKind::SortTitle, "sort_title", false); + } + TrackSort::YearDesc => { + push(AlbumOrderKind::YearMissing, "year_missing", false); + push(AlbumOrderKind::YearValue, "year_value", true); + push(AlbumOrderKind::SortTitle, "sort_title", false); + } + TrackSort::AddedDesc => { + push(AlbumOrderKind::AddedAt, "added_at", true); + push(AlbumOrderKind::SortTitle, "sort_title", false); + } + } + fields +} + +fn album_cursor_predicate( + descriptor: &QueryDescriptor, + cursor: &AlbumCursor, +) -> (String, Vec) { + fn build( + fields: &[AlbumOrderField], + cursor: &AlbumCursor, + index: usize, + params: &mut Vec, + ) -> String { + if index == fields.len() { + params.push(Value::Integer(cursor.edition_id)); + return "am.edition_id>?".to_string(); + } + let field = &fields[index]; + let comparison = if field.descending { "<" } else { ">" }; + params.push(field.cursor_value(cursor)); + params.push(field.cursor_value(cursor)); + let rest = build(fields, cursor, index + 1, params); + format!( + "({expr}{comparison}? OR ({expr}=? AND {rest}))", + expr = field.expression + ) + } + let fields = album_order_fields(descriptor, "am"); + let mut params = Vec::new(); + let predicate = build(&fields, cursor, 0, &mut params); + (predicate, params) +} + +fn map_album_row(row: &Row<'_>) -> rusqlite::Result { + let source_word: String = row.get(1)?; + let source = SourceKind::from_str(&source_word).expect("schema source CHECK"); + Ok(AlbumRowWithCursor { + record: AlbumRecord { + edition_id: row.get(0)?, + source, + native_album_id: row.get(2)?, + source_raw: row.get(3)?, + title: row.get(4)?, + artist: row.get(5)?, + all_artists: row.get(6)?, + year: row.get::<_, Option>(7)?.map(|value| value as u32), + track_count: row.get::<_, i64>(8)?.max(0) as u32, + total_duration_ms: row.get::<_, i64>(9)?.max(0) as u64, + quality_tier: row.get(10)?, + format: row.get(11)?, + bit_depth: row.get::<_, Option>(12)?.map(|value| value as u32), + sample_rate_hz: row.get::<_, Option>(13)?.map(|value| value as u32), + artwork_source: row.get(14)?, + artwork_token: row.get(15)?, + directory_path: row.get(16)?, + folder_count: row.get::<_, i64>(17)?.max(0) as u32, + added_at: row.get(18)?, + }, + cursor: AlbumCursor { + descriptor_key: String::new(), + sort_title: row.get(19)?, + sort_artist: row.get(20)?, + year_missing: row.get(21)?, + year_value: row.get(22)?, + added_at: row.get(18)?, + edition_id: row.get(0)?, + }, + }) +} + +const ALBUM_COLUMNS: &str = " + am.edition_id,am.source_kind,am.native_album_id,am.source_raw, + am.title,am.artist,am.all_artists,am.year,am.track_count,am.total_duration_ms, + am.quality_tier,am.format,am.bit_depth,am.sample_rate_hz, + am.artwork_source,am.artwork_token,am.directory_path,am.folder_count,am.added_at, + am.sort_title,am.sort_artist, + CASE WHEN am.year IS NULL THEN 1 ELSE 0 END,COALESCE(am.year,0)"; + /// Unicode-aware display sort key shared by ingest and query indices. pub fn normalize_sort_key(value: &str) -> String { value @@ -1471,9 +1949,16 @@ pub(crate) fn descriptor_key(descriptor: &QueryDescriptor) -> String { push_key_part(&mut key, source.source.as_str()); push_key_part(&mut key, &source.source_instance); } + for bucket in descriptor.source_buckets() { + push_key_part(&mut key, bucket); + } for format in descriptor.formats() { push_key_part(&mut key, format); } + push_key_part(&mut key, if descriptor.other_formats() { "other" } else { "" }); + for tier in descriptor.quality_tiers() { + push_key_part(&mut key, tier); + } key } diff --git a/crates/qbz-local-catalog/src/lib.rs b/crates/qbz-local-catalog/src/lib.rs index 3ad8d176b..672778f88 100644 --- a/crates/qbz-local-catalog/src/lib.rs +++ b/crates/qbz-local-catalog/src/lib.rs @@ -26,8 +26,9 @@ pub use legacy::{ reconcile_legacy_caches_with_progress, LegacyLocations, LegacySourceSpec, }; pub use model::{ - ArtistCredit, CreditRole, ProjectedTrack, QueryDescriptor, QuerySurface, SourceKey, SourceKind, - TrackCursor, TrackGroup, TrackPage, TrackRecord, TrackRef, TrackSort, + AlbumCursor, AlbumPage, AlbumRecord, ArtistCredit, 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 737503374..eb1f935e1 100644 --- a/crates/qbz-local-catalog/src/model.rs +++ b/crates/qbz-local-catalog/src/model.rs @@ -162,7 +162,10 @@ pub struct QueryDescriptor { surface: QuerySurface, search: String, sources: Vec, + source_buckets: Vec, formats: Vec, + other_formats: bool, + quality_tiers: Vec, sort: TrackSort, group: TrackGroup, available_only: bool, @@ -186,7 +189,10 @@ impl QueryDescriptor { surface, search: String::new(), sources: Vec::new(), + source_buckets: Vec::new(), formats: Vec::new(), + other_formats: false, + quality_tiers: Vec::new(), sort: TrackSort::Default, group: TrackGroup::Off, available_only: true, @@ -216,6 +222,33 @@ impl QueryDescriptor { self } + pub fn including_other_formats(mut self, include: bool) -> Self { + self.other_formats = include; + self + } + + pub fn with_source_buckets(mut self, buckets: Vec) -> Self { + self.source_buckets = buckets + .into_iter() + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect(); + self.source_buckets.sort(); + self.source_buckets.dedup(); + self + } + + pub fn with_quality_tiers(mut self, tiers: Vec) -> Self { + self.quality_tiers = tiers + .into_iter() + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect(); + self.quality_tiers.sort(); + self.quality_tiers.dedup(); + self + } + pub fn with_sort(mut self, sort: TrackSort) -> Self { self.sort = sort; self @@ -243,10 +276,22 @@ impl QueryDescriptor { &self.sources } + pub fn source_buckets(&self) -> &[String] { + &self.source_buckets + } + pub fn formats(&self) -> &[String] { &self.formats } + pub fn other_formats(&self) -> bool { + self.other_formats + } + + pub fn quality_tiers(&self) -> &[String] { + &self.quality_tiers + } + pub fn sort(&self) -> TrackSort { self.sort } @@ -326,3 +371,44 @@ pub struct TrackPage { pub next_cursor: Option, pub has_more: bool, } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AlbumRecord { + pub edition_id: i64, + pub source: SourceKind, + pub native_album_id: String, + pub source_raw: String, + pub title: String, + pub artist: String, + pub all_artists: String, + pub year: Option, + pub track_count: u32, + pub total_duration_ms: u64, + pub quality_tier: String, + pub format: String, + pub bit_depth: Option, + pub sample_rate_hz: Option, + pub artwork_source: String, + pub artwork_token: String, + pub directory_path: String, + pub folder_count: u32, + pub added_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AlbumCursor { + pub(crate) descriptor_key: String, + pub(crate) sort_title: String, + pub(crate) sort_artist: String, + pub(crate) year_missing: i64, + pub(crate) year_value: i64, + pub(crate) added_at: i64, + pub(crate) edition_id: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AlbumPage { + 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 96ebb2914..b6f83a1fc 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 = 2; +pub const SCHEMA_VERSION: u32 = 3; pub const APPLICATION_ID: i64 = 0x5142_5A43; // "QBZC" pub(crate) fn configure(conn: &Connection) -> Result<()> { @@ -271,18 +271,41 @@ CREATE TABLE IF NOT EXISTS albums_materialized ( artist TEXT NOT NULL, sort_artist TEXT NOT NULL, year INTEGER, + year_missing INTEGER NOT NULL DEFAULT 1 CHECK (year_missing IN (0,1)), + year_value INTEGER NOT NULL DEFAULT 0, track_count INTEGER NOT NULL DEFAULT 0, total_duration_ms INTEGER NOT NULL DEFAULT 0, source_count INTEGER NOT NULL DEFAULT 0, available INTEGER NOT NULL DEFAULT 1 CHECK (available IN (0,1)), + source_kind TEXT NOT NULL DEFAULT 'local', + native_album_id TEXT NOT NULL DEFAULT '', + source_raw TEXT NOT NULL DEFAULT '', + all_artists TEXT NOT NULL DEFAULT '', + format TEXT NOT NULL DEFAULT '', + bit_depth INTEGER, + sample_rate_hz INTEGER, + quality_tier TEXT NOT NULL DEFAULT '', + directory_path TEXT NOT NULL DEFAULT '', + folder_count INTEGER NOT NULL DEFAULT 0, + added_at INTEGER NOT NULL DEFAULT 0, artwork_source TEXT NOT NULL DEFAULT '', artwork_token TEXT NOT NULL DEFAULT '' ) STRICT; CREATE INDEX IF NOT EXISTS idx_albums_materialized_artist - ON albums_materialized(available, sort_artist, year, sort_title, edition_id); + ON albums_materialized(available, sort_artist, sort_title, edition_id); +CREATE INDEX IF NOT EXISTS idx_albums_materialized_artist_desc + ON albums_materialized(available, sort_artist DESC, sort_title, edition_id); CREATE INDEX IF NOT EXISTS idx_albums_materialized_title ON albums_materialized(available, sort_title, sort_artist, edition_id); +CREATE INDEX IF NOT EXISTS idx_albums_materialized_title_desc + ON albums_materialized(available, sort_title DESC, sort_artist, edition_id); +CREATE INDEX IF NOT EXISTS idx_albums_materialized_year + ON albums_materialized(available, year_missing, year_value, sort_title, edition_id); +CREATE INDEX IF NOT EXISTS idx_albums_materialized_year_desc + ON albums_materialized(available, year_missing, year_value DESC, sort_title, edition_id); +CREATE INDEX IF NOT EXISTS idx_albums_materialized_added + ON albums_materialized(available, added_at DESC, sort_title, edition_id); CREATE TABLE IF NOT EXISTS artists_materialized ( artist_key TEXT PRIMARY KEY, @@ -334,4 +357,30 @@ CREATE TRIGGER IF NOT EXISTS tracks_fts_update AFTER UPDATE ON tracks BEGIN INSERT INTO tracks_fts(rowid, title, album, artist, credits) VALUES (new.catalog_id, new.title, new.album, new.artist, new.credits); END; + +CREATE VIRTUAL TABLE IF NOT EXISTS albums_fts USING fts5( + title, + artist, + all_artists, + content='albums_materialized', + content_rowid='edition_id', + tokenize='trigram case_sensitive 0' +); + +CREATE TRIGGER IF NOT EXISTS albums_fts_insert AFTER INSERT ON albums_materialized BEGIN + INSERT INTO albums_fts(rowid, title, artist, all_artists) + VALUES (new.edition_id, new.title, new.artist, new.all_artists); +END; + +CREATE TRIGGER IF NOT EXISTS albums_fts_delete AFTER DELETE ON albums_materialized BEGIN + INSERT INTO albums_fts(albums_fts, rowid, title, artist, all_artists) + VALUES ('delete', old.edition_id, old.title, old.artist, old.all_artists); +END; + +CREATE TRIGGER IF NOT EXISTS albums_fts_update AFTER UPDATE ON albums_materialized BEGIN + INSERT INTO albums_fts(albums_fts, rowid, title, artist, all_artists) + VALUES ('delete', old.edition_id, old.title, old.artist, old.all_artists); + INSERT INTO albums_fts(rowid, title, artist, all_artists) + VALUES (new.edition_id, new.title, new.artist, new.all_artists); +END; "#; diff --git a/crates/qbz-local-catalog/src/tests.rs b/crates/qbz-local-catalog/src/tests.rs index 58ebd5aa7..68e0268a6 100644 --- a/crates/qbz-local-catalog/src/tests.rs +++ b/crates/qbz-local-catalog/src/tests.rs @@ -97,6 +97,61 @@ fn collect_all(catalog: &Catalog, descriptor: &QueryDescriptor) -> Vec Vec { + let mut cursor = None; + let mut rows = Vec::new(); + loop { + let page = catalog + .query_albums(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 { + let source = source_for(index); + let track = projected(index, source); + by_source + .entry(SourceKey { + source: track.track_ref.source, + source_instance: track.track_ref.source_instance.clone(), + }) + .or_default() + .push(track); + } + for (source, rows) in by_source { + let version = format!("fixture-{}", source.source.as_str()); + let mut cursor = String::new(); + let total = rows.len(); + for (batch_index, batch) in rows.chunks(BOOTSTRAP_BATCH_ROWS).enumerate() { + let committed = (batch_index * BOOTSTRAP_BATCH_ROWS + batch.len()).min(total); + let saved = catalog + .apply_bootstrap_batch(&BootstrapBatch { + source: source.clone(), + snapshot_version: version.clone(), + expected_cursor: cursor, + next_cursor: committed.to_string(), + tracks: batch.to_vec(), + complete: committed == total, + }) + .unwrap(); + cursor = saved.checkpoint_cursor; + } + } + catalog.rebuild_materialized_views().unwrap(); +} + #[test] fn schema_is_versioned_frontend_agnostic_and_fts5_enabled() { let catalog = Catalog::open_in_memory(7).unwrap(); @@ -132,11 +187,174 @@ fn schema_is_versioned_frontend_agnostic_and_fts5_enabled() { "artists_materialized", "edition_artists", "tracks_fts", + "albums_fts", ] { assert!(tables.contains(required), "missing {required}"); } } +#[test] +fn album_keyset_pages_cover_every_id_once_for_all_orders_and_groups() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + insert_album_fixture(&mut catalog, 731); + + for group in [TrackGroup::Off, TrackGroup::Name, TrackGroup::Artist] { + for sort in [ + TrackSort::ArtistAsc, + TrackSort::ArtistDesc, + TrackSort::TitleAsc, + TrackSort::TitleDesc, + TrackSort::YearAsc, + TrackSort::YearDesc, + TrackSort::AddedDesc, + ] { + let descriptor = QueryDescriptor::albums().with_group(group).with_sort(sort); + let expected = catalog.count_albums(&descriptor).unwrap(); + let rows = collect_all_albums(&catalog, &descriptor, 17); + let ids = rows + .iter() + .map(|row| row.edition_id) + .collect::>(); + assert_eq!(rows.len() as u64, expected, "{group:?} {sort:?}"); + assert_eq!(ids.len(), rows.len(), "{group:?} {sort:?}"); + } + } +} + +#[test] +fn album_entry_counts_include_global_headers_and_never_cross_groups() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + insert_album_fixture(&mut catalog, 257); + + for group in [TrackGroup::Off, TrackGroup::Name, TrackGroup::Artist] { + let descriptor = QueryDescriptor::albums() + .with_group(group) + .with_sort(TrackSort::TitleAsc); + let rows = collect_all_albums(&catalog, &descriptor, 19); + for columns in [1, 3, 7] { + let expected = if group == TrackGroup::Off { + rows.len().div_ceil(columns) + } else { + let mut counts = std::collections::BTreeMap::::new(); + for row in &rows { + let key = if group == TrackGroup::Artist { + normalize_sort_key(&row.artist) + } else { + normalize_sort_key(&row.title) + .chars() + .next() + .unwrap_or('#') + .to_string() + }; + *counts.entry(key).or_default() += 1; + } + counts + .values() + .map(|count| 1 + count.div_ceil(columns)) + .sum() + }; + assert_eq!( + catalog.count_album_entries(&descriptor, columns).unwrap(), + expected as u64, + "{group:?} columns={columns}" + ); + } + } +} + +#[test] +fn album_fts_filters_and_descriptor_bound_cursors_are_exact() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + insert_album_fixture(&mut catalog, 1_200); + + let broad = QueryDescriptor::albums().with_search("Album"); + let broad_rows = collect_all_albums(&catalog, &broad, 23); + assert!(broad_rows.len() > 100); + assert_eq!( + broad_rows.len() as u64, + catalog.count_albums(&broad).unwrap() + ); + + let filter_cases: [(QueryDescriptor, fn(&AlbumRecord) -> bool); 4] = [ + ( + QueryDescriptor::albums().with_source_buckets(vec!["plex".to_string()]), + |row: &AlbumRecord| row.source == SourceKind::Plex, + ), + ( + QueryDescriptor::albums().with_formats(vec!["flac".to_string()]), + |row: &AlbumRecord| row.format == "flac", + ), + ( + QueryDescriptor::albums().including_other_formats(true), + |row: &AlbumRecord| { + !["flac", "alac", "ape", "wav", "wave", "mp3", "aac"].contains(&row.format.as_str()) + }, + ), + ( + QueryDescriptor::albums().with_quality_tiers(vec!["hires".to_string()]), + |row: &AlbumRecord| matches!(row.quality_tier.as_str(), "hires" | "max"), + ), + ]; + for (descriptor, predicate) in filter_cases { + let rows = collect_all_albums(&catalog, &descriptor, 29); + assert!(!rows.is_empty()); + assert!(rows.iter().all(predicate)); + assert_eq!( + rows.len() as u64, + catalog.count_albums(&descriptor).unwrap() + ); + } + + let title = QueryDescriptor::albums().with_sort(TrackSort::TitleAsc); + let first = catalog.query_albums(&title, None, 5).unwrap(); + let cursor = first.next_cursor.expect("fixture spans multiple pages"); + let different = QueryDescriptor::albums().with_sort(TrackSort::YearDesc); + assert!(matches!( + catalog.query_albums(&different, Some(&cursor), 5), + Err(CatalogError::CursorDescriptorMismatch) + )); +} + +#[test] +fn album_query_and_broad_search_metric_uses_a_mixed_deterministic_fixture() { + const ALBUMS: usize = 20_000; + let mut catalog = Catalog::open_in_memory(9).unwrap(); + insert_album_fixture(&mut catalog, ALBUMS); + + let descriptor = QueryDescriptor::albums().including_unavailable(); + let count_started = Instant::now(); + let count = catalog.count_albums(&descriptor).unwrap(); + let count_time = count_started.elapsed(); + let (page, query) = catalog.query_albums_timed(&descriptor, None, 100).unwrap(); + let broad = descriptor.with_search("Album"); + let broad_count_started = Instant::now(); + let broad_count = catalog.count_albums(&broad).unwrap(); + let broad_count_time = broad_count_started.elapsed(); + let (broad_page, broad_query) = catalog.query_albums_timed(&broad, None, 100).unwrap(); + let counts = catalog + .stats() + .unwrap() + .source_counts + .into_iter() + .map(|(source, rows)| format!("{}={rows}", source.source.as_str())) + .collect::>() + .join(","); + + assert_eq!(count, ALBUMS as u64); + assert_eq!(page.rows.len(), 100); + assert!(page.has_more); + assert_eq!(broad_count, ALBUMS as u64); + assert_eq!(broad_page.rows.len(), 100); + println!( + "F1_ALBUMS_QUERY total={ALBUMS} first_albums={} count_ms={:.3} query_ms={:.3} broad_count_ms={:.3} broad_query_ms={:.3} source_counts={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(); @@ -463,6 +681,57 @@ fn sort_indices_match_the_actual_order_by_without_temp_sort() { assert!(fts_plan.contains("INTEGER PRIMARY KEY"), "{fts_plan}"); } +#[test] +fn album_sort_indices_match_the_paged_order_by_without_temp_sort() { + let catalog = Catalog::open_in_memory(1).unwrap(); + for (index, order) in [ + ( + "idx_albums_materialized_artist", + "sort_artist,sort_title,edition_id", + ), + ( + "idx_albums_materialized_artist_desc", + "sort_artist DESC,sort_title,edition_id", + ), + ( + "idx_albums_materialized_title", + "sort_title,sort_artist,edition_id", + ), + ( + "idx_albums_materialized_title_desc", + "sort_title DESC,sort_artist,edition_id", + ), + ( + "idx_albums_materialized_year", + "year_missing,year_value,sort_title,edition_id", + ), + ( + "idx_albums_materialized_year_desc", + "year_missing,year_value DESC,sort_title,edition_id", + ), + ( + "idx_albums_materialized_added", + "added_at DESC,sort_title,edition_id", + ), + ] { + let sql = format!( + "EXPLAIN QUERY PLAN SELECT edition_id FROM albums_materialized + WHERE available=1 ORDER BY {order} LIMIT 100" + ); + let details = catalog + .connection() + .prepare(&sql) + .unwrap() + .query_map([], |row| row.get::<_, String>(3)) + .unwrap() + .collect::>>() + .unwrap() + .join(" | "); + assert!(details.contains(index), "{details}"); + assert!(!details.contains("TEMP B-TREE"), "{details}"); + } +} + /// The contract's reproducible scale gate. It is ignored in ordinary unit /// runs because it intentionally creates and removes a several-hundred-MiB /// database; the implementation handoff records an explicit execution. diff --git a/crates/qbz-plex/src/lib.rs b/crates/qbz-plex/src/lib.rs index a5c564d8b..48599029b 100644 --- a/crates/qbz-plex/src/lib.rs +++ b/crates/qbz-plex/src/lib.rs @@ -1426,18 +1426,34 @@ pub fn plex_cache_get_artists() -> Result, String> { pub fn plex_cache_get_album_tracks(album_key: String) -> Result, String> { let conn = open_plex_cache_db()?; + plex_cache_get_album_tracks_in(&conn, &album_key) +} + +fn plex_cache_get_album_tracks_in( + conn: &Connection, + album_key: &str, +) -> Result, String> { + // The derived catalog carries Plex's source-native parentRatingKey so an + // edition can resolve directly. Legacy cards keep using the content hash. + // Supporting both here preserves the old reader while avoiding a scan of + // every cached album for the native F1 detail route. + let (column, key) = album_key + .strip_prefix("plex:album:") + .map(|key| ("parent_rating_key", key)) + .unwrap_or(("album_key", album_key)); + let sql = format!( + "SELECT rating_key, title, artist, album, duration_ms, container, bit_depth, + sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key + FROM plex_cache_tracks + WHERE {column} = ?1 + ORDER BY disc_number, track_number, title COLLATE NOCASE" + ); let mut stmt = conn - .prepare( - "SELECT rating_key, title, artist, album, duration_ms, container, bit_depth, - sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key - FROM plex_cache_tracks - WHERE album_key = ?1 - ORDER BY disc_number, track_number, title COLLATE NOCASE", - ) + .prepare(&sql) .map_err(|e| format!("Failed to prepare Plex cache album tracks query: {}", e))?; let rows = stmt - .query_map(params![album_key], |row| { + .query_map(params![key], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, @@ -1492,7 +1508,7 @@ pub fn plex_cache_get_album_tracks(album_key: String) -> Result>(), + vec!["1", "2"] + ); + assert!(native + .iter() + .all(|track| track.album_key == "plex:album:parent-a")); + } } diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index abfb2618b..6ae1c9dc9 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -303,6 +303,7 @@ fn build_rhi_items() { "ribbon_item", "tunnelflow_item", "local_tracks_model", + "local_albums_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 new file mode 100644 index 000000000..2deeba535 --- /dev/null +++ b/crates/qbz-qt/cxx/local_albums_model.cpp @@ -0,0 +1,305 @@ +#include "local_albums_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +QPointer g_model; + +void registerLocalAlbumsModel() +{ + auto *application = QCoreApplication::instance(); + auto *model = new LocalAlbumsModel(application); + g_model = model; + qmlRegisterSingletonInstance( + "com.blitzfc.qbz", 1, 0, "QbzLocalAlbums", model); +} + +Q_COREAPP_STARTUP_FUNCTION(registerLocalAlbumsModel) +} + +LocalAlbumsModel::LocalAlbumsModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int LocalAlbumsModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return int(qMin(m_totalCount, std::numeric_limits::max())); +} + +QVariant LocalAlbumsModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || role != ModelDataRole) + return {}; + return modelData(index.row()); +} + +QHash LocalAlbumsModel::roleNames() const +{ + return {{ ModelDataRole, QByteArrayLiteral("modelData") }}; +} + +QVariantMap LocalAlbumsModel::rowAt(qint64 row) const +{ + if (row < 0 || row >= m_totalCount) + return {}; + return modelData(row); +} + +QVariantMap LocalAlbumsModel::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("items"), QVariantList() }, + }; + } + touch(page); + QVariantMap values = found->at(offset).values; + values.insert(QStringLiteral("loading"), false); + QVariantList items = values.value(QStringLiteral("items")).toList(); + for (QVariant &value : items) { + QVariantMap album = value.toMap(); + album.insert( + QStringLiteral("selected"), + selected(album.value(QStringLiteral("nativeIndex")).toLongLong())); + value = album; + } + values.insert(QStringLiteral("items"), items); + return values; +} + +void LocalAlbumsModel::requestPage(int page, bool prefetch) const +{ + if (page < 0) + return; + const QMetaMethod missSignal = QMetaMethod::fromSignal(&LocalAlbumsModel::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; + const QMetaMethod missSignal = + QMetaMethod::fromSignal(&LocalAlbumsModel::pageMiss); + if (self->isSignalConnected(missSignal)) { + emit self->pageMiss(page, generation); + } else { + self->m_pending.remove(page); + } + }, + Qt::QueuedConnection); +} + +void LocalAlbumsModel::touch(int page) const +{ + m_lru.removeAll(page); + m_lru.append(page); +} + +bool LocalAlbumsModel::selected(qint64 row) const +{ + bool inRange = false; + for (const auto &range : m_selectionRanges) { + if (row < range.first) + break; + if (row <= range.second) { + inRange = true; + break; + } + } + return m_selectAll ? !inRange : inRange; +} + +void LocalAlbumsModel::resetQuery(int generation, qint64 totalCount, qint64 albumTotal) +{ + beginResetModel(); + m_generation = generation; + m_totalCount = qMax(0, totalCount); + m_albumTotal = qMax(0, albumTotal); + m_pages.clear(); + m_lru.clear(); + m_pending.clear(); + m_residentAlbums = 0; + m_evictions = 0; + m_selectAll = false; + m_selectionRanges.clear(); + m_selectedCount = 0; + endResetModel(); + emit generationChanged(); + emit totalCountChanged(); + emit albumTotalChanged(); + emit residentAlbumsChanged(); + emit evictionsChanged(); + emit selectedCountChanged(); +} + +bool LocalAlbumsModel::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(); + const QVariantList items = entry.values.value(QStringLiteral("items")).toList(); + entry.albumCount = items.size(); + for (const QVariant &item : items) { + const QString key = item.toMap().value(QStringLiteral("artKey")).toString(); + if (!key.isEmpty()) + entry.artKeys.append(key); + } + entries.append(std::move(entry)); + } + + const auto previous = m_pages.constFind(page); + if (previous != m_pages.cend()) { + for (const Entry &entry : *previous) + m_residentAlbums -= entry.albumCount; + } + m_pages.insert(page, std::move(entries)); + for (const Entry &entry : m_pages.value(page)) + m_residentAlbums += entry.albumCount; + 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 residentAlbumsChanged(); + + const int next = page + 1; + if (!wasPrefetch && qint64(next) * PageEntries < m_totalCount && !m_pages.contains(next)) + requestPage(next, true); + return true; +} + +void LocalAlbumsModel::evictIfNeeded() +{ + while (m_residentAlbums > MaxResidentAlbums && 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_residentAlbums -= entry.albumCount; + 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 LocalAlbumsModel::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.artKeys.contains(artKey)) + continue; + QVariantList items = entry.values.value(QStringLiteral("items")).toList(); + for (QVariant &value : items) { + QVariantMap album = value.toMap(); + if (album.value(QStringLiteral("artKey")).toString() == artKey) + album.insert(QStringLiteral("artPath"), path); + value = album; + } + entry.values.insert(QStringLiteral("items"), items); + const int row = page.key() * PageEntries + offset; + emit dataChanged(index(row, 0), index(row, 0), { ModelDataRole }); + } + } +} + +void LocalAlbumsModel::setSelection( + int generation, const QByteArray &json, qint64 selectedCount) +{ + if (generation != m_generation) + return; + const QJsonDocument document = QJsonDocument::fromJson(json); + if (!document.isObject()) + return; + const QJsonObject object = document.object(); + QList> ranges; + for (const QJsonValue &value : object.value(QStringLiteral("ranges")).toArray()) { + const QJsonArray pair = value.toArray(); + if (pair.size() != 2) + continue; + ranges.append({ qMax(0, pair.at(0).toInteger()), + qMax(0, pair.at(1).toInteger()) }); + } + m_selectAll = object.value(QStringLiteral("all")).toBool(); + m_selectionRanges = std::move(ranges); + m_selectedCount = qMax(0, selectedCount); + emit selectedCountChanged(); + if (rowCount() > 0) + emit dataChanged(index(0, 0), index(rowCount() - 1, 0), { ModelDataRole }); +} + +extern "C" void qbz_local_albums_register_qml_type() +{ + // Link anchor. Registration is performed by Q_COREAPP_STARTUP_FUNCTION. +} + +extern "C" void qbz_local_albums_reset( + int generation, qint64 totalCount, qint64 albumTotal) +{ + if (g_model) + g_model->resetQuery(generation, totalCount, albumTotal); +} + +extern "C" bool qbz_local_albums_apply_page(int generation, int page, const char *json) +{ + return g_model && g_model->applyPage( + generation, page, QByteArray(json ? json : "[]")); +} + +extern "C" void qbz_local_albums_set_selection( + int generation, const char *json, qint64 selectedCount) +{ + if (g_model) + g_model->setSelection( + generation, QByteArray(json ? json : "{}"), selectedCount); +} diff --git a/crates/qbz-qt/cxx/local_albums_model.h b/crates/qbz-qt/cxx/local_albums_model.h new file mode 100644 index 000000000..a9cd5c753 --- /dev/null +++ b/crates/qbz-qt/cxx/local_albums_model.h @@ -0,0 +1,88 @@ +#ifndef QBZ_LOCAL_ALBUMS_MODEL_H +#define QBZ_LOCAL_ALBUMS_MODEL_H + +#include +#include +#include +#include + +class LocalAlbumsModel final : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int generation READ generation NOTIFY generationChanged) + Q_PROPERTY(qint64 totalCount READ totalCount NOTIFY totalCountChanged) + Q_PROPERTY(qint64 albumTotal READ albumTotal NOTIFY albumTotalChanged) + Q_PROPERTY(int residentAlbums READ residentAlbums NOTIFY residentAlbumsChanged) + Q_PROPERTY(qint64 evictions READ evictions NOTIFY evictionsChanged) + Q_PROPERTY(qint64 selectedCount READ selectedCount NOTIFY selectedCountChanged) + +public: + enum Role { ModelDataRole = Qt::UserRole + 1 }; + + explicit LocalAlbumsModel(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 albumTotal() const { return m_albumTotal; } + int residentAlbums() const { return m_residentAlbums; } + qint64 evictions() const { return m_evictions; } + qint64 selectedCount() const { return m_selectedCount; } + + Q_INVOKABLE QVariantMap rowAt(qint64 row) const; + Q_INVOKABLE void setArtwork(const QString &artKey, const QString &path); + + void resetQuery(int generation, qint64 totalCount, qint64 albumTotal); + bool applyPage(int generation, int page, const QByteArray &json); + void setSelection(int generation, const QByteArray &json, qint64 selectedCount); + +signals: + void generationChanged(); + void totalCountChanged(); + void albumTotalChanged(); + void residentAlbumsChanged(); + void evictionsChanged(); + void selectedCountChanged(); + void pageMiss(int page, int generation); + +private: + struct Entry { + QVariantMap values; + QStringList artKeys; + int albumCount = 0; + }; + using Page = QList; + + static constexpr int PageEntries = 100; + static constexpr int MaxResidentAlbums = 2000; + + QVariantMap modelData(qint64 row) const; + void requestPage(int page, bool prefetch = false) const; + void touch(int page) const; + bool selected(qint64 row) const; + void evictIfNeeded(); + + int m_generation = 0; + qint64 m_totalCount = 0; + qint64 m_albumTotal = 0; + QHash m_pages; + mutable QList m_lru; + mutable QHash m_pending; + int m_residentAlbums = 0; + qint64 m_evictions = 0; + bool m_selectAll = false; + QList> m_selectionRanges; + qint64 m_selectedCount = 0; +}; + +extern "C" { +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); +} + +#endif // QBZ_LOCAL_ALBUMS_MODEL_H diff --git a/crates/qbz-qt/qml/views/LocalLibraryView.qml b/crates/qbz-qt/qml/views/LocalLibraryView.qml index 1959fa181..97c34d9a6 100644 --- a/crates/qbz-qt/qml/views/LocalLibraryView.qml +++ b/crates/qbz-qt/qml/views/LocalLibraryView.qml @@ -62,6 +62,7 @@ Rectangle { // hand-written singleton owns the resident LRU; keeping this reference at // the composition root also gives every Tracks delegate one stable model. readonly property var nativeTracksModel: QbzLocalTracks + readonly property var nativeAlbumsModel: QbzLocalAlbums // ============================ state ================================== // Slint defaults, verbatim (state.slint LocalLibraryState). @@ -74,7 +75,9 @@ Rectangle { property string albumsView: "grid" // grid | list property bool albumsMultiSelect: false property var albumsSelected: ({}) - readonly property int albumsSelectedCount: Object.keys(albumsSelected).length + readonly property int albumsSelectedCount: QbzLocal.localAlbumsNativeActive + ? QbzLocal.localAlbumsNativeSelectedCount + : Object.keys(albumsSelected).length // Folders tab. property string foldersMode: "tree" // flat | tree @@ -331,6 +334,7 @@ Rectangle { target: QbzLocal function onLocalArtworkReady(key, path) { root.nativeTracksModel.setArtwork(key, path) + root.nativeAlbumsModel.setArtwork(key, path) root._artInbox[key] = path if (!artFlush.running) artFlush.start() // An arrival is live evidence that the pass is still running, so @@ -347,6 +351,12 @@ Rectangle { QbzLocal.tracksNativePageMiss(page, generation) } } + Connections { + target: root.nativeAlbumsModel + function onPageMiss(page, generation) { + QbzLocal.albumsNativePageMiss(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 @@ -393,11 +403,16 @@ Rectangle { consumePendingArtist() consumePendingRoute() } + Component.onDestruction: { + if (QbzLocal.localAlbumsNativeActive) QbzLocal.albumsNativeClearSelection() + if (QbzLocal.localTracksNativeActive) QbzLocal.tracksNativeClearSelection() + } 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") + 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() @@ -551,8 +566,12 @@ Rectangle { return out } - readonly property var albumsVisible: - applyFilter(sortRows(filterRows(albums, albumsSearch), albumsSort)) + // Once F1 owns the Albums tab, never run the legacy O(n) JS pipeline in + // parallel. The old document may still be resident because Artists uses + // it until F2; it is evaluated only while that legacy surface is active. + readonly property var albumsVisible: QbzLocal.localAlbumsNativeActive + && activeTab === "albums" ? [] + : applyFilter(sortRows(filterRows(albums, albumsSearch), albumsSort)) readonly property var albumsGrouped: groupRows(albumsVisible, albumsGroup) readonly property var foldersVisible: @@ -905,7 +924,10 @@ Rectangle { function toggleAlbumsMultiSelect() { albumsMultiSelect = !albumsMultiSelect - if (!albumsMultiSelect) { albumsSelected = ({}); albumSel.anchorId = "" } + if (!albumsMultiSelect) { + albumsSelected = ({}); albumSel.anchorId = "" + if (QbzLocal.localAlbumsNativeActive) QbzLocal.albumsNativeClearSelection() + } } /// Excel-style selection — controls/SelectionModel.qml holds the anchor /// and the Shift-range rule; this view keeps owning its maps. Ranges run @@ -916,7 +938,17 @@ Rectangle { albumsSelected = albumSel.next(albumsSelected, id, albumsVisible, mods === undefined ? Qt.NoModifier : mods) } + function toggleNativeAlbumSelected(index, mods) { + QbzLocal.albumsNativeToggleSelect( + index, (mods & Qt.ShiftModifier) !== 0) + } function albumsBulkAction(action) { + if (QbzLocal.localAlbumsNativeActive) { + if (action === "clear") QbzLocal.albumsNativeClearSelection() + else if (action === "select-all") QbzLocal.albumsNativeSelectAll() + else QbzLocal.albumsNativeBulkAction(action) + return + } if (action === "clear") { albumsSelected = ({}); albumSel.anchorId = ""; return } if (action === "select-all") { var s = {} @@ -974,7 +1006,11 @@ Rectangle { function exitMultiSelectMode() { // Same "leaving drops the selection" contract the toggle buttons // carry (toggleAlbumsMultiSelect / toggleTracksMultiSelect). - if (root.albumsMultiSelect) { root.albumsMultiSelect = false; root.albumsSelected = ({}) } + if (root.albumsMultiSelect) { + root.albumsMultiSelect = false + root.albumsSelected = ({}) + if (QbzLocal.localAlbumsNativeActive) QbzLocal.albumsNativeClearSelection() + } if (root.tracksMultiSelect) { root.tracksMultiSelect = false root.tracksSelected = ({}) diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml index e87387596..15f2e7ac5 100644 --- a/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml +++ b/crates/qbz-qt/qml/views/local/LocalAlbumCollection.qml @@ -44,6 +44,12 @@ Item { property bool selectMode: false /// { albumId: true } — the host owns the selection set. property var selected: ({}) + /// Only the Albums tab opts into the phase-F1 native stream. The other + /// two collection users keep their existing bounded arrays. + property bool nativeSurface: false + property bool nativeActive: false + property var nativeModel: null + property string nativeJumpsJson: "[]" /// The scrollbar belongs on the WINDOW's right edge, but hosts inset /// their content (LocalAlbumsTab pads 32, plus 20 more to clear the A-Z @@ -62,6 +68,7 @@ Item { /// `modifiers` rides through from the card's mouse event — Shift is /// what turns a click into a range (controls/SelectionModel.qml). signal toggleSelect(string id, int modifiers) + signal nativeToggleSelect(int index, int modifiers) QbzTheme { id: theme } @@ -120,12 +127,24 @@ Item { } property int rebuildCount: 0 + function parseNativeJumps() { + try { return JSON.parse(nativeJumpsJson || "[]") } + catch (e) { return [] } + } + function rebuild() { // No width yet: build NOTHING rather than something guaranteed wrong. // One empty frame beats a full-length one-column grid that has to be // discarded — and in the common case the width IS known here, so this // never fires and the synchronous first build is unchanged. if (cols <= 0) return + if (nativeActive) { + flat = [] + entries = [] + alphaJumps = parseNativeJumps() + report() + return + } var _t0 = Date.now() var out = [] var flatOut = [] @@ -191,11 +210,43 @@ Item { onRowsChanged: scheduleRebuild() onGroupsChanged: scheduleRebuild() onGroupedChanged: scheduleRebuild() - onViewModeChanged: scheduleRebuild() - onColsChanged: scheduleRebuild() + onViewModeChanged: { scheduleRebuild(); scheduleNativeReset() } + onColsChanged: { scheduleRebuild(); scheduleNativeReset() } + onNativeActiveChanged: { scheduleRebuild(); reportSoon() } + onNativeJumpsJsonChanged: if (nativeActive) alphaJumps = parseNativeJumps() + + Timer { + id: nativeQueryCoalescer + interval: 0 + repeat: false + onTriggered: root.resetNativeQuery() + } + function scheduleNativeReset() { + if (nativeSurface && cols > 0) nativeQueryCoalescer.restart() + } + function resetNativeQuery() { + if (!nativeSurface || !view || cols <= 0) return + QbzLocal.albumsNativeReset( + view.albumsSearch, + view.albumsSort, + view.albumsGroup, + JSON.stringify(view.filter || ({})), + cols) + } + Connections { + target: root.nativeSurface ? root.view : null + function onAlbumsSearchChanged() { root.scheduleNativeReset() } + function onAlbumsSortChanged() { root.scheduleNativeReset() } + function onAlbumsGroupChanged() { root.scheduleNativeReset() } + function onFilterChanged() { root.scheduleNativeReset() } + } + Connections { + target: root.nativeSurface ? QbzLocal : null + function onLocalAlbumModeChanged() { root.scheduleNativeReset() } + } // The first build stays synchronous — deferring it would show one frame of // empty grid on every mount. - Component.onCompleted: { rebuild(); reportSoon() } + Component.onCompleted: { rebuild(); reportSoon(); scheduleNativeReset() } Component.onDestruction: if (view) view.releaseWindow(root.surface) // --------------------------------------------------------------------- @@ -211,14 +262,29 @@ Item { // a viewport resize, and the scroll. function report() { if (!view || !list) return - if (!list.visible || entries.length === 0 || width <= 0) { + var count = nativeActive && nativeModel ? nativeModel.totalCount : entries.length + if (!list.visible || count === 0 || width <= 0) { view.releaseWindow(root.surface) return } var first = list.indexAt(4, list.contentY + 1) var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1) if (first < 0) first = 0 - if (last < 0) last = Math.min(entries.length - 1, first + 8) + if (last < 0) last = Math.min(count - 1, first + 8) + if (nativeActive && nativeModel) { + var resident = [] + for (var entryIndex = first; entryIndex <= last; entryIndex++) { + var entry = nativeModel.rowAt(entryIndex) + if (!entry || entry.loading || !entry.items) continue + for (var itemIndex = 0; itemIndex < entry.items.length; itemIndex++) + resident.push(entry.items[itemIndex]) + } + if (resident.length > 0) + view.queueWindowReport(resident, 0, resident.length - 1, root.surface) + else + view.releaseWindow(root.surface) + return + } var lo = entries[first] var hi = entries[last] // Both entry kinds now carry `base`, so a header at either edge of @@ -273,7 +339,7 @@ Item { // pooled delegate its own Popup — so the menus above had to become // lazy FIRST. In the other order this would install that bug. reuseItems: true - model: root.entries + model: root.nativeActive && root.nativeModel ? root.nativeModel : root.entries onContentYChanged: root.report() onModelChanged: root.report() onHeightChanged: root.report() @@ -284,8 +350,23 @@ Item { width: list.width height: modelData.t === 0 ? root.headerH : root.viewMode === "grid" ? root.cellH : root.listRowH - sourceComponent: modelData.t === 0 ? headerComp + sourceComponent: modelData.loading ? loadingComp + : modelData.t === 0 ? headerComp : root.viewMode === "grid" ? cardRowComp : listRowComp + onModelDataChanged: root.reportSoon() + + Component { + id: loadingComp + QbzSkeleton { + variant: root.viewMode === "grid" ? "cardGrid" : "rowList" + cellW: root.cellW + cellH: root.cellH + rowH: root.listRowH + rowGap: 0 + rowArtSize: 40 + phase: root.view ? root.view.skelPhase : false + } + } Component { id: headerComp @@ -397,8 +478,8 @@ Item { genre: "" year: cardCell.slot.year qualityTier: cardCell.slot.qualityTier - artSource: root.view - ? (root.view.artMap[cardCell.slot.artKey] || "") : "" + artSource: cardCell.slot.artPath || (root.view + ? (root.view.artMap[cardCell.slot.artKey] || "") : "") // LocalLibraryView.slint:1267 `show-source-badge` // — the card takes the raw source word and the // BADGE is what the flag switches (blanking @@ -432,8 +513,15 @@ Item { // which is exactly what a host overlay cannot // do. Do not re-add one. selectMode: root.selectMode - selected: root.selected[cardCell.slot.id] === true - onSelectToggled: root.toggleSelect(cardCell.slot.id) + selected: root.nativeActive + ? cardCell.slot.selected === true + : root.selected[cardCell.slot.id] === true + onSelectToggled: { + if (root.nativeActive) + root.nativeToggleSelect(cardCell.slot.nativeIndex, Qt.NoModifier) + else + root.toggleSelect(cardCell.slot.id, Qt.NoModifier) + } // Non-select mode only — the card routes a // select-mode click to `selectToggled` and never // emits this while ticking. @@ -494,17 +582,23 @@ Item { view: root.view item: modelData.items[0] artSource: root.view - ? (root.view.artMap[modelData.items[0].artKey] || "") : "" + ? (modelData.items[0].artPath + || root.view.artMap[modelData.items[0].artKey] || "") : "" showSource: root.showSource selectMode: root.selectMode - checked: root.selected[modelData.items[0].id] === true + checked: root.nativeActive + ? modelData.items[0].selected === true + : root.selected[modelData.items[0].id] === true onOpened: root.openRequested(modelData.items[0].id) onPlayRequested: root.playRequested(modelData.items[0].id) onEnqueueRequested: function (m) { root.enqueueRequested(modelData.items[0].id, m) } onToggleSelect: function (mods) { - root.toggleSelect(modelData.items[0].id, mods) + if (root.nativeActive) + root.nativeToggleSelect(modelData.items[0].nativeIndex, mods) + else + root.toggleSelect(modelData.items[0].id, mods) } } } diff --git a/crates/qbz-qt/qml/views/local/LocalAlbumsTab.qml b/crates/qbz-qt/qml/views/local/LocalAlbumsTab.qml index 32d1933f3..1c64ad75c 100644 --- a/crates/qbz-qt/qml/views/local/LocalAlbumsTab.qml +++ b/crates/qbz-qt/qml/views/local/LocalAlbumsTab.qml @@ -19,6 +19,9 @@ Item { id: root property var view: null + readonly property bool nativeActive: QbzLocal.localAlbumsNativeActive + readonly property int albumTotal: nativeActive + ? QbzLocal.localAlbumsNativeTotal : root.view.albums.length QbzTheme { id: theme } @@ -71,12 +74,15 @@ Item { LocalNote { visible: !QbzLocal.localAlbumsLoading && QbzLocal.localAlbumsError === "" - && root.view.albums.length === 0 && root.view.albumsSearch === "" + && root.albumTotal === 0 && root.view.albumsSearch === "" text: QbzSession.tr("No albums in your local library yet.", QbzSession.trRev) } LocalNote { visible: !QbzLocal.localAlbumsLoading && QbzLocal.localAlbumsError === "" - && root.view.albums.length > 0 && root.view.albumsVisible.length === 0 + && ((root.nativeActive && root.albumTotal === 0 + && root.view.albumsSearch !== "") + || (!root.nativeActive && root.view.albums.length > 0 + && root.view.albumsVisible.length === 0)) text: QbzSession.tr("No albums match your search.", QbzSession.trRev) } @@ -87,7 +93,8 @@ Item { anchors.topMargin: 16 spacing: 8 visible: !QbzLocal.localAlbumsLoading && QbzLocal.localAlbumsError === "" - && root.view.albumsVisible.length > 0 + && (root.nativeActive ? root.albumTotal > 0 + : root.view.albumsVisible.length > 0) QbzMultiSelectBar { visible: root.view.albumsMultiSelect @@ -118,6 +125,10 @@ Item { scrollBarInset: root.view.albumsGroup === "alpha" ? 34 : 4 view: root.view surface: "albums" + nativeSurface: true + nativeActive: root.nativeActive + nativeModel: root.view.nativeAlbumsModel + nativeJumpsJson: QbzLocal.localAlbumsNativeJumpsJson scrollScope: "local:albums" rows: root.view.albumsVisible groups: root.view.albumsGrouped @@ -130,6 +141,9 @@ Item { onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) } onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) } onToggleSelect: function (id, mods) { root.view.toggleAlbumSelected(id, mods) } + onNativeToggleSelect: function (index, mods) { + root.view.toggleNativeAlbumSelected(index, mods) + } } QbzAlphaStrip { visible: root.view.albumsGroup === "alpha" diff --git a/crates/qbz-qt/qml/views/local/LocalToolbar.qml b/crates/qbz-qt/qml/views/local/LocalToolbar.qml index b7b6af658..173eaaf0c 100644 --- a/crates/qbz-qt/qml/views/local/LocalToolbar.qml +++ b/crates/qbz-qt/qml/views/local/LocalToolbar.qml @@ -76,7 +76,9 @@ Row { // ============================ ALBUMS ================================= Row { visible: root.view && root.view.activeTab === "albums" - && (root.view.albums.length > 0 || root.view.albumsSearch !== "") + && (root.view.albums.length > 0 || root.view.albumsSearch !== "" + || QbzLocal.localAlbumsLoading + || QbzLocal.localAlbumsNativeActive) spacing: 8 height: parent.height diff --git a/crates/qbz-qt/src/local_albums.rs b/crates/qbz-qt/src/local_albums.rs index c31127e3a..ebf79ab55 100644 --- a/crates/qbz-qt/src/local_albums.rs +++ b/crates/qbz-qt/src/local_albums.rs @@ -520,8 +520,9 @@ pub fn load_counts_blocking() -> LocalCounts { }) } -/// An album's tracks by group key: the Plex cache for a `plex:` key, else -/// the ACTIVE identity mode's query against `library.db`. +/// An album's tracks by group key: the Plex cache for a legacy content hash +/// or native edition key, else the ACTIVE identity mode's query against +/// `library.db`. pub fn fetch_album_tracks_blocking(id: &str) -> Vec { if id.starts_with("plex:") { return crate::local_plex::album_tracks(id); @@ -550,7 +551,7 @@ pub fn fetch_album_tracks_blocking(id: &str) -> Vec { } /// The local album-detail document (header + versions + track list). `id` is -/// the group key of the ACTIVE identity mode, or a Plex `plex:` key. +/// the group key of the ACTIVE identity mode, or a Plex legacy/native key. /// /// The tracks are split into VERSIONS by source directory before the header is /// built — two physical copies of the same album must not merge into one diff --git a/crates/qbz-qt/src/local_albums_model_qt.rs b/crates/qbz-qt/src/local_albums_model_qt.rs new file mode 100644 index 000000000..5e54dd782 --- /dev/null +++ b/crates/qbz-qt/src/local_albums_model_qt.rs @@ -0,0 +1,1434 @@ +//! Native paged Albums collection over the derived catalog (phase F1). + +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, BootstrapLayout, QueryDescriptor, SourceKey, + SourceKind, TrackGroup, TrackSort, +}; +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}; + +const PAGE_ENTRIES: usize = 100; +const FLAT_QUERY_ROWS: usize = 500; +const MAX_RESIDENT_ALBUMS: usize = 2_000; +const MAX_UNANCHORED_ENTRIES: usize = 500; +const MAX_COLUMNS: usize = MAX_RESIDENT_ALBUMS / PAGE_ENTRIES; + +static QUERY_GENERATION: AtomicU64 = AtomicU64::new(0); +static SESSION_FAILED: AtomicBool = AtomicBool::new(false); +static SESSION: Mutex> = Mutex::new(None); +static LAST_QUERY: Mutex> = Mutex::new(None); + +#[derive(Clone)] +struct LastQuery { + search: String, + sort: String, + group: String, + filter_json: String, + columns: i32, +} + +unsafe extern "C" { + fn qbz_local_albums_register_qml_type(); + fn qbz_local_albums_reset(generation: i32, total_count: i64, album_total: i64); + fn qbz_local_albums_apply_page(generation: i32, page: i32, json: *const c_char) -> bool; + fn qbz_local_albums_set_selection(generation: i32, json: *const c_char, selected_count: i64); +} + +#[derive(Clone)] +struct IndexedAlbum { + index: usize, + record: AlbumRecord, +} + +#[derive(Clone, Default)] +struct StreamAnchor { + entry_index: usize, + flat_index: usize, + cursor: Option, + previous_group: Option, + pending: Vec, + done: bool, +} + +#[derive(Clone)] +struct CachedPage { + wire: Arc>, + albums: usize, +} + +struct NativeSession { + generation: u64, + catalog_generation: u64, + descriptor: QueryDescriptor, + columns: usize, + album_total: u64, + entry_total: u64, + anchors: BTreeMap, + anchors_ready: bool, + anchor_building: bool, + pending_pages: HashSet, + waiting_pages: HashSet, + cache: PageLru, + selection: QuerySelection, +} + +#[derive(Clone, Serialize)] +struct NativeEntry { + 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(Serialize)] +struct NativeJump { + letter: String, + index: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct QuerySelection { + all: bool, + ranges: Vec<(u64, u64)>, + anchor: Option, +} + +impl QuerySelection { + fn contains(&self, index: u64) -> bool { + let inside = self + .ranges + .iter() + .any(|&(first, last)| first <= index && index <= last); + if self.all { + !inside + } else { + inside + } + } + + fn count(&self, total: u64) -> u64 { + let ranges = self + .ranges + .iter() + .map(|&(first, last)| last.saturating_sub(first).saturating_add(1)) + .sum::(); + if self.all { + total.saturating_sub(ranges) + } else { + ranges.min(total) + } + } + + fn toggle(&mut self, index: u64, shift: bool) { + if shift { + if let Some(anchor) = self.anchor { + let (first, last) = if anchor <= index { + (anchor, index) + } else { + (index, anchor) + }; + if self.all { + remove_interval(&mut self.ranges, first, last); + } else { + add_interval(&mut self.ranges, first, last); + } + return; + } + } + if self.contains(index) { + if self.all { + add_interval(&mut self.ranges, index, index); + } else { + remove_interval(&mut self.ranges, index, index); + } + } else if self.all { + remove_interval(&mut self.ranges, index, index); + } else { + add_interval(&mut self.ranges, index, index); + } + self.anchor = Some(index); + } +} + +#[derive(Default)] +struct PageLru { + entries: HashMap, + order: VecDeque, + resident_albums: usize, + evictions: u64, +} + +impl PageLru { + fn get(&mut self, page: usize) -> Option<&CachedPage> { + if !self.entries.contains_key(&page) { + return None; + } + self.touch(page); + self.entries.get(&page) + } + + fn insert(&mut self, page: usize, value: CachedPage) { + 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 OpenedQuery { + generation: u64, + catalog_generation: u64, + descriptor: QueryDescriptor, + columns: usize, + album_total: u64, + entry_total: u64, + loaded: LoadedPage, + count_time: std::time::Duration, +} + +struct LoadedPage { + generation: u64, + page: usize, + wire: Vec, + anchors: Vec, + jumps: 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_albums_register_qml_type() }; +} + +pub(crate) fn requested() -> bool { + if SESSION_FAILED.load(Ordering::Acquire) { + return false; + } + std::env::var("QBZ_LOCAL_CATALOG_ALBUMS") + .ok() + .is_some_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on")) +} + +pub(crate) fn reset( + search: String, + sort: String, + group: String, + filter_json: String, + columns: i32, +) -> bool { + if !requested() { + return false; + } + let query = LastQuery { + search, + sort, + group, + filter_json, + columns, + }; + *LAST_QUERY.lock().unwrap_or_else(|error| error.into_inner()) = Some(query.clone()); + // The derived identity currently matches the owner's default folder + // grouping. Keep the metadata-mode reader as this surface's fallback + // until that alternate projection can preserve its duplicate semantics. + if crate::local_library_qt::album_mode() != "folder" { + deactivate_for_legacy("metadata-mode"); + crate::local_bridge_ops::load_albums_legacy(); + return false; + } + let generation = next_generation(); + let columns = usize::try_from(query.columns.max(1)) + .unwrap_or(1) + .clamp(1, MAX_COLUMNS); + let descriptor = descriptor(query.search, &query.sort, &query.group, &query.filter_json); + *SESSION.lock().unwrap_or_else(|error| error.into_inner()) = None; + set_loading(); + if !descriptor.search().is_empty() && descriptor.search().chars().count() < 3 { + publish_empty(generation); + return true; + } + crate::spawn(async move { + let result = + tokio::task::spawn_blocking(move || open_query(generation, descriptor, columns)).await; + match result { + Ok(Ok(opened)) => activate_query(opened), + Ok(Err(reason)) => fallback(generation, reason), + Err(_) => fallback(generation, "worker-join"), + } + }); + true +} + +/// Retry the last QML descriptor after background bootstrap/catch-up makes a +/// catalog generation available. +pub(crate) fn retry_last() -> bool { + let query = LAST_QUERY + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + query.is_some_and(|query| { + reset( + query.search, + query.sort, + query.group, + query.filter_json, + query.columns, + ) + }) +} + +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_albums; + let evictions = current.cache.evictions; + drop(session); + publish_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_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 start_builder = !current.anchor_building; + drop(session); + if start_builder { + build_anchors(generation); + } + return; + } + let descriptor = current.descriptor.clone(); + let catalog_generation = current.catalog_generation; + let columns = current.columns; + drop(session); + crate::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + load_page( + generation, + catalog_generation, + descriptor, + columns, + page, + anchor, + ) + }) + .await; + match result { + Ok(Ok(loaded)) => commit_page(loaded), + Ok(Err(reason)) => fallback(generation, reason), + Err(_) => fallback(generation, "page-worker-join"), + } + }); +} + +pub(crate) fn toggle_selection(index: i64, shift: bool) { + if index < 0 { + return; + } + update_selection(|selection, total| { + if (index as u64) < total { + selection.toggle(index as u64, shift); + } + }); +} + +pub(crate) fn select_all() { + update_selection(|selection, _| { + selection.all = true; + selection.ranges.clear(); + selection.anchor = None; + }); +} + +pub(crate) fn clear_selection() { + update_selection(|selection, _| *selection = QuerySelection::default()); +} + +pub(crate) fn bulk_action(action: String) { + let snapshot = { + let session = SESSION.lock().unwrap_or_else(|error| error.into_inner()); + session.as_ref().map(|current| { + ( + current.generation, + current.catalog_generation, + current.descriptor.clone(), + current.album_total, + current.selection.clone(), + ) + }) + }; + let Some((generation, catalog_generation, descriptor, total, selection)) = snapshot else { + return; + }; + if selection.count(total) == 0 { + return; + } + crate::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + selected_album_ids( + generation, + catalog_generation, + &descriptor, + total, + &selection, + ) + }) + .await; + let ids = match result { + Ok(Ok(ids)) => ids, + _ => return, + }; + if QUERY_GENERATION.load(Ordering::Acquire) != generation { + return; + } + let rows = tokio::task::spawn_blocking(move || { + crate::local_bulk::resolve_album_ids_blocking(&ids) + }) + .await + .unwrap_or_default(); + if QUERY_GENERATION.load(Ordering::Acquire) != generation { + return; + } + if crate::local_bulk::apply(rows, &action).await { + clear_selection_generation(generation); + } + }); +} + +fn open_query( + generation: u64, + mut descriptor: QueryDescriptor, + columns: usize, +) -> Result { + let (catalog, catalog_generation) = open_active()?; + let sources = enabled_sources(&catalog)?; + if sources.is_empty() && catalog.stats().map_err(|_| "source-counts")?.track_count > 0 { + return Ok(OpenedQuery { + generation, + catalog_generation, + descriptor, + columns, + album_total: 0, + entry_total: 0, + loaded: LoadedPage { + 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 count_started = Instant::now(); + let album_total = catalog.count_albums(&descriptor).map_err(|_| "count")?; + let entry_total = catalog + .count_album_entries(&descriptor, columns) + .map_err(|_| "entry-count")?; + let count_time = count_started.elapsed(); + let loaded = load_page_from_catalog( + &catalog, + generation, + &descriptor, + columns, + 0, + StreamAnchor::default(), + )?; + Ok(OpenedQuery { + generation, + catalog_generation, + descriptor, + columns, + album_total, + entry_total, + loaded, + count_time, + }) +} + +fn activate_query(opened: OpenedQuery) { + if QUERY_GENERATION.load(Ordering::Acquire) != opened.generation { + return; + } + let first_wire = Arc::new(opened.loaded.wire.clone()); + let albums = album_count(&first_wire); + let mut cache = PageLru::default(); + cache.insert( + 0, + CachedPage { + wire: Arc::clone(&first_wire), + albums, + }, + ); + let mut anchors = BTreeMap::new(); + anchors.insert(0, StreamAnchor::default()); + for anchor in opened.loaded.anchors { + anchors.insert(anchor.entry_index, anchor); + } + *SESSION.lock().unwrap_or_else(|error| error.into_inner()) = Some(NativeSession { + generation: opened.generation, + catalog_generation: opened.catalog_generation, + descriptor: opened.descriptor.clone(), + columns: opened.columns, + album_total: opened.album_total, + 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, + selection: QuerySelection::default(), + }); + let generation = opened.generation; + let total = opened.entry_total; + let albums_total = opened.album_total; + let count_time = opened.count_time; + let query_time = opened.loaded.query_time; + let map_time = opened.loaded.map_time; + let initial_jumps = jumps_json(opened.loaded.jumps); + ui(move |mut bridge| { + cpp_reset(generation, total, albums_total); + let bytes = publish_page_now(generation, 0, &first_wire); + bridge.as_mut().set_local_albums_native_active(true); + bridge + .as_mut() + .set_local_albums_native_total(albums_total.min(i64::MAX as u64) as i64); + bridge.as_mut().set_local_albums_native_selected_count(0); + bridge + .as_mut() + .set_local_albums_native_jumps_json(QString::from(initial_jumps.as_str())); + bridge + .as_mut() + .set_local_albums_native_error(QString::default()); + bridge.as_mut().set_local_albums_loading(false); + log::info!( + "[local-catalog] phase=albums-native generation={generation} albums={albums_total} entries={total} first_entries={} first_albums={albums} json_bytes={bytes} count={count_time:?} query={query_time:?} map={map_time:?}", + first_wire.len() + ); + }); + if opened.entry_total > MAX_UNANCHORED_ENTRIES as u64 + || opened.descriptor.group() == TrackGroup::Name + { + build_anchors(generation); + } +} + +fn publish_empty(generation: u64) { + ui(move |mut bridge| { + cpp_reset(generation, 0, 0); + bridge.as_mut().set_local_albums_native_active(true); + bridge.as_mut().set_local_albums_native_total(0); + bridge.as_mut().set_local_albums_native_selected_count(0); + bridge + .as_mut() + .set_local_albums_native_jumps_json(QString::from("[]")); + bridge.as_mut().set_local_albums_loading(false); + }); +} + +fn load_page( + generation: u64, + catalog_generation: u64, + descriptor: QueryDescriptor, + columns: usize, + page: usize, + anchor: StreamAnchor, +) -> Result { + let (catalog, active_generation) = open_active()?; + if active_generation != catalog_generation { + return Err("catalog-generation-changed"); + } + load_page_from_catalog(&catalog, generation, &descriptor, columns, page, anchor) +} + +fn load_page_from_catalog( + catalog: &qbz_local_catalog::Catalog, + generation: u64, + descriptor: &QueryDescriptor, + columns: usize, + page: usize, + anchor: StreamAnchor, +) -> Result { + let target = page.saturating_mul(PAGE_ENTRIES); + if anchor.entry_index > target { + return Err("anchor-after-target"); + } + let started = Instant::now(); + let mapped = scan_stream( + catalog, + generation, + descriptor, + columns, + anchor, + target, + target.saturating_add(PAGE_ENTRIES), + true, + )?; + Ok(LoadedPage { + generation, + page, + wire: mapped.wire, + anchors: mapped.anchors, + jumps: mapped.jumps, + query_time: started.elapsed(), + map_time: mapped.map_time, + }) +} + +struct ScanResult { + wire: Vec, + anchors: Vec, + jumps: Vec, + final_state: StreamAnchor, + map_time: std::time::Duration, +} + +fn scan_stream( + catalog: &qbz_local_catalog::Catalog, + generation: u64, + descriptor: &QueryDescriptor, + columns: usize, + mut state: StreamAnchor, + 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_albums(descriptor, state.cursor.as_ref(), FLAT_QUERY_ROWS) + .map_err(|_| "album-query")?; + for record in page.rows { + let group_key = album_group_key(&record, descriptor.group()); + if descriptor.group() != TrackGroup::Off + && state.previous_group.as_deref() != Some(group_key.as_str()) + { + emit_pending( + &mut state, + descriptor, + collect_start, + stop_entry, + collect, + &mut wire, + &mut map_time, + ); + state.previous_group = Some(group_key.clone()); + let label = album_group_label(&record, descriptor.group()); + if descriptor.group() == TrackGroup::Name { + jumps.push(NativeJump { + letter: label.clone(), + index: state.entry_index, + }); + } + emit_header( + &mut state, + label, + collect_start, + stop_entry, + collect, + &mut wire, + ); + } + let index = state.flat_index; + state.flat_index = state.flat_index.saturating_add(1); + state.pending.push(IndexedAlbum { index, record }); + if state.pending.len() == columns { + emit_pending( + &mut state, + descriptor, + 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_pending( + &mut state, + descriptor, + collect_start, + stop_entry, + collect, + &mut wire, + &mut map_time, + ); + state.cursor = None; + state.done = true; + } + } + Ok(ScanResult { + wire, + anchors, + jumps, + final_state: state, + map_time, + }) +} + +fn emit_header( + state: &mut StreamAnchor, + label: String, + collect_start: usize, + collect_end: usize, + collect: bool, + wire: &mut Vec, +) { + if collect && (collect_start..collect_end).contains(&state.entry_index) { + wire.push(NativeEntry { + t: 0, + label, + base: state.flat_index, + items: Vec::new(), + }); + } + state.entry_index = state.entry_index.saturating_add(1); +} + +fn emit_pending( + state: &mut StreamAnchor, + descriptor: &QueryDescriptor, + 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(NativeEntry { + t: 1, + label: String::new(), + base: state.pending[0].index, + items, + }); + } + state.pending.clear(); + state.entry_index = state.entry_index.saturating_add(1); + let _ = descriptor; +} + +fn commit_page(loaded: LoadedPage) { + 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 albums = album_count(&wire); + current.cache.insert( + loaded.page, + CachedPage { + wire: Arc::clone(&wire), + albums, + }, + ); + let resident = current.cache.resident_albums; + let evictions = current.cache.evictions; + let entries = wire.len(); + drop(session); + publish_page( + loaded.generation, + loaded.page, + wire, + false, + resident, + evictions, + ); + log::info!( + "[local-catalog] phase=albums-page generation={} page={} entries={} albums={} query={:?} map={:?} resident_albums={} evictions={}", + loaded.generation, + loaded.page, + entries, + albums, + loaded.query_time, + loaded.map_time, + resident, + evictions + ); +} + +fn publish_page( + generation: u64, + page: usize, + wire: Arc>, + cache_hit: bool, + resident: usize, + evictions: u64, +) { + ui(move |_| { + let bytes = publish_page_now(generation, page, &wire); + log::info!( + "[local-catalog] phase=albums-publish generation={generation} page={page} entries={} albums={} json_bytes={bytes} cache_hit={cache_hit} resident_albums={resident} evictions={evictions}", + wire.len(), + album_count(&wire) + ); + }); +} + +fn publish_page_now(generation: u64, page: usize, wire: &[NativeEntry]) -> 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_albums_apply_page(generation as i32, page as i32, json.as_ptr()) }; + bytes +} + +fn build_anchors(generation: u64) { + let (descriptor, catalog_generation, columns, 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; + }; + current.anchor_building = true; + ( + current.descriptor.clone(), + current.catalog_generation, + 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_stream( + &catalog, + generation, + &descriptor, + columns, + StreamAnchor::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_anchors(generation, anchors, jumps, final_state, elapsed) + } + Ok(Err("superseded")) => {} + Ok(Err(reason)) => fallback(generation, reason), + Err(_) => fallback(generation, "anchor-worker-join"), + } + }); +} + +fn finish_anchors( + generation: u64, + anchors: Vec, + jumps: Vec, + final_state: StreamAnchor, + 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=albums-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_albums_native_jumps_json(QString::from(jumps.as_str())); + }); + for page in waiting { + request_page(page as i32, generation as i32); + } +} + +fn selected_album_ids( + generation: u64, + catalog_generation: u64, + descriptor: &QueryDescriptor, + total: u64, + selection: &QuerySelection, +) -> Result, &'static str> { + let (catalog, active_generation) = open_active()?; + if active_generation != catalog_generation { + return Err("catalog-generation-changed"); + } + let mut cursor = None; + let mut index = 0_u64; + let mut ids = Vec::with_capacity(selection.count(total).min(16_384) as usize); + loop { + if QUERY_GENERATION.load(Ordering::Acquire) != generation { + return Err("superseded"); + } + let page = catalog + .query_albums(descriptor, cursor.as_ref(), FLAT_QUERY_ROWS) + .map_err(|_| "action-query")?; + for record in page.rows { + if selection.contains(index) { + ids.push(album_action_id(&record)); + } + index = index.saturating_add(1); + } + let Some(next) = page.next_cursor else { + break; + }; + cursor = Some(next); + } + Ok(ids) +} + +fn descriptor(search: String, sort: &str, group: &str, filter_json: &str) -> QueryDescriptor { + let filter: serde_json::Value = serde_json::from_str(filter_json).unwrap_or_default(); + let on = |key: &str| filter.get(key).and_then(|value| value.as_bool()) == Some(true); + let formats = ["flac", "alac", "ape", "wav", "mp3", "aac"] + .into_iter() + .filter(|key| on(key)) + .map(str::to_string) + .collect(); + let qualities = ["hires", "cd", "lossy"] + .into_iter() + .filter(|key| on(key)) + .map(str::to_string) + .collect(); + let buckets = ["local", "offline", "plex", "jellyfin", "subsonic"] + .into_iter() + .filter(|key| on(key)) + .map(str::to_string) + .collect(); + QueryDescriptor::albums() + .with_search(search) + .with_sort(parse_sort(sort)) + .with_group(parse_group(group)) + .with_formats(formats) + .including_other_formats(on("other")) + .with_quality_tiers(qualities) + .with_source_buckets(buckets) +} + +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(record.source_raw_or_kind())), + source_raw: badge_source_raw(Some(record.source_raw_or_kind())), + directory_path: record.directory_path.clone(), + folder_count: record.folder_count, + }, + native_index: index, + art_path: String::new(), + } +} + +trait AlbumSourceWord { + fn source_raw_or_kind(&self) -> &str; +} + +impl AlbumSourceWord for AlbumRecord { + fn source_raw_or_kind(&self) -> &str { + if self.source_raw.trim().is_empty() { + self.source.as_str() + } else { + &self.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 album_group_key(record: &AlbumRecord, group: TrackGroup) -> String { + match group { + TrackGroup::Artist => qbz_local_catalog::normalize_sort_key(&record.artist), + TrackGroup::Name | TrackGroup::Album => { + qbz_local_catalog::normalize_sort_key(&record.title) + .chars() + .next() + .unwrap_or('#') + .to_string() + } + TrackGroup::Off => String::new(), + } +} + +fn album_group_label(record: &AlbumRecord, group: TrackGroup) -> String { + match group { + TrackGroup::Artist => record.artist.clone(), + TrackGroup::Name | TrackGroup::Album => album_group_key(record, group).to_uppercase(), + TrackGroup::Off => String::new(), + } +} + +fn enabled_sources(catalog: &qbz_local_catalog::Catalog) -> Result, &'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(); + Ok(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()) +} + +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_anchor(anchors: &BTreeMap, target: usize) -> StreamAnchor { + anchors + .range(..=target) + .next_back() + .map(|(_, anchor)| anchor.clone()) + .unwrap_or_default() +} + +fn update_selection(update: impl FnOnce(&mut QuerySelection, u64)) { + let mut session = SESSION.lock().unwrap_or_else(|error| error.into_inner()); + let Some(current) = session.as_mut() else { + return; + }; + update(&mut current.selection, current.album_total); + let generation = current.generation; + let count = current.selection.count(current.album_total); + let json = selection_json(¤t.selection); + drop(session); + ui(move |mut bridge| { + bridge + .as_mut() + .set_local_albums_native_selected_count(count.min(i64::MAX as u64) as i64); + cpp_set_selection(generation, &json, count); + }); +} + +fn clear_selection_generation(generation: 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; + }; + current.selection = QuerySelection::default(); + drop(session); + ui(move |mut bridge| { + bridge.as_mut().set_local_albums_native_selected_count(0); + cpp_set_selection(generation, &selection_json(&QuerySelection::default()), 0); + }); +} + +fn set_loading() { + ui(|mut bridge| { + bridge.as_mut().set_local_albums_loading(true); + bridge + .as_mut() + .set_local_albums_error(QString::default()); + bridge + .as_mut() + .set_local_albums_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; + log::warn!("[local-catalog] phase=albums-fallback generation={generation} reason={reason}"); + ui(move |mut bridge| { + bridge.as_mut().set_local_albums_native_active(false); + bridge.as_mut().set_local_albums_native_total(0); + bridge.as_mut().set_local_albums_native_selected_count(0); + bridge + .as_mut() + .set_local_albums_native_jumps_json(QString::from("[]")); + bridge + .as_mut() + .set_local_albums_native_error(QString::from(reason)); + crate::local_bridge_ops::load_albums_legacy(); + }); +} + +fn deactivate_for_legacy(reason: &'static str) { + *SESSION.lock().unwrap_or_else(|error| error.into_inner()) = None; + ui(move |mut bridge| { + bridge.as_mut().set_local_albums_native_active(false); + bridge.as_mut().set_local_albums_native_total(0); + bridge.as_mut().set_local_albums_native_selected_count(0); + bridge + .as_mut() + .set_local_albums_native_jumps_json(QString::from("[]")); + bridge + .as_mut() + .set_local_albums_native_error(QString::from(reason)); + }); +} + +fn parse_sort(value: &str) -> TrackSort { + match value { + "artist-desc" => TrackSort::ArtistDesc, + "title-asc" => TrackSort::TitleAsc, + "title-desc" => TrackSort::TitleDesc, + "year-asc" => TrackSort::YearAsc, + "year-desc" => TrackSort::YearDesc, + _ => TrackSort::ArtistAsc, + } +} + +fn parse_group(value: &str) -> TrackGroup { + match value { + "alpha" => TrackGroup::Name, + "artist" => TrackGroup::Artist, + _ => TrackGroup::Off, + } +} + +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 album_count(wire: &[NativeEntry]) -> 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 selection_json(selection: &QuerySelection) -> String { + serde_json::json!({ "all": selection.all, "ranges": selection.ranges }).to_string() +} + +fn cpp_reset(generation: u64, total: u64, albums: u64) { + // SAFETY: primitive values only; C++ applies synchronously on the Qt thread. + unsafe { + qbz_local_albums_reset( + generation as i32, + total.min(i64::MAX as u64) as i64, + albums.min(i64::MAX as u64) as i64, + ) + }; +} + +fn cpp_set_selection(generation: u64, json: &str, count: u64) { + let Ok(json) = CString::new(json) else { + return; + }; + // SAFETY: C++ copies the bounded interval document synchronously. + unsafe { + qbz_local_albums_set_selection( + generation as i32, + json.as_ptr(), + count.min(i64::MAX as u64) as i64, + ) + }; +} + +fn next_generation() -> u64 { + let mut generation = QUERY_GENERATION + .fetch_add(1, Ordering::AcqRel) + .wrapping_add(1); + if generation == 0 { + QUERY_GENERATION.store(1, Ordering::Release); + generation = 1; + } + generation +} + +fn add_interval(ranges: &mut Vec<(u64, u64)>, first: u64, last: u64) { + let mut merged = (first.min(last), first.max(last)); + let mut out = Vec::with_capacity(ranges.len() + 1); + for &(start, end) in ranges.iter() { + if end.saturating_add(1) < merged.0 { + out.push((start, end)); + } else if merged.1.saturating_add(1) < start { + out.push(merged); + merged = (start, end); + } else { + merged.0 = merged.0.min(start); + merged.1 = merged.1.max(end); + } + } + out.push(merged); + *ranges = out; +} + +fn remove_interval(ranges: &mut Vec<(u64, u64)>, first: u64, last: u64) { + let (first, last) = (first.min(last), first.max(last)); + let mut out = Vec::with_capacity(ranges.len() + 1); + for &(start, end) in ranges.iter() { + if end < first || start > last { + out.push((start, end)); + continue; + } + if start < first { + out.push((start, first - 1)); + } + if end > last { + out.push((last + 1, end)); + } + } + *ranges = out; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn album_page_cache_is_bounded_by_resident_album_rows() { + let mut cache = PageLru::default(); + for page in 0..25 { + cache.insert( + page, + CachedPage { + wire: Arc::new(Vec::new()), + albums: 100, + }, + ); + } + assert_eq!(cache.resident_albums, MAX_RESIDENT_ALBUMS); + assert_eq!(cache.entries.len(), 20); + assert_eq!(cache.evictions, 5); + assert!(cache.get(0).is_none()); + assert!(cache.get(24).is_some()); + } + + #[test] + fn album_selection_uses_bounded_intervals_and_descriptor_all() { + let mut selection = QuerySelection::default(); + selection.toggle(10, false); + selection.toggle(50_000, true); + assert_eq!(selection.ranges, vec![(10, 50_000)]); + assert_eq!(selection.count(1_000_000), 49_991); + + selection = QuerySelection { + all: true, + ..QuerySelection::default() + }; + selection.toggle(25, false); + selection.toggle(26, false); + assert_eq!(selection.ranges, vec![(25, 26)]); + assert_eq!(selection.count(1_000_000), 999_998); + } + + #[test] + fn superseded_album_generation_cannot_publish_as_current() { + QUERY_GENERATION.store(70, Ordering::Release); + let old = QUERY_GENERATION.load(Ordering::Acquire); + let current = next_generation(); + assert_eq!(current, 71); + assert_ne!(QUERY_GENERATION.load(Ordering::Acquire), old); + } + + #[test] + fn native_album_page_mapping_and_transport_are_bounded() { + let page = (0..PAGE_ENTRIES) + .map(|index| AlbumRecord { + edition_id: index as i64 + 1, + source: SourceKind::Local, + native_album_id: format!("album-{index:05}"), + source_raw: "local".to_string(), + title: format!("Album {index:05}"), + artist: format!("Artist {:03}", index % 17), + all_artists: format!("Artist {:03}", index % 17), + year: Some(1980 + (index % 45) as u32), + track_count: 10, + total_duration_ms: 1_800_000, + quality_tier: "hires".to_string(), + format: "flac".to_string(), + bit_depth: Some(24), + sample_rate_hz: Some(96_000), + artwork_source: String::new(), + artwork_token: String::new(), + directory_path: format!("/fixture/album-{index:05}"), + folder_count: 1, + added_at: index as i64, + }) + .collect::>(); + let map_started = Instant::now(); + let wire = page + .iter() + .enumerate() + .map(|(index, row)| NativeEntry { + t: 1, + label: String::new(), + base: index, + items: vec![map_album(row, index)], + }) + .collect::>(); + let map_time = map_started.elapsed(); + let serialize_started = Instant::now(); + let bytes = serde_json::to_vec(&wire).unwrap().len(); + let serialize_time = serialize_started.elapsed(); + assert!(bytes < 256 * 1024, "bounded album page was {bytes} bytes"); + println!( + "F1_ALBUMS_TRANSPORT first_entries={} first_albums={} json_bytes={bytes} map_ms={:.3} serialize_ms={:.3} qml_album_json_bytes=0", + wire.len(), + page.len(), + 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 7df160a97..3886a9bf5 100644 --- a/crates/qbz-qt/src/local_bridge.rs +++ b/crates/qbz-qt/src/local_bridge.rs @@ -56,6 +56,14 @@ pub mod qbz_local { #[qproperty(bool, local_albums_loading)] #[qproperty(QString, local_albums_error)] #[qproperty(QString, local_albums_json)] + /// Phase-F1 surface switch. False keeps the legacy JSON collection; + /// true binds LocalAlbumsTab to the paged QAbstractListModel. + #[qproperty(bool, local_albums_native_active)] + #[qproperty(i64, local_albums_native_total)] + #[qproperty(i64, local_albums_native_selected_count)] + /// Bounded A-Z jump metadata (`[{letter,index}]`), never album rows. + #[qproperty(QString, local_albums_native_jumps_json)] + #[qproperty(QString, local_albums_native_error)] // --- Artists tab -------------------------------------------------- #[qproperty(bool, local_artists_loading)] @@ -245,6 +253,36 @@ pub mod qbz_local { #[qinvokable] fn set_album_mode(self: Pin<&mut QbzLocal>, mode: QString); + // --- Albums tab (native paged model) ------------------------------ + /// Reset the immutable SQL descriptor and publish its first page. + #[qinvokable] + fn albums_native_reset( + self: Pin<&mut QbzLocal>, + search: QString, + sort: QString, + group: QString, + filter_json: QString, + columns: i32, + ); + #[qinvokable] + fn albums_native_page_miss( + self: Pin<&mut QbzLocal>, + page: i32, + generation: i32, + ); + #[qinvokable] + fn albums_native_toggle_select( + self: Pin<&mut QbzLocal>, + index: i64, + shift: bool, + ); + #[qinvokable] + fn albums_native_select_all(self: Pin<&mut QbzLocal>); + #[qinvokable] + fn albums_native_clear_selection(self: Pin<&mut QbzLocal>); + #[qinvokable] + fn albums_native_bulk_action(self: Pin<&mut QbzLocal>, action: QString); + // --- Tracks tab ---------------------------------------------------- /// Toolbar search (server-side; resets to page 1). #[qinvokable] @@ -564,6 +602,11 @@ pub struct QbzLocalRust { local_albums_loading: bool, local_albums_error: QString, local_albums_json: QString, + local_albums_native_active: bool, + local_albums_native_total: i64, + local_albums_native_selected_count: i64, + local_albums_native_jumps_json: QString, + local_albums_native_error: QString, local_artists_loading: bool, local_artists_json: QString, local_folders_loading: bool, @@ -626,6 +669,11 @@ impl Default for QbzLocalRust { local_albums_loading: false, local_albums_error: QString::default(), local_albums_json: QString::from("[]"), + local_albums_native_active: false, + local_albums_native_total: 0, + local_albums_native_selected_count: 0, + local_albums_native_jumps_json: QString::from("[]"), + local_albums_native_error: QString::default(), local_artists_loading: false, local_artists_json: QString::from("[]"), local_folders_loading: false, @@ -757,6 +805,51 @@ impl qbz_local::QbzLocal { invalidate_artists(); } + pub fn albums_native_reset( + self: Pin<&mut Self>, + search: QString, + sort: QString, + group: QString, + filter_json: QString, + columns: i32, + ) { + crate::local_albums_model_qt::reset( + search.to_string(), + sort.to_string(), + group.to_string(), + filter_json.to_string(), + columns, + ); + } + + pub fn albums_native_page_miss( + self: Pin<&mut Self>, + page: i32, + generation: i32, + ) { + crate::local_albums_model_qt::request_page(page, generation); + } + + pub fn albums_native_toggle_select( + self: Pin<&mut Self>, + index: i64, + shift: bool, + ) { + crate::local_albums_model_qt::toggle_selection(index, shift); + } + + pub fn albums_native_select_all(self: Pin<&mut Self>) { + crate::local_albums_model_qt::select_all(); + } + + pub fn albums_native_clear_selection(self: Pin<&mut Self>) { + crate::local_albums_model_qt::clear_selection(); + } + + pub fn albums_native_bulk_action(self: Pin<&mut Self>, action: QString) { + crate::local_albums_model_qt::bulk_action(action.to_string()); + } + 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 49978bd64..187f2fa3f 100644 --- a/crates/qbz-qt/src/local_bridge_ops.rs +++ b/crates/qbz-qt/src/local_bridge_ops.rs @@ -207,6 +207,9 @@ pub(crate) fn emit_artwork(pairs: Vec<(String, String)>) { pub(crate) fn load_tab_impl(tab: String) { match tab.as_str() { "albums" => load_albums(), + // Artists remains on the bounded legacy reader until its own F2 + // commit, and it still derives album credits from that document. + "albums-legacy" => load_albums_legacy(), "artists" => load_artists(), "folders" => load_folders(), "tracks" => load_tracks(true), @@ -221,6 +224,23 @@ pub(crate) fn load_tab_impl(tab: String) { } pub(crate) fn load_albums() { + ui(|mut b| { + b.as_mut().set_local_albums_loading(true); + b.as_mut().set_local_albums_error(QString::from("")); + }); + if crate::local_albums_model_qt::requested() + && crate::local_library_qt::album_mode() == "folder" + { + // Once QML has supplied its width-dependent descriptor this is also + // the retry path used by tab remounts and album-mode changes. On the + // first mount LocalAlbumCollection supplies it immediately. + crate::local_albums_model_qt::retry_last(); + return; + } + load_albums_legacy(); +} + +pub(crate) fn load_albums_legacy() { ui(|mut b| { b.as_mut().set_local_albums_loading(true); b.as_mut().set_local_albums_error(QString::from("")); diff --git a/crates/qbz-qt/src/local_bulk.rs b/crates/qbz-qt/src/local_bulk.rs index aa1cb385d..e8c42dfb0 100644 --- a/crates/qbz-qt/src/local_bulk.rs +++ b/crates/qbz-qt/src/local_bulk.rs @@ -175,6 +175,12 @@ fn resolve_blocking(scope: &str, ids: &[String]) -> Vec { .collect() } +pub(crate) fn resolve_album_ids_blocking(ids: &[String]) -> Vec { + ids.iter() + .flat_map(|key| fetch_album_tracks_blocking(key)) + .collect() +} + /// Run one action over already-resolved rows. Returns whether the caller /// should DROP its selection afterwards — the Slint clears after an enqueue /// and keeps it while a picker is still open. diff --git a/crates/qbz-qt/src/local_catalog_qt.rs b/crates/qbz-qt/src/local_catalog_qt.rs index 120815e9a..46b2f9476 100644 --- a/crates/qbz-qt/src/local_catalog_qt.rs +++ b/crates/qbz-qt/src/local_catalog_qt.rs @@ -144,6 +144,9 @@ pub(crate) fn start() { if ready && crate::local_tracks_model_qt::requested() { crate::local_bridge_ops::load_tracks(true); } + if ready && crate::local_albums_model_qt::requested() { + crate::local_albums_model_qt::retry_last(); + } } Ok(Err(CatalogError::InsufficientSpace { required_bytes, @@ -252,6 +255,9 @@ pub(crate) fn request_catch_up() { if ready && crate::local_tracks_model_qt::requested() { crate::local_bridge_ops::load_tracks(true); } + if ready && crate::local_albums_model_qt::requested() { + crate::local_albums_model_qt::retry_last(); + } } Ok(Err(CatalogError::InsufficientSpace { required_bytes, diff --git a/crates/qbz-qt/src/local_plex.rs b/crates/qbz-qt/src/local_plex.rs index e05cb40b1..af2727cef 100644 --- a/crates/qbz-qt/src/local_plex.rs +++ b/crates/qbz-qt/src/local_plex.rs @@ -379,7 +379,8 @@ pub fn search_tracks_page( .collect() } -/// One Plex album's tracks, by the content-hash key (`plex:`). +/// One Plex album's tracks, by the legacy content hash (`plex:`) or the +/// source-native edition key (`plex:album:`). pub fn album_tracks(album_key: &str) -> Vec { qbz_plex::plex_cache_get_album_tracks(album_key.to_string()) .unwrap_or_default() diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 0f2f7e5af..5bae1403a 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -184,6 +184,8 @@ mod local_catalog_qt; // Native bounded Tracks QAbstractListModel + catalog query controller (phase E). // Plain Rust module; the QML type itself is hand-written C++ under cxx/. mod local_tracks_model_qt; +// Native bounded Albums entry model (phase F1); grid/list geometry stays QML. +mod local_albums_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 @@ -3037,6 +3039,7 @@ fn main() { // Link anchor for the hand-written QAbstractListModel. Its QML singleton // registration itself runs at QCoreApplication startup. local_tracks_model_qt::register_qml_model(); + local_albums_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