Files
qbz/crates/qbz-qt/src/local_albums_model_qt.rs
T
vicrodh baf0dc2bb9 feat(local-library): DSD quality chip
DSD gets its own Quality chip and Hi-Res goes back to 24-bit PCM only, in
the MediaFilter, the native catalog SQL (tracks CASE and the materialized
album quality_tier) and the legacy QML filters. A Format chip would be the
same predicate, so there is none. Schema version 6 re-derives the album
tiers that were materialized as hires.

cc
2026-09-02 20:34:59 -06:00

1462 lines
45 KiB
Rust

//! Native paged Albums collection over the derived catalog (phase F1).
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::ffi::{c_char, CString};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use cxx_qt_lib::QString;
use qbz_library::AudioFormat;
use qbz_local_catalog::{
ActiveCatalog, AlbumCursor, AlbumRecord, BootstrapLayout, QueryDescriptor, SourceKey,
SourceKind, TrackGroup, TrackSort,
};
use serde::Serialize;
use crate::local_bridge::ui;
use crate::local_rows::{
album_favorite_source, badge_source, badge_source_raw, catalog_album_sources, detail_of,
total_duration, AlbumRow,
};
const PAGE_ENTRIES: usize = 100;
const FLAT_QUERY_ROWS: usize = 500;
const MAX_RESIDENT_ALBUMS: usize = 2_000;
const MAX_UNANCHORED_ENTRIES: usize = 500;
const MAX_COLUMNS: usize = MAX_RESIDENT_ALBUMS / PAGE_ENTRIES;
static QUERY_GENERATION: AtomicU64 = AtomicU64::new(0);
static SESSION_FAILED: AtomicBool = AtomicBool::new(false);
static SESSION: Mutex<Option<NativeSession>> = Mutex::new(None);
static LAST_QUERY: Mutex<Option<LastQuery>> = Mutex::new(None);
#[derive(Clone)]
struct LastQuery {
search: String,
sort: String,
group: String,
filter_json: String,
columns: i32,
}
unsafe extern "C" {
fn qbz_local_albums_register_qml_type();
fn qbz_local_albums_reset(generation: i32, total_count: i64, album_total: i64);
fn qbz_local_albums_apply_page(generation: i32, page: i32, json: *const c_char) -> bool;
fn qbz_local_albums_set_selection(generation: i32, json: *const c_char, selected_count: i64);
}
#[derive(Clone)]
struct IndexedAlbum {
index: usize,
record: AlbumRecord,
}
#[derive(Clone, Default)]
struct StreamAnchor {
entry_index: usize,
flat_index: usize,
cursor: Option<AlbumCursor>,
previous_group: Option<String>,
pending: Vec<IndexedAlbum>,
done: bool,
}
#[derive(Clone)]
struct CachedPage {
wire: Arc<Vec<NativeEntry>>,
albums: usize,
}
struct NativeSession {
generation: u64,
catalog_generation: u64,
descriptor: QueryDescriptor,
columns: usize,
album_total: u64,
entry_total: u64,
anchors: BTreeMap<usize, StreamAnchor>,
anchors_ready: bool,
anchor_building: bool,
pending_pages: HashSet<usize>,
waiting_pages: HashSet<usize>,
cache: PageLru,
selection: QuerySelection,
}
#[derive(Clone, Serialize)]
struct NativeEntry {
t: u8,
label: String,
base: usize,
items: Vec<NativeAlbum>,
}
#[derive(Clone, Serialize)]
struct NativeAlbum {
#[serde(flatten)]
row: AlbumRow,
#[serde(rename = "nativeIndex")]
native_index: usize,
#[serde(rename = "artPath")]
art_path: String,
}
#[derive(Serialize)]
struct NativeJump {
letter: String,
index: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct QuerySelection {
all: bool,
ranges: Vec<(u64, u64)>,
anchor: Option<u64>,
}
impl QuerySelection {
fn contains(&self, index: u64) -> bool {
let inside = self
.ranges
.iter()
.any(|&(first, last)| first <= index && index <= last);
if self.all {
!inside
} else {
inside
}
}
fn count(&self, total: u64) -> u64 {
let ranges = self
.ranges
.iter()
.map(|&(first, last)| last.saturating_sub(first).saturating_add(1))
.sum::<u64>();
if self.all {
total.saturating_sub(ranges)
} else {
ranges.min(total)
}
}
fn toggle(&mut self, index: u64, shift: bool) {
if shift {
if let Some(anchor) = self.anchor {
let (first, last) = if anchor <= index {
(anchor, index)
} else {
(index, anchor)
};
if self.all {
remove_interval(&mut self.ranges, first, last);
} else {
add_interval(&mut self.ranges, first, last);
}
return;
}
}
if self.contains(index) {
if self.all {
add_interval(&mut self.ranges, index, index);
} else {
remove_interval(&mut self.ranges, index, index);
}
} else if self.all {
remove_interval(&mut self.ranges, index, index);
} else {
add_interval(&mut self.ranges, index, index);
}
self.anchor = Some(index);
}
}
#[derive(Default)]
struct PageLru {
entries: HashMap<usize, CachedPage>,
order: VecDeque<usize>,
resident_albums: usize,
evictions: u64,
}
impl PageLru {
fn get(&mut self, page: usize) -> Option<&CachedPage> {
if !self.entries.contains_key(&page) {
return None;
}
self.touch(page);
self.entries.get(&page)
}
fn insert(&mut self, page: usize, value: CachedPage) {
if let Some(previous) = self.entries.insert(page, value) {
self.resident_albums = self.resident_albums.saturating_sub(previous.albums);
}
self.resident_albums += self.entries[&page].albums;
self.touch(page);
while self.resident_albums > MAX_RESIDENT_ALBUMS && self.entries.len() > 1 {
let Some(oldest) = self.order.pop_front() else {
break;
};
if let Some(removed) = self.entries.remove(&oldest) {
self.resident_albums = self.resident_albums.saturating_sub(removed.albums);
self.evictions += 1;
}
}
}
fn touch(&mut self, page: usize) {
self.order.retain(|candidate| *candidate != page);
self.order.push_back(page);
}
}
struct OpenedQuery {
generation: u64,
catalog_generation: u64,
descriptor: QueryDescriptor,
columns: usize,
album_total: u64,
entry_total: u64,
loaded: LoadedPage,
count_time: std::time::Duration,
}
struct LoadedPage {
generation: u64,
page: usize,
wire: Vec<NativeEntry>,
anchors: Vec<StreamAnchor>,
jumps: Vec<NativeJump>,
query_time: std::time::Duration,
map_time: std::time::Duration,
}
pub(crate) fn register_qml_model() {
// SAFETY: link anchor only; registration is a QCoreApplication startup hook.
unsafe { qbz_local_albums_register_qml_type() };
}
pub(crate) fn requested() -> bool {
if SESSION_FAILED.load(Ordering::Acquire) {
return false;
}
!std::env::var("QBZ_LOCAL_CATALOG_ALBUMS")
.ok()
.is_some_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off"
)
})
}
pub(crate) fn reset(
search: String,
sort: String,
group: String,
filter_json: String,
columns: i32,
) -> bool {
if !requested() {
return false;
}
let query = LastQuery {
search,
sort,
group,
filter_json,
columns,
};
*LAST_QUERY.lock().unwrap_or_else(|error| error.into_inner()) = Some(query.clone());
// The derived identity currently matches the owner's default folder
// grouping. Keep the metadata-mode reader as this surface's fallback
// until that alternate projection can preserve its duplicate semantics.
if crate::local_library_qt::album_mode() != "folder" {
deactivate_for_legacy("metadata-mode");
crate::local_bridge_ops::load_albums_legacy();
return false;
}
let generation = next_generation();
let columns = usize::try_from(query.columns.max(1))
.unwrap_or(1)
.clamp(1, MAX_COLUMNS);
let descriptor = descriptor(query.search, &query.sort, &query.group, &query.filter_json);
*SESSION.lock().unwrap_or_else(|error| error.into_inner()) = None;
set_loading();
if !descriptor.search().is_empty() && descriptor.search().chars().count() < 3 {
publish_empty(generation);
return true;
}
crate::spawn(async move {
let result =
tokio::task::spawn_blocking(move || open_query(generation, descriptor, columns)).await;
match result {
Ok(Ok(opened)) => activate_query(opened),
Ok(Err(reason)) => fallback(generation, reason),
Err(_) => fallback(generation, "worker-join"),
}
});
true
}
/// Retry the last QML descriptor after background bootstrap/catch-up makes a
/// catalog generation available.
pub(crate) fn retry_last() -> bool {
let query = LAST_QUERY
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone();
query.is_some_and(|query| {
reset(
query.search,
query.sort,
query.group,
query.filter_json,
query.columns,
)
})
}
pub(crate) fn request_page(page: i32, generation: i32) {
if page < 0 || generation <= 0 {
return;
}
let page = page as usize;
let generation = generation as u64;
let mut session = SESSION.lock().unwrap_or_else(|error| error.into_inner());
let Some(current) = session
.as_mut()
.filter(|state| state.generation == generation)
else {
return;
};
if let Some(cached) = current.cache.get(page).cloned() {
let resident = current.cache.resident_albums;
let evictions = current.cache.evictions;
drop(session);
publish_page(generation, page, cached.wire, true, resident, evictions);
return;
}
if !current.pending_pages.insert(page) {
return;
}
let target = page.saturating_mul(PAGE_ENTRIES);
let anchor = nearest_anchor(&current.anchors, target);
if target.saturating_sub(anchor.entry_index) > MAX_UNANCHORED_ENTRIES && !current.anchors_ready
{
current.pending_pages.remove(&page);
current.waiting_pages.insert(page);
let start_builder = !current.anchor_building;
drop(session);
if start_builder {
build_anchors(generation);
}
return;
}
let descriptor = current.descriptor.clone();
let catalog_generation = current.catalog_generation;
let columns = current.columns;
drop(session);
crate::spawn(async move {
let result = tokio::task::spawn_blocking(move || {
load_page(
generation,
catalog_generation,
descriptor,
columns,
page,
anchor,
)
})
.await;
match result {
Ok(Ok(loaded)) => commit_page(loaded),
Ok(Err(reason)) => fallback(generation, reason),
Err(_) => fallback(generation, "page-worker-join"),
}
});
}
pub(crate) fn toggle_selection(index: i64, shift: bool) {
if index < 0 {
return;
}
update_selection(|selection, total| {
if (index as u64) < total {
selection.toggle(index as u64, shift);
}
});
}
pub(crate) fn select_all() {
update_selection(|selection, _| {
selection.all = true;
selection.ranges.clear();
selection.anchor = None;
});
}
pub(crate) fn clear_selection() {
update_selection(|selection, _| *selection = QuerySelection::default());
}
pub(crate) fn bulk_action(action: String) {
let snapshot = {
let session = SESSION.lock().unwrap_or_else(|error| error.into_inner());
session.as_ref().map(|current| {
(
current.generation,
current.catalog_generation,
current.descriptor.clone(),
current.album_total,
current.selection.clone(),
)
})
};
let Some((generation, catalog_generation, descriptor, total, selection)) = snapshot else {
return;
};
if selection.count(total) == 0 {
return;
}
crate::spawn(async move {
let result = tokio::task::spawn_blocking(move || {
selected_album_ids(
generation,
catalog_generation,
&descriptor,
total,
&selection,
)
})
.await;
let ids = match result {
Ok(Ok(ids)) => ids,
_ => return,
};
if QUERY_GENERATION.load(Ordering::Acquire) != generation {
return;
}
let rows = tokio::task::spawn_blocking(move || {
crate::local_bulk::resolve_album_ids_blocking(&ids)
})
.await
.unwrap_or_default();
if QUERY_GENERATION.load(Ordering::Acquire) != generation {
return;
}
if crate::local_bulk::apply(rows, &action).await {
clear_selection_generation(generation);
}
});
}
fn open_query(
generation: u64,
mut descriptor: QueryDescriptor,
columns: usize,
) -> Result<OpenedQuery, &'static str> {
let (catalog, catalog_generation) = open_active()?;
let sources = enabled_sources(&catalog)?;
if sources.is_empty() && catalog.stats().map_err(|_| "source-counts")?.track_count > 0 {
return Ok(OpenedQuery {
generation,
catalog_generation,
descriptor,
columns,
album_total: 0,
entry_total: 0,
loaded: LoadedPage {
generation,
page: 0,
wire: Vec::new(),
anchors: Vec::new(),
jumps: Vec::new(),
query_time: std::time::Duration::ZERO,
map_time: std::time::Duration::ZERO,
},
count_time: std::time::Duration::ZERO,
});
}
descriptor = descriptor.with_sources(sources);
let count_started = Instant::now();
let album_total = catalog.count_albums(&descriptor).map_err(|_| "count")?;
let entry_total = catalog
.count_album_entries(&descriptor, columns)
.map_err(|_| "entry-count")?;
let count_time = count_started.elapsed();
let loaded = load_page_from_catalog(
&catalog,
generation,
&descriptor,
columns,
0,
StreamAnchor::default(),
)?;
Ok(OpenedQuery {
generation,
catalog_generation,
descriptor,
columns,
album_total,
entry_total,
loaded,
count_time,
})
}
fn activate_query(opened: OpenedQuery) {
if QUERY_GENERATION.load(Ordering::Acquire) != opened.generation {
return;
}
let first_wire = Arc::new(opened.loaded.wire.clone());
let albums = album_count(&first_wire);
let mut cache = PageLru::default();
cache.insert(
0,
CachedPage {
wire: Arc::clone(&first_wire),
albums,
},
);
let mut anchors = BTreeMap::new();
anchors.insert(0, StreamAnchor::default());
for anchor in opened.loaded.anchors {
anchors.insert(anchor.entry_index, anchor);
}
*SESSION.lock().unwrap_or_else(|error| error.into_inner()) = Some(NativeSession {
generation: opened.generation,
catalog_generation: opened.catalog_generation,
descriptor: opened.descriptor.clone(),
columns: opened.columns,
album_total: opened.album_total,
entry_total: opened.entry_total,
anchors,
anchors_ready: opened.entry_total <= MAX_UNANCHORED_ENTRIES as u64,
anchor_building: false,
pending_pages: HashSet::new(),
waiting_pages: HashSet::new(),
cache,
selection: QuerySelection::default(),
});
let generation = opened.generation;
let total = opened.entry_total;
let albums_total = opened.album_total;
let count_time = opened.count_time;
let query_time = opened.loaded.query_time;
let map_time = opened.loaded.map_time;
let initial_jumps = jumps_json(opened.loaded.jumps);
ui(move |mut bridge| {
cpp_reset(generation, total, albums_total);
let bytes = publish_page_now(generation, 0, &first_wire);
bridge.as_mut().set_local_albums_native_active(true);
bridge
.as_mut()
.set_local_albums_native_total(albums_total.min(i64::MAX as u64) as i64);
bridge.as_mut().set_local_albums_native_selected_count(0);
bridge
.as_mut()
.set_local_albums_native_jumps_json(QString::from(initial_jumps.as_str()));
bridge
.as_mut()
.set_local_albums_native_error(QString::default());
bridge.as_mut().set_local_albums_loading(false);
log::info!(
"[local-catalog] phase=albums-native generation={generation} albums={albums_total} entries={total} first_entries={} first_albums={albums} json_bytes={bytes} count={count_time:?} query={query_time:?} map={map_time:?}",
first_wire.len()
);
});
if opened.entry_total > MAX_UNANCHORED_ENTRIES as u64
|| opened.descriptor.group() == TrackGroup::Name
{
build_anchors(generation);
}
}
fn publish_empty(generation: u64) {
ui(move |mut bridge| {
cpp_reset(generation, 0, 0);
bridge.as_mut().set_local_albums_native_active(true);
bridge.as_mut().set_local_albums_native_total(0);
bridge.as_mut().set_local_albums_native_selected_count(0);
bridge
.as_mut()
.set_local_albums_native_jumps_json(QString::from("[]"));
bridge.as_mut().set_local_albums_loading(false);
});
}
fn load_page(
generation: u64,
catalog_generation: u64,
descriptor: QueryDescriptor,
columns: usize,
page: usize,
anchor: StreamAnchor,
) -> Result<LoadedPage, &'static str> {
let (catalog, active_generation) = open_active()?;
if active_generation != catalog_generation {
return Err("catalog-generation-changed");
}
load_page_from_catalog(&catalog, generation, &descriptor, columns, page, anchor)
}
fn load_page_from_catalog(
catalog: &qbz_local_catalog::Catalog,
generation: u64,
descriptor: &QueryDescriptor,
columns: usize,
page: usize,
anchor: StreamAnchor,
) -> Result<LoadedPage, &'static str> {
let target = page.saturating_mul(PAGE_ENTRIES);
if anchor.entry_index > target {
return Err("anchor-after-target");
}
let started = Instant::now();
let mapped = scan_stream(
catalog,
generation,
descriptor,
columns,
anchor,
target,
target.saturating_add(PAGE_ENTRIES),
true,
)?;
Ok(LoadedPage {
generation,
page,
wire: mapped.wire,
anchors: mapped.anchors,
jumps: mapped.jumps,
query_time: started.elapsed(),
map_time: mapped.map_time,
})
}
struct ScanResult {
wire: Vec<NativeEntry>,
anchors: Vec<StreamAnchor>,
jumps: Vec<NativeJump>,
final_state: StreamAnchor,
map_time: std::time::Duration,
}
fn scan_stream(
catalog: &qbz_local_catalog::Catalog,
generation: u64,
descriptor: &QueryDescriptor,
columns: usize,
mut state: StreamAnchor,
collect_start: usize,
stop_entry: usize,
collect: bool,
) -> Result<ScanResult, &'static str> {
let mut wire = Vec::new();
let mut anchors = Vec::new();
let mut jumps = Vec::new();
let mut map_time = std::time::Duration::ZERO;
while state.entry_index < stop_entry && !state.done {
if QUERY_GENERATION.load(Ordering::Acquire) != generation {
return Err("superseded");
}
let page = catalog
.query_albums(descriptor, state.cursor.as_ref(), FLAT_QUERY_ROWS)
.map_err(|_| "album-query")?;
for record in page.rows {
let group_key = album_group_key(&record, descriptor.group());
if descriptor.group() != TrackGroup::Off
&& state.previous_group.as_deref() != Some(group_key.as_str())
{
emit_pending(
&mut state,
descriptor,
collect_start,
stop_entry,
collect,
&mut wire,
&mut map_time,
);
state.previous_group = Some(group_key.clone());
let label = album_group_label(&record, descriptor.group());
if descriptor.group() == TrackGroup::Name {
jumps.push(NativeJump {
letter: label.clone(),
index: state.entry_index,
});
}
emit_header(
&mut state,
label,
collect_start,
stop_entry,
collect,
&mut wire,
);
}
let index = state.flat_index;
state.flat_index = state.flat_index.saturating_add(1);
state.pending.push(IndexedAlbum { index, record });
if state.pending.len() == columns {
emit_pending(
&mut state,
descriptor,
collect_start,
stop_entry,
collect,
&mut wire,
&mut map_time,
);
}
}
if let Some(cursor) = page.next_cursor {
state.cursor = Some(cursor);
anchors.push(state.clone());
} else {
emit_pending(
&mut state,
descriptor,
collect_start,
stop_entry,
collect,
&mut wire,
&mut map_time,
);
state.cursor = None;
state.done = true;
}
}
Ok(ScanResult {
wire,
anchors,
jumps,
final_state: state,
map_time,
})
}
fn emit_header(
state: &mut StreamAnchor,
label: String,
collect_start: usize,
collect_end: usize,
collect: bool,
wire: &mut Vec<NativeEntry>,
) {
if collect && (collect_start..collect_end).contains(&state.entry_index) {
wire.push(NativeEntry {
t: 0,
label,
base: state.flat_index,
items: Vec::new(),
});
}
state.entry_index = state.entry_index.saturating_add(1);
}
fn emit_pending(
state: &mut StreamAnchor,
descriptor: &QueryDescriptor,
collect_start: usize,
collect_end: usize,
collect: bool,
wire: &mut Vec<NativeEntry>,
map_time: &mut std::time::Duration,
) {
if state.pending.is_empty() {
return;
}
if collect && (collect_start..collect_end).contains(&state.entry_index) {
let started = Instant::now();
let items = state
.pending
.iter()
.map(|album| map_album(&album.record, album.index))
.collect();
*map_time += started.elapsed();
wire.push(NativeEntry {
t: 1,
label: String::new(),
base: state.pending[0].index,
items,
});
}
state.pending.clear();
state.entry_index = state.entry_index.saturating_add(1);
let _ = descriptor;
}
fn commit_page(loaded: LoadedPage) {
let mut session = SESSION.lock().unwrap_or_else(|error| error.into_inner());
let Some(current) = session
.as_mut()
.filter(|state| state.generation == loaded.generation)
else {
return;
};
current.pending_pages.remove(&loaded.page);
for anchor in loaded.anchors {
current.anchors.insert(anchor.entry_index, anchor);
}
let wire = Arc::new(loaded.wire);
let albums = album_count(&wire);
current.cache.insert(
loaded.page,
CachedPage {
wire: Arc::clone(&wire),
albums,
},
);
let resident = current.cache.resident_albums;
let evictions = current.cache.evictions;
let entries = wire.len();
drop(session);
publish_page(
loaded.generation,
loaded.page,
wire,
false,
resident,
evictions,
);
log::info!(
"[local-catalog] phase=albums-page generation={} page={} entries={} albums={} query={:?} map={:?} resident_albums={} evictions={}",
loaded.generation,
loaded.page,
entries,
albums,
loaded.query_time,
loaded.map_time,
resident,
evictions
);
}
fn publish_page(
generation: u64,
page: usize,
wire: Arc<Vec<NativeEntry>>,
cache_hit: bool,
resident: usize,
evictions: u64,
) {
ui(move |_| {
let bytes = publish_page_now(generation, page, &wire);
log::info!(
"[local-catalog] phase=albums-publish generation={generation} page={page} entries={} albums={} json_bytes={bytes} cache_hit={cache_hit} resident_albums={resident} evictions={evictions}",
wire.len(),
album_count(&wire)
);
});
}
fn publish_page_now(generation: u64, page: usize, wire: &[NativeEntry]) -> usize {
let json = serde_json::to_string(wire).unwrap_or_else(|_| "[]".to_string());
let bytes = json.len();
let Ok(json) = CString::new(json) else {
return 0;
};
// SAFETY: C++ copies the bounded page synchronously.
unsafe { qbz_local_albums_apply_page(generation as i32, page as i32, json.as_ptr()) };
bytes
}
fn build_anchors(generation: u64) {
let (descriptor, catalog_generation, columns, entry_total) = {
let mut session = SESSION.lock().unwrap_or_else(|error| error.into_inner());
let Some(current) = session
.as_mut()
.filter(|state| state.generation == generation)
else {
return;
};
current.anchor_building = true;
(
current.descriptor.clone(),
current.catalog_generation,
current.columns,
current.entry_total,
)
};
crate::spawn(async move {
let result = tokio::task::spawn_blocking(move || {
let (catalog, active_generation) = open_active()?;
if active_generation != catalog_generation {
return Err("catalog-generation-changed");
}
let started = Instant::now();
let scan = scan_stream(
&catalog,
generation,
&descriptor,
columns,
StreamAnchor::default(),
usize::MAX,
entry_total.min(usize::MAX as u64) as usize,
false,
)?;
Ok((
scan.anchors,
scan.jumps,
scan.final_state,
started.elapsed(),
))
})
.await;
match result {
Ok(Ok((anchors, jumps, final_state, elapsed))) => {
finish_anchors(generation, anchors, jumps, final_state, elapsed)
}
Ok(Err("superseded")) => {}
Ok(Err(reason)) => fallback(generation, reason),
Err(_) => fallback(generation, "anchor-worker-join"),
}
});
}
fn finish_anchors(
generation: u64,
anchors: Vec<StreamAnchor>,
jumps: Vec<NativeJump>,
final_state: StreamAnchor,
elapsed: std::time::Duration,
) {
let waiting = {
let mut session = SESSION.lock().unwrap_or_else(|error| error.into_inner());
let Some(current) = session
.as_mut()
.filter(|state| state.generation == generation)
else {
return;
};
let count = anchors.len();
for anchor in anchors {
current.anchors.insert(anchor.entry_index, anchor);
}
current.anchors.insert(final_state.entry_index, final_state);
current.anchors_ready = true;
current.anchor_building = false;
log::info!(
"[local-catalog] phase=albums-anchors generation={generation} anchors={count} elapsed={elapsed:?}"
);
current.waiting_pages.drain().collect::<Vec<_>>()
};
let jumps = jumps_json(jumps);
ui(move |mut bridge| {
bridge
.as_mut()
.set_local_albums_native_jumps_json(QString::from(jumps.as_str()));
});
for page in waiting {
request_page(page as i32, generation as i32);
}
}
fn selected_album_ids(
generation: u64,
catalog_generation: u64,
descriptor: &QueryDescriptor,
total: u64,
selection: &QuerySelection,
) -> Result<Vec<String>, &'static str> {
let (catalog, active_generation) = open_active()?;
if active_generation != catalog_generation {
return Err("catalog-generation-changed");
}
let mut cursor = None;
let mut index = 0_u64;
let mut ids = Vec::with_capacity(selection.count(total).min(16_384) as usize);
loop {
if QUERY_GENERATION.load(Ordering::Acquire) != generation {
return Err("superseded");
}
let page = catalog
.query_albums(descriptor, cursor.as_ref(), FLAT_QUERY_ROWS)
.map_err(|_| "action-query")?;
for record in page.rows {
if selection.contains(index) {
ids.push(album_action_id(&record));
}
index = index.saturating_add(1);
}
let Some(next) = page.next_cursor else {
break;
};
cursor = Some(next);
}
Ok(ids)
}
fn descriptor(search: String, sort: &str, group: &str, filter_json: &str) -> QueryDescriptor {
let filter: serde_json::Value = serde_json::from_str(filter_json).unwrap_or_default();
let on = |key: &str| filter.get(key).and_then(|value| value.as_bool()) == Some(true);
let formats = ["flac", "alac", "ape", "wav", "mp3", "aac"]
.into_iter()
.filter(|key| on(key))
.map(str::to_string)
.collect();
let qualities = ["dsd", "hires", "cd", "lossy"]
.into_iter()
.filter(|key| on(key))
.map(str::to_string)
.collect();
let buckets = ["local", "offline", "plex", "jellyfin", "subsonic"]
.into_iter()
.filter(|key| on(key))
.map(str::to_string)
.collect();
QueryDescriptor::albums()
.with_search(search)
.with_sort(parse_sort(sort))
.with_group(parse_group(group))
.with_formats(formats)
.including_other_formats(on("other"))
.with_quality_tiers(qualities)
.with_source_buckets(buckets)
}
fn map_album(record: &AlbumRecord, index: usize) -> NativeAlbum {
let id = album_action_id(record);
let art_key = format!("catalog-album:{}", record.edition_id);
if !record.artwork_token.is_empty() {
if let Some(reference) =
crate::local_rows::art_token(Some(&record.artwork_source), &record.artwork_token)
{
crate::local_state::with_art(|art| {
art.insert(art_key.clone(), reference);
});
}
}
let format = audio_format(&record.format);
let names = [record.artist.as_str()]
.into_iter()
.chain(record.all_artists.split(','))
.collect::<Vec<_>>();
let aliases = crate::local_artist_match::build_artist_family_aliases(&names);
let artists = crate::local_artist_match::album_credit_names(
&record.artist,
&record.all_artists,
&aliases,
);
let source = badge_source(Some(record.source_raw_or_kind()));
let source_raw = badge_source_raw(Some(record.source_raw_or_kind()));
let sources = catalog_album_sources(record);
let favoriteable = album_favorite_source(&sources).is_some();
NativeAlbum {
row: AlbumRow {
is_favorite: favoriteable && crate::library_qt::is_local_favorite("album", &id),
favoriteable,
id,
title: record.title.clone(),
artist: record.artist.clone(),
all_artists: record.all_artists.clone(),
artists,
year: record.year.map(|year| year.to_string()).unwrap_or_default(),
track_count: record.track_count,
duration: total_duration(record.total_duration_ms / 1_000),
quality_tier: record.quality_tier.clone(),
quality_detail: detail_of(
&format,
record.bit_depth,
record.sample_rate_hz.unwrap_or(0) as f64,
),
format: record.format.to_ascii_uppercase(),
genres: Vec::new(),
art_key,
source,
sources,
source_raw,
directory_path: record.directory_path.clone(),
folder_count: record.folder_count,
},
native_index: index,
art_path: String::new(),
}
}
trait AlbumSourceWord {
fn source_raw_or_kind(&self) -> &str;
}
impl AlbumSourceWord for AlbumRecord {
fn source_raw_or_kind(&self) -> &str {
if self.source_raw.trim().is_empty() {
self.source.as_str()
} else {
&self.source_raw
}
}
}
fn album_action_id(record: &AlbumRecord) -> String {
let native = record.native_album_id.trim();
match record.source {
SourceKind::Plex if native.starts_with("plex:") => native.to_string(),
SourceKind::Plex => format!("plex:album:{native}"),
SourceKind::Jellyfin if native.starts_with("jellyfin:") => native.to_string(),
SourceKind::Jellyfin => format!("jellyfin:{native}"),
SourceKind::Subsonic if native.starts_with("subsonic:") => native.to_string(),
SourceKind::Subsonic => format!("subsonic:{native}"),
_ => native.to_string(),
}
}
fn album_group_key(record: &AlbumRecord, group: TrackGroup) -> String {
match group {
TrackGroup::Artist => qbz_local_catalog::normalize_sort_key(&record.artist),
TrackGroup::Name | TrackGroup::Album => {
qbz_local_catalog::normalize_sort_key(&record.title)
.chars()
.next()
.unwrap_or('#')
.to_string()
}
TrackGroup::Off => String::new(),
}
}
fn album_group_label(record: &AlbumRecord, group: TrackGroup) -> String {
match group {
TrackGroup::Artist => record.artist.clone(),
TrackGroup::Name | TrackGroup::Album => album_group_key(record, group).to_uppercase(),
TrackGroup::Off => String::new(),
}
}
fn enabled_sources(catalog: &qbz_local_catalog::Catalog) -> Result<Vec<SourceKey>, &'static str> {
let stats = catalog.stats().map_err(|_| "source-counts")?;
let plex = crate::local_plex::is_enabled();
let remote = crate::media_servers_qt::configured_words();
Ok(stats
.source_counts
.into_iter()
.map(|(source, _)| source)
.filter(|source| match source.source {
SourceKind::Local | SourceKind::Offline => true,
SourceKind::Plex => plex,
SourceKind::Jellyfin => remote.iter().any(|word| *word == "jellyfin"),
SourceKind::Subsonic => remote.iter().any(|word| *word == "subsonic"),
})
.collect())
}
fn open_active() -> Result<(qbz_local_catalog::Catalog, u64), &'static str> {
let locations = crate::local_catalog_qt::locations().ok_or("missing-data-directory")?;
match BootstrapLayout::new(locations.catalog_dir).open_active() {
ActiveCatalog::Ready { catalog, manifest } => Ok((catalog, manifest.active_generation)),
ActiveCatalog::Fallback(_) => Err("catalog-not-ready"),
}
}
fn nearest_anchor(anchors: &BTreeMap<usize, StreamAnchor>, target: usize) -> StreamAnchor {
anchors
.range(..=target)
.next_back()
.map(|(_, anchor)| anchor.clone())
.unwrap_or_default()
}
fn update_selection(update: impl FnOnce(&mut QuerySelection, u64)) {
let mut session = SESSION.lock().unwrap_or_else(|error| error.into_inner());
let Some(current) = session.as_mut() else {
return;
};
update(&mut current.selection, current.album_total);
let generation = current.generation;
let count = current.selection.count(current.album_total);
let json = selection_json(&current.selection);
drop(session);
ui(move |mut bridge| {
bridge
.as_mut()
.set_local_albums_native_selected_count(count.min(i64::MAX as u64) as i64);
cpp_set_selection(generation, &json, count);
});
}
fn clear_selection_generation(generation: u64) {
let mut session = SESSION.lock().unwrap_or_else(|error| error.into_inner());
let Some(current) = session
.as_mut()
.filter(|state| state.generation == generation)
else {
return;
};
current.selection = QuerySelection::default();
drop(session);
ui(move |mut bridge| {
bridge.as_mut().set_local_albums_native_selected_count(0);
cpp_set_selection(generation, &selection_json(&QuerySelection::default()), 0);
});
}
fn set_loading() {
ui(|mut bridge| {
bridge.as_mut().set_local_albums_loading(true);
bridge.as_mut().set_local_albums_error(QString::default());
bridge
.as_mut()
.set_local_albums_native_error(QString::default());
});
}
fn fallback(generation: u64, reason: &'static str) {
if QUERY_GENERATION.load(Ordering::Acquire) != generation {
return;
}
if reason != "catalog-not-ready" {
SESSION_FAILED.store(true, Ordering::Release);
}
*SESSION.lock().unwrap_or_else(|error| error.into_inner()) = None;
log::warn!("[local-catalog] phase=albums-fallback generation={generation} reason={reason}");
ui(move |mut bridge| {
bridge.as_mut().set_local_albums_native_active(false);
bridge.as_mut().set_local_albums_native_total(0);
bridge.as_mut().set_local_albums_native_selected_count(0);
bridge
.as_mut()
.set_local_albums_native_jumps_json(QString::from("[]"));
bridge
.as_mut()
.set_local_albums_native_error(QString::from(reason));
crate::local_bridge_ops::load_albums_legacy();
});
}
fn deactivate_for_legacy(reason: &'static str) {
*SESSION.lock().unwrap_or_else(|error| error.into_inner()) = None;
ui(move |mut bridge| {
bridge.as_mut().set_local_albums_native_active(false);
bridge.as_mut().set_local_albums_native_total(0);
bridge.as_mut().set_local_albums_native_selected_count(0);
bridge
.as_mut()
.set_local_albums_native_jumps_json(QString::from("[]"));
bridge
.as_mut()
.set_local_albums_native_error(QString::from(reason));
});
}
fn parse_sort(value: &str) -> TrackSort {
match value {
"artist-desc" => TrackSort::ArtistDesc,
"title-asc" => TrackSort::TitleAsc,
"title-desc" => TrackSort::TitleDesc,
"year-asc" => TrackSort::YearAsc,
"year-desc" => TrackSort::YearDesc,
_ => TrackSort::ArtistAsc,
}
}
fn parse_group(value: &str) -> TrackGroup {
match value {
"alpha" => TrackGroup::Name,
"artist" => TrackGroup::Artist,
_ => TrackGroup::Off,
}
}
fn audio_format(value: &str) -> AudioFormat {
match value.to_ascii_lowercase().as_str() {
"flac" => AudioFormat::Flac,
"alac" | "m4a" => AudioFormat::Alac,
"wav" | "wave" => AudioFormat::Wav,
"aiff" | "aif" => AudioFormat::Aiff,
"ape" => AudioFormat::Ape,
"mp3" => AudioFormat::Mp3,
"dsd" | "dsf" | "dff" => AudioFormat::Dsd,
_ => AudioFormat::Unknown,
}
}
fn album_count(wire: &[NativeEntry]) -> usize {
wire.iter().map(|entry| entry.items.len()).sum()
}
fn jumps_json(jumps: Vec<NativeJump>) -> String {
serde_json::to_string(&jumps).unwrap_or_else(|_| "[]".to_string())
}
fn selection_json(selection: &QuerySelection) -> String {
serde_json::json!({ "all": selection.all, "ranges": selection.ranges }).to_string()
}
fn cpp_reset(generation: u64, total: u64, albums: u64) {
// SAFETY: primitive values only; C++ applies synchronously on the Qt thread.
unsafe {
qbz_local_albums_reset(
generation as i32,
total.min(i64::MAX as u64) as i64,
albums.min(i64::MAX as u64) as i64,
)
};
}
fn cpp_set_selection(generation: u64, json: &str, count: u64) {
let Ok(json) = CString::new(json) else {
return;
};
// SAFETY: C++ copies the bounded interval document synchronously.
unsafe {
qbz_local_albums_set_selection(
generation as i32,
json.as_ptr(),
count.min(i64::MAX as u64) as i64,
)
};
}
fn next_generation() -> u64 {
let mut generation = QUERY_GENERATION
.fetch_add(1, Ordering::AcqRel)
.wrapping_add(1);
if generation == 0 {
QUERY_GENERATION.store(1, Ordering::Release);
generation = 1;
}
generation
}
fn add_interval(ranges: &mut Vec<(u64, u64)>, first: u64, last: u64) {
let mut merged = (first.min(last), first.max(last));
let mut out = Vec::with_capacity(ranges.len() + 1);
for &(start, end) in ranges.iter() {
if end.saturating_add(1) < merged.0 {
out.push((start, end));
} else if merged.1.saturating_add(1) < start {
out.push(merged);
merged = (start, end);
} else {
merged.0 = merged.0.min(start);
merged.1 = merged.1.max(end);
}
}
out.push(merged);
*ranges = out;
}
fn remove_interval(ranges: &mut Vec<(u64, u64)>, first: u64, last: u64) {
let (first, last) = (first.min(last), first.max(last));
let mut out = Vec::with_capacity(ranges.len() + 1);
for &(start, end) in ranges.iter() {
if end < first || start > last {
out.push((start, end));
continue;
}
if start < first {
out.push((start, first - 1));
}
if end > last {
out.push((last + 1, end));
}
}
*ranges = out;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn album_page_cache_is_bounded_by_resident_album_rows() {
let mut cache = PageLru::default();
for page in 0..25 {
cache.insert(
page,
CachedPage {
wire: Arc::new(Vec::new()),
albums: 100,
},
);
}
assert_eq!(cache.resident_albums, MAX_RESIDENT_ALBUMS);
assert_eq!(cache.entries.len(), 20);
assert_eq!(cache.evictions, 5);
assert!(cache.get(0).is_none());
assert!(cache.get(24).is_some());
}
#[test]
fn album_selection_uses_bounded_intervals_and_descriptor_all() {
let mut selection = QuerySelection::default();
selection.toggle(10, false);
selection.toggle(50_000, true);
assert_eq!(selection.ranges, vec![(10, 50_000)]);
assert_eq!(selection.count(1_000_000), 49_991);
selection = QuerySelection {
all: true,
..QuerySelection::default()
};
selection.toggle(25, false);
selection.toggle(26, false);
assert_eq!(selection.ranges, vec![(25, 26)]);
assert_eq!(selection.count(1_000_000), 999_998);
}
#[test]
fn superseded_album_generation_cannot_publish_as_current() {
QUERY_GENERATION.store(70, Ordering::Release);
let old = QUERY_GENERATION.load(Ordering::Acquire);
let current = next_generation();
assert_eq!(current, 71);
assert_ne!(QUERY_GENERATION.load(Ordering::Acquire), old);
}
#[test]
fn native_album_page_mapping_and_transport_are_bounded() {
let page = (0..PAGE_ENTRIES)
.map(|index| AlbumRecord {
edition_id: index as i64 + 1,
source: SourceKind::Local,
native_album_id: format!("album-{index:05}"),
source_raw: "local".to_string(),
source_words: vec!["local".to_string()],
title: format!("Album {index:05}"),
artist: format!("Artist {:03}", index % 17),
all_artists: format!("Artist {:03}", index % 17),
year: Some(1980 + (index % 45) as u32),
track_count: 10,
total_duration_ms: 1_800_000,
quality_tier: "hires".to_string(),
format: "flac".to_string(),
bit_depth: Some(24),
sample_rate_hz: Some(96_000),
artwork_source: String::new(),
artwork_token: String::new(),
directory_path: format!("/fixture/album-{index:05}"),
folder_count: 1,
added_at: index as i64,
})
.collect::<Vec<_>>();
let map_started = Instant::now();
let wire = page
.iter()
.enumerate()
.map(|(index, row)| NativeEntry {
t: 1,
label: String::new(),
base: index,
items: vec![map_album(row, index)],
})
.collect::<Vec<_>>();
let map_time = map_started.elapsed();
let serialize_started = Instant::now();
let bytes = serde_json::to_vec(&wire).unwrap().len();
let serialize_time = serialize_started.elapsed();
assert!(bytes < 256 * 1024, "bounded album page was {bytes} bytes");
println!(
"F1_ALBUMS_TRANSPORT first_entries={} first_albums={} json_bytes={bytes} map_ms={:.3} serialize_ms={:.3} qml_album_json_bytes=0",
wire.len(),
page.len(),
map_time.as_secs_f64() * 1_000.0,
serialize_time.as_secs_f64() * 1_000.0,
);
}
}