feat(library): page Tracks from local catalog

Add an opt-in QAbstractListModel backed by keyset pages from the derived catalog, with bounded residency, global query semantics, source-aware actions, and per-session fallback to the existing reader.

Wire profile-scoped bootstrap locations and refresh the native surface only after a complete catalog is active.

cx
This commit is contained in:
vicrodh committed 2026-08-23 09:37:15 -06:00
1 parent 7689fb0df6
commit 1bb3c41dcf
23 files changed
+2634 -130

No files matched your search

@@ -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(
+82 -51
View File
@@ -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<Value>) {
fn cursor_predicate(cursor: &TrackCursor, group: TrackGroup, alias: &str) -> (String, Vec<Value>) {
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<Value>) {
integer(cursor.row_id),
],
),
}
};
(predicate.replace("t.", &format!("{alias}.")), values)
}
@@ -1307,15 +1331,16 @@ fn cursor_predicate(cursor: &TrackCursor, alias: &str) -> (String, Vec<Value>) {
fn map_row(row: &Row<'_>) -> rusqlite::Result<RowWithCursor> {
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<RowWithCursor> {
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<i64>>(10)?.map(|value| value as u32),
disc_number: row.get::<_, Option<i64>>(11)?.map(|value| value as u32),
track_number: row.get::<_, Option<i64>>(12)?.map(|value| value as u32),
format: row.get(13)?,
bit_depth: row.get::<_, Option<i64>>(14)?.map(|value| value as u32),
sample_rate_hz: row.get::<_, Option<i64>>(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<String>>(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<i64>>(12)?.map(|value| value as u32),
disc_number: row.get::<_, Option<i64>>(13)?.map(|value| value as u32),
track_number: row.get::<_, Option<i64>>(14)?.map(|value| value as u32),
format: row.get(15)?,
bit_depth: row.get::<_, Option<i64>>(16)?.map(|value| value as u32),
sample_rate_hz: row.get::<_, Option<i64>>(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<RowWithCursor> {
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<RowWithCursor> {
}
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 {
+64 -8
View File
@@ -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<PathBuf>) -> 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<Vec<LegacySourceSpec>> {
discover_legacy_sources_at(&LegacyLocations::co_located(data_dir))
}
pub fn discover_legacy_sources_at(locations: &LegacyLocations) -> Result<Vec<LegacySourceSpec>> {
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<BootstrapOutcome> {
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<BootstrapOutcome> {
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<ProjectionOutcome> {
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<ProjectionOutcome> {
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<Vec<LegacyRow>> {
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,
+4 -2
View File
@@ -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,
+27
View File
@@ -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<i64>,
pub local_path: Option<String>,
/// 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<i64>,
pub local_path: Option<String>,
pub native_album_id: Option<String>,
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<TrackRecord>,
@@ -37,6 +37,7 @@ fn row(source: &SourceKey, id: u64, title: impl Into<String>) -> 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)),
+5 -1
View File
@@ -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
+40 -10
View File
@@ -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<TrackReco
fn schema_is_versioned_frontend_agnostic_and_fts5_enabled() {
let catalog = Catalog::open_in_memory(7).unwrap();
let stats = catalog.stats().unwrap();
assert_eq!(stats.schema_version, 1);
assert_eq!(stats.schema_version, crate::SCHEMA_VERSION);
assert_eq!(stats.generation, 7);
assert_eq!(stats.track_count, 0);
let fts5: i64 = catalog
@@ -166,6 +167,7 @@ fn identical_native_ids_from_distinct_sources_never_collide() {
let mut catalog = Catalog::open_in_memory(1).unwrap();
let mut local = projected(42, SourceKind::Local);
let mut plex = projected(43, SourceKind::Plex);
local.source_raw = "qobuz_purchase".to_string();
local.track_ref.native_id = "same".to_string();
plex.track_ref.native_id = "same".to_string();
catalog
@@ -185,6 +187,8 @@ fn identical_native_ids_from_distinct_sources_never_collide() {
catalog.resolve(&plex.track_ref).unwrap().unwrap().track_ref,
plex.track_ref
);
let resolved = catalog.resolve(&local.track_ref).unwrap().unwrap();
assert_eq!(resolved.source_raw, "qobuz_purchase");
}
#[test]
@@ -335,6 +339,27 @@ fn search_source_format_availability_and_group_are_descriptor_scoped() {
));
}
#[test]
fn artist_group_uses_track_artist_globally_across_keyset_pages() {
let mut catalog = Catalog::open_in_memory(1).unwrap();
let mut zed = projected(1, SourceKind::Local);
zed.artist = "Zed Performer".to_string();
zed.album_artist = "Alpha Album Artist".to_string();
let mut alpha = projected(2, SourceKind::Plex);
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();
assert_eq!(first.rows[0].artist, "Alpha Performer");
let cursor = first.next_cursor.expect("second grouped page");
assert_eq!(cursor.group_key(TrackGroup::Artist), "alpha performer");
let second = catalog.query_tracks(&descriptor, Some(&cursor), 1).unwrap();
assert_eq!(second.rows[0].artist, "Zed Performer");
assert!(!second.has_more);
}
#[test]
fn album_hierarchy_keeps_weak_same_name_matches_reversible() {
let catalog = Catalog::open_in_memory(1).unwrap();
@@ -392,6 +417,10 @@ fn sort_indices_match_the_actual_order_by_without_temp_sort() {
"idx_tracks_year_desc",
"year_missing,year_value DESC,sort_album,disc_sort,track_sort,catalog_id",
),
(
"idx_tracks_group_artist",
"sort_track_artist,sort_album,sort_title,catalog_id",
),
] {
let sql = format!(
"EXPLAIN QUERY PLAN SELECT catalog_id FROM tracks
@@ -485,8 +514,8 @@ fn fixture_one_million_tracks_meets_the_query_shape_gate() {
let deep_cursor = catalog
.connection()
.query_row(
"SELECT sort_title,sort_artist,sort_album,year_missing,year_value,
disc_sort,track_sort,added_at,catalog_id
"SELECT sort_title,sort_artist,sort_track_artist,sort_album,
year_missing,year_value,disc_sort,track_sort,added_at,catalog_id
FROM tracks WHERE available=1
ORDER BY sort_title,sort_artist,catalog_id
LIMIT 1 OFFSET 800000",
@@ -499,13 +528,14 @@ fn fixture_one_million_tracks_meets_the_query_shape_gate() {
),
sort_title: row.get(0)?,
sort_artist: row.get(1)?,
sort_album: row.get(2)?,
year_missing: row.get(3)?,
year_value: row.get(4)?,
disc_sort: row.get(5)?,
track_sort: row.get(6)?,
added_at: row.get(7)?,
row_id: row.get(8)?,
sort_track_artist: row.get(2)?,
sort_album: row.get(3)?,
year_missing: row.get(4)?,
year_value: row.get(5)?,
disc_sort: row.get(6)?,
track_sort: row.get(7)?,
added_at: row.get(8)?,
row_id: row.get(9)?,
})
},
)
+10 -5
View File
@@ -252,7 +252,7 @@ fn format_ymd(epoch_secs: i64) -> 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 "<name>.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
+296
View File
@@ -0,0 +1,296 @@
#include "local_tracks_model.h"
#include <QtCore/QCoreApplication>
#include <QtCore/QJsonArray>
#include <QtCore/QJsonDocument>
#include <QtCore/QJsonObject>
#include <QtCore/QMetaMethod>
#include <QtCore/QMetaObject>
#include <QtCore/QPointer>
#include <QtQml/qqml.h>
#include <limits>
namespace {
QPointer<LocalTracksModel> g_model;
void registerLocalTracksModel()
{
auto *application = QCoreApplication::instance();
auto *model = new LocalTracksModel(application);
g_model = model;
qmlRegisterSingletonInstance<LocalTracksModel>(
"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<qint64>(m_totalCount, std::numeric_limits<int>::max()));
}
QVariant LocalTracksModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || role != ModelDataRole)
return {};
return modelData(index.row());
}
QHash<int, QByteArray> 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<LocalTracksModel *>(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<qint64>(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<QPair<qint64, qint64>> ranges;
for (const QJsonValue &value : object.value(QStringLiteral("ranges")).toArray()) {
const QJsonArray pair = value.toArray();
if (pair.size() != 2)
continue;
ranges.append({ qMax<qint64>(0, pair.at(0).toInteger()),
qMax<qint64>(0, pair.at(1).toInteger()) });
}
m_selectAll = object.value(QStringLiteral("all")).toBool();
m_selectionRanges = std::move(ranges);
m_selectedCount = qMax<qint64>(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);
}
+86
View File
@@ -0,0 +1,86 @@
#ifndef QBZ_LOCAL_TRACKS_MODEL_H
#define QBZ_LOCAL_TRACKS_MODEL_H
#include <QtCore/QAbstractListModel>
#include <QtCore/QHash>
#include <QtCore/QList>
#include <QtCore/QVariantMap>
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<int, QByteArray> 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<TrackRow>;
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<int, Page> m_pages;
mutable QList<int> m_lru;
mutable QHash<int, bool> m_pending;
int m_residentRows = 0;
qint64 m_evictions = 0;
bool m_selectAll = false;
QList<QPair<qint64, qint64>> 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
+34 -6
View File
@@ -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() {
@@ -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
@@ -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)
+127 -20
View File
@@ -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
+4
View File
@@ -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.
+98 -3
View File
@@ -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 {
+25 -5
View File
@@ -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
+1 -1
View File
@@ -178,7 +178,7 @@ fn resolve_blocking(scope: &str, ids: &[String]) -> Vec<LocalTrack> {
/// 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<LocalTrack>, action: &str) -> bool {
pub(crate) async fn apply(rows: Vec<LocalTrack>, action: &str) -> bool {
match action {
"queue" | "play-next" | "play-later" => {
if rows.is_empty() {
+82 -10
View File
@@ -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<qbz_local_catalog::LegacyLocations> {
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,
+4
View File
@@ -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 {
File diff suppressed because it is too large. Load diff
+6
View File
@@ -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