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