diff --git a/crates/qbz-local-catalog/src/bootstrap_tests.rs b/crates/qbz-local-catalog/src/bootstrap_tests.rs index 309a59d97..574d93473 100644 --- a/crates/qbz-local-catalog/src/bootstrap_tests.rs +++ b/crates/qbz-local-catalog/src/bootstrap_tests.rs @@ -7,9 +7,10 @@ use rusqlite::Connection; use tempfile::tempdir; use crate::{ - bootstrap_legacy_caches, ActiveCatalog, BootstrapBatch, BootstrapLayout, BootstrapOutcome, - Catalog, CatalogError, FallbackReason, PreflightReport, ProjectedTrack, QueryDescriptor, - SourceKey, SourceKind, SourceProbe, TrackRef, BOOTSTRAP_BATCH_ROWS, + bootstrap_legacy_caches, bootstrap_legacy_caches_at_with_progress, ActiveCatalog, + BootstrapBatch, BootstrapLayout, BootstrapOutcome, Catalog, CatalogError, FallbackReason, + LegacyLocations, PreflightReport, ProjectedTrack, QueryDescriptor, SourceKey, SourceKind, + SourceProbe, TrackRef, BOOTSTRAP_BATCH_ROWS, }; fn source(kind: SourceKind, instance: &str) -> SourceKey { @@ -37,6 +38,7 @@ fn track(source: &SourceKey, id: u64) -> ProjectedTrack { source_instance: source.source_instance.clone(), native_id: id.to_string(), }, + source_raw: source.source.as_str().to_string(), local_track_id: (source.source == SourceKind::Local).then_some(id as i64), local_path: (source.source == SourceKind::Local).then(|| format!("/music/{id}.flac")), native_album_id: Some(format!("album-{}", id / 20)), @@ -355,6 +357,39 @@ fn legacy_fixture_bootstraps_all_sources_read_only_and_is_idempotent() { assert!(!BootstrapLayout::new(temp.path()).building_path(2).exists()); } +#[test] +fn split_profile_and_global_cache_locations_build_one_user_catalog() { + let temp = tempdir().unwrap(); + let root = temp.path().join("qbz"); + let user = root.join("users/42"); + fs::create_dir_all(&user).unwrap(); + create_local_fixture(&user.join("library.db")); + create_plex_fixture(&root.join("plex_cache.db")); + create_remote_fixture(&user.join("remote_cache.db")); + let locations = LegacyLocations { + catalog_dir: user.clone(), + local_database: user.join("library.db"), + plex_database: root.join("plex_cache.db"), + remote_database: user.join("remote_cache.db"), + }; + + let outcome = + bootstrap_legacy_caches_at_with_progress(&locations, &AtomicBool::new(false), |_| {}) + .unwrap(); + assert!(matches!( + outcome, + BootstrapOutcome::Activated { + track_count: 505, + .. + } + )); + let ActiveCatalog::Ready { catalog, .. } = BootstrapLayout::new(&user).open_active() else { + panic!("profile catalog did not activate") + }; + assert_eq!(catalog.stats().unwrap().track_count, 505); + assert!(!root.join("local_catalog-v1-manifest.json").exists()); +} + fn create_local_fixture(path: &std::path::Path) { let conn = Connection::open(path).unwrap(); conn.execute_batch( diff --git a/crates/qbz-local-catalog/src/catalog.rs b/crates/qbz-local-catalog/src/catalog.rs index e58bc1889..d632af9e3 100644 --- a/crates/qbz-local-catalog/src/catalog.rs +++ b/crates/qbz-local-catalog/src/catalog.rs @@ -923,6 +923,7 @@ fn upsert_track( } else { &track.album_artist }); + let sort_track_artist = normalize_sort_key(&track.artist); let sort_album = normalize_sort_key(&track.album); let year_missing = i64::from(track.year.is_none()); let year_value = track.year.unwrap_or(0) as i64; @@ -944,6 +945,7 @@ fn upsert_track( track.track_ref.source.as_str(), track.track_ref.source_instance, track.track_ref.native_id, + track.source_raw, track.local_track_id, track.local_path, track.source_copy_id, @@ -951,6 +953,7 @@ fn upsert_track( sort_title, track.artist, sort_artist, + sort_track_artist, track.album_artist, track.album, sort_album, @@ -994,17 +997,19 @@ fn upsert_track( } const UPSERT_TRACK_SQL: &str = "INSERT INTO tracks ( - source_kind, source_instance, native_track_id, local_track_id, local_path, - source_copy_id, title, sort_title, artist, sort_artist, album_artist, - album, sort_album, credits, duration_ms, year, year_missing, year_value, + source_kind, source_instance, native_track_id, source_raw, local_track_id, + local_path, source_copy_id, title, sort_title, artist, sort_artist, + sort_track_artist, album_artist, album, sort_album, credits, duration_ms, + year, year_missing, year_value, disc_number, disc_sort, track_number, track_sort, format, bit_depth, sample_rate_hz, artwork_token, isrc, musicbrainz_recording_id, added_at, available, last_observed_generation ) VALUES ( ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18, - ?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,?29,?30,?31 + ?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,?29,?30,?31,?32,?33 ) ON CONFLICT(source_kind, source_instance, native_track_id) DO UPDATE SET + source_raw=excluded.source_raw, local_track_id=excluded.local_track_id, local_path=excluded.local_path, source_copy_id=excluded.source_copy_id, @@ -1012,6 +1017,7 @@ ON CONFLICT(source_kind, source_instance, native_track_id) DO UPDATE SET sort_title=excluded.sort_title, artist=excluded.artist, sort_artist=excluded.sort_artist, + sort_track_artist=excluded.sort_track_artist, album_artist=excluded.album_artist, album=excluded.album, sort_album=excluded.sort_album, @@ -1086,7 +1092,7 @@ fn filter_parts( } if let Some(cursor) = cursor { - let (predicate, cursor_values) = cursor_predicate(cursor, alias); + let (predicate, cursor_values) = cursor_predicate(cursor, descriptor.group(), alias); predicates.push(predicate); values.extend(cursor_values); } @@ -1138,7 +1144,7 @@ fn page_query( ORDER BY {} LIMIT {}", parts.where_sql, - order_clause(parts.sort, "t"), + order_clause(parts.sort, descriptor.group(), "t"), limit + 1 ); return Ok((sql, parts.params, parts.sort)); @@ -1156,7 +1162,7 @@ fn page_query( ORDER BY {} LIMIT {}", parts.where_sql, - order_clause(parts.sort, "t"), + order_clause(parts.sort, descriptor.group(), "t"), limit + 1, ); let mut values = vec![match_value]; @@ -1181,22 +1187,28 @@ fn effective_sort(descriptor: &QueryDescriptor) -> TrackSort { } } -fn order_clause(sort: TrackSort, alias: &str) -> String { - let columns = match sort { - TrackSort::Default => { - "sort_album, sort_artist, disc_sort, track_sort, sort_title, catalog_id" +fn order_clause(sort: TrackSort, group: TrackGroup, alias: &str) -> String { + let columns = if group == TrackGroup::Artist { + "sort_track_artist, sort_album, sort_title, catalog_id" + } else { + match sort { + TrackSort::Default => { + "sort_album, sort_artist, disc_sort, track_sort, sort_title, catalog_id" + } + TrackSort::TitleAsc => "sort_title, sort_artist, catalog_id", + TrackSort::TitleDesc => "sort_title DESC, sort_artist, catalog_id", + TrackSort::ArtistAsc => "sort_artist, sort_album, disc_sort, track_sort, catalog_id", + TrackSort::ArtistDesc => { + "sort_artist DESC, sort_album, disc_sort, track_sort, catalog_id" + } + TrackSort::YearAsc => { + "year_missing, year_value, sort_album, disc_sort, track_sort, catalog_id" + } + TrackSort::YearDesc => { + "year_missing, year_value DESC, sort_album, disc_sort, track_sort, catalog_id" + } + TrackSort::AddedDesc => "added_at DESC, sort_album, disc_sort, track_sort, catalog_id", } - TrackSort::TitleAsc => "sort_title, sort_artist, catalog_id", - TrackSort::TitleDesc => "sort_title DESC, sort_artist, catalog_id", - TrackSort::ArtistAsc => "sort_artist, sort_album, disc_sort, track_sort, catalog_id", - TrackSort::ArtistDesc => "sort_artist DESC, sort_album, disc_sort, track_sort, catalog_id", - TrackSort::YearAsc => { - "year_missing, year_value, sort_album, disc_sort, track_sort, catalog_id" - } - TrackSort::YearDesc => { - "year_missing, year_value DESC, sort_album, disc_sort, track_sort, catalog_id" - } - TrackSort::AddedDesc => "added_at DESC, sort_album, disc_sort, track_sort, catalog_id", }; columns .split(", ") @@ -1212,10 +1224,21 @@ fn order_clause(sort: TrackSort, alias: &str) -> String { .join(", ") } -fn cursor_predicate(cursor: &TrackCursor, alias: &str) -> (String, Vec) { +fn cursor_predicate(cursor: &TrackCursor, group: TrackGroup, alias: &str) -> (String, Vec) { let text = |value: &str| Value::Text(value.to_string()); let integer = Value::Integer; - let (predicate, values) = match cursor.sort { + let (predicate, values) = if group == TrackGroup::Artist { + ( + "(t.sort_track_artist,t.sort_album,t.sort_title,t.catalog_id) > (?,?,?,?)", + vec![ + text(&cursor.sort_track_artist), + text(&cursor.sort_album), + text(&cursor.sort_title), + integer(cursor.row_id), + ], + ) + } else { + match cursor.sort { TrackSort::Default => ( "(t.sort_album,t.sort_artist,t.disc_sort,t.track_sort,t.sort_title,t.catalog_id) > (?,?,?,?,?,?)", vec![ @@ -1300,6 +1323,7 @@ fn cursor_predicate(cursor: &TrackCursor, alias: &str) -> (String, Vec) { integer(cursor.row_id), ], ), + } }; (predicate.replace("t.", &format!("{alias}.")), values) } @@ -1307,15 +1331,16 @@ fn cursor_predicate(cursor: &TrackCursor, alias: &str) -> (String, Vec) { fn map_row(row: &Row<'_>) -> rusqlite::Result { let source_word: String = row.get(0)?; let source = SourceKind::from_str(&source_word).expect("schema source CHECK"); - let sort_title = row.get(18)?; - let sort_artist = row.get(19)?; - let sort_album = row.get(20)?; - let year_missing = row.get(21)?; - let year_value = row.get(22)?; - let disc_sort = row.get(23)?; - let track_sort = row.get(24)?; - let added_at = row.get(25)?; - let row_id = row.get(26)?; + let sort_title = row.get(20)?; + let sort_artist = row.get(21)?; + let sort_track_artist = row.get(22)?; + let sort_album = row.get(23)?; + let year_missing = row.get(24)?; + let year_value = row.get(25)?; + let disc_sort = row.get(26)?; + let track_sort = row.get(27)?; + let added_at = row.get(28)?; + let row_id = row.get(29)?; Ok(RowWithCursor { record: TrackRecord { track_ref: TrackRef { @@ -1323,21 +1348,23 @@ fn map_row(row: &Row<'_>) -> rusqlite::Result { source_instance: row.get(1)?, native_id: row.get(2)?, }, - local_track_id: row.get(3)?, - local_path: row.get(4)?, - title: row.get(5)?, - artist: row.get(6)?, - album_artist: row.get(7)?, - album: row.get(8)?, - duration_ms: row.get::<_, i64>(9)?.max(0) as u64, - year: row.get::<_, Option>(10)?.map(|value| value as u32), - disc_number: row.get::<_, Option>(11)?.map(|value| value as u32), - track_number: row.get::<_, Option>(12)?.map(|value| value as u32), - format: row.get(13)?, - bit_depth: row.get::<_, Option>(14)?.map(|value| value as u32), - sample_rate_hz: row.get::<_, Option>(15)?.map(|value| value as u32), - artwork_token: row.get(16)?, - available: row.get::<_, i64>(17)? != 0, + source_raw: row.get(3)?, + local_track_id: row.get(4)?, + local_path: row.get(5)?, + native_album_id: row.get::<_, Option>(6)?, + title: row.get(7)?, + artist: row.get(8)?, + album_artist: row.get(9)?, + album: row.get(10)?, + duration_ms: row.get::<_, i64>(11)?.max(0) as u64, + year: row.get::<_, Option>(12)?.map(|value| value as u32), + disc_number: row.get::<_, Option>(13)?.map(|value| value as u32), + track_number: row.get::<_, Option>(14)?.map(|value| value as u32), + format: row.get(15)?, + bit_depth: row.get::<_, Option>(16)?.map(|value| value as u32), + sample_rate_hz: row.get::<_, Option>(17)?.map(|value| value as u32), + artwork_token: row.get(18)?, + available: row.get::<_, i64>(19)? != 0, }, cursor: TrackCursor { // Filled by query_tracks_timed after the effective sort is known. @@ -1345,6 +1372,7 @@ fn map_row(row: &Row<'_>) -> rusqlite::Result { descriptor_key: String::new(), sort_title, sort_artist, + sort_track_artist, sort_album, year_missing, year_value, @@ -1357,11 +1385,14 @@ fn map_row(row: &Row<'_>) -> rusqlite::Result { } const TRACK_COLUMNS: &str = " - t.source_kind, t.source_instance, t.native_track_id, t.local_track_id, t.local_path, + t.source_kind, t.source_instance, t.native_track_id, t.source_raw, + t.local_track_id, t.local_path, + (SELECT NULLIF(sc.native_album_id,'') FROM source_copies sc + WHERE sc.source_copy_id=t.source_copy_id), t.title, t.artist, t.album_artist, t.album, t.duration_ms, t.year, t.disc_number, t.track_number, t.format, t.bit_depth, t.sample_rate_hz, t.artwork_token, t.available, - t.sort_title, t.sort_artist, t.sort_album, t.year_missing, t.year_value, t.disc_sort, - t.track_sort, t.added_at, t.catalog_id"; + 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"; /// Unicode-aware display sort key shared by ingest and query indices. pub fn normalize_sort_key(value: &str) -> String { diff --git a/crates/qbz-local-catalog/src/legacy.rs b/crates/qbz-local-catalog/src/legacy.rs index 89cbe72b6..a5810c7ac 100644 --- a/crates/qbz-local-catalog/src/legacy.rs +++ b/crates/qbz-local-catalog/src/legacy.rs @@ -27,11 +27,39 @@ pub struct LegacySourceSpec { table: LegacyTable, } +/// Physical locations of the authoritative caches for one user. Qt keeps the +/// local and remote mirrors in the user's directory while the existing Plex +/// cache is installation-wide, so assuming co-location would silently build +/// an empty catalog for real profiles. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LegacyLocations { + pub catalog_dir: PathBuf, + pub local_database: PathBuf, + pub plex_database: PathBuf, + pub remote_database: PathBuf, +} + +impl LegacyLocations { + pub fn co_located(data_dir: impl Into) -> Self { + let catalog_dir = data_dir.into(); + Self { + local_database: catalog_dir.join("library.db"), + plex_database: catalog_dir.join("plex_cache.db"), + remote_database: catalog_dir.join("remote_cache.db"), + catalog_dir, + } + } +} + pub fn discover_legacy_sources(data_dir: &Path) -> Result> { + discover_legacy_sources_at(&LegacyLocations::co_located(data_dir)) +} + +pub fn discover_legacy_sources_at(locations: &LegacyLocations) -> Result> { let mut specs = Vec::new(); - discover_local(&data_dir.join("library.db"), &mut specs)?; - discover_plex(&data_dir.join("plex_cache.db"), &mut specs)?; - discover_remote(&data_dir.join("remote_cache.db"), &mut specs)?; + discover_local(&locations.local_database, &mut specs)?; + discover_plex(&locations.plex_database, &mut specs)?; + discover_remote(&locations.remote_database, &mut specs)?; specs.sort_by(|left, right| left.source.cmp(&right.source)); specs.dedup_by(|left, right| left.source == right.source); Ok(specs) @@ -47,9 +75,21 @@ pub fn bootstrap_legacy_caches( pub fn bootstrap_legacy_caches_with_progress( data_dir: &Path, cancelled: &AtomicBool, + publish: impl FnMut(&BootstrapProgress), +) -> Result { + bootstrap_legacy_caches_at_with_progress( + &LegacyLocations::co_located(data_dir), + cancelled, + publish, + ) +} + +pub fn bootstrap_legacy_caches_at_with_progress( + locations: &LegacyLocations, + cancelled: &AtomicBool, mut publish: impl FnMut(&BootstrapProgress), ) -> Result { - let layout = BootstrapLayout::new(data_dir); + let layout = BootstrapLayout::new(&locations.catalog_dir); if let ActiveCatalog::Ready { catalog, manifest } = layout.open_active() { return Ok(BootstrapOutcome::Activated { generation: manifest.active_generation, @@ -58,7 +98,7 @@ pub fn bootstrap_legacy_caches_with_progress( }); } - let specs = discover_legacy_sources(data_dir)?; + let specs = discover_legacy_sources_at(locations)?; let mut readers = specs .into_iter() .map(LegacyReader::open) @@ -147,9 +187,21 @@ pub fn reconcile_legacy_caches( pub fn reconcile_legacy_caches_with_progress( data_dir: &Path, cancelled: &AtomicBool, + publish: impl FnMut(&ProjectionProgress), +) -> Result { + reconcile_legacy_caches_at_with_progress( + &LegacyLocations::co_located(data_dir), + cancelled, + publish, + ) +} + +pub fn reconcile_legacy_caches_at_with_progress( + locations: &LegacyLocations, + cancelled: &AtomicBool, mut publish: impl FnMut(&ProjectionProgress), ) -> Result { - let layout = BootstrapLayout::new(data_dir); + let layout = BootstrapLayout::new(&locations.catalog_dir); let (active, active_generation) = match layout.open_active() { ActiveCatalog::Ready { catalog, manifest } => (catalog, manifest.active_generation), ActiveCatalog::Fallback(reason) => { @@ -158,7 +210,7 @@ pub fn reconcile_legacy_caches_with_progress( ))) } }; - let specs = discover_legacy_sources(data_dir)?; + let specs = discover_legacy_sources_at(locations)?; let mut readers = specs .into_iter() .map(LegacyReader::open) @@ -437,10 +489,12 @@ fn legacy_cursor(row: &LegacyRow) -> i64 { fn read_local(conn: &Connection, spec: &LegacySourceSpec, after: i64) -> Result> { let columns = table_columns(conn, "local_tracks")?; let album_id = optional_column(&columns, "album_group_key", "NULL"); + let source_raw = optional_column(&columns, "source", "''"); let sql = format!( "SELECT id, file_path, title, artist, COALESCE(album_artist,''), album, duration_secs, year, disc_number, track_number, format, bit_depth, - CAST(sample_rate AS INTEGER), artwork_path, indexed_at, {album_id} + CAST(sample_rate AS INTEGER), artwork_path, indexed_at, {album_id}, + COALESCE({source_raw},'') FROM local_tracks WHERE id > ?1 ORDER BY id @@ -460,6 +514,7 @@ fn read_local(conn: &Connection, spec: &LegacySourceSpec, after: i64) -> Result< source_instance: spec.source.source_instance.clone(), native_id: id.to_string(), }, + source_raw: row.get(16)?, local_track_id: Some(id), local_path: row.get(1)?, native_album_id: row.get(15)?, @@ -605,6 +660,7 @@ fn map_remote_like( source_instance: spec.source.source_instance.clone(), native_id: value_as_string(row, 1)?, }, + source_raw: source.as_str().to_string(), local_track_id: None, local_path: None, native_album_id: None, diff --git a/crates/qbz-local-catalog/src/lib.rs b/crates/qbz-local-catalog/src/lib.rs index b40674dcf..3ad8d176b 100644 --- a/crates/qbz-local-catalog/src/lib.rs +++ b/crates/qbz-local-catalog/src/lib.rs @@ -20,8 +20,10 @@ pub use catalog::{ normalize_artist_key, normalize_sort_key, Catalog, CatalogStats, IntegrityReport, QueryMetrics, }; pub use legacy::{ - bootstrap_legacy_caches, bootstrap_legacy_caches_with_progress, discover_legacy_sources, - reconcile_legacy_caches, reconcile_legacy_caches_with_progress, LegacySourceSpec, + bootstrap_legacy_caches, bootstrap_legacy_caches_at_with_progress, + bootstrap_legacy_caches_with_progress, discover_legacy_sources, discover_legacy_sources_at, + reconcile_legacy_caches, reconcile_legacy_caches_at_with_progress, + reconcile_legacy_caches_with_progress, LegacyLocations, LegacySourceSpec, }; pub use model::{ ArtistCredit, CreditRole, ProjectedTrack, QueryDescriptor, QuerySurface, SourceKey, SourceKind, diff --git a/crates/qbz-local-catalog/src/model.rs b/crates/qbz-local-catalog/src/model.rs index 38fb54e22..737503374 100644 --- a/crates/qbz-local-catalog/src/model.rs +++ b/crates/qbz-local-catalog/src/model.rs @@ -92,6 +92,10 @@ pub struct ArtistCredit { #[derive(Debug, Clone)] pub struct ProjectedTrack { pub track_ref: TrackRef, + /// Raw presentation provenance from the authoritative row. Local cache + /// rows may be `qobuz_download`/`qobuz_purchase` while their stable + /// ownership remains [`SourceKind::Local`]. + pub source_raw: String, pub local_track_id: Option, pub local_path: Option, /// Stable album id from the same authoritative source when available. @@ -259,8 +263,10 @@ impl QueryDescriptor { #[derive(Debug, Clone, PartialEq, Eq)] pub struct TrackRecord { pub track_ref: TrackRef, + pub source_raw: String, pub local_track_id: Option, pub local_path: Option, + pub native_album_id: Option, pub title: String, pub artist: String, pub album_artist: String, @@ -284,6 +290,7 @@ pub struct TrackCursor { pub(crate) descriptor_key: String, pub(crate) sort_title: String, pub(crate) sort_artist: String, + pub(crate) sort_track_artist: String, pub(crate) sort_album: String, pub(crate) year_missing: i64, pub(crate) year_value: i64, @@ -293,6 +300,26 @@ pub struct TrackCursor { pub(crate) row_id: i64, } +impl TrackCursor { + /// Normalized grouping key of the row immediately before a keyset page. + /// This lets a paged frontend decide whether the first resident row starts + /// a new global group without issuing an overlap query. + pub fn group_key(&self, group: TrackGroup) -> String { + match group { + TrackGroup::Off => String::new(), + TrackGroup::Album => self.sort_album.clone(), + TrackGroup::Artist => self.sort_track_artist.clone(), + TrackGroup::Name => self + .sort_title + .chars() + .next() + .unwrap_or('#') + .to_uppercase() + .collect(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TrackPage { pub rows: Vec, diff --git a/crates/qbz-local-catalog/src/projection_tests.rs b/crates/qbz-local-catalog/src/projection_tests.rs index c367d303d..58e6cf9c2 100644 --- a/crates/qbz-local-catalog/src/projection_tests.rs +++ b/crates/qbz-local-catalog/src/projection_tests.rs @@ -37,6 +37,7 @@ fn row(source: &SourceKey, id: u64, title: impl Into) -> ProjectedTrack source_instance: source.source_instance.clone(), native_id: id.to_string(), }, + source_raw: source.source.as_str().to_string(), local_track_id: (source.source == SourceKind::Local).then_some(id as i64), local_path: (source.source == SourceKind::Local).then(|| format!("/music/{id}.flac")), native_album_id: Some(format!("album-{}", id / 10)), diff --git a/crates/qbz-local-catalog/src/schema.rs b/crates/qbz-local-catalog/src/schema.rs index 8e48e6ecf..96ebb2914 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 = 1; +pub const SCHEMA_VERSION: u32 = 2; pub const APPLICATION_ID: i64 = 0x5142_5A43; // "QBZC" pub(crate) fn configure(conn: &Connection) -> Result<()> { @@ -184,6 +184,7 @@ CREATE TABLE IF NOT EXISTS tracks ( ), source_instance TEXT NOT NULL, native_track_id TEXT NOT NULL, + source_raw TEXT NOT NULL DEFAULT '', local_track_id INTEGER, local_path TEXT, source_copy_id INTEGER REFERENCES source_copies(source_copy_id), @@ -191,6 +192,7 @@ CREATE TABLE IF NOT EXISTS tracks ( sort_title TEXT NOT NULL, artist TEXT NOT NULL, sort_artist TEXT NOT NULL, + sort_track_artist TEXT NOT NULL, album_artist TEXT NOT NULL, album TEXT NOT NULL, sort_album TEXT NOT NULL, @@ -238,6 +240,8 @@ CREATE INDEX IF NOT EXISTS idx_tracks_artist_asc ON tracks(available, sort_artist, sort_album, disc_sort, track_sort, catalog_id); CREATE INDEX IF NOT EXISTS idx_tracks_artist_desc ON tracks(available, sort_artist DESC, sort_album, disc_sort, track_sort, catalog_id); +CREATE INDEX IF NOT EXISTS idx_tracks_group_artist + ON tracks(available, sort_track_artist, sort_album, sort_title, catalog_id); CREATE INDEX IF NOT EXISTS idx_tracks_year_asc ON tracks(available, year_missing, year_value, sort_album, disc_sort, track_sort, catalog_id); CREATE INDEX IF NOT EXISTS idx_tracks_year_desc diff --git a/crates/qbz-local-catalog/src/tests.rs b/crates/qbz-local-catalog/src/tests.rs index 8062b4667..58ebd5aa7 100644 --- a/crates/qbz-local-catalog/src/tests.rs +++ b/crates/qbz-local-catalog/src/tests.rs @@ -39,6 +39,7 @@ fn projected(index: usize, source: SourceKind) -> ProjectedTrack { source_instance: source_instance(source).to_string(), native_id: index.to_string(), }, + source_raw: source.as_str().to_string(), local_track_id: (source == SourceKind::Local).then_some(index as i64 + 1), local_path: (source == SourceKind::Local) .then(|| format!("/fixture/artist-{artist_number:05}/track-{index:07}.{format}")), @@ -100,7 +101,7 @@ fn collect_all(catalog: &Catalog, descriptor: &QueryDescriptor) -> Vec String { format!("{year:04}-{m:02}-{d:02}") } -/// The hand-written C++ QQuickRhiItems: A4's `LineBedItem`, A2's +/// The hand-written C++ Qt types: A4's `LineBedItem`, A2's /// `PlasmaItem`, A3's `RibbonItem` and B1's `TunnelFlowItem` (`cxx/*_item.*`, /// the scenes a ShaderEffect cannot express — line strips / feedback /// ping-pong / persistent sub-rect texture writes — spec 01 §2.5/§2.1/§2.4, @@ -297,7 +297,13 @@ fn build_rhi_items() { .std("c++17") .pic(true) .include("cxx"); // the moc outputs do `#include ".h"` - for item in ["linebed_item", "plasma_item", "ribbon_item", "tunnelflow_item"] { + for item in [ + "linebed_item", + "plasma_item", + "ribbon_item", + "tunnelflow_item", + "local_tracks_model", + ] { let header = format!("cxx/{item}.h"); let source = format!("cxx/{item}.cpp"); println!("cargo:rerun-if-changed={header}"); @@ -352,9 +358,8 @@ fn main() { // freshly compiled .qsb is the one that gets embedded. build_shaders(); - // The hand-written C++ QQuickRhiItems (cxx/). Static lib linked into - // the binary; the QML types self-register at QGuiApplication - // construction. + // The hand-written C++ Qt types (cxx/). Static lib linked into the binary; + // the QML types self-register at QGuiApplication construction. build_rhi_items(); // The WHOLE asset tree, recursively. This used to name the root-level diff --git a/crates/qbz-qt/cxx/local_tracks_model.cpp b/crates/qbz-qt/cxx/local_tracks_model.cpp new file mode 100644 index 000000000..119e313da --- /dev/null +++ b/crates/qbz-qt/cxx/local_tracks_model.cpp @@ -0,0 +1,296 @@ +#include "local_tracks_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +QPointer g_model; + +void registerLocalTracksModel() +{ + auto *application = QCoreApplication::instance(); + auto *model = new LocalTracksModel(application); + g_model = model; + qmlRegisterSingletonInstance( + "com.blitzfc.qbz", 1, 0, "QbzLocalTracks", model); +} + +Q_COREAPP_STARTUP_FUNCTION(registerLocalTracksModel) +} + +LocalTracksModel::LocalTracksModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int LocalTracksModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return int(qMin(m_totalCount, std::numeric_limits::max())); +} + +QVariant LocalTracksModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || role != ModelDataRole) + return {}; + return modelData(index.row()); +} + +QHash LocalTracksModel::roleNames() const +{ + return {{ ModelDataRole, QByteArrayLiteral("modelData") }}; +} + +QVariantMap LocalTracksModel::rowAt(qint64 row) const +{ + if (row < 0 || row >= m_totalCount) + return {}; + return modelData(row); +} + +QVariantMap LocalTracksModel::modelData(qint64 row) const +{ + const int page = int(row / PageRows); + const int offset = int(row % PageRows); + const auto found = m_pages.constFind(page); + if (found == m_pages.cend() || offset >= found->size()) { + requestPage(page, false); + return { + { QStringLiteral("t"), 1 }, + { QStringLiteral("n"), row + 1 }, + { QStringLiteral("loading"), true }, + { QStringLiteral("groupStart"), false }, + { QStringLiteral("groupLabel"), QString() }, + { QStringLiteral("selected"), false }, + { QStringLiteral("row"), QVariantMap() }, + }; + } + touch(page); + const TrackRow &track = found->at(offset); + QVariantMap values = track.row; + values.insert(QStringLiteral("selected"), selected(row)); + return { + { QStringLiteral("t"), 1 }, + { QStringLiteral("n"), row + 1 }, + { QStringLiteral("loading"), false }, + { QStringLiteral("groupStart"), track.groupStart }, + { QStringLiteral("groupLabel"), track.groupLabel }, + { QStringLiteral("selected"), selected(row) }, + { QStringLiteral("row"), values }, + }; +} + +void LocalTracksModel::requestPage(int page, bool prefetch) const +{ + if (page < 0) + return; + const QMetaMethod missSignal = QMetaMethod::fromSignal(&LocalTracksModel::pageMiss); + // The Local Library view is lazy. A prefetch published before its + // Connections object exists would otherwise be lost while leaving this + // page permanently marked pending across the later mount. + 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(&LocalTracksModel::pageMiss); + if (self->isSignalConnected(missSignal)) { + emit self->pageMiss(page, generation); + } else { + self->m_pending.remove(page); + } + }, + Qt::QueuedConnection); +} + +void LocalTracksModel::touch(int page) const +{ + m_lru.removeAll(page); + m_lru.append(page); +} + +bool LocalTracksModel::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 LocalTracksModel::resetQuery(int generation, qint64 totalCount, const QString &group) +{ + beginResetModel(); + m_generation = generation; + m_totalCount = qMax(0, totalCount); + m_group = group; + m_pages.clear(); + m_lru.clear(); + m_pending.clear(); + m_residentRows = 0; + m_evictions = 0; + m_selectAll = false; + m_selectionRanges.clear(); + m_selectedCount = 0; + endResetModel(); + emit generationChanged(); + emit totalCountChanged(); + emit residentRowsChanged(); + emit evictionsChanged(); + emit selectedCountChanged(); +} + +bool LocalTracksModel::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 rows; + const QJsonArray array = document.array(); + rows.reserve(array.size()); + for (const QJsonValue &value : array) { + if (!value.isObject()) + return false; + const QJsonObject object = value.toObject(); + TrackRow track; + track.groupStart = object.value(QStringLiteral("groupStart")).toBool(); + track.groupLabel = object.value(QStringLiteral("groupLabel")).toString(); + track.row = object.value(QStringLiteral("row")).toObject().toVariantMap(); + track.artKey = track.row.value(QStringLiteral("artKey")).toString(); + rows.append(std::move(track)); + } + + const auto previous = m_pages.constFind(page); + if (previous != m_pages.cend()) + m_residentRows -= previous->size(); + m_pages.insert(page, std::move(rows)); + m_residentRows += m_pages.value(page).size(); + const bool wasPrefetch = m_pending.take(page); + touch(page); + evictIfNeeded(); + + const int first = page * PageRows; + const int last = qMin(rowCount() - 1, first + PageRows - 1); + if (first <= last) + emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole }); + emit residentRowsChanged(); + + const int next = page + 1; + if (!wasPrefetch && qint64(next) * PageRows < m_totalCount && !m_pages.contains(next)) + requestPage(next, true); + return true; +} + +void LocalTracksModel::evictIfNeeded() +{ + while (m_pages.size() > MaxResidentPages && !m_lru.isEmpty()) { + const int page = m_lru.takeFirst(); + const auto found = m_pages.find(page); + if (found == m_pages.end()) + continue; + m_residentRows -= found->size(); + m_pages.erase(found); + ++m_evictions; + const int first = page * PageRows; + const int last = qMin(rowCount() - 1, first + PageRows - 1); + if (first <= last) + emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole }); + } + emit evictionsChanged(); +} + +void LocalTracksModel::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) { + TrackRow &track = (*page)[offset]; + if (track.artKey != artKey) + continue; + track.row.insert(QStringLiteral("artPath"), path); + const int row = page.key() * PageRows + offset; + emit dataChanged(index(row, 0), index(row, 0), { ModelDataRole }); + } + } +} + +void LocalTracksModel::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_tracks_register_qml_type() +{ + // Link anchor. Registration is performed by Q_COREAPP_STARTUP_FUNCTION. +} + +extern "C" void qbz_local_tracks_reset( + int generation, qint64 totalCount, const char *group) +{ + if (g_model) + g_model->resetQuery(generation, totalCount, QString::fromUtf8(group ? group : "")); +} + +extern "C" bool qbz_local_tracks_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_tracks_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_tracks_model.h b/crates/qbz-qt/cxx/local_tracks_model.h new file mode 100644 index 000000000..94fa2c6a2 --- /dev/null +++ b/crates/qbz-qt/cxx/local_tracks_model.h @@ -0,0 +1,86 @@ +#ifndef QBZ_LOCAL_TRACKS_MODEL_H +#define QBZ_LOCAL_TRACKS_MODEL_H + +#include +#include +#include +#include + +class LocalTracksModel final : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int generation READ generation NOTIFY generationChanged) + Q_PROPERTY(qint64 totalCount READ totalCount NOTIFY totalCountChanged) + Q_PROPERTY(int residentRows READ residentRows NOTIFY residentRowsChanged) + Q_PROPERTY(qint64 evictions READ evictions NOTIFY evictionsChanged) + Q_PROPERTY(qint64 selectedCount READ selectedCount NOTIFY selectedCountChanged) + +public: + enum Role { ModelDataRole = Qt::UserRole + 1 }; + + explicit LocalTracksModel(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; } + int residentRows() const { return m_residentRows; } + 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, const QString &group); + bool applyPage(int generation, int page, const QByteArray &json); + void setSelection(int generation, const QByteArray &json, qint64 selectedCount); + +signals: + void generationChanged(); + void totalCountChanged(); + void residentRowsChanged(); + void evictionsChanged(); + void selectedCountChanged(); + void pageMiss(int page, int generation); + +private: + struct TrackRow { + QVariantMap row; + QString artKey; + QString groupLabel; + bool groupStart = false; + }; + using Page = QList; + + static constexpr int PageRows = 250; + static constexpr int MaxResidentPages = 8; + + 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; + QString m_group; + QHash m_pages; + mutable QList m_lru; + mutable QHash m_pending; + int m_residentRows = 0; + qint64 m_evictions = 0; + bool m_selectAll = false; + QList> m_selectionRanges; + qint64 m_selectedCount = 0; +}; + +extern "C" { +void qbz_local_tracks_register_qml_type(); +void qbz_local_tracks_reset(int generation, qint64 totalCount, const char *group); +bool qbz_local_tracks_apply_page(int generation, int page, const char *json); +void qbz_local_tracks_set_selection(int generation, const char *json, qint64 selectedCount); +} + +#endif // QBZ_LOCAL_TRACKS_MODEL_H diff --git a/crates/qbz-qt/qml/views/LocalLibraryView.qml b/crates/qbz-qt/qml/views/LocalLibraryView.qml index d3d825c06..1959fa181 100644 --- a/crates/qbz-qt/qml/views/LocalLibraryView.qml +++ b/crates/qbz-qt/qml/views/LocalLibraryView.qml @@ -58,6 +58,11 @@ Rectangle { QbzTheme { id: theme } + // Force construction before Rust publishes the first bounded page. The + // 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 + // ============================ state ================================== // Slint defaults, verbatim (state.slint LocalLibraryState). property string activeTab: "albums" @@ -98,7 +103,9 @@ Rectangle { readonly property string tracksGroup: QbzLocal.localTracksGroup property bool tracksMultiSelect: false property var tracksSelected: ({}) - readonly property int tracksSelectedCount: Object.keys(tracksSelected).length + readonly property int tracksSelectedCount: QbzLocal.localTracksNativeActive + ? QbzLocal.localTracksNativeSelectedCount + : Object.keys(tracksSelected).length // Albums quality/format/source filter (LibAlbumFilterState). property bool filterOpen: false @@ -323,6 +330,7 @@ Rectangle { Connections { target: QbzLocal function onLocalArtworkReady(key, path) { + root.nativeTracksModel.setArtwork(key, path) root._artInbox[key] = path if (!artFlush.running) artFlush.start() // An arrival is live evidence that the pass is still running, so @@ -333,6 +341,12 @@ Rectangle { artPulseOff.restart() } } + Connections { + target: root.nativeTracksModel + function onPageMiss(page, generation) { + QbzLocal.tracksNativePageMiss(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 @@ -545,9 +559,10 @@ Rectangle { sortRows(filterRows(folders, foldersSearch), foldersSort) readonly property var foldersGrouped: groupRows(foldersVisible, foldersGroup) - // Tracks: search + sort are SERVER-side (they define the pagination - // order), so the loaded pages arrive already ordered and the visible set - // is the pages as published. + // Legacy Tracks: search + sort are server-side and grouping remains its + // client-side compatibility reorder. Phase E does not evaluate this array: + // its immutable SQL descriptor owns search/sort/group globally and the + // QAbstractListModel pages only the visible window. // // The GROUP modes are the exception: they are a CLIENT-side visual // reorder layered on top of that order (`derive_tracks`, @@ -914,7 +929,10 @@ Rectangle { function toggleTracksMultiSelect() { tracksMultiSelect = !tracksMultiSelect - if (!tracksMultiSelect) { tracksSelected = ({}); trackSel.anchorId = "" } + if (!tracksMultiSelect) { + tracksSelected = ({}); trackSel.anchorId = "" + if (QbzLocal.localTracksNativeActive) QbzLocal.tracksNativeClearSelection() + } } SelectionModel { id: trackSel } function toggleTrackSelected(id, mods) { @@ -922,6 +940,12 @@ Rectangle { mods === undefined ? Qt.NoModifier : mods) } function tracksBulkAction(action) { + if (QbzLocal.localTracksNativeActive) { + if (action === "clear") QbzLocal.tracksNativeClearSelection() + else if (action === "select-all") QbzLocal.tracksNativeSelectAll() + else QbzLocal.tracksNativeBulkAction(action) + return + } if (action === "clear") { tracksSelected = ({}); trackSel.anchorId = ""; return } if (action === "select-all") { var s = {} @@ -951,7 +975,11 @@ Rectangle { // Same "leaving drops the selection" contract the toggle buttons // carry (toggleAlbumsMultiSelect / toggleTracksMultiSelect). if (root.albumsMultiSelect) { root.albumsMultiSelect = false; root.albumsSelected = ({}) } - if (root.tracksMultiSelect) { root.tracksMultiSelect = false; root.tracksSelected = ({}) } + if (root.tracksMultiSelect) { + root.tracksMultiSelect = false + root.tracksSelected = ({}) + if (QbzLocal.localTracksNativeActive) QbzLocal.tracksNativeClearSelection() + } } function toggleTreeSelectMode() { diff --git a/crates/qbz-qt/qml/views/local/LocalToolbar.qml b/crates/qbz-qt/qml/views/local/LocalToolbar.qml index 1baf7018f..b7b6af658 100644 --- a/crates/qbz-qt/qml/views/local/LocalToolbar.qml +++ b/crates/qbz-qt/qml/views/local/LocalToolbar.qml @@ -235,7 +235,10 @@ Row { // ============================ TRACKS ================================= Row { visible: root.view && root.view.activeTab === "tracks" - && (root.view.tracks.length > 0 || root.view.tracksSearch !== "") + && ((QbzLocal.localTracksNativeActive + ? QbzLocal.localTracksNativeTotal > 0 + : root.view.tracks.length > 0) + || root.view.tracksSearch !== "") spacing: 8 height: parent.height @@ -272,9 +275,9 @@ Row { currentIndex: Math.max(0, root.tracksGroupIds.indexOf(root.view.tracksGroup)) // Through the bridge, like the sort above and the album identity // below it: the choice is PERSISTED (locallibrary_ui.json, shared - // with the Slint build). Writing the QML property directly is what - // made it reset on every restart (PARITY-DEBT #13). No re-query — - // grouping is a client-side reorder, not a new ORDER BY. + // with the Slint build). The legacy reader reorders its loaded + // rows; Phase E resets an immutable SQL descriptor so grouping is + // global across every keyset page. onSelected: function (i) { QbzLocal.tracksSetGroup(root.tracksGroupIds[i]) } } // Multi-select LAST — never to the LEFT of a search box. The expandable diff --git a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml index 2c27aaee6..b63aa7527 100644 --- a/crates/qbz-qt/qml/views/local/LocalTrackRow.qml +++ b/crates/qbz-qt/qml/views/local/LocalTrackRow.qml @@ -64,6 +64,8 @@ Item { property var view: null property bool selectMode: false property bool checked: false + property bool nativeActions: false + property int nativeIndex: -1 /// Alternating row tint, forwarded to the shared row. Off by default there /// (rows/TrackRow.qml), which is why the local album page had none while /// the Qobuz one (AlbumView.qml) did — the flag was simply never handed @@ -246,7 +248,10 @@ Item { // point, which owns the only source-aware row -> ref resolver // (see the file header). else if (a === "add-to-playlist" || a === "add-to-mixtape") { - QbzLocal.bulkAction("track", JSON.stringify([String(root.item.id)]), a) + if (root.nativeActions) + QbzLocal.tracksNativeRowAction(root.nativeIndex, a) + else + QbzLocal.bulkAction("track", JSON.stringify([String(root.item.id)]), a) } else if (a === "go-album") { QbzLocal.openAlbum(root.item.albumId) diff --git a/crates/qbz-qt/qml/views/local/LocalTracksTab.qml b/crates/qbz-qt/qml/views/local/LocalTracksTab.qml index e996a1a20..6fe24220d 100644 --- a/crates/qbz-qt/qml/views/local/LocalTracksTab.qml +++ b/crates/qbz-qt/qml/views/local/LocalTracksTab.qml @@ -1,8 +1,7 @@ -// Tracks tab body (LocalLibraryView.slint:1317) — the SERVER-PAGINATED flat -// list. This is the surface that froze the Slint build at 16k tracks: the -// model is never the whole table (500 rows per page, appended within 600px -// of the tail — the Slint's own trigger distance at :1442) and the ListView -// only ever instantiates its window. +// Tracks tab body (LocalLibraryView.slint:1317). The compatibility reader is +// server-paginated JSON; Phase E binds the same delegates to a native +// QAbstractListModel with exact rowCount, 250-row keyset pages and an eight-page +// LRU. Missing data is a placeholder and only emits an async page request. // // Grouping (off / album / artist / name) inserts 32px header rows, so the // model is an entry array — { t: 0, label } | { t: 1, row, n } — exactly as @@ -28,6 +27,7 @@ Item { readonly property int rowH: 50 readonly property int headerH: 32 + readonly property bool nativeModelActive: QbzLocal.localTracksNativeActive // --------------------------- entry model ------------------------------ property var entries: [] @@ -38,6 +38,17 @@ Item { /// screen, by the number of headers scrolled past. property var rowIndex: [] function rebuild() { + if (root.nativeModelActive) { + entries = [] + rowIndex = [] + try { + alphaJumps = JSON.parse(QbzLocal.localTracksNativeJumpsJson) || [] + } catch (e) { + alphaJumps = [] + } + reportSoon() + return + } var rows = view ? view.tracksVisible : [] var mode = view ? view.tracksGroup : "off" var out = [] @@ -68,13 +79,23 @@ Item { Component.onDestruction: if (view) view.releaseWindow("tracks") Connections { target: root.view - function onTracksVisibleChanged() { root.rebuild() } + function onTracksVisibleChanged() { if (!root.nativeModelActive) root.rebuild() } function onTracksGroupChanged() { root.rebuild() } // The row artwork column is OFF by default; turning it on has to ask // for the covers the rows are already bound to. function onTrackArtworkChanged() { root.reportSoon() } function onArtworkRefresh() { root.reportSoon() } } + Connections { + target: QbzLocal + function onLocalTracksNativeActiveChanged() { root.rebuild() } + function onLocalTracksNativeJumpsJsonChanged() { root.rebuild() } + } + Connections { + target: root.view ? root.view.nativeTracksModel : null + function onDataChanged() { root.reportSoon() } + function onModelReset() { root.reportSoon() } + } // --------------------------- row play --------------------------------- // PARITY-DEBT #14. Rust used to queue `state.tracks_raw` — the raw SQL @@ -94,8 +115,9 @@ Item { for (var i = 0; i < rows.length; i++) out.push(rows[i].id) return out } - function playRow(id) { - QbzLocal.playTracksVisible(JSON.stringify(visibleTrackIds()), id) + function playRow(index, id) { + if (root.nativeModelActive) QbzLocal.tracksNativePlay(index) + else QbzLocal.playTracksVisible(JSON.stringify(visibleTrackIds()), id) } // First page = the shape of the 50px track rows (the Slint mounts a bare @@ -114,13 +136,17 @@ Item { phase: root.view.skelPhase } LocalNote { - visible: !QbzLocal.localTracksLoading && root.view.tracks.length === 0 + visible: !QbzLocal.localTracksLoading + && (root.nativeModelActive ? QbzLocal.localTracksNativeTotal === 0 + : root.view.tracks.length === 0) && root.view.tracksSearch === "" text: QbzSession.tr("No tracks in your local library yet.", QbzSession.trRev) } LocalNote { visible: !QbzLocal.localTracksLoading - && (root.view.tracks.length === 0 || root.view.tracksVisible.length === 0) + && (root.nativeModelActive ? QbzLocal.localTracksNativeTotal === 0 + : (root.view.tracks.length === 0 + || root.view.tracksVisible.length === 0)) && root.view.tracksSearch !== "" text: QbzSession.tr("No tracks match your search.", QbzSession.trRev) } @@ -131,7 +157,9 @@ Item { anchors.rightMargin: root.sideInset anchors.topMargin: 12 spacing: 8 - visible: !QbzLocal.localTracksLoading && root.view.tracksVisible.length > 0 + visible: !QbzLocal.localTracksLoading + && (root.nativeModelActive ? QbzLocal.localTracksNativeTotal > 0 + : root.view.tracksVisible.length > 0) QbzMultiSelectBar { visible: root.view.tracksMultiSelect @@ -165,7 +193,7 @@ Item { clip: true cacheBuffer: 50 * 12 boundsBehavior: Flickable.StopAtBounds - model: root.entries + model: root.nativeModelActive ? root.view.nativeTracksModel : root.entries onContentYChanged: { root.report() @@ -195,7 +223,8 @@ Item { // behaviour today; it is kept because it is true and free, // and because the only shape that would misfire is the one // it covers. - if (contentHeight > height + if (!root.nativeModelActive + && contentHeight > height && contentY + height >= contentHeight - 600 && QbzLocal.localTracksHasMore && !QbzLocal.localTracksLoadingMore @@ -213,8 +242,11 @@ Item { delegate: Loader { required property var modelData width: list.width - height: modelData.t === 0 ? root.headerH : root.rowH - sourceComponent: modelData.t === 0 ? headerComp : rowComp + height: root.nativeModelActive + ? root.rowH + (modelData.groupStart ? root.headerH : 0) + : (modelData.t === 0 ? root.headerH : root.rowH) + sourceComponent: root.nativeModelActive ? nativeRowComp + : (modelData.t === 0 ? headerComp : rowComp) Component { id: headerComp @@ -251,13 +283,70 @@ Item { showArtwork: root.view.trackArtwork selectMode: root.view.tracksMultiSelect checked: root.view.tracksSelected[modelData.row.id] === true - onPlayRequested: root.playRow(modelData.row.id) + onPlayRequested: root.playRow(modelData.n - 1, modelData.row.id) onEnqueueRequested: function (m) { QbzLocal.enqueue("track", modelData.row.id, m) } onToggleSelect: function (mods) { root.view.toggleTrackSelected(modelData.row.id, mods) } } } + Component { + id: nativeRowComp + Item { + Text { + x: 2 + y: 0 + width: parent.width - 4 + height: root.headerH + visible: modelData.groupStart && !modelData.loading + verticalAlignment: Text.AlignVCenter + text: modelData.groupLabel + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + LocalTrackRow { + y: modelData.groupStart ? root.headerH : 0 + width: parent.width + height: root.rowH + visible: !modelData.loading + enabled: !modelData.loading + view: root.view + item: modelData.row + artSource: modelData.row.artPath + || root.view.artMap[modelData.row.artKey] || "" + number: modelData.n + showAlbum: root.view.tracksGroup !== "album" + showArtwork: root.view.trackArtwork + selectMode: root.view.tracksMultiSelect + checked: modelData.selected + nativeActions: true + nativeIndex: modelData.n - 1 + onPlayRequested: QbzLocal.tracksNativePlay(modelData.n - 1) + onEnqueueRequested: function (m) { + QbzLocal.tracksNativeEnqueue(modelData.n - 1, m) + } + onToggleSelect: function (mods) { + QbzLocal.tracksNativeToggleSelect( + modelData.n - 1, + (mods & Qt.ShiftModifier) !== 0) + } + } + QbzSkeleton { + y: modelData.groupStart ? root.headerH : 0 + width: parent.width + height: root.rowH + visible: modelData.loading + variant: "rowList" + rowH: root.rowH + rowGap: 0 + rowArt: root.view.trackArtwork + rowArtSize: 36 + phase: root.view.skelPhase + } + } + } } // Infinite scroll: the NEXT page in the shape it will arrive @@ -265,11 +354,12 @@ Item { // placeholder is replaced by a real row as the page lands. footer: Item { width: list.width - height: QbzLocal.localTracksLoadingMore ? 3 * root.rowH : 0 + height: !root.nativeModelActive && QbzLocal.localTracksLoadingMore + ? 3 * root.rowH : 0 QbzSkeleton { variant: "rowList" anchors.fill: parent - visible: QbzLocal.localTracksLoadingMore + visible: !root.nativeModelActive && QbzLocal.localTracksLoadingMore rowH: root.rowH rowGap: 0 rowArt: root.view.trackArtwork @@ -322,14 +412,31 @@ Item { // The row artwork column is gated on the appearance setting (default // OFF, for the 16K-track freeze). With it off nothing on this list // renders a cover, so nothing is worth decoding. - if (!list.visible || !view.trackArtwork || root.entries.length === 0) { + var modelCount = root.nativeModelActive + ? QbzLocal.localTracksNativeTotal : root.entries.length + if (!list.visible || !view.trackArtwork || modelCount === 0) { view.releaseWindow("tracks") 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(root.entries.length - 1, first + 12) + if (last < 0) last = Math.min(modelCount - 1, first + 12) + if (root.nativeModelActive) { + var resident = [] + var nativeFirst = Math.max(0, first - 4) + var nativeLast = Math.min(modelCount - 1, last + 4) + for (var j = nativeFirst; j <= nativeLast; j++) { + var data = root.view.nativeTracksModel.rowAt(j) + if (!data.loading && data.row && data.row.artKey) resident.push(data.row) + } + if (resident.length === 0) { + view.releaseWindow("tracks") + return + } + view.queueWindowReport(resident, 0, resident.length - 1, "tracks") + return + } // Entry band -> row band (group headers carry no cover). var lo = -1 var hi = -1 diff --git a/crates/qbz-qt/src/auth_qt.rs b/crates/qbz-qt/src/auth_qt.rs index 2e1b30d2c..b082b6716 100644 --- a/crates/qbz-qt/src/auth_qt.rs +++ b/crates/qbz-qt/src/auth_qt.rs @@ -237,6 +237,10 @@ async fn bind_per_user_stores(dir: &std::path::Path, user_id: u64) { // Offline DOWNLOAD cache (index.db + library.db + cached-id seed) — // awaited so the ready-set is seeded before any view can build rows. crate::offline_qt::activate(user_id).await; + // The catalog sidecar is profile-scoped. Coalescing here covers first + // login and account switches even when the one-shot boot worker began on + // the previous/guest profile. + crate::local_catalog_qt::request_catch_up(); } /// Make sure the Qobuz client holds bundle tokens before a sign-in call. diff --git a/crates/qbz-qt/src/local_bridge.rs b/crates/qbz-qt/src/local_bridge.rs index 6fd700004..7df160a97 100644 --- a/crates/qbz-qt/src/local_bridge.rs +++ b/crates/qbz-qt/src/local_bridge.rs @@ -88,6 +88,14 @@ pub mod qbz_local { /// Slint writes — so it survives a restart and both frontends agree. #[qproperty(QString, local_tracks_group)] #[qproperty(QString, local_tracks_json)] + /// Phase-E surface switch. False keeps the existing JSON reader; + /// true binds LocalTracksTab to QbzLocalTracks (QAbstractListModel). + #[qproperty(bool, local_tracks_native_active)] + #[qproperty(i64, local_tracks_native_total)] + #[qproperty(i64, local_tracks_native_selected_count)] + /// Bounded A-Z jump metadata (`[{letter,index}]`), never track rows. + #[qproperty(QString, local_tracks_native_jumps_json)] + #[qproperty(QString, local_tracks_native_error)] // --- Local album detail (the album pane) --------------------------- #[qproperty(bool, local_album_loading)] @@ -252,6 +260,41 @@ pub mod qbz_local { /// Infinite scroll: append the next page. #[qinvokable] fn tracks_load_more(self: Pin<&mut QbzLocal>); + /// QAbstractListModel page miss -> async catalog keyset page. + #[qinvokable] + fn tracks_native_page_miss( + self: Pin<&mut QbzLocal>, + page: i32, + generation: i32, + ); + #[qinvokable] + fn tracks_native_toggle_select( + self: Pin<&mut QbzLocal>, + index: i64, + shift: bool, + ); + #[qinvokable] + fn tracks_native_select_all(self: Pin<&mut QbzLocal>); + #[qinvokable] + fn tracks_native_clear_selection(self: Pin<&mut QbzLocal>); + /// Index-based actions keep the source-native TrackRef inside Rust; + /// QML never converts it back to a legacy integer id. + #[qinvokable] + fn tracks_native_play(self: Pin<&mut QbzLocal>, index: i64); + #[qinvokable] + fn tracks_native_enqueue( + self: Pin<&mut QbzLocal>, + index: i64, + mode: QString, + ); + #[qinvokable] + fn tracks_native_row_action( + self: Pin<&mut QbzLocal>, + index: i64, + action: QString, + ); + #[qinvokable] + fn tracks_native_bulk_action(self: Pin<&mut QbzLocal>, action: QString); // --- Folder tree --------------------------------------------------- /// Chevron: expand (lazy one-level fetch) or collapse (pure UI). @@ -536,6 +579,11 @@ pub struct QbzLocalRust { local_tracks_sort: QString, local_tracks_group: QString, local_tracks_json: QString, + local_tracks_native_active: bool, + local_tracks_native_total: i64, + local_tracks_native_selected_count: i64, + local_tracks_native_jumps_json: QString, + local_tracks_native_error: QString, local_album_loading: bool, local_album_json: QString, local_track_artwork: bool, @@ -593,6 +641,11 @@ impl Default for QbzLocalRust { local_tracks_sort: QString::from("default"), local_tracks_group: QString::from("off"), local_tracks_json: QString::from("[]"), + local_tracks_native_active: false, + local_tracks_native_total: 0, + local_tracks_native_selected_count: 0, + local_tracks_native_jumps_json: QString::from("[]"), + local_tracks_native_error: QString::default(), local_album_loading: false, local_album_json: QString::from(""), local_track_artwork: false, @@ -726,9 +779,11 @@ impl qbz_local::QbzLocal { b.as_mut() .set_local_tracks_group(QString::from(mode.as_str())); }); - // NO reload: unlike the sort, grouping is a client-side visual - // reorder over the pages already loaded (the reference's - // `set_tracks_group` only persists + re-derives). + // The legacy reader keeps its QML reorder. Phase E makes grouping + // part of the immutable SQL descriptor, so only that arm resets. + if crate::local_tracks_model_qt::requested() { + load_tracks(true); + } } pub fn tracks_load_more(self: Pin<&mut Self>) { @@ -738,6 +793,46 @@ impl qbz_local::QbzLocal { load_tracks(false); } + pub fn tracks_native_page_miss(self: Pin<&mut Self>, page: i32, generation: i32) { + crate::local_tracks_model_qt::request_page(page, generation); + } + + pub fn tracks_native_toggle_select( + self: Pin<&mut Self>, + index: i64, + shift: bool, + ) { + crate::local_tracks_model_qt::toggle_selection(index, shift); + } + + pub fn tracks_native_select_all(self: Pin<&mut Self>) { + crate::local_tracks_model_qt::select_all(); + } + + pub fn tracks_native_clear_selection(self: Pin<&mut Self>) { + crate::local_tracks_model_qt::clear_selection(); + } + + pub fn tracks_native_play(self: Pin<&mut Self>, index: i64) { + crate::local_tracks_model_qt::play(index); + } + + pub fn tracks_native_enqueue(self: Pin<&mut Self>, index: i64, mode: QString) { + crate::local_tracks_model_qt::enqueue(index, mode.to_string()); + } + + pub fn tracks_native_row_action( + self: Pin<&mut Self>, + index: i64, + action: QString, + ) { + crate::local_tracks_model_qt::row_action(index, action.to_string()); + } + + pub fn tracks_native_bulk_action(self: Pin<&mut Self>, action: QString) { + crate::local_tracks_model_qt::bulk_action(action.to_string()); + } + pub fn tree_toggle(self: Pin<&mut Self>, path: QString, expand: bool) { let path = path.to_string(); if !expand { diff --git a/crates/qbz-qt/src/local_bridge_ops.rs b/crates/qbz-qt/src/local_bridge_ops.rs index 8da8fc1c8..49978bd64 100644 --- a/crates/qbz-qt/src/local_bridge_ops.rs +++ b/crates/qbz-qt/src/local_bridge_ops.rs @@ -322,11 +322,6 @@ pub(crate) fn load_folders() { } pub(crate) fn load_tracks(reset: bool) { - // Snapshot the query/sort and mint the generation before the worker is - // scheduled. A superseded worker may finish, but cannot mutate state or - // publish over the newer request. - let request = lib::begin_tracks_load(reset); - let generation = request.generation; ui(move |mut b| { if reset { b.as_mut().set_local_tracks_loading(true); @@ -334,6 +329,31 @@ pub(crate) fn load_tracks(reset: bool) { b.as_mut().set_local_tracks_loading_more(true); } }); + if reset && crate::local_tracks_model_qt::reset() { + return; + } + load_tracks_legacy_body(reset); +} + +/// Per-surface rollback target for phase E. The native controller calls this +/// after disabling itself for the session; the old reader remains intact. +pub(crate) fn load_tracks_legacy(reset: bool) { + ui(move |mut b| { + if reset { + b.as_mut().set_local_tracks_loading(true); + } else { + b.as_mut().set_local_tracks_loading_more(true); + } + }); + load_tracks_legacy_body(reset); +} + +fn load_tracks_legacy_body(reset: bool) { + // Snapshot the query/sort and mint the generation before the worker is + // scheduled. A superseded worker may finish, but cannot mutate state or + // publish over the newer request. + let request = lib::begin_tracks_load(reset); + let generation = request.generation; crate::spawn(async move { let result = tokio::task::spawn_blocking(move || lib::load_tracks_page_blocking(request)) .await diff --git a/crates/qbz-qt/src/local_bulk.rs b/crates/qbz-qt/src/local_bulk.rs index 1cc70312e..aa1cb385d 100644 --- a/crates/qbz-qt/src/local_bulk.rs +++ b/crates/qbz-qt/src/local_bulk.rs @@ -178,7 +178,7 @@ fn resolve_blocking(scope: &str, ids: &[String]) -> Vec { /// 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. -async fn apply(rows: Vec, action: &str) -> bool { +pub(crate) async fn apply(rows: Vec, action: &str) -> bool { match action { "queue" | "play-next" | "play-later" => { if rows.is_empty() { diff --git a/crates/qbz-qt/src/local_catalog_qt.rs b/crates/qbz-qt/src/local_catalog_qt.rs index f19c93f1f..120815e9a 100644 --- a/crates/qbz-qt/src/local_catalog_qt.rs +++ b/crates/qbz-qt/src/local_catalog_qt.rs @@ -8,13 +8,35 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; -use qbz_local_catalog::{BootstrapOutcome, CatalogError, ProjectionOutcome}; +use qbz_local_catalog::{ + ActiveCatalog, BootstrapLayout, BootstrapOutcome, CatalogError, ProjectionOutcome, +}; + +enum RefreshOutcome { + Bootstrap(BootstrapOutcome), + Projection(ProjectionOutcome), +} static STARTED: AtomicBool = AtomicBool::new(false); static CANCELLED: AtomicBool = AtomicBool::new(false); static CATCHUP_RUNNING: AtomicBool = AtomicBool::new(false); static CATCHUP_REQUESTED: AtomicBool = AtomicBool::new(false); +/// One profile-scoped sidecar fed by the actual legacy cache locations. Plex +/// remains installation-wide in the current backend; local and remote mirrors +/// follow the active (or guest) user. +pub(crate) fn locations() -> Option { + let root = dirs::data_dir()?.join("qbz"); + let user_id = qbz_app::user_data::UserDataPaths::load_last_user_id().unwrap_or(0); + let user_dir = root.join("users").join(user_id.to_string()); + Some(qbz_local_catalog::LegacyLocations { + catalog_dir: user_dir.clone(), + local_database: user_dir.join("library.db"), + plex_database: root.join("plex_cache.db"), + remote_database: user_dir.join("remote_cache.db"), + }) +} + pub(crate) fn start() { if STARTED.swap(true, Ordering::AcqRel) { return; @@ -29,7 +51,7 @@ pub(crate) fn start() { worker_finished(); return; } - let Some(data_dir) = dirs::data_dir().map(|root| root.join("qbz")) else { + let Some(locations) = locations() else { log::warn!("[local-catalog] fallback=missing-data-directory"); worker_finished(); return; @@ -37,8 +59,8 @@ pub(crate) fn start() { let result = tokio::task::spawn_blocking(move || { let mut last_publish = std::time::Instant::now(); let mut last_source = None; - let bootstrap = qbz_local_catalog::bootstrap_legacy_caches_with_progress( - &data_dir, + let bootstrap = qbz_local_catalog::bootstrap_legacy_caches_at_with_progress( + &locations, &CANCELLED, |progress| { let source_changed = last_source.as_ref() != Some(&progress.source); @@ -64,8 +86,8 @@ pub(crate) fn start() { { let mut last_projection = std::time::Instant::now(); let mut last_projection_source = None; - Some(qbz_local_catalog::reconcile_legacy_caches_with_progress( - &data_dir, + Some(qbz_local_catalog::reconcile_legacy_caches_at_with_progress( + &locations, &CANCELLED, |progress| { let source_changed = @@ -96,6 +118,7 @@ pub(crate) fn start() { .await; match result { Ok(Ok((bootstrap, projection))) => { + let ready = matches!(&bootstrap, BootstrapOutcome::Activated { .. }); match bootstrap { BootstrapOutcome::Activated { generation, @@ -118,6 +141,9 @@ pub(crate) fn start() { if let Some(projection) = projection { log_projection(projection); } + if ready && crate::local_tracks_model_qt::requested() { + crate::local_bridge_ops::load_tracks(true); + } } Ok(Err(CatalogError::InsufficientSpace { required_bytes, @@ -146,16 +172,27 @@ pub(crate) fn request_catch_up() { worker_finished(); return; } - let Some(data_dir) = dirs::data_dir().map(|root| root.join("qbz")) else { + let Some(locations) = locations() else { log::warn!("[local-catalog] fallback=missing-data-directory"); worker_finished(); return; }; let result = tokio::task::spawn_blocking(move || { + if !matches!( + BootstrapLayout::new(&locations.catalog_dir).open_active(), + ActiveCatalog::Ready { .. } + ) { + return qbz_local_catalog::bootstrap_legacy_caches_at_with_progress( + &locations, + &CANCELLED, + |_| {}, + ) + .map(RefreshOutcome::Bootstrap); + } let mut last_publish = std::time::Instant::now(); let mut last_source = None; - qbz_local_catalog::reconcile_legacy_caches_with_progress( - &data_dir, + qbz_local_catalog::reconcile_legacy_caches_at_with_progress( + &locations, &CANCELLED, |progress| { let source_changed = last_source.as_ref() != Some(&progress.source); @@ -177,10 +214,45 @@ pub(crate) fn request_catch_up() { } }, ) + .map(RefreshOutcome::Projection) }) .await; match result { - Ok(Ok(outcome)) => log_projection(outcome), + Ok(Ok(outcome)) => { + let ready = match outcome { + RefreshOutcome::Projection(outcome) => { + log_projection(outcome); + true + } + RefreshOutcome::Bootstrap(BootstrapOutcome::Activated { + generation, + track_count, + resumed_rows, + }) => { + log::info!( + "[local-catalog] phase=active generation={generation} tracks={track_count} resumed_rows={resumed_rows}" + ); + true + } + RefreshOutcome::Bootstrap(BootstrapOutcome::Paused { + generation, + source, + committed_rows, + }) => { + log::info!( + "[local-catalog] phase=paused generation={generation} source={source:?} committed_rows={committed_rows}" + ); + false + } + RefreshOutcome::Bootstrap(BootstrapOutcome::Fallback(reason)) => { + log::warn!("[local-catalog] fallback={reason:?}"); + false + } + }; + if ready && crate::local_tracks_model_qt::requested() { + crate::local_bridge_ops::load_tracks(true); + } + } Ok(Err(CatalogError::InsufficientSpace { required_bytes, available_bytes, diff --git a/crates/qbz-qt/src/local_state.rs b/crates/qbz-qt/src/local_state.rs index d5f67e141..43ceeb1e5 100644 --- a/crates/qbz-qt/src/local_state.rs +++ b/crates/qbz-qt/src/local_state.rs @@ -353,6 +353,10 @@ pub fn set_tracks_query(q: &str) { state(|s| s.tracks_query = q.to_string()); } +pub fn tracks_query() -> String { + state(|s| s.tracks_query.clone()) +} + pub fn begin_tracks_load(reset: bool) -> TracksLoadRequest { state(|s| { if reset { diff --git a/crates/qbz-qt/src/local_tracks_model_qt.rs b/crates/qbz-qt/src/local_tracks_model_qt.rs new file mode 100644 index 000000000..36f0b9f71 --- /dev/null +++ b/crates/qbz-qt/src/local_tracks_model_qt.rs @@ -0,0 +1,1587 @@ +//! Native, bounded Tracks model controller (Local Library backend phase E). +//! +//! QML sees the hand-written `QAbstractListModel` in +//! `cxx/local_tracks_model.*`. This module owns immutable query descriptors, +//! catalog keysets, sparse anchors, async page work, the mirrored eight-page +//! LRU and descriptor/range selection. No database handle crosses an await and +//! the C++ model's `data()` only reads resident rows. + +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, LocalTrack}; +use qbz_local_catalog::{ + ActiveCatalog, BootstrapLayout, QueryDescriptor, SourceKey, SourceKind, TrackCursor, + TrackGroup, TrackRecord, TrackRef, TrackSort, +}; +use qbz_source::SourceId; +use serde::Serialize; + +use crate::local_bridge::ui; +use crate::local_rows::{badge_source, badge_source_raw, detail_of, mmss, tier_of, TrackRow}; + +const PAGE_ROWS: usize = 250; +const MAX_RESIDENT_PAGES: usize = 8; +const ANCHOR_STRIDE_ROWS: usize = 1_000; + +static QUERY_GENERATION: AtomicU64 = AtomicU64::new(0); +static SESSION_FAILED: AtomicBool = AtomicBool::new(false); +static SESSION: Mutex> = Mutex::new(None); + +unsafe extern "C" { + fn qbz_local_tracks_register_qml_type(); + fn qbz_local_tracks_reset(generation: i32, total_count: i64, group: *const c_char); + fn qbz_local_tracks_apply_page(generation: i32, page: i32, json: *const c_char) -> bool; + fn qbz_local_tracks_set_selection(generation: i32, json: *const c_char, selected_count: i64); +} + +#[derive(Clone)] +struct CachedPage { + wire: Arc>, + records: Arc>, +} + +struct NativeSession { + generation: u64, + catalog_generation: u64, + descriptor: QueryDescriptor, + 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 { + #[serde(rename = "groupStart")] + group_start: bool, + #[serde(rename = "groupLabel")] + group_label: String, + row: TrackRow, +} + +#[derive(Serialize)] +struct NativeJump { + letter: String, + index: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct QuerySelection { + /// `false`: ranges are inclusions. `true`: ranges are exclusions from the + /// immutable query descriptor. + all: bool, + ranges: Vec<(u64, u64)>, + anchor: Option, +} + +impl QuerySelection { + fn contains(&self, index: u64) -> bool { + let in_ranges = self + .ranges + .iter() + .any(|&(first, last)| first <= index && index <= last); + if self.all { + !in_ranges + } else { + in_ranges + } + } + + 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); + } + + fn select_all(&mut self) { + self.all = true; + self.ranges.clear(); + self.anchor = None; + } + + fn clear(&mut self) { + *self = Self::default(); + } +} + +#[derive(Debug)] +struct PageLru { + capacity: usize, + entries: HashMap, + order: VecDeque, + evictions: u64, +} + +impl PageLru { + fn new(capacity: usize) -> Self { + Self { + capacity, + entries: HashMap::new(), + order: VecDeque::new(), + evictions: 0, + } + } + + fn get(&mut self, page: usize) -> Option<&T> { + if !self.entries.contains_key(&page) { + return None; + } + self.touch(page); + self.entries.get(&page) + } + + fn insert(&mut self, page: usize, value: T) { + self.entries.insert(page, value); + self.touch(page); + while self.entries.len() > self.capacity { + let Some(oldest) = self.order.pop_front() else { + break; + }; + if self.entries.remove(&oldest).is_some() { + self.evictions += 1; + } + } + } + + fn touch(&mut self, page: usize) { + self.order.retain(|candidate| *candidate != page); + self.order.push_back(page); + } + + fn resident_rows(&self) -> usize { + self.entries.len() * PAGE_ROWS + } +} + +pub(crate) fn register_qml_model() { + // SAFETY: link/registration anchor; no arguments and no retained Rust + // pointer. Actual registration runs through Q_COREAPP_STARTUP_FUNCTION. + unsafe { qbz_local_tracks_register_qml_type() }; +} + +pub(crate) fn requested() -> bool { + if SESSION_FAILED.load(Ordering::Acquire) { + return false; + } + std::env::var("QBZ_LOCAL_CATALOG_TRACKS") + .ok() + .is_some_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on")) +} + +/// Start a new immutable native query. Returns `false` when the runtime flag +/// is off or this surface already fell back for the session. +pub(crate) fn reset() -> bool { + if !requested() { + return false; + } + let generation = next_generation(); + let query = crate::local_state::tracks_query(); + let sort = parse_sort(&crate::local_state::tracks_sort()); + let group = parse_group(&crate::local_state::tracks_group()); + let descriptor = QueryDescriptor::tracks() + .with_search(query.clone()) + .with_sort(sort) + .with_group(group); + + *SESSION.lock().unwrap_or_else(|error| error.into_inner()) = None; + if !query.is_empty() && query.chars().count() < 3 { + publish_empty(generation, group); + return true; + } + + crate::spawn(async move { + let result = tokio::task::spawn_blocking(move || open_query(generation, descriptor)).await; + match result { + Ok(Ok(opened)) => activate_query(opened), + Ok(Err(reason)) => fallback(generation, reason), + Err(_) => fallback(generation, "worker-join"), + } + }); + true +} + +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 { + log::debug!("[local-catalog] phase=page-discard generation={generation} reason=stale-miss"); + return; + }; + if let Some(cached) = current.cache.get(page).cloned() { + let rows = current.cache.resident_rows(); + let evictions = current.cache.evictions; + drop(session); + publish_page(generation, page, &cached.wire, true, rows, evictions); + return; + } + if !current.pending_pages.insert(page) { + return; + } + + let start = page.saturating_mul(PAGE_ROWS); + let nearest = nearest_anchor(¤t.anchors, start); + if start.saturating_sub(nearest.0) > ANCHOR_STRIDE_ROWS && !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 anchor_row = nearest.0; + let anchor_cursor = nearest.1.cloned(); + drop(session); + + crate::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + load_page( + generation, + catalog_generation, + descriptor, + page, + anchor_row, + anchor_cursor, + ) + }) + .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 { + return; + } + selection.toggle(index as u64, shift); + }); +} + +pub(crate) fn select_all() { + update_selection(|selection, _| selection.select_all()); +} + +pub(crate) fn clear_selection() { + update_selection(|selection, _| selection.clear()); +} + +/// Play the resident keyset window containing `index`. The clicked page and +/// any already-prefetched consecutive pages become the queue; resolving the +/// stable catalog refs happens off the Qt thread against the authoritative +/// caches. +pub(crate) fn play(index: i64) { + let Some((generation, records, start)) = resident_play_window(index) else { + return; + }; + crate::spawn(async move { + let rows = tokio::task::spawn_blocking(move || resolve_records_blocking(&records)) + .await + .unwrap_or_default(); + if rows.is_empty() || QUERY_GENERATION.load(Ordering::Acquire) != generation { + return; + } + crate::local_playback::play_rows(&crate::app(), rows, start, false).await; + }); +} + +/// Apply a one-row queue action without making the QML row carry a legacy +/// integer id. The index resolves to the resident TrackRef snapshot. +pub(crate) fn enqueue(index: i64, mode: String) { + let Some((generation, record)) = resident_record(index) else { + return; + }; + crate::spawn(async move { + let rows = tokio::task::spawn_blocking(move || resolve_records_blocking(&[record])) + .await + .unwrap_or_default(); + if rows.is_empty() || QUERY_GENERATION.load(Ordering::Acquire) != generation { + return; + } + let action = match mode.as_str() { + "next" => "play-next", + "later" => "play-later", + _ => "queue", + }; + crate::local_bulk::apply(rows, action).await; + }); +} + +pub(crate) fn row_action(index: i64, action: String) { + let Some((generation, record)) = resident_record(index) else { + return; + }; + crate::spawn(async move { + let rows = tokio::task::spawn_blocking(move || resolve_records_blocking(&[record])) + .await + .unwrap_or_default(); + if rows.is_empty() || QUERY_GENERATION.load(Ordering::Acquire) != generation { + return; + } + crate::local_bulk::apply(rows, &action).await; + }); +} + +/// Resolve the descriptor/range selection only when an explicit action needs +/// concrete authoritative rows. Select-all itself remains O(1) memory. +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.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 resolved = tokio::task::spawn_blocking(move || { + let records = selected_records_blocking( + generation, + catalog_generation, + &descriptor, + total, + &selection, + )?; + Ok::<_, &'static str>(resolve_records_blocking(&records)) + }) + .await; + let rows = match resolved { + Ok(Ok(rows)) => rows, + Ok(Err("superseded")) => return, + Ok(Err(reason)) => { + log::warn!( + "[local-catalog] phase=tracks-action generation={generation} action={action} reason={reason}" + ); + return; + } + Err(_) => return, + }; + if QUERY_GENERATION.load(Ordering::Acquire) != generation { + return; + } + log::info!( + "[local-catalog] phase=tracks-action generation={generation} action={action} resolved_rows={}", + rows.len() + ); + if crate::local_bulk::apply(rows, &action).await { + clear_selection_generation(generation); + } + }); +} + +fn resident_record(index: i64) -> Option<(u64, TrackRecord)> { + let index = usize::try_from(index).ok()?; + let mut session = SESSION.lock().unwrap_or_else(|error| error.into_inner()); + let current = session.as_mut()?; + if index as u64 >= current.total { + return None; + } + let page = index / PAGE_ROWS; + let offset = index % PAGE_ROWS; + let record = current.cache.get(page)?.records.get(offset)?.clone(); + Some((current.generation, record)) +} + +fn resident_play_window(index: i64) -> Option<(u64, Vec, usize)> { + let index = usize::try_from(index).ok()?; + let mut session = SESSION.lock().unwrap_or_else(|error| error.into_inner()); + let current = session.as_mut()?; + if index as u64 >= current.total { + return None; + } + let first_page = index / PAGE_ROWS; + let start = index % PAGE_ROWS; + let mut records = Vec::new(); + for page in first_page..first_page.saturating_add(MAX_RESIDENT_PAGES) { + let Some(cached) = current.cache.get(page) else { + break; + }; + records.extend(cached.records.iter().cloned()); + } + (start < records.len()).then_some((current.generation, records, start)) +} + +fn selected_records_blocking( + 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 capacity = selection.count(total).min(16_384) as usize; + let mut selected = Vec::with_capacity(capacity); + loop { + if QUERY_GENERATION.load(Ordering::Acquire) != generation { + return Err("superseded"); + } + let page = catalog + .query_tracks(descriptor, cursor.as_ref(), 500) + .map_err(|_| "action-query")?; + for record in page.rows { + if selection.contains(index) { + selected.push(record); + } + index = index.saturating_add(1); + } + let Some(next) = page.next_cursor else { + break; + }; + cursor = Some(next); + } + Ok(selected) +} + +fn resolve_records_blocking(records: &[TrackRecord]) -> Vec { + let local_ids = records + .iter() + .filter_map(|record| record.local_track_id) + .collect::>(); + let local_rows = crate::local_state::with_db(|db| { + let mut rows = HashMap::new(); + for id in &local_ids { + if let Some(track) = db.get_track(*id)? { + rows.insert(*id, track); + } + } + Ok(rows) + }) + .unwrap_or_default(); + + let plex_ids = records + .iter() + .filter(|record| record.track_ref.source == SourceKind::Plex) + .map(|record| record.track_ref.native_id.clone()) + .collect::>(); + let mut plex_rows = HashMap::new(); + for chunk in plex_ids.chunks(500) { + if let Ok(rows) = qbz_plex::plex_cache_get_cached_tracks_by_keys(chunk) { + for row in rows { + let mapped = crate::local_plex::map_cached_to_local_track(row); + plex_rows.insert(mapped.file_path.clone(), mapped); + } + } + } + + let jellyfin = resolve_remote_records(records, SourceKind::Jellyfin); + let subsonic = resolve_remote_records(records, SourceKind::Subsonic); + let mut missing = 0usize; + let rows = records + .iter() + .filter_map(|record| { + let resolved = match record.track_ref.source { + SourceKind::Local | SourceKind::Offline => record + .local_track_id + .and_then(|id| local_rows.get(&id).cloned()), + SourceKind::Plex => plex_rows.get(&record.track_ref.native_id).cloned(), + SourceKind::Jellyfin => jellyfin.get(&record.track_ref.native_id).cloned(), + SourceKind::Subsonic => subsonic.get(&record.track_ref.native_id).cloned(), + }; + if resolved.is_none() { + missing += 1; + } + resolved + }) + .collect::>(); + if missing > 0 { + log::warn!( + "[local-catalog] phase=tracks-resolve requested={} missing={missing}", + records.len() + ); + } + rows +} + +fn resolve_remote_records( + records: &[TrackRecord], + source_kind: SourceKind, +) -> HashMap { + let source = match source_kind { + SourceKind::Jellyfin => qbz_media_cache::RemoteSource::Jellyfin, + SourceKind::Subsonic => qbz_media_cache::RemoteSource::Subsonic, + _ => return HashMap::new(), + }; + let ids = records + .iter() + .filter(|record| record.track_ref.source == source_kind) + .map(|record| record.track_ref.native_id.as_str()) + .collect::>(); + let read = |conn: &rusqlite::Connection| { + ids.iter() + .filter_map(|id| { + qbz_media_cache::track_by_item_id(conn, source, id) + .ok() + .flatten() + .map(crate::media_servers_qt::cached_to_local_track) + .map(|track| ((*id).to_string(), track)) + }) + .collect::>() + }; + match source { + qbz_media_cache::RemoteSource::Jellyfin => qbz_source::registry() + .jellyfin() + .cache() + .with(read) + .unwrap_or_default(), + qbz_media_cache::RemoteSource::Subsonic => qbz_source::registry() + .subsonic() + .cache() + .with(read) + .unwrap_or_default(), + } +} + +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.clear(); + drop(session); + ui(move |mut bridge| { + bridge.as_mut().set_local_tracks_native_selected_count(0); + cpp_set_selection(generation, &selection_json(&QuerySelection::default()), 0); + }); +} + +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.total); + let generation = current.generation; + let count = current.selection.count(current.total); + let json = selection_json(¤t.selection); + drop(session); + ui(move |mut bridge| { + bridge + .as_mut() + .set_local_tracks_native_selected_count(count.min(i64::MAX as u64) as i64); + cpp_set_selection(generation, &json, count); + }); +} + +struct OpenedQuery { + generation: u64, + catalog_generation: u64, + descriptor: QueryDescriptor, + total: u64, + page: LoadedPage, + count_time: std::time::Duration, +} + +struct LoadedPage { + generation: u64, + page: usize, + wire: Vec, + records: Vec, + anchors: Vec<(usize, TrackCursor)>, + sql_time: std::time::Duration, + map_time: std::time::Duration, +} + +fn open_query( + generation: u64, + mut descriptor: QueryDescriptor, +) -> 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, + total: 0, + page: LoadedPage { + generation, + page: 0, + wire: Vec::new(), + records: Vec::new(), + anchors: Vec::new(), + sql_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 total = catalog.count_tracks(&descriptor).map_err(|_| "count")?; + let count_time = count_started.elapsed(); + let page = load_page_from_catalog(&catalog, generation, &descriptor, 0, 0, None)?; + Ok(OpenedQuery { + generation, + catalog_generation, + descriptor, + total, + page, + count_time, + }) +} + +fn activate_query(opened: OpenedQuery) { + if QUERY_GENERATION.load(Ordering::Acquire) != opened.generation { + log::info!( + "[local-catalog] phase=query-discard generation={} reason=superseded-open", + opened.generation + ); + return; + } + let name_group = opened.descriptor.group() == TrackGroup::Name; + let group = group_word(opened.descriptor.group()).to_string(); + let initial_jumps = jumps_json(if name_group { + name_jumps(&opened.page.records, 0) + } else { + Vec::new() + }); + let first_wire = Arc::new(opened.page.wire.clone()); + let first_records = Arc::new(opened.page.records.clone()); + let mut cache = PageLru::new(MAX_RESIDENT_PAGES); + cache.insert( + 0, + CachedPage { + wire: Arc::clone(&first_wire), + records: first_records, + }, + ); + let mut anchors = BTreeMap::new(); + for (row, cursor) in opened.page.anchors { + anchors.insert(row, cursor); + } + *SESSION.lock().unwrap_or_else(|error| error.into_inner()) = Some(NativeSession { + generation: opened.generation, + catalog_generation: opened.catalog_generation, + descriptor: opened.descriptor, + total: opened.total, + anchors, + anchors_ready: opened.total <= ANCHOR_STRIDE_ROWS as u64, + anchor_building: false, + pending_pages: HashSet::new(), + waiting_pages: HashSet::new(), + cache, + selection: QuerySelection::default(), + }); + + let generation = opened.generation; + let total = opened.total; + let count_time = opened.count_time; + let sql_time = opened.page.sql_time; + let map_time = opened.page.map_time; + let wire = Arc::clone(&first_wire); + ui(move |mut bridge| { + cpp_reset(generation, total, &group); + let bytes = publish_page_now(generation, 0, &wire); + bridge.as_mut().set_local_tracks_native_active(true); + bridge + .as_mut() + .set_local_tracks_native_total(total.min(i64::MAX as u64) as i64); + bridge.as_mut().set_local_tracks_native_selected_count(0); + bridge + .as_mut() + .set_local_tracks_native_jumps_json(QString::from(initial_jumps.as_str())); + bridge + .as_mut() + .set_local_tracks_native_error(QString::default()); + bridge.as_mut().set_local_tracks_loading(false); + bridge.as_mut().set_local_tracks_loading_more(false); + log::info!( + "[local-catalog] phase=tracks-native generation={generation} total={total} page=0 rows={} json_bytes={bytes} count={count_time:?} sql={sql_time:?} map={map_time:?} resident_rows={} cache_pages=1", + wire.len(), + wire.len() + ); + }); + if opened.total > ANCHOR_STRIDE_ROWS as u64 || (name_group && opened.total > PAGE_ROWS as u64) { + build_anchors(generation); + } +} + +fn publish_empty(generation: u64, group: TrackGroup) { + let group = group_word(group).to_string(); + ui(move |mut bridge| { + cpp_reset(generation, 0, &group); + bridge.as_mut().set_local_tracks_native_active(true); + bridge.as_mut().set_local_tracks_native_total(0); + bridge.as_mut().set_local_tracks_native_selected_count(0); + bridge + .as_mut() + .set_local_tracks_native_jumps_json(QString::from("[]")); + bridge.as_mut().set_local_tracks_loading(false); + bridge.as_mut().set_local_tracks_loading_more(false); + }); +} + +fn load_page( + generation: u64, + catalog_generation: u64, + descriptor: QueryDescriptor, + page: usize, + anchor_row: usize, + anchor_cursor: Option, +) -> Result { + let (catalog, active_generation) = open_active()?; + if active_generation != catalog_generation { + return Err("catalog-generation-changed"); + } + load_page_from_catalog( + &catalog, + generation, + &descriptor, + page, + anchor_row, + anchor_cursor, + ) +} + +fn load_page_from_catalog( + catalog: &qbz_local_catalog::Catalog, + generation: u64, + descriptor: &QueryDescriptor, + target_page: usize, + mut row: usize, + mut cursor: Option, +) -> Result { + let target_row = target_page.saturating_mul(PAGE_ROWS); + let mut anchors = Vec::new(); + while row < target_row { + if QUERY_GENERATION.load(Ordering::Acquire) != generation { + return Err("superseded"); + } + let (page, _) = catalog + .query_tracks_timed(descriptor, cursor.as_ref(), PAGE_ROWS) + .map_err(|_| "seek-query")?; + let count = page.rows.len(); + let Some(next) = page.next_cursor else { + return Err("seek-past-end"); + }; + row = row.saturating_add(count); + cursor = Some(next.clone()); + anchors.push((row, next)); + } + if row != target_row { + return Err("anchor-misaligned"); + } + + let previous_group = cursor + .as_ref() + .map(|value| cursor_group_key(value, descriptor.group())); + let query_started = Instant::now(); + let (page, metrics) = catalog + .query_tracks_timed(descriptor, cursor.as_ref(), PAGE_ROWS) + .map_err(|_| "page-query")?; + let sql_time = query_started.elapsed().max(metrics.sql_time); + if let Some(next) = page.next_cursor.clone() { + anchors.push((target_row.saturating_add(page.rows.len()), next)); + } + let records = page.rows; + let map_started = Instant::now(); + let wire = map_entries(&records, descriptor.group(), previous_group.as_deref()); + let map_time = map_started.elapsed(); + Ok(LoadedPage { + generation, + page: target_page, + wire, + records, + anchors, + sql_time, + map_time, + }) +} + +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 { + log::info!( + "[local-catalog] phase=query-discard generation={} page={} reason=superseded-page", + loaded.generation, + loaded.page + ); + return; + }; + current.pending_pages.remove(&loaded.page); + for (row, cursor) in &loaded.anchors { + current.anchors.insert(*row, cursor.clone()); + } + let cached = CachedPage { + wire: Arc::new(loaded.wire), + records: Arc::new(loaded.records), + }; + let wire = Arc::clone(&cached.wire); + current.cache.insert(loaded.page, cached); + let resident = current.cache.resident_rows(); + let evictions = current.cache.evictions; + drop(session); + publish_page( + loaded.generation, + loaded.page, + &wire, + false, + resident, + evictions, + ); + log::info!( + "[local-catalog] phase=tracks-page generation={} page={} rows={} sql={:?} map={:?} resident_rows={} evictions={}", + loaded.generation, + loaded.page, + wire.len(), + loaded.sql_time, + loaded.map_time, + resident, + evictions + ); +} + +fn publish_page( + generation: u64, + page: usize, + wire: &Arc>, + cache_hit: bool, + resident: usize, + evictions: u64, +) { + let wire = Arc::clone(wire); + ui(move |_| { + let bytes = publish_page_now(generation, page, &wire); + log::info!( + "[local-catalog] phase=tracks-publish generation={generation} page={page} rows={} json_bytes={bytes} cache_hit={cache_hit} resident_rows={resident} evictions={evictions}", + wire.len() + ); + }); +} + +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: the C++ call consumes the bounded JSON synchronously and never + // retains the pointer. + unsafe { + qbz_local_tracks_apply_page(generation as i32, page as i32, json.as_ptr()); + } + bytes +} + +fn cpp_reset(generation: u64, total: u64, group: &str) { + let group = CString::new(group).expect("group words contain no NUL"); + // SAFETY: synchronous copy into QString; pointer is not retained. + unsafe { + qbz_local_tracks_reset( + generation as i32, + total.min(i64::MAX as u64) as i64, + group.as_ptr(), + ) + }; +} + +fn cpp_set_selection(generation: u64, json: &str, count: u64) { + let Ok(json) = CString::new(json) else { + return; + }; + // SAFETY: synchronous QByteArray copy; pointer is not retained. + unsafe { + qbz_local_tracks_set_selection( + generation as i32, + json.as_ptr(), + count.min(i64::MAX as u64) as i64, + ) + }; +} + +fn build_anchors(generation: u64) { + let (descriptor, catalog_generation) = { + 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) + }; + 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 mut cursor = None; + let mut row = 0usize; + let mut anchors = Vec::new(); + let mut jumps = Vec::new(); + let mut previous_letter = String::new(); + loop { + if QUERY_GENERATION.load(Ordering::Acquire) != generation { + return Err("superseded"); + } + let page = catalog + .query_tracks(&descriptor, cursor.as_ref(), 500) + .map_err(|_| "anchor-query")?; + if descriptor.group() == TrackGroup::Name { + for jump in name_jumps(&page.rows, row) { + if jump.letter != previous_letter { + previous_letter = jump.letter.clone(); + jumps.push(jump); + } + } + } + row = row.saturating_add(page.rows.len()); + let Some(next) = page.next_cursor else { + break; + }; + if row % ANCHOR_STRIDE_ROWS == 0 { + anchors.push((row, next.clone())); + } + cursor = Some(next); + } + Ok((anchors, jumps, started.elapsed())) + }) + .await; + match result { + Ok(Ok((anchors, jumps, elapsed))) => { + finish_anchors(generation, anchors, jumps, elapsed) + } + Ok(Err("superseded")) => { + log::info!( + "[local-catalog] phase=query-discard generation={generation} reason=superseded-anchors" + ); + } + Ok(Err(reason)) => fallback(generation, reason), + Err(_) => fallback(generation, "anchor-worker-join"), + } + }); +} + +fn finish_anchors( + generation: u64, + anchors: Vec<(usize, TrackCursor)>, + jumps: Vec, + 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(); + current.anchors.extend(anchors); + current.anchors_ready = true; + current.anchor_building = false; + log::info!( + "[local-catalog] phase=tracks-anchors generation={generation} anchors={count} stride_rows={ANCHOR_STRIDE_ROWS} elapsed={elapsed:?}" + ); + current.waiting_pages.drain().collect::>() + }; + let jumps = jumps_json(jumps); + ui(move |mut bridge| { + bridge + .as_mut() + .set_local_tracks_native_jumps_json(QString::from(jumps.as_str())); + }); + for page in waiting { + request_page(page as i32, generation as i32); + } +} + +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 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 map_entries( + records: &[TrackRecord], + group: TrackGroup, + previous: Option<&str>, +) -> Vec { + crate::local_state::with_art(|art| { + let mut previous = previous.map(str::to_string); + records + .iter() + .map(|record| { + let key = record_group_key(record, group); + let group_start = + group != TrackGroup::Off && previous.as_deref() != Some(key.as_str()); + previous = Some(key); + NativeEntry { + group_start, + group_label: record_group_label(record, group), + row: map_record(record, art), + } + }) + .collect() + }) +} + +fn map_record(record: &TrackRecord, art: &mut HashMap) -> TrackRow { + let id = encode_ref(&record.track_ref); + let art_key = format!("catalog-track:{id}"); + let raw = if record.source_raw.trim().is_empty() { + record.track_ref.source.as_str() + } else { + record.source_raw.as_str() + }; + if let Some(token) = record + .artwork_token + .as_deref() + .filter(|value| !value.is_empty()) + { + if let Some(source) = SourceId::from_word(raw) { + art.insert(art_key.clone(), (source, token.to_string())); + } + } + let format = audio_format(&record.format); + TrackRow { + id, + title: record.title.clone(), + artist: record.artist.clone(), + album: record.album.clone(), + album_id: album_id(record), + artist_id: String::new(), + number: record.track_number.unwrap_or(0), + disc: record.disc_number.unwrap_or(1), + duration: mmss(record.duration_ms / 1_000), + quality_tier: tier_of( + &format, + record.bit_depth, + record.sample_rate_hz.unwrap_or(0) as f64, + ) + .to_string(), + quality_detail: detail_of( + &format, + record.bit_depth, + record.sample_rate_hz.unwrap_or(0) as f64, + ), + format: record.format.to_ascii_uppercase(), + year: record.year.map(|year| year.to_string()).unwrap_or_default(), + art_key, + art_path: String::new(), + source: badge_source(Some(raw)), + source_raw: badge_source_raw(Some(raw)), + explicit: false, + is_favorite: false, + } +} + +fn album_id(record: &TrackRecord) -> String { + let native = record.native_album_id.as_deref().unwrap_or(""); + match record.track_ref.source { + SourceKind::Plex if native.starts_with("plex:") => native.to_string(), + SourceKind::Plex if !native.is_empty() => format!("plex:album:{native}"), + SourceKind::Jellyfin if !native.is_empty() => format!("jellyfin:{native}"), + SourceKind::Subsonic if !native.is_empty() => format!("subsonic:{native}"), + _ => native.to_string(), + } +} + +fn audio_format(value: &str) -> AudioFormat { + match value.to_ascii_lowercase().as_str() { + "flac" => AudioFormat::Flac, + "alac" | "m4a" => AudioFormat::Alac, + "wav" | "wave" => AudioFormat::Wav, + "aiff" | "aif" => AudioFormat::Aiff, + "ape" => AudioFormat::Ape, + "mp3" => AudioFormat::Mp3, + "dsd" | "dsf" | "dff" => AudioFormat::Dsd, + _ => AudioFormat::Unknown, + } +} + +fn encode_ref(track_ref: &TrackRef) -> String { + serde_json::to_string(track_ref).unwrap_or_default() +} + +fn record_group_key(record: &TrackRecord, group: TrackGroup) -> String { + match group { + TrackGroup::Off => String::new(), + TrackGroup::Album => qbz_local_catalog::normalize_sort_key(&record.album), + TrackGroup::Artist => qbz_local_catalog::normalize_sort_key(&record.artist), + TrackGroup::Name => qbz_local_catalog::normalize_sort_key(&record.title) + .chars() + .next() + .unwrap_or('#') + .to_uppercase() + .collect(), + } +} + +fn record_group_label(record: &TrackRecord, group: TrackGroup) -> String { + match group { + TrackGroup::Off => String::new(), + TrackGroup::Album => record.album.clone(), + TrackGroup::Artist => record.artist.clone(), + TrackGroup::Name => record_group_key(record, TrackGroup::Name), + } +} + +fn name_jumps(records: &[TrackRecord], base: usize) -> Vec { + let mut previous = String::new(); + let mut jumps = Vec::new(); + for (offset, record) in records.iter().enumerate() { + let letter = record_group_label(record, TrackGroup::Name); + if letter != previous { + previous = letter.clone(); + jumps.push(NativeJump { + letter, + index: base.saturating_add(offset), + }); + } + } + jumps +} + +fn jumps_json(jumps: Vec) -> String { + serde_json::to_string(&jumps).unwrap_or_else(|_| "[]".to_string()) +} + +fn cursor_group_key(cursor: &TrackCursor, group: TrackGroup) -> String { + cursor.group_key(group) +} + +fn nearest_anchor( + anchors: &BTreeMap, + target: usize, +) -> (usize, Option<&TrackCursor>) { + anchors + .range(..=target) + .next_back() + .map(|(row, cursor)| (*row, Some(cursor))) + .unwrap_or((0, None)) +} + +fn parse_sort(value: &str) -> TrackSort { + match value { + "title-asc" => TrackSort::TitleAsc, + "title-desc" => TrackSort::TitleDesc, + "artist-asc" => TrackSort::ArtistAsc, + "artist-desc" => TrackSort::ArtistDesc, + "year-asc" => TrackSort::YearAsc, + "year-desc" => TrackSort::YearDesc, + "added-desc" => TrackSort::AddedDesc, + _ => TrackSort::Default, + } +} + +fn parse_group(value: &str) -> TrackGroup { + match value { + "album" => TrackGroup::Album, + "artist" => TrackGroup::Artist, + "name" => TrackGroup::Name, + _ => TrackGroup::Off, + } +} + +fn group_word(group: TrackGroup) -> &'static str { + match group { + TrackGroup::Off => "off", + TrackGroup::Album => "album", + TrackGroup::Artist => "artist", + TrackGroup::Name => "name", + } +} + +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 fallback(generation: u64, reason: &'static str) { + if QUERY_GENERATION.load(Ordering::Acquire) != generation { + return; + } + // A first paint can legitimately beat the background bootstrap. Serve the + // legacy reader now but allow the ready notification to retry. Query, + // schema and model failures remain session-sticky as required. + 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=tracks-fallback generation={generation} reason={reason}"); + ui(move |mut bridge| { + bridge.as_mut().set_local_tracks_native_active(false); + bridge.as_mut().set_local_tracks_native_total(0); + bridge.as_mut().set_local_tracks_native_selected_count(0); + bridge + .as_mut() + .set_local_tracks_native_jumps_json(QString::from("[]")); + bridge + .as_mut() + .set_local_tracks_native_error(QString::from(reason)); + crate::local_bridge_ops::load_tracks_legacy(true); + }); +} + +fn selection_json(selection: &QuerySelection) -> String { + serde_json::json!({ + "all": selection.all, + "ranges": selection.ranges, + }) + .to_string() +} + +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::*; + use qbz_local_catalog::{Catalog, ProjectedTrack}; + + #[test] + fn page_cache_is_a_bounded_lru_and_reports_hits_and_evictions() { + let mut cache = PageLru::new(8); + for page in 0..8 { + cache.insert(page, page); + } + assert_eq!(cache.resident_rows(), 2_000); + assert_eq!(cache.get(0), Some(&0)); + cache.insert(8, 8); + assert!(cache.get(1).is_none()); + assert_eq!(cache.get(0), Some(&0)); + assert_eq!(cache.evictions, 1); + assert_eq!(cache.resident_rows(), 2_000); + } + + #[test] + fn page_miss_does_not_change_residency_before_async_delivery() { + let mut cache = PageLru::new(8); + cache.insert(0, 10); + assert!(cache.get(7).is_none()); + assert_eq!(cache.entries.len(), 1); + assert_eq!(cache.resident_rows(), PAGE_ROWS); + assert_eq!(cache.evictions, 0); + } + + #[test] + fn old_query_generation_cannot_become_current() { + QUERY_GENERATION.store(41, Ordering::Release); + let old = QUERY_GENERATION.load(Ordering::Acquire); + let current = next_generation(); + assert_eq!(old, 41); + assert_eq!(current, 42); + assert_ne!(QUERY_GENERATION.load(Ordering::Acquire), old); + } + + #[test] + fn selection_all_is_descriptor_plus_bounded_exclusion_ranges() { + let mut selection = QuerySelection::default(); + selection.select_all(); + selection.toggle(25, false); + selection.toggle(26, false); + selection.toggle(100, false); + assert!(selection.all); + assert_eq!(selection.ranges, vec![(25, 26), (100, 100)]); + assert_eq!(selection.count(1_000_000), 999_997); + selection.anchor = Some(20); + selection.toggle(30, true); + assert!(selection.ranges.iter().all(|&(a, b)| b < 20 || a > 30)); + assert_eq!(selection.count(1_000_000), 999_999); + } + + #[test] + fn shift_selection_uses_intervals_not_one_id_per_result() { + 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); + } + + #[test] + fn name_jump_metadata_is_bounded_and_uses_global_row_indices() { + let records = ["Alpha", "Another", "Beta", "Zulu"] + .into_iter() + .enumerate() + .map(|(index, title)| metric_track(index as u64, SourceKind::Local, title)) + .collect::>(); + let mut catalog = Catalog::open_in_memory(1).unwrap(); + catalog.upsert_tracks(&records).unwrap(); + let page = catalog + .query_tracks( + &QueryDescriptor::tracks().with_group(TrackGroup::Name), + None, + 10, + ) + .unwrap(); + let jumps = name_jumps(&page.rows, 10_000); + assert_eq!(jumps.len(), 3); + assert_eq!(jumps[0].letter, "A"); + assert_eq!(jumps[0].index, 10_000); + assert_eq!(jumps[2].letter, "Z"); + assert_eq!(jumps[2].index, 10_003); + } + + #[test] + fn artist_group_headers_follow_track_artist_across_pages() { + let mut catalog = Catalog::open_in_memory(1).unwrap(); + let mut zed = metric_track(1, SourceKind::Local, "Track Z"); + zed.artist = "Zed Performer".to_string(); + zed.album_artist = "Alpha Album Artist".to_string(); + let mut alpha = metric_track(2, SourceKind::Plex, "Track A"); + alpha.artist = "Alpha Performer".to_string(); + alpha.album_artist = "Zed Album Artist".to_string(); + catalog.upsert_tracks(&[zed, alpha]).unwrap(); + + let descriptor = QueryDescriptor::tracks().with_group(TrackGroup::Artist); + let first = catalog.query_tracks(&descriptor, None, 1).unwrap(); + let first_wire = map_entries(&first.rows, TrackGroup::Artist, None); + assert!(first_wire[0].group_start); + assert_eq!(first_wire[0].group_label, "Alpha Performer"); + + let cursor = first.next_cursor.expect("second artist group page"); + let previous = cursor.group_key(TrackGroup::Artist); + let second = catalog.query_tracks(&descriptor, Some(&cursor), 1).unwrap(); + let second_wire = map_entries(&second.rows, TrackGroup::Artist, Some(&previous)); + assert!(second_wire[0].group_start); + assert_eq!(second_wire[0].group_label, "Zed Performer"); + } + + #[test] + fn native_first_page_transport_and_broad_search_are_bounded() { + const TOTAL: u64 = 20_000; + let mut catalog = Catalog::open_in_memory(9).unwrap(); + let source_kinds = [ + SourceKind::Local, + SourceKind::Plex, + SourceKind::Jellyfin, + SourceKind::Subsonic, + ]; + let projected = (0..TOTAL) + .map(|index| { + metric_track( + index, + source_kinds[index as usize % source_kinds.len()], + &format!("Track {index:06}"), + ) + }) + .collect::>(); + catalog.upsert_tracks(&projected).unwrap(); + + let descriptor = QueryDescriptor::tracks(); + let count_started = Instant::now(); + let count = catalog.count_tracks(&descriptor).unwrap(); + let count_time = count_started.elapsed(); + let (page, query_metrics) = catalog + .query_tracks_timed(&descriptor, None, PAGE_ROWS) + .unwrap(); + let map_started = Instant::now(); + let wire = map_entries(&page.rows, TrackGroup::Off, None); + 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(); + + let broad = descriptor.with_search("Track"); + let broad_started = Instant::now(); + let broad_count = catalog.count_tracks(&broad).unwrap(); + let broad_count_time = broad_started.elapsed(); + let (broad_page, broad_metrics) = + catalog.query_tracks_timed(&broad, None, PAGE_ROWS).unwrap(); + let counts = catalog + .stats() + .unwrap() + .source_counts + .into_iter() + .map(|(source, rows)| format!("{}={rows}", source.source.as_str())) + .collect::>() + .join(","); + + assert_eq!(count, TOTAL); + assert_eq!(page.rows.len(), PAGE_ROWS); + assert!(page.has_more); + assert_eq!(broad_count, TOTAL); + assert_eq!(broad_page.rows.len(), PAGE_ROWS); + assert!(bytes < 256 * 1024, "bounded page JSON was {bytes} bytes"); + println!( + "E_NATIVE_METRIC total={TOTAL} first_rows={} json_bytes={bytes} count_ms={:.3} query_ms={:.3} map_ms={:.3} serialize_ms={:.3} broad_count_ms={:.3} broad_query_ms={:.3} source_counts={counts} qml_track_json_bytes=0", + page.rows.len(), + count_time.as_secs_f64() * 1_000.0, + query_metrics.sql_time.as_secs_f64() * 1_000.0, + map_time.as_secs_f64() * 1_000.0, + serialize_time.as_secs_f64() * 1_000.0, + broad_count_time.as_secs_f64() * 1_000.0, + broad_metrics.sql_time.as_secs_f64() * 1_000.0, + ); + } + + fn metric_track(index: u64, source: SourceKind, title: &str) -> ProjectedTrack { + ProjectedTrack { + track_ref: TrackRef { + source, + source_instance: format!("{}-fixture", source.as_str()), + native_id: index.to_string(), + }, + source_raw: source.as_str().to_string(), + local_track_id: (source == SourceKind::Local).then_some(index as i64 + 1), + local_path: (source == SourceKind::Local).then(|| format!("/fixture/{index}.flac")), + native_album_id: Some(format!("album-{}", index / 10)), + source_copy_id: None, + title: title.to_string(), + artist: format!("Artist {}", index % 401), + album_artist: format!("Artist {}", index % 401), + album: format!("Album {}", index % 997), + duration_ms: 180_000, + year: Some(1980 + (index % 45) as u32), + disc_number: Some(1), + track_number: Some((index % 20 + 1) as u32), + format: "flac".to_string(), + bit_depth: Some(24), + sample_rate_hz: Some(96_000), + artwork_token: None, + isrc: None, + musicbrainz_recording_id: None, + added_at: index as i64, + available: true, + observed_generation: 1, + credits: Vec::new(), + } + } +} diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 99f0ebb5c..0f2f7e5af 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -181,6 +181,9 @@ mod local_artist_match; mod local_rows; mod local_state; 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; 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 @@ -3031,6 +3034,9 @@ pub(crate) fn arm_hard_exit_watchdog(source: &'static str) { fn main() { qbz_log::install("info"); + // Link anchor for the hand-written QAbstractListModel. Its QML singleton + // registration itself runs at QCoreApplication startup. + local_tracks_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