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:
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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user