- Local Library Tracks: build the play queue from the already-loaded rows (instant) instead of re-querying the DB + filling covers on every click. - Mouse back/forward buttons now drive history nav over track rows and album cards (a TouchArea swallows them, so each interactive surface forwards them; the AppShell catcher only saw the gaps). - ExpandableSearch open width trimmed 20px (no longer rides over left controls on small screens). - QbzSelect toolbar (Group/Sort/filter) trimmed 40px total. cc
2140 lines
81 KiB
Rust
2140 lines
81 KiB
Rust
//! Local Library controller (Slint) — greenfield port of Tauri's
|
|
//! `LocalLibraryView`.
|
|
//!
|
|
//! This module owns the per-tab navigation and (slice by slice) the data
|
|
//! loading for the four browse tabs: Albums / Artists / Folders / Tracks.
|
|
//! It reads the shared per-user `library.db` through the already
|
|
//! frontend-agnostic `qbz-library` crate (see `library_db::with_db`), and
|
|
//! Plex through the `qbz-plex` core crate.
|
|
//!
|
|
//! Folder management, scan, maintenance, and the danger zone do NOT live in
|
|
//! this view — they belong under Settings > Local Library. The view's gear
|
|
//! button routes there.
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Arc, LazyLock, Mutex};
|
|
|
|
use qbz_app::shell::AppRuntime;
|
|
use slint::{ComponentHandle, Model, ModelRc, VecModel};
|
|
|
|
use crate::adapter::SlintAdapter;
|
|
use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache};
|
|
use crate::{
|
|
AlbumCardItem, AlphaJump, AppWindow, DiscoverSection, FolderNode, LocalArtistItem,
|
|
LocalArtistSection, LocalLibraryState, TrackItem,
|
|
};
|
|
|
|
/// The four browse tabs. Order mirrors Tauri's default tab order
|
|
/// (`tracks / folders / albums / artists`); the visible order is a user
|
|
/// preference layered on top later.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum LibTab {
|
|
Tracks,
|
|
Folders,
|
|
Albums,
|
|
Artists,
|
|
}
|
|
|
|
impl LibTab {
|
|
/// Parse a HeaderBar menu route (`"local-albums"` etc.).
|
|
pub fn from_route(route: &str) -> Option<Self> {
|
|
match route {
|
|
"local-tracks" => Some(Self::Tracks),
|
|
"local-folders" => Some(Self::Folders),
|
|
"local-albums" => Some(Self::Albums),
|
|
"local-artists" => Some(Self::Artists),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Parse a tab id (`"albums"` etc.) as carried by a `NavEntry` and by
|
|
/// `LocalLibraryState.active-tab`.
|
|
pub fn from_tab_id(id: &str) -> Option<Self> {
|
|
match id {
|
|
"tracks" => Some(Self::Tracks),
|
|
"folders" => Some(Self::Folders),
|
|
"albums" => Some(Self::Albums),
|
|
"artists" => Some(Self::Artists),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The canonical tab id used in nav entries + `LocalLibraryState.active-tab`.
|
|
pub fn tab_id(self) -> &'static str {
|
|
match self {
|
|
Self::Tracks => "tracks",
|
|
Self::Folders => "folders",
|
|
Self::Albums => "albums",
|
|
Self::Artists => "artists",
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================== Albums tab ===============================
|
|
//
|
|
// The Albums tab browses the metadata-grouped local albums via the
|
|
// server-paginated `get_albums_metadata_page` (the performant path that
|
|
// scales past the documented 16K-row freeze). Sort + search are pushed to
|
|
// SQL; the grid pages on scroll. The shared `AlbumCollectionView` renders
|
|
// the result, covers load from the local filesystem via the source-aware
|
|
// artwork pipeline.
|
|
|
|
/// Generation guard, bumped on every (re)load. A stale in-flight
|
|
/// fetch (older search/sort) is discarded on apply, and an in-flight
|
|
/// load-more is dropped once a reload supersedes it.
|
|
static ALBUMS_GEN: AtomicU64 = AtomicU64::new(0);
|
|
|
|
/// True if `gen` is still the current albums generation. The artwork
|
|
/// pipeline calls this before applying a decoded cover so an in-flight job
|
|
/// from a superseded page (a search/sort/retry replaced the model) doesn't
|
|
/// land on a stale row index.
|
|
pub fn albums_gen_current(gen: u64) -> bool {
|
|
ALBUMS_GEN.load(Ordering::SeqCst) == gen
|
|
}
|
|
|
|
/// Map one local album row to the shared plain `AlbumCard`. Kept as the
|
|
/// Send-safe plain struct (NOT `AlbumCardItem`, which holds a non-Send
|
|
/// `slint::Image`) so it can cross the `spawn_blocking` boundary; the
|
|
/// conversion to `AlbumCardItem` happens on the UI thread via
|
|
/// `album_map::to_item`. Genre is intentionally empty (the local DB carries
|
|
/// genre per-track, not per-album); the cover PATH rides on `artwork_url`
|
|
/// as the artwork-job carrier (the grid renders `artwork`, not the url).
|
|
pub fn map_local_album(a: qbz_library::LocalAlbum) -> crate::album_map::AlbumCard {
|
|
// Format-first classification (mirrors Tauri): a lossy format (MP3) gets
|
|
// the dedicated MP3 badge tier, never CD.
|
|
let tier = if a.format.to_string().eq_ignore_ascii_case("mp3") {
|
|
"mp3"
|
|
} else {
|
|
match a.bit_depth {
|
|
Some(b) if b >= 24 => "hires",
|
|
Some(_) => "cd",
|
|
None => "",
|
|
}
|
|
};
|
|
let (quality_detail, quality_label) = local_quality(a.bit_depth, a.sample_rate);
|
|
let year = a.year.map(|y| y.to_string()).unwrap_or_default();
|
|
let track_count = if a.track_count > 0 {
|
|
a.track_count.to_string()
|
|
} else {
|
|
String::new()
|
|
};
|
|
// Real source for the SOURCE column + the always-visible card badge:
|
|
// user files -> local, offline copies -> qobuz_download, Plex -> plex.
|
|
let source = match a.source.as_str() {
|
|
"plex" => "plex",
|
|
"qobuz_download" => "qobuz_download",
|
|
_ => "local",
|
|
}
|
|
.to_string();
|
|
crate::album_map::AlbumCard {
|
|
id: a.id,
|
|
title: a.title,
|
|
artist: a.artist,
|
|
artist_id: String::new(),
|
|
genre: String::new(),
|
|
year: year.clone(),
|
|
quality_tier: tier.to_string(),
|
|
quality_label,
|
|
artwork_url: a.artwork_path.unwrap_or_default(),
|
|
release_type: crate::album_map::classify_release_type(Some(a.track_count)).to_string(),
|
|
source,
|
|
quality_detail,
|
|
track_count,
|
|
plain_year: year,
|
|
}
|
|
}
|
|
|
|
/// Format a local album's quality. `sample_rate_hz` is Hz (44100.0); the
|
|
/// detail is the bare "24-bit / 96 kHz" (QualityBadgeFull) and the label is
|
|
/// the grid badge tooltip "Hi-Res: 24-bit / 96 kHz".
|
|
fn local_quality(bit_depth: Option<u32>, sample_rate_hz: f64) -> (String, String) {
|
|
let Some(bd) = bit_depth else {
|
|
return (String::new(), String::new());
|
|
};
|
|
let khz = if sample_rate_hz >= 1000.0 {
|
|
sample_rate_hz / 1000.0
|
|
} else {
|
|
sample_rate_hz
|
|
};
|
|
let khz_str = if khz.fract().abs() < 0.05 {
|
|
format!("{}", khz.round() as i64)
|
|
} else {
|
|
format!("{khz:.1}")
|
|
};
|
|
let prefix = if bd >= 24 { "Hi-Res" } else { "CD" };
|
|
(
|
|
format!("{bd}-bit / {khz_str} kHz"),
|
|
format!("{prefix}: {bd}-bit / {khz_str} kHz"),
|
|
)
|
|
}
|
|
|
|
/// The full metadata-grouped LocalAlbum set — the FILTER SOURCE (the quality/
|
|
/// format/source filters need raw bit_depth/format/source, which AlbumCardItem
|
|
/// doesn't carry). Loaded once; `derive_albums` filters it by id.
|
|
static LOCAL_ALBUMS: LazyLock<Mutex<Vec<qbz_library::LocalAlbum>>> =
|
|
LazyLock::new(|| Mutex::new(Vec::new()));
|
|
|
|
fn local_albums() -> std::sync::MutexGuard<'static, Vec<qbz_library::LocalAlbum>> {
|
|
LOCAL_ALBUMS.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// Active quality/format/source filter (read once per derive from the global).
|
|
#[derive(Clone, Copy, Default)]
|
|
struct AlbumFilter {
|
|
hires: bool,
|
|
cd: bool,
|
|
lossy: bool,
|
|
flac: bool,
|
|
alac: bool,
|
|
ape: bool,
|
|
wav: bool,
|
|
mp3: bool,
|
|
aac: bool,
|
|
other: bool,
|
|
local: bool,
|
|
offline: bool,
|
|
plex: bool,
|
|
}
|
|
|
|
fn read_album_filter(window: &AppWindow) -> AlbumFilter {
|
|
let f = window.global::<crate::LibAlbumFilterState>();
|
|
AlbumFilter {
|
|
hires: f.get_hires(),
|
|
cd: f.get_cd(),
|
|
lossy: f.get_lossy(),
|
|
flac: f.get_flac(),
|
|
alac: f.get_alac(),
|
|
ape: f.get_ape(),
|
|
wav: f.get_wav(),
|
|
mp3: f.get_mp3(),
|
|
aac: f.get_aac(),
|
|
other: f.get_other(),
|
|
local: f.get_local(),
|
|
offline: f.get_offline(),
|
|
plex: f.get_plex(),
|
|
}
|
|
}
|
|
|
|
fn album_filter_count(f: &AlbumFilter) -> i32 {
|
|
[
|
|
f.hires, f.cd, f.lossy, f.flac, f.alac, f.ape, f.wav, f.mp3, f.aac, f.other, f.local,
|
|
f.offline, f.plex,
|
|
]
|
|
.iter()
|
|
.filter(|b| **b)
|
|
.count() as i32
|
|
}
|
|
|
|
/// 1:1 with Tauri `matchesQualityFilters`: OR within each group, AND between
|
|
/// groups; an empty group passes everything.
|
|
fn album_matches_filters(a: &qbz_library::LocalAlbum, f: &AlbumFilter) -> bool {
|
|
let format = a.format.to_string().to_lowercase();
|
|
let lossless = matches!(
|
|
format.as_str(),
|
|
"flac" | "wav" | "aiff" | "alac" | "ape" | "dsd" | "dsf" | "dff"
|
|
);
|
|
let lossy = matches!(format.as_str(), "mp3" | "aac" | "m4a" | "ogg" | "opus" | "wma");
|
|
let bit_depth = a.bit_depth.unwrap_or(16);
|
|
|
|
let q_active = f.hires || f.cd || f.lossy;
|
|
let passes_q = !q_active
|
|
|| (f.hires && lossless && (bit_depth >= 24 || a.sample_rate > 48000.0))
|
|
|| (f.cd && lossless && bit_depth <= 16 && a.sample_rate <= 48000.0)
|
|
|| (f.lossy && lossy);
|
|
|
|
let fmt_active = f.flac || f.alac || f.ape || f.wav || f.mp3 || f.aac || f.other;
|
|
let passes_f = !fmt_active
|
|
|| (f.flac && format == "flac")
|
|
|| (f.alac && (format == "alac" || format == "m4a"))
|
|
|| (f.ape && format == "ape")
|
|
|| (f.wav && (format == "wav" || format == "wave"))
|
|
|| (f.mp3 && format == "mp3")
|
|
|| (f.aac && (format == "aac" || format == "m4a"))
|
|
|| (f.other
|
|
&& !matches!(
|
|
format.as_str(),
|
|
"flac" | "alac" | "ape" | "wav" | "wave" | "mp3" | "aac" | "m4a"
|
|
));
|
|
|
|
let s_active = f.local || f.offline || f.plex;
|
|
let src = a.source.as_str();
|
|
let passes_s = !s_active
|
|
|| (f.local && (src == "user" || src.is_empty()))
|
|
|| (f.offline && src == "qobuz_download")
|
|
|| (f.plex && src == "plex");
|
|
|
|
passes_q && passes_f && passes_s
|
|
}
|
|
|
|
/// Build LocalAlbumCard artwork jobs for the full `albums` set (gen-stamped).
|
|
fn album_artwork_jobs(cards: &[crate::album_map::AlbumCard], gen: u64) -> Vec<ArtworkJob> {
|
|
cards
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, c)| !c.artwork_url.is_empty())
|
|
.map(|(i, c)| ArtworkJob {
|
|
target: ArtworkTarget::LocalAlbumCard { index: i, gen },
|
|
url: c.artwork_url.clone(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Set a freshly-decoded local-album cover (by id) on every rendered model:
|
|
/// the full `albums` set + `albums-visible` + each `albums-grouped` section.
|
|
pub fn set_local_album_artwork(window: &AppWindow, id: &str, image: slint::Image) {
|
|
let s = window.global::<LocalLibraryState>();
|
|
let set_in = |m: &ModelRc<AlbumCardItem>| {
|
|
for i in 0..m.row_count() {
|
|
if let Some(mut it) = m.row_data(i) {
|
|
if it.id.as_str() == id {
|
|
it.artwork = image.clone();
|
|
m.set_row_data(i, it);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
set_in(&s.get_albums());
|
|
set_in(&s.get_albums_visible());
|
|
let grouped = s.get_albums_grouped();
|
|
for gi in 0..grouped.row_count() {
|
|
if let Some(sec) = grouped.row_data(gi) {
|
|
set_in(&sec.albums);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Same, for the Folders tab: full `folders` set + `folders-visible` + each
|
|
/// `folders-grouped` section. Without this the cover only lands on the source
|
|
/// `folders` model and the rendered (visible/grouped) views miss it on first
|
|
/// load (the same bug the Albums/Artists tabs had).
|
|
pub fn set_local_folder_artwork(window: &AppWindow, id: &str, image: slint::Image) {
|
|
let s = window.global::<LocalLibraryState>();
|
|
let set_in = |m: &ModelRc<AlbumCardItem>| {
|
|
for i in 0..m.row_count() {
|
|
if let Some(mut it) = m.row_data(i) {
|
|
if it.id.as_str() == id {
|
|
it.artwork = image.clone();
|
|
m.set_row_data(i, it);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
set_in(&s.get_folders());
|
|
set_in(&s.get_folders_visible());
|
|
let grouped = s.get_folders_grouped();
|
|
for gi in 0..grouped.row_count() {
|
|
if let Some(sec) = grouped.row_data(gi) {
|
|
set_in(&sec.albums);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Re-derive the rendered Albums sets (search + quality/format/source filter +
|
|
/// sort + group + A-Z) from the full `albums` card set, filtered by id against
|
|
/// the raw LocalAlbum cache. Mirrors `derive_folders` plus the filter.
|
|
pub fn derive_albums(window: &AppWindow) {
|
|
let s = window.global::<LocalLibraryState>();
|
|
let query_owned = s.get_albums_search().to_lowercase();
|
|
let query = query_owned.trim();
|
|
let sort = s.get_albums_sort().to_string();
|
|
let group = s.get_albums_group().to_string();
|
|
let filter = read_album_filter(window);
|
|
window
|
|
.global::<crate::LibAlbumFilterState>()
|
|
.set_count(album_filter_count(&filter));
|
|
|
|
let matching: std::collections::HashSet<String> = {
|
|
let cache = local_albums();
|
|
cache
|
|
.iter()
|
|
.filter(|a| {
|
|
(query.is_empty()
|
|
|| a.title.to_lowercase().contains(query)
|
|
|| a.artist.to_lowercase().contains(query)
|
|
|| a.all_artists.to_lowercase().contains(query))
|
|
&& album_matches_filters(a, &filter)
|
|
})
|
|
.map(|a| a.id.clone())
|
|
.collect()
|
|
};
|
|
|
|
let all = s.get_albums();
|
|
let mut filtered: Vec<AlbumCardItem> = (0..all.row_count())
|
|
.filter_map(|i| all.row_data(i))
|
|
.filter(|c| matching.contains(&c.id.to_string()))
|
|
.collect();
|
|
crate::album_map::sort_album_items(&mut filtered, &sort);
|
|
s.set_albums_shown(filtered.len() as i32);
|
|
|
|
let empty_sections = || ModelRc::new(VecModel::from(Vec::<DiscoverSection>::new()));
|
|
if group == "off" {
|
|
s.set_albums_visible(ModelRc::new(VecModel::from(filtered)));
|
|
s.set_albums_grouped(empty_sections());
|
|
s.set_albums_alpha(ModelRc::new(VecModel::from(Vec::<AlphaJump>::new())));
|
|
return;
|
|
}
|
|
|
|
let mut map: Vec<(String, Vec<AlbumCardItem>)> = Vec::new();
|
|
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
|
for item in filtered {
|
|
let key = if group == "artist" {
|
|
let a = item.artist.to_string();
|
|
if a.is_empty() {
|
|
"Unknown".to_string()
|
|
} else {
|
|
a
|
|
}
|
|
} else {
|
|
folder_alpha_key(item.title.as_str())
|
|
};
|
|
let idx = *index.entry(key.clone()).or_insert_with(|| {
|
|
map.push((key.clone(), Vec::new()));
|
|
map.len() - 1
|
|
});
|
|
map[idx].1.push(item);
|
|
}
|
|
map.sort_by(|(a, _), (b, _)| match (a == "#", b == "#") {
|
|
(true, false) => std::cmp::Ordering::Greater,
|
|
(false, true) => std::cmp::Ordering::Less,
|
|
_ => a.to_lowercase().cmp(&b.to_lowercase()),
|
|
});
|
|
let jumps: Vec<AlphaJump> = map
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, (k, _))| AlphaJump {
|
|
letter: k.clone().into(),
|
|
index: i as i32,
|
|
})
|
|
.collect();
|
|
let sections: Vec<DiscoverSection> = map
|
|
.into_iter()
|
|
.map(|(key, items)| DiscoverSection {
|
|
title: key.into(),
|
|
endpoint: "".into(),
|
|
albums: ModelRc::new(VecModel::from(items)),
|
|
})
|
|
.collect();
|
|
s.set_albums_grouped(ModelRc::new(VecModel::from(sections)));
|
|
s.set_albums_alpha(ModelRc::new(VecModel::from(jumps)));
|
|
s.set_albums_visible(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
|
|
}
|
|
|
|
/// Clear all quality/format/source filters, then re-derive.
|
|
pub fn clear_album_filter(window: &AppWindow) {
|
|
let f = window.global::<crate::LibAlbumFilterState>();
|
|
f.set_hires(false);
|
|
f.set_cd(false);
|
|
f.set_lossy(false);
|
|
f.set_flac(false);
|
|
f.set_alac(false);
|
|
f.set_ape(false);
|
|
f.set_wav(false);
|
|
f.set_mp3(false);
|
|
f.set_aac(false);
|
|
f.set_other(false);
|
|
f.set_local(false);
|
|
f.set_offline(false);
|
|
f.set_plex(false);
|
|
derive_albums(window);
|
|
}
|
|
|
|
/// Full-load the metadata-grouped albums off the UI thread (mapping + cover
|
|
/// fallback resolution all happen on the blocking thread), store the raw cache
|
|
/// + the mapped card set, then derive + spawn covers on the UI thread.
|
|
fn spawn_albums_load(
|
|
window: &AppWindow,
|
|
handle: tokio::runtime::Handle,
|
|
image_cache: ImageCache,
|
|
gen: u64,
|
|
) {
|
|
let weak = window.as_weak();
|
|
handle.spawn(async move {
|
|
let loaded: Option<(Vec<qbz_library::LocalAlbum>, Vec<crate::album_map::AlbumCard>)> =
|
|
tokio::task::spawn_blocking(|| {
|
|
crate::library_db::with_db(|db| {
|
|
let albums = db.get_albums_metadata_grouped(false, true, false)?;
|
|
let cards: Vec<crate::album_map::AlbumCard> = albums
|
|
.iter()
|
|
.map(|a| {
|
|
let mut card = map_local_album(a.clone());
|
|
if card.artwork_url.is_empty() {
|
|
if let Some(cover) = db.resolve_album_cover_fallback(&card.id) {
|
|
card.artwork_url = cover;
|
|
}
|
|
}
|
|
card
|
|
})
|
|
.collect();
|
|
Ok((albums, cards))
|
|
})
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
if ALBUMS_GEN.load(Ordering::SeqCst) != gen {
|
|
return;
|
|
}
|
|
let s = w.global::<LocalLibraryState>();
|
|
match loaded {
|
|
Some((albums, cards)) => {
|
|
*local_albums() = albums;
|
|
let jobs = album_artwork_jobs(&cards, gen);
|
|
let items: Vec<AlbumCardItem> =
|
|
cards.into_iter().map(crate::album_map::to_item).collect();
|
|
s.set_albums(ModelRc::new(VecModel::from(items.clone())));
|
|
s.set_album_count(items.len() as i32);
|
|
s.set_albums_loading(false);
|
|
s.set_albums_load_failed(false);
|
|
derive_albums(&w);
|
|
crate::artwork::spawn_local_loads(jobs, w.as_weak(), image_cache);
|
|
}
|
|
None => {
|
|
s.set_albums_loading(false);
|
|
s.set_albums_load_failed(true);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/// (Re)load the full album set, bumping the generation guard.
|
|
pub fn reload_albums(
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: tokio::runtime::Handle,
|
|
image_cache: ImageCache,
|
|
) {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let gen = ALBUMS_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
|
let s = w.global::<LocalLibraryState>();
|
|
s.set_albums_loading(true);
|
|
s.set_albums_load_failed(false);
|
|
spawn_albums_load(&w, handle, image_cache, gen);
|
|
});
|
|
}
|
|
|
|
/// Seed all four tab-badge counts up front (mirrors Favorites' seeded
|
|
/// counts) so the nav shows numbers without visiting each tab. Cheap:
|
|
/// bounded album/folder/artist reads + a `COUNT(*)` for the (potentially
|
|
/// huge) tracks table. Album/artist counts match each tab's own loader
|
|
/// exactly (same `get_albums_metadata_grouped` set; same `normalize_artist`
|
|
/// grouping the rail uses), so badges never jump when a tab is opened.
|
|
pub fn seed_counts(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
|
handle.spawn(async move {
|
|
let counts: Option<(usize, usize, usize, usize)> = tokio::task::spawn_blocking(|| {
|
|
crate::library_db::with_db(|db| {
|
|
let albums = db
|
|
.get_albums_metadata_grouped(false, true, false)
|
|
.map(|v| v.len())
|
|
.unwrap_or(0);
|
|
let folders = db
|
|
.get_folders_with_metadata()
|
|
.map(|v| v.len())
|
|
.unwrap_or(0);
|
|
let tracks = db.count_all_local_tracks().unwrap_or(0) as usize;
|
|
// Exact rail count = distinct non-empty normalized names
|
|
// (mirrors merge_artists' grouping key).
|
|
let artists_raw = db.get_artists().unwrap_or_default();
|
|
let mut seen = std::collections::HashSet::new();
|
|
for a in &artists_raw {
|
|
let n = normalize_artist(&a.name);
|
|
if !n.is_empty() {
|
|
seen.insert(n);
|
|
}
|
|
}
|
|
Ok((albums, seen.len(), folders, tracks))
|
|
})
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
if let Some((albums, artists, folders, tracks)) = counts {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
s.set_album_count(albums as i32);
|
|
s.set_artist_count(artists as i32);
|
|
s.set_folder_count(folders as i32);
|
|
s.set_track_count(tracks as i32);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Load on first visit only (re-entry keeps the set + derived views).
|
|
pub fn ensure_albums_loaded(
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: tokio::runtime::Handle,
|
|
image_cache: ImageCache,
|
|
) {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
if s.get_albums().row_count() == 0 && !s.get_albums_loading() {
|
|
let gen = ALBUMS_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
|
s.set_albums_loading(true);
|
|
s.set_albums_load_failed(false);
|
|
spawn_albums_load(&w, handle, image_cache, gen);
|
|
}
|
|
});
|
|
}
|
|
|
|
// =============================== Tracks tab ===============================
|
|
//
|
|
// Server-paginated flat list (the perf path that avoids the documented ~16K
|
|
// freeze): each page is a `search_with_filter_page` query, appended on
|
|
// scroll. Track artwork is off by default (Tauri's perf default), so there
|
|
// are no per-row artwork jobs here. Group-by / multi-select / per-row
|
|
// playback land with the source-aware playback slice.
|
|
|
|
const TRACKS_PAGE: u64 = 200;
|
|
|
|
static TRACKS_GEN: AtomicU64 = AtomicU64::new(0);
|
|
|
|
/// The loaded `LocalTrack` rows backing `tracks`, kept in lockstep with the
|
|
/// paged model in apply/append. The selection-source for bulk queue/play-next/
|
|
/// add-to-playlist (resolves ids -> LocalTrack with no DB round-trip).
|
|
static TRACKS_CURRENT: LazyLock<Mutex<Vec<qbz_library::LocalTrack>>> =
|
|
LazyLock::new(|| Mutex::new(Vec::new()));
|
|
|
|
fn tracks_current() -> std::sync::MutexGuard<'static, Vec<qbz_library::LocalTrack>> {
|
|
TRACKS_CURRENT.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// Snapshot of the currently-loaded Tracks-tab rows (already carry their
|
|
/// covers). Used to build the play queue instantly on a row click — avoiding
|
|
/// the full DB re-query + cover-fill that delayed queue population.
|
|
pub fn tracks_current_snapshot() -> Vec<qbz_library::LocalTrack> {
|
|
tracks_current().clone()
|
|
}
|
|
|
|
fn fmt_duration(secs: u64) -> String {
|
|
format!("{}:{:02}", secs / 60, secs % 60)
|
|
}
|
|
|
|
/// Map one local track row to the rendered `TrackItem` (UI thread — holds a
|
|
/// non-Send `slint::Image`). Local tracks aren't Qobuz-linkable, so the
|
|
/// artist/album link ids are empty (the row renders them as plain text).
|
|
fn map_local_track(t: qbz_library::LocalTrack) -> TrackItem {
|
|
// Format-first classification (mirrors Tauri): MP3/lossy → MP3 tier.
|
|
let tier = if t.format.to_string().eq_ignore_ascii_case("mp3") {
|
|
"mp3"
|
|
} else {
|
|
match t.bit_depth {
|
|
Some(b) if b >= 24 => "hires",
|
|
Some(_) => "cd",
|
|
None => "",
|
|
}
|
|
};
|
|
TrackItem {
|
|
id: t.id.to_string().into(),
|
|
number: t.track_number.map(|n| n.to_string()).unwrap_or_default().into(),
|
|
title: t.title.into(),
|
|
artist: t.artist.into(),
|
|
album: t.album.into(),
|
|
duration: fmt_duration(t.duration_secs).into(),
|
|
quality_tier: tier.into(),
|
|
explicit: false,
|
|
selected: false,
|
|
artwork_url: t.artwork_path.unwrap_or_default().into(),
|
|
artwork: slint::Image::default(),
|
|
is_favorite: false,
|
|
artist_id: "".into(),
|
|
album_id: "".into(),
|
|
removing: false,
|
|
cache_status: 0,
|
|
cache_progress: 0.0,
|
|
// Source indicator: offline copies read as Qobuz, user files as local.
|
|
source: match t.source.as_deref() {
|
|
Some("qobuz_download") => "qobuz",
|
|
Some("plex") => "plex",
|
|
_ => "local",
|
|
}
|
|
.into(),
|
|
unlocking: false,
|
|
}
|
|
}
|
|
|
|
/// Fetch one tracks page off the UI thread. `LocalTrack` is Send, so it
|
|
/// crosses the `spawn_blocking` boundary; the conversion to `TrackItem`
|
|
/// happens on the UI thread. has_more = the page came back full.
|
|
fn fetch_tracks_page(query: String, offset: u64) -> Option<(Vec<qbz_library::LocalTrack>, bool)> {
|
|
let rows = crate::library_db::with_db(|db| {
|
|
db.search_with_filter_page(query.trim(), offset, TRACKS_PAGE, true, false)
|
|
})?;
|
|
let has_more = rows.len() as u64 == TRACKS_PAGE;
|
|
Some((rows, has_more))
|
|
}
|
|
|
|
fn apply_tracks(window: &AppWindow, rows: Vec<qbz_library::LocalTrack>, has_more: bool) {
|
|
// Keep the selection-source cache in lockstep (clone BEFORE the move).
|
|
*tracks_current() = rows.clone();
|
|
let items: Vec<TrackItem> = rows.into_iter().map(map_local_track).collect();
|
|
let s = window.global::<LocalLibraryState>();
|
|
let n = items.len() as i32;
|
|
s.set_tracks(ModelRc::new(VecModel::from(items)));
|
|
s.set_tracks_next_offset(n);
|
|
s.set_tracks_has_more(has_more);
|
|
s.set_tracks_loading(false);
|
|
s.set_tracks_loading_more(false);
|
|
s.set_tracks_load_failed(false);
|
|
derive_tracks(window);
|
|
}
|
|
|
|
fn append_tracks(window: &AppWindow, rows: Vec<qbz_library::LocalTrack>, has_more: bool) {
|
|
tracks_current().extend(rows.clone());
|
|
let new_items: Vec<TrackItem> = rows.into_iter().map(map_local_track).collect();
|
|
let s = window.global::<LocalLibraryState>();
|
|
let model = s.get_tracks();
|
|
let mut combined: Vec<TrackItem> = (0..model.row_count())
|
|
.filter_map(|i| model.row_data(i))
|
|
.collect();
|
|
let added = new_items.len() as i32;
|
|
combined.extend(new_items);
|
|
s.set_tracks(ModelRc::new(VecModel::from(combined)));
|
|
s.set_tracks_next_offset(s.get_tracks_next_offset() + added);
|
|
s.set_tracks_has_more(has_more);
|
|
s.set_tracks_loading_more(false);
|
|
derive_tracks(window);
|
|
}
|
|
|
|
// --- Tracks group-by + multi-select (reduced port of favorites helpers) ---
|
|
|
|
/// Re-derive the group-ordered + search-filtered `tracks-visible` render model
|
|
/// from the loaded `tracks`, plus the A-Z jump strip for name grouping. Uses
|
|
/// the local `folder_alpha_key`; no genre filter (no local genre surface).
|
|
pub fn derive_tracks(window: &AppWindow) {
|
|
let s = window.global::<LocalLibraryState>();
|
|
let query_owned = s.get_tracks_search().to_lowercase();
|
|
let query = query_owned.trim();
|
|
let group = s.get_tracks_group_mode().to_string();
|
|
let all = s.get_tracks();
|
|
s.set_tracks_alpha(ModelRc::new(VecModel::from(Vec::<AlphaJump>::new())));
|
|
|
|
// Fast path: no search + no grouping -> share the loaded model.
|
|
if query.is_empty() && group == "off" {
|
|
s.set_tracks_visible(all);
|
|
return;
|
|
}
|
|
let mut filtered: Vec<TrackItem> = (0..all.row_count())
|
|
.filter_map(|i| all.row_data(i))
|
|
.filter(|t| {
|
|
query.is_empty()
|
|
|| t.title.to_lowercase().contains(query)
|
|
|| t.artist.to_lowercase().contains(query)
|
|
|| t.album.to_lowercase().contains(query)
|
|
})
|
|
.collect();
|
|
let lc = |s: &slint::SharedString| s.to_lowercase();
|
|
match group.as_str() {
|
|
"album" => filtered
|
|
.sort_by(|a, b| lc(&a.album).cmp(&lc(&b.album)).then(lc(&a.title).cmp(&lc(&b.title)))),
|
|
"artist" => filtered.sort_by(|a, b| {
|
|
lc(&a.artist)
|
|
.cmp(&lc(&b.artist))
|
|
.then(lc(&a.album).cmp(&lc(&b.album)))
|
|
.then(lc(&a.title).cmp(&lc(&b.title)))
|
|
}),
|
|
"name" => filtered.sort_by(|a, b| lc(&a.title).cmp(&lc(&b.title))),
|
|
_ => {}
|
|
}
|
|
if group == "name" {
|
|
let mut jumps: Vec<AlphaJump> = Vec::new();
|
|
let mut last = String::new();
|
|
for (i, t) in filtered.iter().enumerate() {
|
|
let key = folder_alpha_key(t.title.as_str());
|
|
if key != last {
|
|
jumps.push(AlphaJump {
|
|
letter: key.clone().into(),
|
|
index: i as i32,
|
|
});
|
|
last = key;
|
|
}
|
|
}
|
|
s.set_tracks_alpha(ModelRc::new(VecModel::from(jumps)));
|
|
}
|
|
s.set_tracks_visible(ModelRc::new(VecModel::from(filtered)));
|
|
}
|
|
|
|
/// Set the group mode, persist it, re-derive.
|
|
pub fn set_tracks_group(window: &AppWindow, mode: &str) {
|
|
window.global::<LocalLibraryState>().set_tracks_group_mode(mode.into());
|
|
crate::locallibrary_prefs::save(window);
|
|
derive_tracks(window);
|
|
}
|
|
|
|
/// Enter/leave multi-select; leaving clears the selection.
|
|
pub fn set_tracks_multi_select(window: &AppWindow, on: bool) {
|
|
window.global::<LocalLibraryState>().set_tracks_multi_select(on);
|
|
if !on {
|
|
clear_tracks_selection(window);
|
|
}
|
|
}
|
|
|
|
/// Recount selected visible rows into `tracks-selected-count`.
|
|
pub fn recount_tracks_selected(window: &AppWindow) {
|
|
let s = window.global::<LocalLibraryState>();
|
|
let model = s.get_tracks_visible();
|
|
let count = (0..model.row_count())
|
|
.filter(|&i| model.row_data(i).map(|t| t.selected).unwrap_or(false))
|
|
.count();
|
|
s.set_tracks_selected_count(count as i32);
|
|
}
|
|
|
|
/// Toggle one row's selection (by id) in the visible model.
|
|
pub fn toggle_track_select(window: &AppWindow, id: &str) {
|
|
let model = window.global::<LocalLibraryState>().get_tracks_visible();
|
|
for i in 0..model.row_count() {
|
|
if let Some(mut item) = model.row_data(i) {
|
|
if item.id.as_str() == id {
|
|
item.selected = !item.selected;
|
|
model.set_row_data(i, item);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
recount_tracks_selected(window);
|
|
}
|
|
|
|
/// Select-all toggle: select every visible row, or clear if all selected.
|
|
pub fn select_all_tracks(window: &AppWindow) {
|
|
let model = window.global::<LocalLibraryState>().get_tracks_visible();
|
|
let total = model.row_count();
|
|
let selected = (0..total)
|
|
.filter(|&i| model.row_data(i).map(|t| t.selected).unwrap_or(false))
|
|
.count();
|
|
let target = selected != total;
|
|
for i in 0..total {
|
|
if let Some(mut item) = model.row_data(i) {
|
|
if item.selected != target {
|
|
item.selected = target;
|
|
model.set_row_data(i, item);
|
|
}
|
|
}
|
|
}
|
|
recount_tracks_selected(window);
|
|
}
|
|
|
|
/// Deselect every visible row (multi-select mode stays on).
|
|
pub fn clear_tracks_selection(window: &AppWindow) {
|
|
let s = window.global::<LocalLibraryState>();
|
|
let model = s.get_tracks_visible();
|
|
for i in 0..model.row_count() {
|
|
if let Some(mut item) = model.row_data(i) {
|
|
if item.selected {
|
|
item.selected = false;
|
|
model.set_row_data(i, item);
|
|
}
|
|
}
|
|
}
|
|
s.set_tracks_selected_count(0);
|
|
}
|
|
|
|
/// The selected rows resolved to `LocalTrack`, in DISPLAY order (iterate the
|
|
/// visible model, look each selected id up in `TRACKS_CURRENT`). Deviates from
|
|
/// favorites' load-order resolution so the queue matches what the user sees.
|
|
pub fn selected_local_tracks(window: &AppWindow) -> Vec<qbz_library::LocalTrack> {
|
|
let model = window.global::<LocalLibraryState>().get_tracks_visible();
|
|
let cache = tracks_current();
|
|
let mut out = Vec::new();
|
|
for i in 0..model.row_count() {
|
|
if let Some(item) = model.row_data(i) {
|
|
if item.selected {
|
|
let id = item.id.to_string();
|
|
if let Some(t) = cache.iter().find(|t| t.id.to_string() == id) {
|
|
out.push(t.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Resolve a single row id (display) to its `LocalTrack` from the cache.
|
|
pub fn local_track_by_id(id: &str) -> Option<qbz_library::LocalTrack> {
|
|
tracks_current().iter().find(|t| t.id.to_string() == id).cloned()
|
|
}
|
|
|
|
fn spawn_tracks_page_load(window: &AppWindow, handle: tokio::runtime::Handle, gen: u64) {
|
|
let s = window.global::<LocalLibraryState>();
|
|
let query = s.get_tracks_search().to_string();
|
|
let weak = window.as_weak();
|
|
handle.spawn(async move {
|
|
let result = tokio::task::spawn_blocking(move || fetch_tracks_page(query, 0))
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
if TRACKS_GEN.load(Ordering::SeqCst) != gen {
|
|
return;
|
|
}
|
|
match result {
|
|
Some((rows, has_more)) => apply_tracks(&w, rows, has_more),
|
|
None => {
|
|
let s = w.global::<LocalLibraryState>();
|
|
s.set_tracks_loading(false);
|
|
s.set_tracks_load_failed(true);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/// (Re)load page 1 of the tracks list with the current search.
|
|
pub fn reload_tracks(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let gen = TRACKS_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
|
let s = w.global::<LocalLibraryState>();
|
|
s.set_tracks_loading(true);
|
|
s.set_tracks_load_failed(false);
|
|
spawn_tracks_page_load(&w, handle, gen);
|
|
});
|
|
}
|
|
|
|
/// Lazy load on first visit (re-entry keeps the loaded set + scroll).
|
|
pub fn ensure_tracks_loaded(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
if s.get_tracks().row_count() == 0 && !s.get_tracks_loading() {
|
|
let gen = TRACKS_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
|
s.set_tracks_loading(true);
|
|
s.set_tracks_load_failed(false);
|
|
spawn_tracks_page_load(&w, handle, gen);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Fetch + append the next tracks page (scroll-near-bottom).
|
|
pub fn load_more_tracks(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
if !s.get_tracks_has_more() || s.get_tracks_loading_more() || s.get_tracks_loading() {
|
|
return;
|
|
}
|
|
let offset = s.get_tracks_next_offset().max(0) as u64;
|
|
let query = s.get_tracks_search().to_string();
|
|
let gen = TRACKS_GEN.load(Ordering::SeqCst);
|
|
s.set_tracks_loading_more(true);
|
|
let weak2 = w.as_weak();
|
|
handle.spawn(async move {
|
|
let result = tokio::task::spawn_blocking(move || fetch_tracks_page(query, offset))
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
let _ = weak2.upgrade_in_event_loop(move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
if TRACKS_GEN.load(Ordering::SeqCst) != gen {
|
|
s.set_tracks_loading_more(false);
|
|
return;
|
|
}
|
|
match result {
|
|
Some((rows, has_more)) => append_tracks(&w, rows, has_more),
|
|
None => s.set_tracks_loading_more(false),
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================ Album detail ================================
|
|
//
|
|
// Local albums reuse the shared `AlbumPageView` + `AlbumState`: we just
|
|
// populate the state from the album's local tracks and flag `is-local` so
|
|
// the media-action dispatcher routes play to local playback.
|
|
|
|
fn fmt_album_duration(secs: u64) -> String {
|
|
let mins = secs / 60;
|
|
if mins >= 60 {
|
|
format!("{} h {} min", mins / 60, mins % 60)
|
|
} else {
|
|
format!("{mins} min")
|
|
}
|
|
}
|
|
|
|
// The open local album's versions (label, tracks) — a "version" is a distinct
|
|
// physical copy (= a distinct source directory). Cached so the version picker
|
|
// switches without a DB round-trip. Splitting by directory is what stops two
|
|
// copies of the same album from merging into a duplicated track list.
|
|
static ALBUM_VERSIONS: LazyLock<Mutex<Vec<(String, Vec<qbz_library::LocalTrack>)>>> =
|
|
LazyLock::new(|| Mutex::new(Vec::new()));
|
|
|
|
fn album_versions() -> std::sync::MutexGuard<'static, Vec<(String, Vec<qbz_library::LocalTrack>)>> {
|
|
ALBUM_VERSIONS.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// Quality rank for ordering versions (hi-res first).
|
|
fn version_rank(t: &qbz_library::LocalTrack) -> (u32, u64) {
|
|
(t.bit_depth.unwrap_or(0), t.sample_rate as u64)
|
|
}
|
|
|
|
/// A version's picker label: "24-bit / 96 kHz · FLAC" (quality + format).
|
|
fn version_label(tracks: &[qbz_library::LocalTrack]) -> String {
|
|
match tracks.first() {
|
|
Some(t) => {
|
|
let (detail, _) = local_quality(t.bit_depth, t.sample_rate);
|
|
let fmt = t.format.to_string();
|
|
if detail.is_empty() {
|
|
fmt
|
|
} else {
|
|
format!("{detail} · {fmt}")
|
|
}
|
|
}
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
/// A version's source ("user" | "qobuz_download" | "plex" | "") — drives the
|
|
/// picker's source icon.
|
|
fn version_source(tracks: &[qbz_library::LocalTrack]) -> String {
|
|
tracks
|
|
.first()
|
|
.and_then(|t| t.source.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Load a local album (dedicated LocalAlbumView), splitting its tracks into
|
|
/// VERSIONS by source directory so multiple copies don't merge into a
|
|
/// duplicate-track list. Applies the best-quality version first; the picker
|
|
/// switches versions in place. Does NOT touch nav — the caller sets the view.
|
|
pub fn open_local_album(
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: tokio::runtime::Handle,
|
|
image_cache: ImageCache,
|
|
group_key: String,
|
|
) {
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
let s = w.global::<crate::LocalAlbumState>();
|
|
s.set_loading(true);
|
|
s.set_tracks(ModelRc::new(VecModel::from(Vec::<TrackItem>::new())));
|
|
s.set_versions(ModelRc::new(VecModel::from(Vec::<crate::LocalAlbumVersion>::new())));
|
|
s.set_version_index(0);
|
|
s.set_cover(slint::Image::default());
|
|
});
|
|
let gk = group_key.clone();
|
|
handle.spawn(async move {
|
|
let tracks = tokio::task::spawn_blocking(move || {
|
|
let mut t = fetch_album_tracks_blocking(&gk);
|
|
// Backfill covers from cover.jpg/folder.jpg on disk (the DB may not
|
|
// have an artwork_path even when a cover sits in the folder).
|
|
crate::playback::fill_missing_covers(&mut t);
|
|
t
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
// Group by source directory (LocalTrack.album_group_key = the dir key).
|
|
let mut groups: std::collections::HashMap<String, Vec<qbz_library::LocalTrack>> =
|
|
std::collections::HashMap::new();
|
|
let mut order: Vec<String> = Vec::new();
|
|
for t in tracks {
|
|
let key = t.album_group_key.clone();
|
|
if !groups.contains_key(&key) {
|
|
order.push(key.clone());
|
|
}
|
|
groups.entry(key).or_default().push(t);
|
|
}
|
|
let mut versions: Vec<(String, Vec<qbz_library::LocalTrack>)> = order
|
|
.into_iter()
|
|
.filter_map(|k| {
|
|
groups.remove(&k).map(|mut v| {
|
|
v.sort_by_key(|t| (t.disc_number.unwrap_or(1), t.track_number.unwrap_or(0)));
|
|
(k, v)
|
|
})
|
|
})
|
|
.collect();
|
|
// Best quality first (so the default selection is the highest-res copy).
|
|
versions.sort_by(|a, b| {
|
|
let qa = a.1.iter().map(version_rank).max().unwrap_or((0, 0));
|
|
let qb = b.1.iter().map(version_rank).max().unwrap_or((0, 0));
|
|
qb.cmp(&qa)
|
|
});
|
|
// (label, source) per version — best-quality first (already sorted).
|
|
let infos: Vec<(String, String)> = versions
|
|
.iter()
|
|
.map(|(_, v)| (version_label(v), version_source(v)))
|
|
.collect();
|
|
// Album cover = the FIRST version (best quality first) that has a cover
|
|
// on disk; fall through versions; else empty (placeholder). Album-level
|
|
// + stable across version switches.
|
|
let album_cover = versions
|
|
.iter()
|
|
.find_map(|(_, v)| {
|
|
v.iter()
|
|
.find_map(|t| t.artwork_path.clone().filter(|p| !p.is_empty()))
|
|
})
|
|
.unwrap_or_default();
|
|
*album_versions() = versions;
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let s = w.global::<crate::LocalAlbumState>();
|
|
s.set_id(group_key.into());
|
|
let vlist: Vec<crate::LocalAlbumVersion> = infos
|
|
.into_iter()
|
|
.map(|(label, source)| crate::LocalAlbumVersion {
|
|
label: label.into(),
|
|
source: source.into(),
|
|
})
|
|
.collect();
|
|
s.set_versions(ModelRc::new(VecModel::from(vlist)));
|
|
s.set_loading(false);
|
|
s.set_cover_url(album_cover.clone().into());
|
|
apply_album_version(&w, 0);
|
|
// Decode the album cover once (stable across version switches).
|
|
if !album_cover.is_empty() {
|
|
crate::artwork::spawn_local_loads(
|
|
vec![ArtworkJob {
|
|
target: ArtworkTarget::LocalAlbumViewCover,
|
|
url: album_cover,
|
|
}],
|
|
w.as_weak(),
|
|
image_cache,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Apply version `index` of the open album to LocalAlbumState (tracks, header,
|
|
/// quality). Reads the cached versions; no DB round-trip. The cover is
|
|
/// album-level (set once by `open_local_album`), so it is NOT touched here.
|
|
pub fn apply_album_version(window: &AppWindow, index: i32) {
|
|
let versions = album_versions();
|
|
let Some((_, tracks)) = versions.get(index as usize) else {
|
|
return;
|
|
};
|
|
let s = window.global::<crate::LocalAlbumState>();
|
|
let group_key = s.get_id().to_string();
|
|
let title = tracks
|
|
.first()
|
|
.map(|t| t.album_group_title.clone())
|
|
.unwrap_or_default();
|
|
let artist_of =
|
|
|t: &qbz_library::LocalTrack| t.album_artist.clone().unwrap_or_else(|| t.artist.clone());
|
|
let artist = match tracks.first() {
|
|
Some(first) => {
|
|
let name = artist_of(first);
|
|
if tracks.iter().all(|t| artist_of(t) == name) {
|
|
name
|
|
} else {
|
|
"Various Artists".to_string()
|
|
}
|
|
}
|
|
None => String::new(),
|
|
};
|
|
let total_secs: u64 = tracks.iter().map(|t| t.duration_secs).sum();
|
|
let info_line = format!("{} tracks · {}", tracks.len(), fmt_album_duration(total_secs));
|
|
let (tier, detail) = match tracks.iter().max_by_key(|t| t.bit_depth.unwrap_or(0)) {
|
|
Some(t) => {
|
|
let tier = match t.bit_depth {
|
|
Some(b) if b >= 24 => "hires",
|
|
Some(_) => "cd",
|
|
None => "",
|
|
};
|
|
(tier.to_string(), local_quality(t.bit_depth, t.sample_rate).0)
|
|
}
|
|
None => (String::new(), String::new()),
|
|
};
|
|
let items: Vec<TrackItem> = tracks
|
|
.iter()
|
|
.cloned()
|
|
.map(|t| {
|
|
let mut it = map_local_track(t);
|
|
it.album_id = group_key.clone().into();
|
|
it
|
|
})
|
|
.collect();
|
|
s.set_title(title.into());
|
|
s.set_artist(artist.into());
|
|
s.set_info_line(info_line.into());
|
|
s.set_quality_tier(tier.into());
|
|
s.set_quality_detail(detail.into());
|
|
s.set_tracks(ModelRc::new(VecModel::from(items)));
|
|
s.set_version_index(index);
|
|
}
|
|
|
|
/// The source directory of version `index` (for the tag editor — a real dir).
|
|
pub fn album_version_dir(index: i32) -> Option<String> {
|
|
album_versions().get(index as usize).map(|(dir, _)| dir.clone())
|
|
}
|
|
|
|
/// The currently-selected album version's tracks (play / shuffle / add / edit).
|
|
pub fn current_album_version_tracks(window: &AppWindow) -> Vec<qbz_library::LocalTrack> {
|
|
let idx = window.global::<crate::LocalAlbumState>().get_version_index();
|
|
album_versions()
|
|
.get(idx as usize)
|
|
.map(|(_, v)| v.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
// ============================ Folders (flat) ==============================
|
|
//
|
|
// The Folders tab (flat mode) is the album grid grouped by directory rather
|
|
// than by metadata. Full-load (folder counts are bounded; the freeze risk is
|
|
// the Tracks table). Reuses AlbumCollectionView; covers load per card.
|
|
|
|
fn fetch_folder_albums() -> Vec<crate::album_map::AlbumCard> {
|
|
crate::library_db::with_db(|db| {
|
|
db.get_albums_with_full_filter(
|
|
/* include_hidden */ false,
|
|
/* include_qobuz_downloads */ true,
|
|
/* exclude_network_folders */ false,
|
|
)
|
|
})
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(map_local_album)
|
|
.collect()
|
|
}
|
|
|
|
fn folder_artwork_jobs(cards: &[crate::album_map::AlbumCard]) -> Vec<ArtworkJob> {
|
|
cards
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, c)| !c.artwork_url.is_empty())
|
|
.map(|(i, c)| ArtworkJob {
|
|
target: ArtworkTarget::LocalFolderCard { index: i },
|
|
url: c.artwork_url.clone(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn apply_folders(window: &AppWindow, cards: Vec<crate::album_map::AlbumCard>) {
|
|
let items: Vec<AlbumCardItem> = cards.into_iter().map(crate::album_map::to_item).collect();
|
|
let s = window.global::<LocalLibraryState>();
|
|
s.set_folders(ModelRc::new(VecModel::from(items)));
|
|
s.set_folders_loading(false);
|
|
s.set_folders_load_failed(false);
|
|
derive_folders(window);
|
|
}
|
|
|
|
/// First-letter bucket key for alpha grouping (`#` for non-alphabetic).
|
|
/// Mirrors favorites' `album_alpha_key` so the two surfaces sort identically.
|
|
fn folder_alpha_key(title: &str) -> String {
|
|
title
|
|
.chars()
|
|
.find(|c| c.is_alphanumeric())
|
|
.map(|c| {
|
|
let up = c.to_uppercase().next().unwrap_or(c);
|
|
if up.is_ascii_digit() {
|
|
"#".to_string()
|
|
} else {
|
|
up.to_string()
|
|
}
|
|
})
|
|
.unwrap_or_else(|| "#".to_string())
|
|
}
|
|
|
|
/// Re-derive the flat-mode visible / grouped folder models from the full
|
|
/// `folders` set, applying the toolbar's search query, sort key and group
|
|
/// mode. Mirrors `favorites::derive_albums` so behaviour is identical to the
|
|
/// metadata Albums tab; the only difference is the source model (directory
|
|
/// grouping instead of metadata grouping).
|
|
pub fn derive_folders(window: &AppWindow) {
|
|
let s = window.global::<LocalLibraryState>();
|
|
let query_owned = s.get_folders_search().to_lowercase();
|
|
let query = query_owned.trim();
|
|
let sort = s.get_folders_sort().to_string();
|
|
let group = s.get_folders_group().to_string();
|
|
let all = s.get_folders();
|
|
let empty_sections = || ModelRc::new(VecModel::from(Vec::<DiscoverSection>::new()));
|
|
|
|
let mut filtered: Vec<AlbumCardItem> = (0..all.row_count())
|
|
.filter_map(|i| all.row_data(i))
|
|
.filter(|a| {
|
|
query.is_empty()
|
|
|| a.title.to_lowercase().contains(query)
|
|
|| a.artist.to_lowercase().contains(query)
|
|
})
|
|
.collect();
|
|
crate::album_map::sort_album_items(&mut filtered, &sort);
|
|
|
|
if group == "off" {
|
|
s.set_folders_visible(ModelRc::new(VecModel::from(filtered)));
|
|
s.set_folders_grouped(empty_sections());
|
|
return;
|
|
}
|
|
|
|
// Grouped: bucket by artist name, or by the title's first letter
|
|
// (`#` for non-alphabetic). Sections ordered alphabetically, `#` last.
|
|
let mut map: Vec<(String, Vec<AlbumCardItem>)> = Vec::new();
|
|
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
|
for item in filtered {
|
|
let key = if group == "artist" {
|
|
let a = item.artist.to_string();
|
|
if a.is_empty() {
|
|
"Unknown".to_string()
|
|
} else {
|
|
a
|
|
}
|
|
} else {
|
|
folder_alpha_key(item.title.as_str())
|
|
};
|
|
let idx = *index.entry(key.clone()).or_insert_with(|| {
|
|
map.push((key.clone(), Vec::new()));
|
|
map.len() - 1
|
|
});
|
|
map[idx].1.push(item);
|
|
}
|
|
map.sort_by(|(a, _), (b, _)| match (a == "#", b == "#") {
|
|
(true, false) => std::cmp::Ordering::Greater,
|
|
(false, true) => std::cmp::Ordering::Less,
|
|
_ => a.to_lowercase().cmp(&b.to_lowercase()),
|
|
});
|
|
let sections: Vec<DiscoverSection> = map
|
|
.into_iter()
|
|
.map(|(key, items)| DiscoverSection {
|
|
title: key.into(),
|
|
endpoint: "".into(),
|
|
albums: ModelRc::new(VecModel::from(items)),
|
|
})
|
|
.collect();
|
|
s.set_folders_grouped(ModelRc::new(VecModel::from(sections)));
|
|
s.set_folders_visible(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
|
|
}
|
|
|
|
/// Load the folder-grouped album grid on first visit (re-entry keeps it).
|
|
pub fn ensure_folders_loaded(
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: tokio::runtime::Handle,
|
|
image_cache: ImageCache,
|
|
) {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
if s.get_folders().row_count() != 0 || s.get_folders_loading() {
|
|
return;
|
|
}
|
|
s.set_folders_loading(true);
|
|
s.set_folders_load_failed(false);
|
|
let weak2 = w.as_weak();
|
|
handle.spawn(async move {
|
|
let cards = tokio::task::spawn_blocking(fetch_folder_albums)
|
|
.await
|
|
.unwrap_or_default();
|
|
let _ = weak2.upgrade_in_event_loop(move |w| {
|
|
let jobs = folder_artwork_jobs(&cards);
|
|
apply_folders(&w, cards);
|
|
crate::artwork::spawn_local_loads(jobs, w.as_weak(), image_cache);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================ Folders (tree) ==============================
|
|
//
|
|
// Slint has no native TreeView and no self-recursive component, so the tree
|
|
// is rendered as a flattened list of visible nodes (`FolderNode`) in a
|
|
// ListView: each node carries its `depth` (drives the indent) and an
|
|
// `expanded` flag. Expanding fetches one level lazily via
|
|
// `list_folder_children`; collapsing drops the contiguous descendant block.
|
|
|
|
/// Last path component for display (the registered root paths are absolute).
|
|
fn path_basename(path: &str) -> String {
|
|
path.trim_end_matches('/')
|
|
.rsplit('/')
|
|
.next()
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or(path)
|
|
.to_string()
|
|
}
|
|
|
|
/// Map a backend tree entry to a flattened `FolderNode` at the given depth.
|
|
fn entry_to_node(entry: &qbz_library::FolderTreeEntry, depth: i32) -> FolderNode {
|
|
match entry {
|
|
qbz_library::FolderTreeEntry::Folder {
|
|
path,
|
|
segment,
|
|
track_count_under,
|
|
..
|
|
} => FolderNode {
|
|
path: path.clone().into(),
|
|
segment: segment.clone().into(),
|
|
depth,
|
|
is_folder: true,
|
|
expanded: false,
|
|
can_expand: *track_count_under > 0,
|
|
track_count: *track_count_under as i32,
|
|
},
|
|
qbz_library::FolderTreeEntry::Track { path, segment } => FolderNode {
|
|
path: path.clone().into(),
|
|
segment: segment.clone().into(),
|
|
depth,
|
|
is_folder: false,
|
|
expanded: false,
|
|
can_expand: false,
|
|
track_count: 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Load the tree roots (registered library folders) on first switch to tree
|
|
/// mode. Each root gets a recursive track count so the rail can show totals
|
|
/// and gate the expand affordance.
|
|
pub fn ensure_folder_tree_loaded(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
if s.get_folder_tree().row_count() != 0 || s.get_folder_tree_loading() {
|
|
return;
|
|
}
|
|
s.set_folder_tree_loading(true);
|
|
let weak2 = w.as_weak();
|
|
handle.spawn(async move {
|
|
let roots = tokio::task::spawn_blocking(|| {
|
|
crate::library_db::with_db(|db| {
|
|
let paths = db.get_folders()?;
|
|
let mut out: Vec<(String, u32)> = Vec::with_capacity(paths.len());
|
|
for p in paths {
|
|
let cnt = db.count_folder_tracks_recursive(&p, false)?;
|
|
out.push((p, cnt));
|
|
}
|
|
Ok::<_, qbz_library::LibraryError>(out)
|
|
})
|
|
.unwrap_or_default()
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
let _ = weak2.upgrade_in_event_loop(move |w| {
|
|
let nodes: Vec<FolderNode> = roots
|
|
.into_iter()
|
|
.map(|(p, cnt)| FolderNode {
|
|
segment: path_basename(&p).into(),
|
|
path: p.into(),
|
|
depth: 0,
|
|
is_folder: true,
|
|
expanded: false,
|
|
can_expand: cnt > 0,
|
|
track_count: cnt as i32,
|
|
})
|
|
.collect();
|
|
let s = w.global::<LocalLibraryState>();
|
|
s.set_folder_tree(ModelRc::new(VecModel::from(nodes)));
|
|
s.set_folder_tree_loading(false);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Collect the current flattened tree into a plain vec for splicing.
|
|
fn collect_tree(s: &LocalLibraryState) -> Vec<FolderNode> {
|
|
let m = s.get_folder_tree();
|
|
(0..m.row_count()).filter_map(|i| m.row_data(i)).collect()
|
|
}
|
|
|
|
/// Expand or collapse a folder node. Collapsing is pure UI (drop the
|
|
/// contiguous descendant block); expanding fetches one child level lazily.
|
|
pub fn toggle_folder_node(
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: tokio::runtime::Handle,
|
|
path: String,
|
|
expand: bool,
|
|
) {
|
|
if !expand {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
let mut nodes = collect_tree(&s);
|
|
if let Some(pos) = nodes.iter().position(|n| n.path == path) {
|
|
let depth = nodes[pos].depth;
|
|
nodes[pos].expanded = false;
|
|
let mut end = pos + 1;
|
|
while end < nodes.len() && nodes[end].depth > depth {
|
|
end += 1;
|
|
}
|
|
nodes.drain(pos + 1..end);
|
|
s.set_folder_tree(ModelRc::new(VecModel::from(nodes)));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
// Expand: fetch this level's children off-thread, then splice them in.
|
|
let path_for_fetch = path.clone();
|
|
handle.spawn(async move {
|
|
let children = tokio::task::spawn_blocking(move || {
|
|
crate::library_db::with_db(|db| db.list_folder_children(&path_for_fetch, false))
|
|
.unwrap_or_default()
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
let mut nodes = collect_tree(&s);
|
|
if let Some(pos) = nodes.iter().position(|n| n.path == path) {
|
|
let depth = nodes[pos].depth;
|
|
nodes[pos].expanded = true;
|
|
let child_nodes: Vec<FolderNode> = children
|
|
.iter()
|
|
.map(|e| entry_to_node(e, depth + 1))
|
|
.collect();
|
|
for (i, cn) in child_nodes.into_iter().enumerate() {
|
|
nodes.insert(pos + 1 + i, cn);
|
|
}
|
|
s.set_folder_tree(ModelRc::new(VecModel::from(nodes)));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Select a folder in the tree: load its detail pane — direct child tracks
|
|
/// plus immediate subfolders (for in-pane drill-down).
|
|
pub fn select_folder(
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: tokio::runtime::Handle,
|
|
path: String,
|
|
segment: String,
|
|
) {
|
|
let _ = weak.upgrade_in_event_loop({
|
|
let path = path.clone();
|
|
let segment = segment.clone();
|
|
move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
s.set_folders_selected_path(path.clone().into());
|
|
s.set_folders_selected_name(segment.clone().into());
|
|
s.set_folder_detail_loading(true);
|
|
}
|
|
});
|
|
let path_for_fetch = path.clone();
|
|
handle.spawn(async move {
|
|
let (tracks, subfolders) = tokio::task::spawn_blocking(move || {
|
|
let tracks = crate::library_db::with_db(|db| {
|
|
db.list_folder_tracks(&path_for_fetch, false)
|
|
})
|
|
.unwrap_or_default();
|
|
let children = crate::library_db::with_db(|db| {
|
|
db.list_folder_children(&path_for_fetch, false)
|
|
})
|
|
.unwrap_or_default();
|
|
(tracks, children)
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let track_items: Vec<TrackItem> = tracks.into_iter().map(map_local_track).collect();
|
|
let sub_nodes: Vec<FolderNode> = subfolders
|
|
.iter()
|
|
.filter(|e| matches!(e, qbz_library::FolderTreeEntry::Folder { .. }))
|
|
.map(|e| entry_to_node(e, 0))
|
|
.collect();
|
|
let s = w.global::<LocalLibraryState>();
|
|
s.set_folder_detail_tracks(ModelRc::new(VecModel::from(track_items)));
|
|
s.set_folder_detail_subfolders(ModelRc::new(VecModel::from(sub_nodes)));
|
|
s.set_folder_detail_loading(false);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================== Artists tab ===============================
|
|
//
|
|
// Two-column master/detail, 1:1 with Tauri's Artists tab (NOT a card grid —
|
|
// the Svelte VirtualizedArtistGrid imports there are dead). Left rail: a
|
|
// merged/deduped, alpha-grouped master list of compact rows (round avatar +
|
|
// name + "N albums · M tracks"). Right pane: the selected artist's albums,
|
|
// filtered IN PLACE from the loaded album set (no new backend call). The
|
|
// name-merge collapses normalized-equal spellings into one canonical row.
|
|
//
|
|
// The Qobuz background image fetch (capped/sequential in Tauri, and whose DB
|
|
// batch path is broken there) is the remaining follow-up; this pass wires the
|
|
// DB custom-image path + the mic placeholder.
|
|
|
|
/// The loaded album set, cached so the right-pane filter (select) doesn't
|
|
/// re-hit the DB — mirrors Tauri filtering its in-memory `albums` array.
|
|
static ARTIST_ALBUMS: std::sync::Mutex<Vec<qbz_library::LocalAlbum>> =
|
|
std::sync::Mutex::new(Vec::new());
|
|
|
|
/// Fold a common Latin accented char to its ASCII base (best-effort, no
|
|
/// `unicode-normalization` dep). Covers Spanish/European music metadata; the
|
|
/// uncovered tail just won't merge across diacritics. Mirrors the intent of
|
|
/// Tauri's NFKD + combining-mark strip in `normalizeArtistName`.
|
|
fn fold_diacritic(c: char) -> char {
|
|
match c {
|
|
'á' | 'à' | 'â' | 'ä' | 'ã' | 'å' | 'ā' => 'a',
|
|
'é' | 'è' | 'ê' | 'ë' | 'ē' | 'ė' => 'e',
|
|
'í' | 'ì' | 'î' | 'ï' | 'ī' => 'i',
|
|
'ó' | 'ò' | 'ô' | 'ö' | 'õ' | 'ō' | 'ø' => 'o',
|
|
'ú' | 'ù' | 'û' | 'ü' | 'ū' => 'u',
|
|
'ñ' => 'n',
|
|
'ç' => 'c',
|
|
'ý' | 'ÿ' => 'y',
|
|
'ß' => 's',
|
|
_ => c,
|
|
}
|
|
}
|
|
|
|
/// Normalize an artist name for merge/match: lowercase, fold diacritics,
|
|
/// collapse every run of non-alphanumerics to a single space, trim. So
|
|
/// "Alice In Chains" and "alice in chains" both -> "alice in chains".
|
|
fn normalize_artist(name: &str) -> String {
|
|
let mut out = String::with_capacity(name.len());
|
|
let mut prev_space = false;
|
|
for ch in name.to_lowercase().chars() {
|
|
let c = fold_diacritic(ch);
|
|
if c.is_ascii_alphanumeric() {
|
|
out.push(c);
|
|
prev_space = false;
|
|
} else if !prev_space {
|
|
out.push(' ');
|
|
prev_space = true;
|
|
}
|
|
}
|
|
out.trim().to_string()
|
|
}
|
|
|
|
/// Split a credit string into individual artist names on the usual
|
|
/// separators (comma already handled by the caller for `all_artists`).
|
|
fn split_credit(s: &str) -> Vec<String> {
|
|
s.split([',', '&', '/', ';'])
|
|
.flat_map(|p| {
|
|
p.split(" feat ")
|
|
.flat_map(|q| q.split(" ft "))
|
|
.flat_map(|q| q.split(" featuring "))
|
|
.flat_map(|q| q.split(" with "))
|
|
.map(|q| q.to_string())
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Build the per-normalized-artist set of album ids, so merged rows get an
|
|
/// accurate unique album count independent of per-track spelling. Mirrors
|
|
/// Tauri's `artistAlbumIds`.
|
|
fn build_artist_album_ids(
|
|
albums: &[qbz_library::LocalAlbum],
|
|
) -> std::collections::HashMap<String, std::collections::HashSet<String>> {
|
|
let mut map: std::collections::HashMap<String, std::collections::HashSet<String>> =
|
|
std::collections::HashMap::new();
|
|
for al in albums {
|
|
if !al.all_artists.is_empty() {
|
|
for part in al.all_artists.split(',') {
|
|
let n = normalize_artist(part);
|
|
if n.is_empty() || n == "various artists" {
|
|
continue;
|
|
}
|
|
map.entry(n).or_default().insert(al.id.clone());
|
|
}
|
|
} else {
|
|
let n = normalize_artist(&al.artist);
|
|
if !n.is_empty() && n != "various artists" {
|
|
map.entry(n).or_default().insert(al.id.clone());
|
|
}
|
|
}
|
|
}
|
|
map
|
|
}
|
|
|
|
/// Send-safe merged-artist row (no `slint::Image`, so it can cross the
|
|
/// `spawn_blocking` boundary). Converted to `LocalArtistItem` on the UI thread
|
|
/// in `apply_artists` (where the non-Send decoded `image` is added).
|
|
struct ArtistRow {
|
|
name: String,
|
|
display_name: String,
|
|
album_count: i32,
|
|
track_count: i32,
|
|
image_path: String,
|
|
}
|
|
|
|
/// Collapse normalized-equal artist spellings into one canonical row and
|
|
/// attach accurate album counts + a custom-image path. Mirrors Tauri's
|
|
/// `artistMergeResult`: canonical = the variant with most albums (tie: most
|
|
/// tracks); merged track count = sum across variants.
|
|
fn merge_artists(
|
|
artists: Vec<qbz_library::LocalArtist>,
|
|
albums: &[qbz_library::LocalAlbum],
|
|
custom_images: &std::collections::HashMap<String, String>,
|
|
) -> Vec<ArtistRow> {
|
|
let album_ids = build_artist_album_ids(albums);
|
|
let norm_imgs: std::collections::HashMap<String, String> = custom_images
|
|
.iter()
|
|
.map(|(k, v)| (normalize_artist(k), v.clone()))
|
|
.collect();
|
|
|
|
let mut groups: std::collections::HashMap<String, Vec<qbz_library::LocalArtist>> =
|
|
std::collections::HashMap::new();
|
|
let mut order: Vec<String> = Vec::new();
|
|
for a in artists {
|
|
let n = normalize_artist(&a.name);
|
|
if n.is_empty() {
|
|
continue;
|
|
}
|
|
if !groups.contains_key(&n) {
|
|
order.push(n.clone());
|
|
}
|
|
groups.entry(n).or_default().push(a);
|
|
}
|
|
|
|
let mut out: Vec<ArtistRow> = Vec::with_capacity(order.len());
|
|
for n in order {
|
|
let variants = match groups.remove(&n) {
|
|
Some(v) => v,
|
|
None => continue,
|
|
};
|
|
let album_set_len = album_ids.get(&n).map(|s| s.len()).unwrap_or(0) as i32;
|
|
let (canonical, album_count, track_count) = if variants.len() == 1 {
|
|
let v = &variants[0];
|
|
let ac = if album_set_len > 0 {
|
|
album_set_len
|
|
} else {
|
|
v.album_count as i32
|
|
};
|
|
(v.name.clone(), ac, v.track_count as i32)
|
|
} else {
|
|
let canon = variants
|
|
.iter()
|
|
.max_by(|a, b| {
|
|
a.album_count
|
|
.cmp(&b.album_count)
|
|
.then(a.track_count.cmp(&b.track_count))
|
|
})
|
|
.unwrap();
|
|
let total_tracks: u32 = variants.iter().map(|v| v.track_count).sum();
|
|
let ac = if album_set_len > 0 {
|
|
album_set_len
|
|
} else {
|
|
canon.album_count as i32
|
|
};
|
|
(canon.name.clone(), ac, total_tracks as i32)
|
|
};
|
|
let image_path = norm_imgs.get(&n).cloned().unwrap_or_default();
|
|
out.push(ArtistRow {
|
|
name: canonical.clone(),
|
|
display_name: canonical,
|
|
album_count,
|
|
track_count,
|
|
image_path,
|
|
});
|
|
}
|
|
out.sort_by(|a, b| {
|
|
a.display_name
|
|
.to_lowercase()
|
|
.cmp(&b.display_name.to_lowercase())
|
|
});
|
|
out
|
|
}
|
|
|
|
/// Does this album credit the (normalized) selected artist — as primary, in
|
|
/// `all_artists`, or as one part of a multi-artist credit? Mirrors Tauri's
|
|
/// `selectedArtistAlbums` predicate.
|
|
fn album_matches_artist(al: &qbz_library::LocalAlbum, nsel: &str) -> bool {
|
|
if nsel == "various artists" {
|
|
return normalize_artist(&al.artist) == "various artists";
|
|
}
|
|
if normalize_artist(&al.artist) == nsel {
|
|
return true;
|
|
}
|
|
for part in al.all_artists.split(',') {
|
|
if normalize_artist(part) == nsel {
|
|
return true;
|
|
}
|
|
}
|
|
for part in split_credit(&al.artist) {
|
|
if normalize_artist(&part) == nsel {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Re-derive the left-rail render sets (search filter + A-Z grouping + jump
|
|
/// strip) from the merged `artists` master list.
|
|
pub fn derive_artists(window: &AppWindow) {
|
|
let s = window.global::<LocalLibraryState>();
|
|
let query_owned = s.get_artists_search().to_lowercase();
|
|
let query = query_owned.trim();
|
|
let all = s.get_artists();
|
|
let filtered: Vec<LocalArtistItem> = (0..all.row_count())
|
|
.filter_map(|i| all.row_data(i))
|
|
.filter(|a| query.is_empty() || a.display_name.to_lowercase().contains(query))
|
|
.collect();
|
|
s.set_artists_shown(filtered.len() as i32);
|
|
|
|
let mut map: Vec<(String, Vec<LocalArtistItem>)> = Vec::new();
|
|
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
|
for item in filtered {
|
|
let key = folder_alpha_key(item.display_name.as_str());
|
|
let idx = *index.entry(key.clone()).or_insert_with(|| {
|
|
map.push((key.clone(), Vec::new()));
|
|
map.len() - 1
|
|
});
|
|
map[idx].1.push(item);
|
|
}
|
|
map.sort_by(|(a, _), (b, _)| match (a == "#", b == "#") {
|
|
(true, false) => std::cmp::Ordering::Greater,
|
|
(false, true) => std::cmp::Ordering::Less,
|
|
_ => a.to_lowercase().cmp(&b.to_lowercase()),
|
|
});
|
|
let jumps: Vec<AlphaJump> = map
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, (k, _))| AlphaJump {
|
|
letter: k.clone().into(),
|
|
index: i as i32,
|
|
})
|
|
.collect();
|
|
let sections: Vec<LocalArtistSection> = map
|
|
.into_iter()
|
|
.map(|(letter, artists)| LocalArtistSection {
|
|
letter: letter.into(),
|
|
artists: ModelRc::new(VecModel::from(artists)),
|
|
})
|
|
.collect();
|
|
s.set_artists_grouped(ModelRc::new(VecModel::from(sections)));
|
|
s.set_artists_alpha(ModelRc::new(VecModel::from(jumps)));
|
|
}
|
|
|
|
fn apply_artists(window: &AppWindow, rows: Vec<ArtistRow>) {
|
|
// Build the Slint items here (UI thread) — `LocalArtistItem.image` holds a
|
|
// non-Send `slint::Image`, so the rows crossed `spawn_blocking` as the
|
|
// Send-safe `ArtistRow` and gain the (default-empty) decoded image now.
|
|
let items: Vec<LocalArtistItem> = rows
|
|
.into_iter()
|
|
.map(|r| LocalArtistItem {
|
|
name: r.name.into(),
|
|
display_name: r.display_name.into(),
|
|
album_count: r.album_count,
|
|
track_count: r.track_count,
|
|
image_path: r.image_path.into(),
|
|
image: slint::Image::default(),
|
|
})
|
|
.collect();
|
|
let s = window.global::<LocalLibraryState>();
|
|
s.set_artists(ModelRc::new(VecModel::from(items)));
|
|
s.set_artists_loading(false);
|
|
s.set_artists_load_failed(false);
|
|
derive_artists(window);
|
|
}
|
|
|
|
/// Generation guard for the artist-image fetch: bumped on every artists load
|
|
/// so a stale in-flight fetch/decode (from a superseded list) is dropped.
|
|
static ARTISTS_IMG_GEN: AtomicU64 = AtomicU64::new(0);
|
|
|
|
/// True if `gen` is still the current artist-image generation (the apply arm
|
|
/// checks this before painting a portrait).
|
|
pub fn artists_img_gen_current() -> u64 {
|
|
ARTISTS_IMG_GEN.load(Ordering::SeqCst)
|
|
}
|
|
|
|
/// Set a freshly-decoded portrait (by artist `name`) on BOTH the flat master
|
|
/// (`artists`, so a later `derive_artists` carries it forward) and every
|
|
/// rendered grouped section (`artists-grouped[*].artists`). Mirrors
|
|
/// `favorites::set_album_artwork`. The `String` name lives here, not in the
|
|
/// `Copy` artwork target.
|
|
pub fn set_artist_row_image(window: &AppWindow, name: &str, image: slint::Image) {
|
|
let s = window.global::<LocalLibraryState>();
|
|
let flat = s.get_artists();
|
|
for i in 0..flat.row_count() {
|
|
if let Some(mut it) = flat.row_data(i) {
|
|
if it.name.as_str() == name {
|
|
it.image = image.clone();
|
|
flat.set_row_data(i, it);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let grouped = s.get_artists_grouped();
|
|
for sx in 0..grouped.row_count() {
|
|
if let Some(sec) = grouped.row_data(sx) {
|
|
for r in 0..sec.artists.row_count() {
|
|
if let Some(mut it) = sec.artists.row_data(r) {
|
|
if it.name.as_str() == name {
|
|
it.image = image.clone();
|
|
sec.artists.set_row_data(r, it);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load + merge the artists master list on first visit (re-entry keeps it).
|
|
/// Also caches the album set for the right-pane filter, seeds decode jobs for
|
|
/// rows that already have an image (custom or previously-cached Qobuz), and
|
|
/// kicks the capped background fetch for the rest.
|
|
pub fn ensure_artists_loaded(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: tokio::runtime::Handle,
|
|
image_cache: ImageCache,
|
|
) {
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
if s.get_artists().row_count() != 0 || s.get_artists_loading() {
|
|
return;
|
|
}
|
|
s.set_artists_loading(true);
|
|
s.set_artists_load_failed(false);
|
|
let gen = ARTISTS_IMG_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
|
let weak2 = w.as_weak();
|
|
let handle_inner = handle.clone();
|
|
handle.spawn(async move {
|
|
let items = tokio::task::spawn_blocking(|| {
|
|
let artists = crate::library_db::with_db(|db| db.get_artists_with_filter(true, false))
|
|
.unwrap_or_default();
|
|
let albums = crate::library_db::with_db(|db| {
|
|
db.get_albums_with_full_filter(false, true, false)
|
|
})
|
|
.unwrap_or_default();
|
|
// Seed custom AND previously-cached Qobuz portraits (fixes the
|
|
// Tauri headline bug: its batch load command was never wired).
|
|
let custom = crate::library_db::with_db(|db| db.get_all_artist_image_urls())
|
|
.unwrap_or_default();
|
|
let merged = merge_artists(artists, &albums, &custom);
|
|
if let Ok(mut cache) = ARTIST_ALBUMS.lock() {
|
|
*cache = albums;
|
|
}
|
|
merged
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
let _ = weak2.upgrade_in_event_loop(move |w| {
|
|
apply_artists(&w, items);
|
|
// Seed decode jobs for rows that already carry an image-path.
|
|
let s = w.global::<LocalLibraryState>();
|
|
let artists = s.get_artists();
|
|
let mut local_jobs = Vec::new();
|
|
let mut http_jobs = Vec::new();
|
|
for i in 0..artists.row_count() {
|
|
if let Some(a) = artists.row_data(i) {
|
|
let p = a.image_path.to_string();
|
|
if p.is_empty() {
|
|
continue;
|
|
}
|
|
let job = ArtworkJob {
|
|
target: ArtworkTarget::LocalArtistRowImage { index: i, gen },
|
|
url: p.clone(),
|
|
};
|
|
if p.starts_with("http") {
|
|
http_jobs.push(job);
|
|
} else {
|
|
local_jobs.push(job);
|
|
}
|
|
}
|
|
}
|
|
crate::artwork::spawn_local_loads(local_jobs, w.as_weak(), image_cache.clone());
|
|
crate::artwork::spawn_loads(http_jobs, w.as_weak(), image_cache.clone());
|
|
// Kick the capped Qobuz portrait fetch for missing rows. Snapshot
|
|
// the names HERE (UI thread, sync) — fetch_missing_artist_images
|
|
// must NOT block the event loop to read the model.
|
|
if s.get_artists_fetch_images() {
|
|
let mut names = Vec::new();
|
|
for i in 0..artists.row_count() {
|
|
if let Some(a) = artists.row_data(i) {
|
|
if a.image_path.is_empty()
|
|
&& normalize_artist(&a.name.to_string()) != "various artists"
|
|
{
|
|
names.push(a.name.to_string());
|
|
}
|
|
}
|
|
}
|
|
s.set_artists_images_fetching(true);
|
|
s.set_artists_images_fetched(0);
|
|
fetch_missing_artist_images(
|
|
runtime,
|
|
w.as_weak(),
|
|
handle_inner,
|
|
image_cache,
|
|
gen,
|
|
names,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Capped, sequential background fetch of missing artist portraits from Qobuz
|
|
/// (max 50/session, 1s apart, exact-normalized match only). 1:1 with Tauri's
|
|
/// `fetchMissingArtistImages`, with per-image immediate paint + a generation
|
|
/// guard. Names with an image already are skipped (snapshotted on the UI
|
|
/// thread; the worker never touches the Slint model except via event-loop hops).
|
|
pub fn fetch_missing_artist_images(
|
|
runtime: Arc<AppRuntime<SlintAdapter>>,
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: tokio::runtime::Handle,
|
|
image_cache: ImageCache,
|
|
gen: u64,
|
|
mut names: Vec<String>,
|
|
) {
|
|
// `names` is snapshotted by the caller on the UI thread (NEVER block the
|
|
// event loop here — this can be invoked from inside an event-loop closure).
|
|
names.truncate(50);
|
|
if names.is_empty() {
|
|
let _ = weak.upgrade_in_event_loop(|w| {
|
|
w.global::<LocalLibraryState>().set_artists_images_fetching(false);
|
|
});
|
|
return;
|
|
}
|
|
|
|
handle.spawn(async move {
|
|
let mut painted = 0i32;
|
|
for name in names {
|
|
if artists_img_gen_current() != gen {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
|
if artists_img_gen_current() != gen {
|
|
break;
|
|
}
|
|
let page = match runtime.core().search_artists(&name, 3, 0, None).await {
|
|
Ok(p) => p,
|
|
Err(qbz_core::CoreError::NotInitialized) => break,
|
|
Err(e) => {
|
|
log::debug!("[locallibrary] artist image search failed for {name}: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
let nsel = normalize_artist(&name);
|
|
let matched = page
|
|
.items
|
|
.into_iter()
|
|
.find(|a| normalize_artist(&a.name) == nsel);
|
|
let Some(artist) = matched else {
|
|
continue; // no exact match -> skip (no wrong-artist persist)
|
|
};
|
|
let Some(url) = artist.image.as_ref().and_then(|i| i.best().cloned()) else {
|
|
continue;
|
|
};
|
|
|
|
// Persist the fetched portrait (best-effort; paints regardless).
|
|
let name_c = name.clone();
|
|
let url_c = url.clone();
|
|
let canon = artist.name.clone();
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
crate::library_db::with_db(|db| {
|
|
db.cache_artist_image_with_canonical(
|
|
&name_c,
|
|
Some(&url_c),
|
|
"qobuz",
|
|
None,
|
|
Some(&canon),
|
|
)
|
|
})
|
|
})
|
|
.await;
|
|
|
|
// Paint now: resolve the current flat-master index on the UI thread.
|
|
let url_p = url.clone();
|
|
let name_p = name.clone();
|
|
let cache = image_cache.clone();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
let flat = s.get_artists();
|
|
let mut idx = None;
|
|
for i in 0..flat.row_count() {
|
|
if let Some(a) = flat.row_data(i) {
|
|
if a.name.as_str() == name_p {
|
|
idx = Some(i);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if let Some(i) = idx {
|
|
crate::artwork::spawn_loads(
|
|
vec![ArtworkJob {
|
|
target: ArtworkTarget::LocalArtistRowImage { index: i, gen },
|
|
url: url_p,
|
|
}],
|
|
w.as_weak(),
|
|
cache,
|
|
);
|
|
}
|
|
});
|
|
painted += 1;
|
|
}
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
s.set_artists_images_fetching(false);
|
|
s.set_artists_images_fetched(painted);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Select an artist: filter their albums (in place, from the cached album
|
|
/// set) into the right pane and kick cover loads.
|
|
pub fn select_local_artist(
|
|
weak: slint::Weak<AppWindow>,
|
|
handle: tokio::runtime::Handle,
|
|
image_cache: ImageCache,
|
|
name: String,
|
|
) {
|
|
let _ = weak.upgrade_in_event_loop({
|
|
let name = name.clone();
|
|
move |w| {
|
|
let s = w.global::<LocalLibraryState>();
|
|
s.set_artists_selected_name(name.clone().into());
|
|
// Display name = the merged row's display, else the raw name.
|
|
let all = s.get_artists();
|
|
let display = (0..all.row_count())
|
|
.filter_map(|i| all.row_data(i))
|
|
.find(|a| a.name == name)
|
|
.map(|a| a.display_name.to_string())
|
|
.unwrap_or_else(|| name.clone());
|
|
s.set_artists_selected_display(display.into());
|
|
s.set_artists_selected_loading(true);
|
|
}
|
|
});
|
|
handle.spawn(async move {
|
|
let cards = tokio::task::spawn_blocking(move || {
|
|
let albums = ARTIST_ALBUMS
|
|
.lock()
|
|
.map(|c| c.clone())
|
|
.unwrap_or_default();
|
|
let nsel = normalize_artist(&name);
|
|
albums
|
|
.into_iter()
|
|
.filter(|al| album_matches_artist(al, &nsel))
|
|
.map(map_local_album)
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
|
let jobs: Vec<ArtworkJob> = cards
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, c)| !c.artwork_url.is_empty())
|
|
.map(|(i, c)| ArtworkJob {
|
|
target: ArtworkTarget::LocalArtistAlbumCard { index: i },
|
|
url: c.artwork_url.clone(),
|
|
})
|
|
.collect();
|
|
let items: Vec<AlbumCardItem> =
|
|
cards.into_iter().map(crate::album_map::to_item).collect();
|
|
let s = w.global::<LocalLibraryState>();
|
|
s.set_artists_selected_albums(ModelRc::new(VecModel::from(items)));
|
|
s.set_artists_selected_loading(false);
|
|
crate::artwork::spawn_local_loads(jobs, w.as_weak(), image_cache);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Fetch an album's tracks by group key, trying the metadata grouping first
|
|
/// (Albums tab) then the folder grouping (Folders tab). Blocking.
|
|
pub fn fetch_album_tracks_blocking(group_key: &str) -> Vec<qbz_library::LocalTrack> {
|
|
crate::library_db::with_db(|db| {
|
|
let meta = db.get_album_tracks_metadata(group_key)?;
|
|
if !meta.is_empty() {
|
|
return Ok(meta);
|
|
}
|
|
db.get_album_tracks(group_key)
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn route_and_tab_id_round_trip() {
|
|
for (route, id, tab) in [
|
|
("local-tracks", "tracks", LibTab::Tracks),
|
|
("local-folders", "folders", LibTab::Folders),
|
|
("local-albums", "albums", LibTab::Albums),
|
|
("local-artists", "artists", LibTab::Artists),
|
|
] {
|
|
assert_eq!(LibTab::from_route(route), Some(tab));
|
|
assert_eq!(LibTab::from_tab_id(id), Some(tab));
|
|
assert_eq!(tab.tab_id(), id);
|
|
}
|
|
assert_eq!(LibTab::from_route("favorites-albums"), None);
|
|
assert_eq!(LibTab::from_tab_id("bogus"), None);
|
|
}
|
|
}
|