feat(myqbz): Discography Builder — artist_collection creation flow

Phase-2 Slice 11. The third MyQBZ kind (artist_collection / artist_discography),
1:1 with spec 13 (the 1445-line DiscographyBuilderView):
- ui/myqbz/DiscographyBuilderView.slint: header (artist avatar + name + eyebrow),
  name input, 3-segment order-by, the 7-col grouped release table
  (28 56 72 1fr 72 60 156) with tri-state select-all, primary + alternate rows
  (↳ connector, source indicator, QualityBadge, per-row type-override popover),
  footer (count + Create). Exact §0 dark colors + type tints.
- src/myqbz_builder.rs: candidate/group model + normalize/classify/quality-score;
  THREE sources fully wired (Qobuz get_artist_page + local + Plex via the unified
  metadata query); build_groups dedupes/buckets/quality-sorts (Qobuz wins ties);
  persisted release-type override sidecar. Save = create_collection (ArtistCollection,
  ArtistDiscography, source_ref=artist_id) + add_item per checked candidate in sort
  order (Plex->local) -> navigate to the new collection's detail.
- Entry: the artist page "Build Artist Collection" button (was unwired).

Known polish nits (S12): view-wide uppercase eyebrow/labels; Create should disable
on whitespace-only name. Toast uses real strings (toast.collectionCreated/Failed are
absent from en.json; DQ4). Backend crates untouched. cargo check -p qbz-slint green.

cc
This commit is contained in:
vicrodh committed 2026-06-09 07:19:06 -06:00
1 parent 1db40d87ef
commit c6aa61a09e
7 files changed
+2192 -2

No files matched your search

+264
View File
@@ -37,6 +37,7 @@ mod mix;
mod musician;
mod myqbz;
mod myqbz_add;
mod myqbz_builder;
mod myqbz_cover;
mod myqbz_detail;
mod myqbz_edit;
@@ -712,6 +713,85 @@ fn navigate_artist(
});
}
/// Open the Discography Builder for `artist_id` (spec 13). Fetches the artist's
/// releases from Qobuz (sets name + avatar), then local + Plex by that name
/// (sequential — parallelizing drops local matches against an empty name),
/// dedupes into groups, installs the default selection, and decodes the avatar.
/// Plex gets a single 2-second cold-start retry when enabled and the first
/// fetch returns nothing.
fn navigate_discography_builder(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: artwork::ImageCache,
artist_id: String,
) {
let id_for_reset = artist_id.clone();
handle.spawn(async move {
let _ = weak.upgrade_in_event_loop(move |w| {
myqbz_builder::reset(&w, &id_for_reset);
w.global::<NavState>().set_view(ContentView::DiscographyBuilder);
});
// 1. Qobuz first — sets artist name + avatar URL (side effect).
match myqbz_builder::fetch_qobuz(&runtime, &artist_id).await {
Ok((qobuz, artist_name, avatar_url)) => {
// 2. Local + Plex by the resolved name (sequential, mandatory).
let name_for_local = artist_name.clone();
let mut local = tokio::task::spawn_blocking(move || {
myqbz_builder::fetch_local_and_plex(&name_for_local)
})
.await
.unwrap_or_default();
// 2b. Plex cold-start retry: if Plex is enabled and we got
// nothing from the Plex source, wait 2s and refetch once.
let plex_enabled = crate::plex_settings::get().enabled;
let got_plex = local.iter().any(|c| c.source == "plex");
if plex_enabled && !got_plex {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let name_retry = artist_name.clone();
let retried = tokio::task::spawn_blocking(move || {
myqbz_builder::fetch_local_and_plex(&name_retry)
})
.await
.unwrap_or_default();
if retried.iter().any(|c| c.source == "plex") {
local = retried;
}
}
// 3. Merge + group (Qobuz first so it wins primary ties).
let mut all = qobuz;
all.extend(local);
let groups = myqbz_builder::build_groups(all);
let avatar_for_fetch = avatar_url.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
myqbz_builder::install(&w, artist_name, avatar_url, groups);
});
// 4. Decode the avatar (72px circle).
if !avatar_for_fetch.is_empty() {
if let Some((pixels, width, height)) =
artwork::fetch_and_decode(&avatar_for_fetch, &image_cache, 144).await
{
let _ = weak.upgrade_in_event_loop(move |w| {
myqbz_builder::apply_avatar(&w, &pixels, width, height);
});
}
}
}
Err(e) => {
log::error!("[qbz-slint] discography builder load failed: {e}");
let _ = weak.upgrade_in_event_loop(move |w| {
myqbz_builder::fail(&w, e);
});
}
}
});
}
thread_local! {
/// Debounce timer for the header live search — restarted on every
/// keystroke, fires the search 300 ms after typing stops.
@@ -779,6 +859,7 @@ fn scope_for(entry: &nav::NavEntry) -> String {
nav::NavEntry::Mixtapes => "mixtapes".into(),
nav::NavEntry::Collections => "collections".into(),
nav::NavEntry::MixtapeDetail(_) => "mixtape-detail".into(),
nav::NavEntry::DiscographyBuilder(_) => "discography-builder".into(),
nav::NavEntry::Album(_) => "album".into(),
nav::NavEntry::LocalAlbum(_) => "local-album".into(),
nav::NavEntry::Artist(_) => "artist".into(),
@@ -943,6 +1024,15 @@ fn apply_entry(
nav::NavEntry::MixtapeDetail(id) => {
myqbz_detail::navigate(weak.clone(), handle.clone(), image_cache.clone(), id);
}
nav::NavEntry::DiscographyBuilder(artist_id) => {
navigate_discography_builder(
runtime.clone(),
weak.clone(),
handle,
image_cache.clone(),
artist_id,
);
}
nav::NavEntry::Location {
mbid,
area_id,
@@ -2769,6 +2859,156 @@ fn wire_myqbz_detail(
}
}
/// Wire the Discography Builder (spec 13). Back -> browser-back (returns to the
/// artist page). Name / order / checkbox / select-all / type-override drive the
/// `crate::myqbz_builder` session re-renders. Open-album routes through the
/// top-level `open-album` (Qobuz album view vs local-album by id). Create runs
/// the save flow on a blocking worker then navigates to the new collection.
fn wire_disco_builder(
window: &AppWindow,
tokio_rt: &tokio::runtime::Runtime,
image_cache: &artwork::ImageCache,
) {
use DiscoBuilderActions as Act;
// Back / Cancel -> browser-back to the artist page.
{
let weak = window.as_weak();
window.global::<Act>().on_back(move || {
if let Some(w) = weak.upgrade() {
w.global::<NavState>().invoke_request_back();
}
});
}
// Collection-name input.
{
let weak = window.as_weak();
window.global::<Act>().on_name_changed(move |name| {
if let Some(w) = weak.upgrade() {
myqbz_builder::name_changed(&w, name.as_str());
}
});
}
// Order segmented control.
{
let weak = window.as_weak();
window.global::<Act>().on_set_order(move |order| {
if let Some(w) = weak.upgrade() {
myqbz_builder::set_order(&w, order.as_str());
}
});
}
// Per-row checkbox.
{
let weak = window.as_weak();
window
.global::<Act>()
.on_toggle_checked(move |group_key, cand_key| {
if let Some(w) = weak.upgrade() {
myqbz_builder::toggle_checked(&w, group_key.as_str(), cand_key.as_str());
}
});
}
// Header select-all.
{
let weak = window.as_weak();
window.global::<Act>().on_toggle_all(move || {
if let Some(w) = weak.upgrade() {
myqbz_builder::toggle_all(&w);
}
});
}
// Open an album (Qobuz album view, or local-album by group key).
{
let weak = window.as_weak();
window
.global::<Act>()
.on_open_album(move |_source, source_item_id| {
if let Some(w) = weak.upgrade() {
if !source_item_id.trim().is_empty() {
w.invoke_open_album(source_item_id);
}
}
});
}
// Release-type override set / reset.
{
let weak = window.as_weak();
window
.global::<Act>()
.on_set_type_override(move |source, id, choice| {
if let Some(w) = weak.upgrade() {
myqbz_builder::set_type_override(
&w,
source.as_str(),
id.as_str(),
choice.as_str(),
);
}
});
}
{
let weak = window.as_weak();
window
.global::<Act>()
.on_reset_type_override(move |source, id| {
if let Some(w) = weak.upgrade() {
myqbz_builder::reset_type_override(&w, source.as_str(), id.as_str());
}
});
}
// Create — save the artist_collection + bulk-add, then navigate to detail.
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window.global::<Act>().on_create(move || {
let Some(w) = weak.upgrade() else { return };
// Snapshot the selection in current sort order (UI thread).
let Some(payload) = myqbz_builder::save_payload(&w) else {
return;
};
if w.global::<DiscoBuilderState>().get_creating() {
return;
}
myqbz_builder::set_creating(&w, true);
let weak = weak.clone();
let handle = handle.clone();
let image_cache = image_cache.clone();
handle.clone().spawn(async move {
let result = tokio::task::spawn_blocking(move || {
myqbz_builder::create_collection(&payload)
})
.await
.ok()
.flatten();
let _ = weak.upgrade_in_event_loop(move |w| {
myqbz_builder::set_creating(&w, false);
match result {
Some(collection_id) => {
myqbz_builder::toast_created(&w);
// Navigate to the new collection's detail.
nav::record(nav::NavEntry::MixtapeDetail(collection_id.clone()));
myqbz_detail::navigate(
w.as_weak(),
handle.clone(),
image_cache.clone(),
collection_id,
);
update_nav_flags(&w);
}
None => {
myqbz_builder::toast_failed(&w);
}
}
});
});
});
}
}
/// Open a MusicianPageView for `name + role`. Routes to the artist
/// page instead when the resolved musician has a Confirmed Qobuz
/// match (Tauri's `confidence === 'confirmed'` shortcut). Fetches
@@ -3845,6 +4085,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
id.clone(),
);
}
// Build Artist Collection — open the Discography Builder for the
// current artist (the button passes an empty id, so resolve it
// from ArtistState). Records a history entry then routes.
("artist", "build-collection") => {
if let Some(w) = weak.upgrade() {
let artist_id = if id.is_empty() {
w.global::<ArtistState>().get_id().to_string()
} else {
id.clone()
};
if !artist_id.is_empty() {
nav::record(nav::NavEntry::DiscographyBuilder(artist_id.clone()));
navigate_discography_builder(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
artist_id,
);
update_nav_flags(&w);
}
}
}
("artist", "play-top") => playback::play_artist_top_tracks(
runtime.clone(),
weak.clone(),
@@ -7429,6 +7692,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
wire_playlist_manager(&window, &app_runtime, &tokio_rt, &image_cache);
wire_myqbz(&window, &tokio_rt, &image_cache);
wire_myqbz_detail(&window, &app_runtime, &tokio_rt, &image_cache);
wire_disco_builder(&window, &tokio_rt, &image_cache);
{
let weak = window.as_weak();
window
+922
View File
@@ -0,0 +1,922 @@
//! Discography Builder controller (spec 13) — the Rust side of
//! `DiscographyBuilderView`. It fetches an artist's complete discography from
//! THREE sources (Qobuz artist page + local library + Plex cache), dedupes the
//! releases into GROUPS (one logical album = a chosen "primary" + "alternates"),
//! and persists the user-selected groups as a `kind='artist_collection'`
//! collection (`source_type='artist_discography'`, `source_ref=<artist_id>`).
//!
//! `artist_discography` is a MARKER, not a sync feature (spec 40 §8): the builder
//! resolves the artist's albums and bulk-adds them as ordinary album items via
//! `qbz_mixtape::repo` — there is NO sync command. On save it collapses Plex
//! candidates to `source='local'` (the LocalLibrary resolver re-detects Plex at
//! enqueue time) and navigates to the new collection's detail.
//!
//! Sourcing notes (degradation):
//! - Qobuz: `runtime.core().get_artist_page` — the full path, sets artist name +
//! avatar.
//! - Local + Plex: ONE unified `get_albums_metadata_page(…, plex_cache_db_path)`
//! query (the same one the Local Library Albums tab uses). Plex rows arrive in
//! that set with `source == "plex"` when the Plex master toggle is ON; with it
//! OFF the query runs local-only. Rows are filtered to the artist by a
//! case-insensitive match on `artist` OR any comma-split entry in `all_artists`
//! (mirrors the PSD `matchesArtist`). If the DB is unavailable the local/plex
//! contribution degrades to empty (logged) — the Qobuz path still fully works.
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex};
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
use qbz_models::mixtape::{CollectionKind, CollectionSourceType};
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::{
AppWindow, DiscoBuilderState, DiscoCandidate, DiscoGroup, ToastKind,
};
// ──────────────────────────── candidate model ─────────────────────────────
/// One release candidate (a primary or an alternate). Plain `Send` data built
/// on the worker thread; the UI thread maps it into the Slint `DiscoCandidate`.
#[derive(Clone)]
pub struct Candidate {
pub group_key: String,
/// "qobuz" | "local" | "plex".
pub source: String,
pub source_item_id: String,
pub title: String,
pub artist: String,
pub year: Option<i32>,
pub artwork_url: Option<String>,
pub track_count: Option<i32>,
pub max_bit_depth: Option<u32>,
/// kHz.
pub max_sample_rate: Option<f64>,
pub format: String,
pub is_compilation: bool,
/// The AUTO-classified release type (before any user override).
pub release_type: String,
pub quality_score: i64,
}
impl Candidate {
/// `${source}|${source_item_id}` — the dedupe + selection key.
fn key(&self) -> String {
format!("{}|{}", self.source, self.source_item_id)
}
}
/// One dedupe group: a primary plus its alternates.
#[derive(Clone)]
pub struct Group {
pub key: String,
pub title: String,
pub year: Option<i32>,
pub primary: Candidate,
pub alternates: Vec<Candidate>,
pub is_compilation: bool,
}
/// The full builder session state, held process-global on the UI thread side
/// (the controller mutates it; `apply` renders it). Mirrors the Svelte `$state`.
struct Session {
artist_id: String,
artist_name: String,
artist_avatar_url: String,
groups: Vec<Group>,
/// `Record<groupKey, Set<candidateKey>>`.
checked: HashMap<String, Vec<String>>,
order_by: String,
}
impl Default for Session {
fn default() -> Self {
Self {
artist_id: String::new(),
artist_name: String::new(),
artist_avatar_url: String::new(),
groups: Vec::new(),
checked: HashMap::new(),
order_by: "release_date".to_string(),
}
}
}
static SESSION: LazyLock<Mutex<Session>> = LazyLock::new(|| Mutex::new(Session::default()));
fn session() -> std::sync::MutexGuard<'static, Session> {
SESSION.lock().unwrap_or_else(|e| e.into_inner())
}
// ──────────────────────── release-type override store ──────────────────────
//
// The PSD writes overrides to a per-user localStorage sidecar shared with the
// Collection detail view. Here it's a process-global map persisted to a JSON
// sidecar under the user data dir so a chosen override survives the session and
// (when the Slint Collection-detail override UI lands) can be read back. Keyed
// `${source}|${source_item_id}` -> one of album|ep|single|live|compilation.
static OVERRIDES: LazyLock<Mutex<HashMap<String, String>>> =
LazyLock::new(|| Mutex::new(load_overrides()));
fn overrides_path() -> Option<std::path::PathBuf> {
dirs::data_dir().map(|d| {
d.join("qbz")
.join("discography-release-type-overrides.json")
})
}
fn load_overrides() -> HashMap<String, String> {
let Some(path) = overrides_path() else {
return HashMap::new();
};
let Ok(bytes) = std::fs::read(&path) else {
return HashMap::new();
};
serde_json::from_slice(&bytes).unwrap_or_default()
}
fn save_overrides(map: &HashMap<String, String>) {
let Some(path) = overrides_path() else { return };
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(bytes) = serde_json::to_vec(map) {
let _ = std::fs::write(&path, bytes);
}
}
fn override_for(source: &str, source_item_id: &str) -> Option<String> {
let key = format!("{source}|{source_item_id}");
OVERRIDES.lock().ok().and_then(|m| m.get(&key).cloned())
}
/// The EFFECTIVE release type after applying the user override (if any).
fn effective_type(c: &Candidate) -> String {
override_for(&c.source, &c.source_item_id).unwrap_or_else(|| c.release_type.clone())
}
fn is_overridden(c: &Candidate) -> bool {
override_for(&c.source, &c.source_item_id).is_some()
}
// ──────────────────────────── classification ──────────────────────────────
/// `normalizeTitle`: lowercase, strip parenthetical/bracket edition suffixes,
/// collapse whitespace, trim. Mirrors the PSD regex.
fn normalize_title(title: &str) -> String {
let lower = title.to_lowercase();
// Edition keywords that mark a re-issue suffix.
const KEYWORDS: [&str; 14] = [
"deluxe",
"remaster",
"expanded",
"anniversary",
"collector",
"special",
"bonus",
"extended",
"definitive",
"20th",
"25th",
"30th",
"40th",
"50th",
];
// Find the first '(' or '[' whose inner text starts with an edition
// keyword (or an Nth marker) and strip from there to the end.
let bytes = lower.as_bytes();
let mut cut = lower.len();
let mut i = 0;
while i < bytes.len() {
let ch = bytes[i] as char;
if ch == '(' || ch == '[' {
let inner = lower[i + 1..].trim_start();
let starts_kw = KEYWORDS.iter().any(|k| inner.starts_with(k))
|| starts_with_nth(inner);
if starts_kw {
cut = i;
break;
}
}
i += 1;
}
let trimmed = &lower[..cut];
// Collapse whitespace + trim.
trimmed.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Whether `s` begins with an `<digits>th` token (e.g. "10th anniversary").
fn starts_with_nth(s: &str) -> bool {
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
!digits.is_empty() && s[digits.len()..].starts_with("th")
}
/// `isCompilation(title)` — best-of / greatest-hits markers.
fn title_is_compilation(title: &str) -> bool {
let l = title.to_lowercase();
const M: [&str; 6] = [
"best of",
"greatest hits",
"anthology",
"the very best",
"essential",
"collection",
];
M.iter().any(|m| l.contains(m))
}
/// `classifyRelease` — precedence: compilation → live → ep → single → album →
/// track-count heuristic. `qobuz_release_type` / `qobuz_group_type` are the
/// per-item and the group's release_type from /artist/page (None for local/plex).
fn classify_release(
title: &str,
track_count: Option<i32>,
qobuz_release_type: Option<&str>,
qobuz_group_type: Option<&str>,
title_is_comp: bool,
) -> String {
let l = title.to_lowercase();
let rt = qobuz_release_type.unwrap_or("");
let gt = qobuz_group_type.unwrap_or("");
let type_is = |needle: &str| rt.eq_ignore_ascii_case(needle) || gt.eq_ignore_ascii_case(needle);
if rt.to_lowercase().contains("compilation")
|| gt.to_lowercase().contains("compilation")
|| title_is_comp
{
return "compilation".to_string();
}
let word = |w: &str| {
// crude word-boundary contains
l.split(|c: char| !c.is_alphanumeric()).any(|tok| tok == w)
};
if type_is("live") || word("live") || word("concert") || word("unplugged") {
return "live".to_string();
}
if type_is("ep") || word("ep") {
return "ep".to_string();
}
if type_is("single") {
return "single".to_string();
}
if type_is("album") {
return "album".to_string();
}
match track_count {
Some(n) if n <= 3 => "single".to_string(),
Some(n) if n <= 6 => "ep".to_string(),
_ => "album".to_string(),
}
}
/// `qualityScore`: bit*10_000_000 + rateHz + fmtBonus.
fn quality_score(
max_bit_depth: Option<u32>,
max_sample_rate_khz: Option<f64>,
format: &str,
) -> i64 {
let bit = max_bit_depth.unwrap_or(16) as i64;
let rate_hz = ((max_sample_rate_khz.unwrap_or(44.1)) * 1000.0).round() as i64;
let fmt = format.to_lowercase();
let fmt_bonus = if fmt.contains("flac") || fmt.contains("alac") {
1000
} else if fmt.contains("mp3") || fmt.contains("aac") {
0
} else {
500
};
bit * 10_000_000 + rate_hz + fmt_bonus
}
// ──────────────────────────── source fetchers ─────────────────────────────
/// Fetch the artist's Qobuz releases. Returns `(candidates, artist_name,
/// avatar_url)`. Mirrors `fetchQobuzAlbums` — name + avatar are a side effect.
pub async fn fetch_qobuz<A>(
runtime: &Arc<AppRuntime<A>>,
artist_id: &str,
) -> Result<(Vec<Candidate>, String, String), String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let id: u64 = artist_id
.parse()
.map_err(|_| format!("invalid artist id: {artist_id}"))?;
let page = runtime
.core()
.get_artist_page(id, None)
.await
.map_err(|e| e.to_string())?;
let artist_name = page.name.display.clone();
let avatar_url = page
.images
.as_ref()
.and_then(|imgs| imgs.portrait.as_ref())
.map(|p| {
format!(
"https://static.qobuz.com/images/artists/covers/medium/{}.{}",
p.hash, p.format
)
})
.unwrap_or_default();
let mut out: Vec<Candidate> = Vec::new();
for group in page.releases.into_iter().flatten() {
let group_type = group.release_type.clone();
for r in group.items.into_iter() {
let year = r
.dates
.as_ref()
.and_then(|d| d.original.as_deref())
.and_then(|s| s.get(..4))
.and_then(|y| y.parse::<i32>().ok());
let artwork = r
.image
.as_ref()
.and_then(|img| img.large.clone().or_else(|| img.best().cloned()));
let track_count = r.tracks_count.map(|n| n as i32);
let bit = r.audio_info.as_ref().and_then(|a| a.maximum_bit_depth);
let rate = r.audio_info.as_ref().and_then(|a| a.maximum_sampling_rate);
let title = r.title.clone();
let title_comp = title_is_compilation(&title);
let release_type = classify_release(
&title,
track_count,
r.release_type.as_deref(),
Some(&group_type),
title_comp,
);
let is_comp = release_type == "compilation";
let group_key = format!(
"{}|{}",
normalize_title(&title),
year.map(|y| y.to_string()).unwrap_or_default()
);
out.push(Candidate {
group_key,
source: "qobuz".to_string(),
source_item_id: r.id.clone(),
title,
artist: artist_name.clone(),
year,
artwork_url: artwork,
track_count,
max_bit_depth: bit,
max_sample_rate: rate,
format: "FLAC".to_string(),
is_compilation: is_comp,
release_type,
quality_score: quality_score(bit, rate, "FLAC"),
});
}
}
Ok((out, artist_name, avatar_url))
}
fn plex_cache_db_path() -> Option<std::path::PathBuf> {
if !crate::plex_settings::get().enabled {
return None;
}
dirs::data_dir().map(|d| d.join("qbz").join("plex_cache.db"))
}
/// Whether a local album's `artist` / `all_artists` matches `artist_name`
/// (case-insensitive exact match on artist OR any comma-split all_artists entry).
fn matches_artist(artist: &str, all_artists: &str, artist_name: &str) -> bool {
let needle = artist_name.trim().to_lowercase();
if needle.is_empty() {
return false;
}
if artist.trim().to_lowercase() == needle {
return true;
}
all_artists
.split(',')
.any(|a| a.trim().to_lowercase() == needle)
}
/// Fetch local + Plex albums by the artist via the unified metadata-grouped
/// page (Plex union included when enabled). Blocking (DB). Degrades to empty on
/// DB failure (logged by `with_db`). `artist_name` MUST be resolved first (the
/// Qobuz fetch sets it) — an empty name drops every match (mirrors the PSD).
pub fn fetch_local_and_plex(artist_name: &str) -> Vec<Candidate> {
if artist_name.trim().is_empty() {
return Vec::new();
}
let plex_path = plex_cache_db_path();
let albums = crate::library_db::with_db(|db| {
let page = db.get_albums_metadata_page(
0,
1_000_000,
None,
"artist",
"asc",
false,
true,
plex_path.as_deref(),
)?;
Ok(page.albums)
})
.unwrap_or_default();
albums
.into_iter()
.filter(|a| matches_artist(&a.artist, &a.all_artists, artist_name))
.map(|a| {
let source = if a.source == "plex" { "plex" } else { "local" }.to_string();
let year = a.year.map(|y| y as i32);
let track_count = if a.track_count > 0 {
Some(a.track_count as i32)
} else {
None
};
let bit = a.bit_depth;
// LocalAlbum.sample_rate is Hz; the candidate carries kHz.
let rate_khz = if a.sample_rate >= 1000.0 {
Some(a.sample_rate / 1000.0)
} else if a.sample_rate > 0.0 {
Some(a.sample_rate)
} else {
None
};
let format = a.format.to_string();
let title = a.title.clone();
let title_comp = title_is_compilation(&title);
let release_type = classify_release(&title, track_count, None, None, title_comp);
let is_comp = release_type == "compilation";
let group_key = format!(
"{}|{}",
normalize_title(&title),
year.map(|y| y.to_string()).unwrap_or_default()
);
Candidate {
group_key,
source,
source_item_id: a.id,
title,
artist: a.artist,
year,
artwork_url: None,
track_count,
max_bit_depth: bit,
max_sample_rate: rate_khz,
format,
is_compilation: is_comp,
release_type,
quality_score: quality_score(bit, rate_khz, &a.format.to_string()),
}
})
.collect()
}
// ──────────────────────────── buildGroups ─────────────────────────────────
/// Dedupe incoming candidates by `${source}|${source_item_id}`, bucket by
/// `group_key`, sort each bucket by quality (Qobuz wins ties for the primary
/// slot), and produce groups.
pub fn build_groups(candidates: Vec<Candidate>) -> Vec<Group> {
// 1. Dedupe by candidate key, preserving first-seen order.
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut deduped: Vec<Candidate> = Vec::new();
for c in candidates {
if seen.insert(c.key()) {
deduped.push(c);
}
}
// 2. Bucket by group_key, preserving first-seen bucket order (Map order).
let mut order: Vec<String> = Vec::new();
let mut buckets: HashMap<String, Vec<Candidate>> = HashMap::new();
for c in deduped {
if !buckets.contains_key(&c.group_key) {
order.push(c.group_key.clone());
}
buckets.entry(c.group_key.clone()).or_default().push(c);
}
// 3+4. Within each bucket sort by quality_score desc, Qobuz-preferred on
// ties; primary = sorted[0], alternates = rest.
let mut groups: Vec<Group> = Vec::new();
for key in order {
let mut bucket = buckets.remove(&key).unwrap_or_default();
bucket.sort_by(|a, b| {
b.quality_score.cmp(&a.quality_score).then_with(|| {
// equal quality → qobuz first.
let aq = a.source == "qobuz";
let bq = b.source == "qobuz";
bq.cmp(&aq)
})
});
let is_compilation = bucket.iter().all(|c| c.is_compilation);
let primary = bucket.remove(0);
let title = primary.title.clone();
let year = primary.year;
groups.push(Group {
key,
title,
year,
primary,
alternates: bucket,
is_compilation,
});
}
groups
}
// ──────────────────────────── sort (orderedGroups) ─────────────────────────
/// Return the groups in the active sort order (clones into a fresh Vec).
fn ordered_groups(groups: &[Group], order_by: &str) -> Vec<Group> {
let mut g = groups.to_vec();
match order_by {
"title" => g.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())),
"manual" => {} // insertion order — unchanged.
_ => {
// release_date: year asc, nulls last, tiebreak by title.
g.sort_by(|a, b| match (a.year, b.year) {
(None, None) => a.title.to_lowercase().cmp(&b.title.to_lowercase()),
(None, Some(_)) => std::cmp::Ordering::Greater,
(Some(_), None) => std::cmp::Ordering::Less,
(Some(ya), Some(yb)) => ya.cmp(&yb),
});
}
}
g
}
// ──────────────────────────── selection helpers ────────────────────────────
fn candidate_key(c: &Candidate) -> String {
c.key()
}
fn is_checked(s: &Session, group_key: &str, cand_key: &str) -> bool {
s.checked
.get(group_key)
.map(|set| set.iter().any(|k| k == cand_key))
.unwrap_or(false)
}
/// `allPrimariesChecked` — every non-compilation group has its primary checked
/// (compilations are treated as always-satisfied).
fn all_primaries_checked(s: &Session) -> bool {
!s.groups.is_empty()
&& s.groups.iter().all(|g| {
g.is_compilation || is_checked(s, &g.key, &candidate_key(&g.primary))
})
}
fn some_primaries_checked(s: &Session) -> bool {
!all_primaries_checked(s)
&& s.groups
.iter()
.any(|g| !g.is_compilation && is_checked(s, &g.key, &candidate_key(&g.primary)))
}
/// Total checked candidate count across all groups (primaries + alternates).
fn selected_count(s: &Session) -> usize {
s.checked.values().map(|set| set.len()).sum()
}
// ──────────────────────────── apply / render ──────────────────────────────
fn map_candidate(s: &Session, c: &Candidate) -> DiscoCandidate {
let eff = effective_type(c);
let (tier, detail, _) = crate::quality::badge(&c.format, c.max_bit_depth, c.max_sample_rate);
DiscoCandidate {
key: candidate_key(c).into(),
source: c.source.clone().into(),
source_item_id: c.source_item_id.clone().into(),
title: c.title.clone().into(),
artist: c.artist.clone().into(),
year: c
.year
.map(|y| y.to_string())
.unwrap_or_else(|| "—".to_string())
.into(),
tracks: c
.track_count
.map(|n| n.to_string())
.unwrap_or_else(|| "—".to_string())
.into(),
artwork_url: c.artwork_url.clone().unwrap_or_default().into(),
release_type: eff.into(),
is_overridden: is_overridden(c),
quality_tier: tier.into(),
quality_detail: detail.into(),
checked: is_checked(s, &c.group_key, &candidate_key(c)),
}
}
/// Render the full state global from the session. UI thread only.
pub fn apply(window: &AppWindow) {
let s = session();
let state = window.global::<DiscoBuilderState>();
let groups = ordered_groups(&s.groups, &s.order_by);
let rows: Vec<DiscoGroup> = groups
.iter()
.map(|g| {
let alternates: Vec<DiscoCandidate> =
g.alternates.iter().map(|c| map_candidate(&s, c)).collect();
DiscoGroup {
key: g.key.clone().into(),
title: g.title.clone().into(),
year: g
.year
.map(|y| y.to_string())
.unwrap_or_else(|| "—".to_string())
.into(),
primary: map_candidate(&s, &g.primary),
alternates: ModelRc::new(VecModel::from(alternates)),
is_compilation: g.is_compilation,
}
})
.collect();
state.set_groups(ModelRc::new(VecModel::from(rows)));
state.set_all_checked(all_primaries_checked(&s));
state.set_some_checked(some_primaries_checked(&s));
state.set_selected_count(selected_count(&s) as i32);
state.set_group_count(s.groups.len() as i32);
state.set_artist_name(s.artist_name.clone().into());
state.set_artist_id(s.artist_id.clone().into());
state.set_artist_avatar_url(s.artist_avatar_url.clone().into());
}
/// Reset the builder state before a fresh load.
pub fn reset(window: &AppWindow, artist_id: &str) {
{
let mut s = session();
*s = Session {
artist_id: artist_id.to_string(),
order_by: "release_date".to_string(),
..Session::default()
};
}
let state = window.global::<DiscoBuilderState>();
state.set_loading(true);
state.set_load_error("".into());
state.set_creating(false);
state.set_open_type_menu_key("".into());
state.set_collection_name("".into());
state.set_order_by("release_date".into());
state.set_artist_id(artist_id.into());
state.set_artist_name("".into());
state.set_artist_avatar_url("".into());
state.set_artist_avatar(slint::Image::default());
state.set_groups(ModelRc::new(VecModel::from(Vec::<DiscoGroup>::new())));
state.set_selected_count(0);
state.set_group_count(0);
}
/// Install the fetched + grouped data into the session and seed the default
/// selection (each non-compilation primary pre-checked). UI thread.
pub fn install(
window: &AppWindow,
artist_name: String,
artist_avatar_url: String,
groups: Vec<Group>,
) {
{
let mut s = session();
s.artist_name = artist_name.clone();
s.artist_avatar_url = artist_avatar_url.clone();
s.groups = groups;
// Default selection: non-compilation primaries pre-checked.
s.checked.clear();
let group_snapshot: Vec<(String, bool, String)> = s
.groups
.iter()
.map(|g| (g.key.clone(), g.is_compilation, candidate_key(&g.primary)))
.collect();
for (key, is_comp, primary_key) in group_snapshot {
let mut set = Vec::new();
if !is_comp {
set.push(primary_key);
}
s.checked.insert(key, set);
}
}
let state = window.global::<DiscoBuilderState>();
// Default collection name if still empty.
if state.get_collection_name().is_empty() {
let base = if artist_name.is_empty() {
"Artist".to_string()
} else {
artist_name.clone()
};
state.set_collection_name(format!("{base} — Complete Discography").into());
}
state.set_loading(false);
apply(window);
}
/// Mark the load failed with a raw error message (shown verbatim).
pub fn fail(window: &AppWindow, message: String) {
let state = window.global::<DiscoBuilderState>();
state.set_loading(false);
state.set_load_error(message.into());
}
// ──────────────────────────── interactions ────────────────────────────────
/// Toggle one candidate's checked state. UI thread; re-renders.
pub fn toggle_checked(window: &AppWindow, group_key: &str, cand_key: &str) {
{
let mut s = session();
let set = s.checked.entry(group_key.to_string()).or_default();
if let Some(pos) = set.iter().position(|k| k == cand_key) {
set.remove(pos);
} else {
set.push(cand_key.to_string());
}
}
apply(window);
}
/// Header tri-state toggle — only ever touches non-compilation PRIMARIES;
/// alternates + compilations are left exactly as the user had them.
pub fn toggle_all(window: &AppWindow) {
{
let mut s = session();
let target = !all_primaries_checked(&s);
let prims: Vec<(String, String)> = s
.groups
.iter()
.filter(|g| !g.is_compilation)
.map(|g| (g.key.clone(), candidate_key(&g.primary)))
.collect();
for (gkey, pkey) in prims {
let set = s.checked.entry(gkey).or_default();
let has = set.iter().any(|k| k == &pkey);
if target && !has {
set.push(pkey);
} else if !target && has {
set.retain(|k| k != &pkey);
}
}
}
apply(window);
}
/// Change the sort order.
pub fn set_order(window: &AppWindow, order_by: &str) {
{
let mut s = session();
s.order_by = order_by.to_string();
}
window.global::<DiscoBuilderState>().set_order_by(order_by.into());
apply(window);
}
/// Sync the collection-name into the state (the input is bound but we mirror
/// it so the save flow reads a single source).
pub fn name_changed(window: &AppWindow, name: &str) {
window
.global::<DiscoBuilderState>()
.set_collection_name(name.into());
}
/// Apply a release-type override (persisted) + re-render.
pub fn set_type_override(window: &AppWindow, source: &str, source_item_id: &str, choice: &str) {
{
if let Ok(mut m) = OVERRIDES.lock() {
m.insert(format!("{source}|{source_item_id}"), choice.to_string());
save_overrides(&m);
}
}
apply(window);
}
/// Clear a release-type override + re-render.
pub fn reset_type_override(window: &AppWindow, source: &str, source_item_id: &str) {
{
if let Ok(mut m) = OVERRIDES.lock() {
m.remove(&format!("{source}|{source_item_id}"));
save_overrides(&m);
}
}
apply(window);
}
// ──────────────────────────── save flow ───────────────────────────────────
/// Build the ordered list of `(candidate, checked)` for the save flow, in the
/// CURRENT sort order, each group emitting `[primary, ...alternates]`.
pub struct SavePayload {
pub name: String,
pub artist_id: String,
/// Checked candidates, in save order.
pub items: Vec<Candidate>,
}
/// Snapshot the current selection into a `SavePayload` (UI thread).
pub fn save_payload(window: &AppWindow) -> Option<SavePayload> {
let s = session();
let name_raw = window.global::<DiscoBuilderState>().get_collection_name().to_string();
let name = {
let t = name_raw.trim();
if t.is_empty() {
"Artist Collection".to_string()
} else {
t.to_string()
}
};
let groups = ordered_groups(&s.groups, &s.order_by);
let mut items: Vec<Candidate> = Vec::new();
for g in &groups {
let mut ordered = vec![g.primary.clone()];
ordered.extend(g.alternates.iter().cloned());
for c in ordered {
if is_checked(&s, &c.group_key, &candidate_key(&c)) {
items.push(c);
}
}
}
if items.is_empty() {
return None;
}
Some(SavePayload {
name,
artist_id: s.artist_id.clone(),
items,
})
}
/// Create the artist_collection + bulk-add the checked candidates. Blocking
/// (DB). Returns the new collection id on success. Plex candidates are stored
/// as `source='local'` (resolver re-detects at enqueue). Mirrors `handleCreate`.
pub fn create_collection(payload: &SavePayload) -> Option<String> {
crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| {
let col = qbz_mixtape::repo::create_collection(
conn,
CollectionKind::ArtistCollection,
&payload.name,
None,
CollectionSourceType::ArtistDiscography,
Some(&payload.artist_id),
)?;
for c in &payload.items {
// Source collapse on save: qobuz -> Qobuz; local OR plex ->
// Local (the LocalLibrary resolver re-detects Plex at enqueue).
let src = if c.source == "qobuz" {
qbz_models::mixtape::AlbumSource::Qobuz
} else {
qbz_models::mixtape::AlbumSource::Local
};
if let Err(e) = qbz_mixtape::repo::add_item(
conn,
&col.id,
qbz_models::mixtape::ItemType::Album,
src,
&c.source_item_id,
&c.title,
Some(c.artist.as_str()),
c.artwork_url.as_deref(),
c.year,
c.track_count,
) {
log::warn!("[qbz-slint] disco builder add_item failed: {e}");
}
}
Ok::<String, rusqlite::Error>(col.id)
})
.map_err(|e| {
qbz_library::LibraryError::Database(format!("disco builder create failed: {e}"))
})?)
})
}
/// Set the creating flag (UI thread).
pub fn set_creating(window: &AppWindow, creating: bool) {
window.global::<DiscoBuilderState>().set_creating(creating);
}
/// Success toast (spec note: the PSD keys are missing; use a real string).
pub fn toast_created(window: &AppWindow) {
crate::toast::show(window, "Artist Collection created", ToastKind::Success);
}
/// Failure toast.
pub fn toast_failed(window: &AppWindow) {
crate::toast::show(window, "Failed to create collection", ToastKind::Error);
}
/// Apply the decoded artist avatar to the state. UI thread.
pub fn apply_avatar(window: &AppWindow, pixels: &[u8], width: u32, height: u32) {
let mut buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
let dst = buffer.make_mut_bytes();
if dst.len() != pixels.len() {
return;
}
dst.copy_from_slice(pixels);
window
.global::<DiscoBuilderState>()
.set_artist_avatar(slint::Image::from_rgba8(buffer));
}
+7
View File
@@ -72,6 +72,13 @@ pub enum NavEntry {
/// view-mode) is session-scoped in the controller, so the entry carries
/// only the id.
MixtapeDetail(String),
/// The Discography Builder full-page flow (spec 13). The string is the
/// artist id; the builder fetches that artist's releases from Qobuz +
/// local + Plex, dedupes into groups, and saves a `kind='artist_collection'`
/// collection. Mirrors `MixtapeDetail` / `Artist` — session-scoped builder
/// state (selection / order / name) lives in the controller, so the entry
/// carries only the id.
DiscographyBuilder(String),
Album(String),
/// A Local Library album detail page (dedicated view, separate from the
/// Qobuz Album view); the string is the metadata group key.
+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -0,0 +1,886 @@
// Discography Builder — the full-page flow that assembles an artist's
// complete discography into a saved `kind='artist_collection'` collection
// (spec 13). A 1:1 port of the Tauri DiscographyBuilderView.svelte.
//
// Two-part layout: `.builder-scroll` holds everything that scrolls; the
// footer is a fixed-height flex sibling pinned at the bottom (NOT
// sticky-inside-scroll) so last rows never peek past the footer.
//
// CSS variable values are resolved to the concrete dark-theme values from
// spec §0 (this view hardcodes them rather than going through the Theme
// global so the port stays pixel-exact to the quoted PSD).
import { Typography } from "../foundation/typography.slint";
import { DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
// ── Concrete dark-theme values (spec §0) ──────────────────────────────────
// Resolved here as global consts so the whole view shares one source.
global D {
out property <color> bg-primary: #0f0f0f; // footer bg
out property <color> bg-secondary: #1a1a1a; // input/segment/menu/secondary-btn
out property <color> bg-tertiary: #2a2a2a; // avatar placeholder, hairlines
out property <color> bg-hover: #1f1f1f; // row/segment/menu-item hover
out property <color> text-primary: #ffffff;
out property <color> text-secondary: #cccccc;
out property <color> text-muted: #888888;
out property <color> accent: #4285F4;
out property <color> btn-primary-text: #ffffff;
out property <color> alpha-6: #ffffff0f; // rgba(255,255,255,0.06)
out property <color> error: #e57373;
// Type-label tints (hardcoded non-var colors, spec §0).
out property <color> tint-compilation: #f59e0b; // amber
out property <color> tint-live: #e91e63; // pink
out property <color> tint-ep: #60a5fa; // blue
out property <color> tint-single: #a78bfa; // violet
}
// Resolve a release-type to its tint (plain "album" = muted, no tint).
component TypeColor {
in property <string> kind;
out property <color> color:
root.kind == "compilation" ? D.tint-compilation
: root.kind == "live" ? D.tint-live
: root.kind == "ep" ? D.tint-ep
: root.kind == "single" ? D.tint-single
: D.text-muted;
}
// ── Source indicator (Qobuz / Plex / Local accent-tinted silhouette) ──────
component SourceIndicator inherits Rectangle {
in property <string> source;
width: 28px;
height: 28px;
opacity: 0.9;
HorizontalLayout {
alignment: center;
VerticalLayout {
alignment: center;
if root.source == "plex": QbzIcon {
source: @image-url("../assets/icons/plex-logo.svg");
width: 14px;
height: 14px;
tint: D.accent;
}
if root.source == "qobuz": QbzIcon {
source: @image-url("../assets/icons/qobuz-logo-filled.svg");
width: 16px;
height: 16px;
tint: D.accent;
}
if root.source != "plex" && root.source != "qobuz": QbzIcon {
source: @image-url("../assets/icons/hard-drive.svg");
width: 14px;
height: 14px;
tint: D.accent;
}
}
}
}
// ── Per-row release-type override button + popover (spec §7.1) ─────────────
component TypeOverride inherits Rectangle {
in property <DiscoCandidate> candidate;
// 11px primary / 10px alternate.
in property <length> font-size: 11px;
property <string> menu-key: root.candidate.source + "|" + root.candidate.source-item-id;
property <bool> menu-open: DiscoBuilderState.open-type-menu-key == root.menu-key;
// The wrapper anchors the absolute popover; it sizes to the button.
width: type-btn.preferred-width;
height: type-btn.preferred-height;
type-btn := Rectangle {
// .type-btn negative margin (0 -4px) is replicated by a -4px touch
// bleed; the visual glyph stays put.
background: btn-ta.has-hover ? D.alpha-6 : transparent;
border-radius: 4px;
HorizontalLayout {
padding-left: 4px;
padding-right: 4px;
padding-top: 2px;
padding-bottom: 2px;
label := Text {
text: root.candidate.release-type;
color: type-color.color;
font-size: root.font-size;
font-weight: 600;
letter-spacing: 0.4px;
vertical-alignment: center;
}
}
type-color := TypeColor { kind: root.candidate.release-type; }
// Dotted-underline marker for an active override (Slint has no
// text-decoration; draw a 1px dotted rule under the label).
if root.candidate.is-overridden: Rectangle {
x: label.x;
y: parent.height - 3px;
width: label.width;
height: 1px;
background: type-color.color;
opacity: 0.6;
}
btn-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderState.open-type-menu-key =
root.menu-open ? "" : root.menu-key;
}
}
}
// Backdrop + menu (rendered above siblings; z-order is paint order).
if root.menu-open: Rectangle {
// Backdrop click-catcher — full-row local catch; closes the menu.
// (A true full-viewport backdrop isn't expressible here without a
// global overlay; the menu's close-on-outside is handled by the
// backdrop TouchArea plus re-clicking the button.)
TouchArea {
x: -2000px;
y: -2000px;
width: 4000px;
height: 4000px;
clicked => {
DiscoBuilderState.open-type-menu-key = "";
}
}
menu := Rectangle {
x: 0;
y: parent.height + 4px;
width: 160px;
background: D.bg-secondary;
border-width: 1px;
border-color: D.bg-tertiary;
border-radius: 6px;
drop-shadow-blur: 12px;
drop-shadow-color: #00000059; // rgba(0,0,0,0.35)
drop-shadow-offset-y: 4px;
VerticalLayout {
padding: 4px;
// Album, EP, Single, Live, Compilation (spec order).
for choice in [
{ id: "album", lbl: "Album" },
{ id: "ep", lbl: "EP" },
{ id: "single", lbl: "Single" },
{ id: "live", lbl: "Live" },
{ id: "compilation", lbl: "Compilation" },
]: Rectangle {
height: 28px;
border-radius: 4px;
background: item-ta.has-hover ? D.bg-hover : transparent;
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 8px;
alignment: start;
VerticalLayout {
alignment: center;
// Check glyph if this is the selected type; else a
// 12px placeholder so labels stay aligned.
if root.candidate.release-type == choice.id: QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 12px;
height: 12px;
tint: D.accent;
}
if root.candidate.release-type != choice.id: Rectangle {
width: 12px;
height: 12px;
}
}
Text {
text: choice.lbl;
color: root.candidate.release-type == choice.id
? D.accent : D.text-primary;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
item-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.set-type-override(
root.candidate.source,
root.candidate.source-item-id,
choice.id);
DiscoBuilderState.open-type-menu-key = "";
}
}
}
// Reset row — only when an override is active.
if root.candidate.is-overridden: Rectangle {
height: 9px;
Rectangle {
y: 4px;
height: 1px;
background: D.bg-tertiary;
}
}
if root.candidate.is-overridden: Rectangle {
height: 28px;
border-radius: 4px;
background: reset-ta.has-hover ? D.bg-hover : transparent;
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 8px;
alignment: start;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/rotate-ccw.svg");
width: 12px;
height: 12px;
tint: D.text-muted;
}
}
Text {
text: @tr("Use auto-detected");
color: D.text-muted;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
reset-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.reset-type-override(
root.candidate.source,
root.candidate.source-item-id);
DiscoBuilderState.open-type-menu-key = "";
}
}
}
}
}
}
}
// 7-column row grid — the alignment backbone (spec §2.9):
// grid-template-columns: 28px 56px 72px minmax(0,1fr) 72px 60px 156px, gap 10px.
// Each row below inlines this template (header/primary/alternate share it).
//
// One candidate row (primary or alternate). `alt` dims it + swaps the col[0]
// checkbox for a connector + checkbox and shrinks/mutes the text.
component CandidateRow inherits Rectangle {
in property <DiscoCandidate> candidate;
in property <string> group-key;
in property <bool> alt: false;
// Whether the OWNING group is a compilation — drives the primary row's
// "compilation" tag (spec §2.10: tag is `{#if grp.is_compilation}`, the
// group flag, not the candidate's effective type).
in property <bool> group-is-compilation: false;
// Persistent dim (compilation primary 0.65 / alternate 0.72); lifts to 1
// on hover. The parent passes the resting opacity.
in property <float> rest-opacity: 1.0;
height: 36px;
border-radius: 6px;
background: row-ta.has-hover ? D.bg-hover : transparent;
opacity: row-ta.has-hover ? 1.0 : root.rest-opacity;
row-ta := TouchArea { mouse-cursor: default; }
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 10px;
// [0] 28px — checkbox cell (alt: connector + checkbox).
Rectangle {
width: 28px;
HorizontalLayout {
alignment: start;
spacing: 3px;
if root.alt: Text {
text: "↳";
color: D.text-muted;
font-size: 12px;
vertical-alignment: center;
}
VerticalLayout {
alignment: center;
Rectangle {
width: 15px;
height: 15px;
border-radius: 3px;
background: root.candidate.checked ? D.accent : transparent;
border-width: root.candidate.checked ? 0px : 1.5px;
border-color: D.text-muted;
if root.candidate.checked: QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 11px;
height: 11px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: D.btn-primary-text;
}
TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.toggle-checked(
root.group-key, root.candidate.key);
}
}
}
}
}
}
// [1] 56px — year.
Rectangle {
width: 56px;
Text {
text: root.candidate.year;
color: root.alt ? D.text-muted : D.text-secondary;
font-size: root.alt ? 12px : 13px;
vertical-alignment: center;
horizontal-alignment: left;
width: 100%;
}
}
// [2] 72px — type override button + popover.
Rectangle {
width: 72px;
HorizontalLayout {
alignment: start;
VerticalLayout {
alignment: center;
TypeOverride {
candidate: root.candidate;
font-size: root.alt ? 10px : 11px;
}
}
}
}
// [3] 1fr — title (flexes) + tag.
Rectangle {
horizontal-stretch: 1;
HorizontalLayout {
alignment: start;
spacing: 8px;
title-ta := TouchArea {
mouse-cursor: pointer;
horizontal-stretch: 1;
clicked => {
DiscoBuilderActions.open-album(
root.candidate.source, root.candidate.source-item-id);
}
title := Text {
text: root.candidate.title;
color: title-ta.has-hover
? D.accent
: (root.alt ? D.text-muted : D.text-primary);
font-size: root.alt ? 13px : 14px;
overflow: elide;
vertical-alignment: center;
width: 100%;
}
}
// alternates always show the "alternate" tag; primaries show
// "compilation" only when the group is a compilation (passed
// by the parent baking it into the candidate's title? No —
// the parent sets `tag` separately). Here we infer from the
// row kind + the candidate's own type.
if root.alt: Text {
text: @tr("alternate");
color: D.text-muted;
font-size: 10px;
font-weight: 500;
font-italic: true;
letter-spacing: 0.8px;
vertical-alignment: center;
}
if !root.alt && root.group-is-compilation: Text {
text: @tr("compilation");
color: D.text-muted;
font-size: 10px;
font-weight: 500;
font-italic: true;
letter-spacing: 0.8px;
vertical-alignment: center;
}
}
}
// [4] 72px — tracks (centered, tabular).
Rectangle {
width: 72px;
Text {
text: root.candidate.tracks;
color: root.alt ? D.text-muted : D.text-secondary;
font-size: root.alt ? 12px : 13px;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
}
}
// [5] 60px — source indicator.
Rectangle {
width: 60px;
HorizontalLayout {
alignment: center;
VerticalLayout {
alignment: center;
SourceIndicator { source: root.candidate.source; }
}
}
}
// [6] 156px — quality badge.
Rectangle {
width: 156px;
HorizontalLayout {
alignment: center;
VerticalLayout {
alignment: center;
QualityBadgeFull {
tier: root.candidate.quality-tier;
detail: root.candidate.quality-detail;
}
}
}
}
}
}
export component DiscographyBuilderView inherits Rectangle {
background: transparent;
// ── .builder-view (flex column, full size) ──
VerticalLayout {
// ── .builder-scroll (scrolls everything) ──
scroll := Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: content.preferred-height;
content := VerticalLayout {
width: scroll.width;
padding-left: 32px;
padding-right: 32px;
padding-top: 24px;
padding-bottom: 24px;
spacing: 0px;
alignment: start;
// ── .back-btn ──
Rectangle {
height: 20px;
horizontal-stretch: 0;
back-ta := TouchArea {
width: back-row.preferred-width;
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.back();
}
back-row := HorizontalLayout {
spacing: 8px;
alignment: start;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/arrow-left.svg");
width: 16px;
height: 16px;
tint: back-ta.has-hover ? D.text-secondary : D.text-muted;
}
}
Text {
text: @tr("Back");
color: back-ta.has-hover ? D.text-secondary : D.text-muted;
font-size: 14px;
vertical-alignment: center;
}
}
}
}
Rectangle { height: 24px; }
// ── header.builder-header ──
HorizontalLayout {
spacing: 20px;
alignment: start;
// Avatar (img or placeholder) — 72px circle.
Rectangle {
width: 72px;
height: 72px;
border-radius: 36px;
background: D.bg-tertiary;
clip: true;
if DiscoBuilderState.artist-avatar-url != "": Image {
width: 100%;
height: 100%;
source: DiscoBuilderState.artist-avatar;
image-fit: cover;
}
}
// Header text — eyebrow + title.
VerticalLayout {
alignment: center;
spacing: 4px;
horizontal-stretch: 1;
Text {
text: @tr("Build Artist Collection");
color: D.text-muted;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.5px;
// uppercase via the literal (en already caps the eyebrow).
}
Text {
text: DiscoBuilderState.artist-name == ""
? "—" : DiscoBuilderState.artist-name;
color: D.text-primary;
font-size: 28px;
font-weight: 700;
overflow: elide;
}
}
}
Rectangle { height: 24px; }
// ── label.field (collection-name input) ──
VerticalLayout {
spacing: 8px;
alignment: start;
Text {
text: @tr("NAME");
color: D.text-muted;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.5px;
}
Rectangle {
width: 540px;
height: 40px;
background: D.bg-secondary;
border-radius: 8px;
border-width: 1px;
border-color: name-input.has-focus ? D.accent : D.bg-tertiary;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
name-input := TextInput {
color: D.text-primary;
font-size: 14px;
vertical-alignment: center;
single-line: true;
text: DiscoBuilderState.collection-name;
edited => {
DiscoBuilderActions.name-changed(self.text);
}
}
}
}
}
Rectangle { height: 16px; }
// ── .order-row (segmented control) ──
HorizontalLayout {
spacing: 12px;
alignment: start;
VerticalLayout {
alignment: center;
Text {
text: @tr("Order by");
color: D.text-muted;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.5px;
}
}
// .segmented — 8px corners, NO pills.
Rectangle {
width: seg.preferred-width;
height: 32px;
border-radius: 8px;
border-width: 1px;
border-color: D.bg-tertiary;
clip: true;
seg := HorizontalLayout {
spacing: 0px;
for s[i] in [
{ id: "release_date", lbl: "Release date" },
{ id: "title", lbl: "Title" },
{ id: "manual", lbl: "Manual" },
]: seg-rect := Rectangle {
property <bool> active: DiscoBuilderState.order-by == s.id;
background: seg-rect.active
? D.accent
: (seg-ta.has-hover ? D.bg-hover : D.bg-secondary);
// Divider between segments.
if i > 0: Rectangle {
x: 0;
width: 1px;
height: 100%;
background: D.bg-tertiary;
}
HorizontalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 7px;
padding-bottom: 7px;
Text {
text: s.lbl;
color: seg-rect.active ? D.btn-primary-text
: (seg-ta.has-hover ? D.text-primary : D.text-secondary);
font-size: 12px;
font-weight: 500;
vertical-alignment: center;
}
}
seg-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.set-order(s.id);
}
}
}
}
}
}
Rectangle { height: 20px; }
// ── {state branch} ──
// loading
if DiscoBuilderState.loading: Rectangle {
height: 96px;
Text {
text: @tr("Loading...");
color: D.text-muted;
font-size: 14px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// error
if !DiscoBuilderState.loading && DiscoBuilderState.load-error != "": Rectangle {
height: 96px;
Text {
text: DiscoBuilderState.load-error;
color: D.error;
font-size: 14px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// empty
if !DiscoBuilderState.loading
&& DiscoBuilderState.load-error == ""
&& DiscoBuilderState.groups.length == 0: Rectangle {
height: 96px;
Text {
text: @tr("No results found");
color: D.text-muted;
font-size: 14px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// populated — .groups table
if !DiscoBuilderState.loading
&& DiscoBuilderState.load-error == ""
&& DiscoBuilderState.groups.length > 0: VerticalLayout {
spacing: 0px;
// border-top + 4px pad-top.
Rectangle { height: 1px; background: D.bg-tertiary; }
Rectangle { height: 4px; }
// ── header row (.row.header-row) ──
Rectangle {
height: 36px;
Rectangle {
y: parent.height - 1px;
height: 1px;
background: D.bg-tertiary;
}
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 10px;
// [0] 28px — select-all checkbox (tri-state).
Rectangle {
width: 28px;
HorizontalLayout {
alignment: start;
VerticalLayout {
alignment: center;
Rectangle {
width: 15px;
height: 15px;
border-radius: 3px;
background: DiscoBuilderState.all-checked
? D.accent : transparent;
border-width: DiscoBuilderState.all-checked ? 0px : 1.5px;
border-color: D.text-muted;
if DiscoBuilderState.all-checked: QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 11px;
height: 11px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: D.btn-primary-text;
}
// Indeterminate dash.
if !DiscoBuilderState.all-checked
&& DiscoBuilderState.some-checked: Rectangle {
width: 9px;
height: 2px;
background: D.accent;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.toggle-all();
}
}
}
}
}
}
// [1..6] column labels.
Rectangle {
width: 56px;
Text { text: @tr("Year"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; }
}
Rectangle {
width: 72px;
Text { text: @tr("Type"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; }
}
Rectangle {
horizontal-stretch: 1;
Text { text: @tr("Album"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; horizontal-alignment: left; }
}
Rectangle {
width: 72px;
Text { text: @tr("Tracks"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
}
Rectangle {
width: 60px;
Text { text: @tr("Source"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
}
Rectangle {
width: 156px;
Text { text: @tr("Quality"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
}
}
}
// ── per group ──
for grp in DiscoBuilderState.groups: VerticalLayout {
spacing: 0px;
// primary row (dimmed when the group is a compilation).
CandidateRow {
candidate: grp.primary;
group-key: grp.key;
alt: false;
group-is-compilation: grp.is-compilation;
rest-opacity: grp.is-compilation ? 0.65 : 1.0;
}
// alternate rows.
for alt in grp.alternates: CandidateRow {
candidate: alt;
group-key: grp.key;
alt: true;
rest-opacity: 0.72;
}
}
}
}
}
// ── footer.builder-footer (fixed-height flex sibling) ──
Rectangle {
vertical-stretch: 0;
height: 60px;
background: D.bg-primary;
Rectangle {
y: 0;
height: 1px;
background: D.bg-tertiary;
}
HorizontalLayout {
padding-left: 32px;
padding-right: 88px; // asymmetric — clears the back-to-top FAB.
padding-top: 12px;
padding-bottom: 12px;
spacing: 16px;
// .footer-count.
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: DiscoBuilderState.selected-count + " of "
+ DiscoBuilderState.group-count + " albums selected";
color: D.text-secondary;
font-size: 13px;
}
}
// .footer-actions.
HorizontalLayout {
spacing: 8px;
alignment: end;
// Cancel (secondary-btn).
Rectangle {
width: cancel-row.preferred-width + 32px;
height: 36px;
background: cancel-ta.has-hover ? D.bg-hover : D.bg-secondary;
border-radius: 8px;
border-width: 1px;
border-color: D.bg-tertiary;
cancel-row := HorizontalLayout {
alignment: center;
Text {
text: @tr("Cancel");
color: D.text-primary;
font-size: 13px;
font-weight: 600;
vertical-alignment: center;
}
}
cancel-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.back();
}
}
}
// Create (primary-btn).
create-btn := Rectangle {
property <bool> disabled:
DiscoBuilderState.creating
|| DiscoBuilderState.selected-count == 0
|| DiscoBuilderState.collection-name == "";
width: create-row.preferred-width + 40px;
height: 36px;
background: D.accent;
border-radius: 8px;
opacity: create-btn.disabled ? 0.45 : 1.0;
create-row := HorizontalLayout {
alignment: center;
Text {
text: DiscoBuilderState.creating
? @tr("Loading...") : @tr("Create Collection");
color: D.btn-primary-text;
font-size: 13px;
font-weight: 600;
vertical-alignment: center;
}
}
TouchArea {
mouse-cursor: create-btn.disabled ? default : pointer;
clicked => {
if (!create-btn.disabled) {
DiscoBuilderActions.create();
}
}
}
}
}
}
}
}
}
+2
View File
@@ -50,6 +50,7 @@ import { PlaylistManagerView } from "../playlist/PlaylistManagerView.slint";
import { MixtapesView } from "../myqbz/MixtapesView.slint";
import { CollectionsView } from "../myqbz/CollectionsView.slint";
import { MixtapeDetailView } from "../myqbz/MixtapeDetailView.slint";
import { DiscographyBuilderView } from "../myqbz/DiscographyBuilderView.slint";
import { OfflineManagerView } from "../offline/OfflineManagerView.slint";
import { LocalLibraryView } from "../locallibrary/LocalLibraryView.slint";
import { SettingsView } from "../settings/SettingsView.slint";
@@ -239,6 +240,7 @@ export component AppShell inherits Rectangle {
if NavState.view == ContentView.mixtapes: MixtapesView { }
if NavState.view == ContentView.collections: CollectionsView { }
if NavState.view == ContentView.mixtape-detail: MixtapeDetailView { }
if NavState.view == ContentView.discography-builder: DiscographyBuilderView { }
if NavState.view == ContentView.offline-manager: OfflineManagerView { }
if NavState.view == ContentView.local-library: LocalLibraryView { }
if NavState.view == ContentView.mix: MixView {
+110 -1
View File
@@ -345,7 +345,7 @@ export global DragActions {
}
// Which content view the shell shows.
export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager, local-library, local-album, mixtapes, collections, mixtape-detail }
export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager, local-library, local-album, mixtapes, collections, mixtape-detail, discography-builder }
// In-app toast (transient notification). Mirrors Tauri's toastStore:
// a single toast at a time (a new one replaces the current), auto-hidden
@@ -1703,6 +1703,115 @@ export global MyQbzDetailActions {
callback bulk-action(string /* id */);
}
// ─────────────────────── Discography Builder (spec 13) ───────────────────
// The full-page flow that assembles an artist's complete discography into a
// saved `kind='artist_collection'` collection. It fetches every release for
// the artist from three sources (Qobuz artist page + local library + Plex
// cache), dedupes them into GROUPS (one logical album = a chosen "primary" +
// "alternates"), lets the user check which to include, reorder (by sort),
// rename, and correct release-type mislabels, then persists the result.
//
// One candidate row (a primary OR an alternate). Every display string is
// precomputed in Rust (crate::myqbz_builder) so the view does no lookups.
export struct DiscoCandidate {
// `${source}|${source_item_id}` — the per-candidate selection key.
key: string,
// "qobuz" | "local" | "plex" — drives the source indicator icon.
source: string,
source-item-id: string,
title: string,
artist: string,
// "2019" or "—" (em-dash placeholder).
year: string,
// "12" or "—".
tracks: string,
artwork-url: string,
// Effective release type AFTER the user's override is applied:
// "album" | "ep" | "single" | "live" | "compilation".
release-type: string,
// True when a user override is active for this candidate (drives the
// dotted-underline marker on the type button).
is-overridden: bool,
// QualityBadgeFull inputs.
quality-tier: string,
quality-detail: string,
// Whether THIS candidate is currently checked (included on save).
checked: bool,
}
// One dedupe GROUP: a primary candidate plus zero or more alternates.
export struct DiscoGroup {
// group_key (`${normalizedTitle}|${year}`) — stable row key.
key: string,
title: string,
// "2019" / "—".
year: string,
primary: DiscoCandidate,
alternates: [DiscoCandidate],
// ALL candidates are compilations → the group is treated as a comp
// (dimmed primary row, ignored by select-all).
is-compilation: bool,
}
export global DiscoBuilderState {
// The artist being built (id + resolved display name + portrait URL).
in property <string> artist-id;
in property <string> artist-name;
in property <string> artist-avatar-url;
in property <image> artist-avatar;
// Render states (spec 13 §4). `loading` true initially + during load;
// `load-error` non-empty shows the raw error message; otherwise the
// groups table renders (empty groups -> "No results found").
in property <bool> loading: true;
in property <string> load-error: "";
// The dedupe groups in CURRENT sort order (Rust re-sorts on order change).
in property <[DiscoGroup]> groups: [];
// The collection-name input (defaulted to "<artist> — Complete Discography").
in-out property <string> collection-name: "";
// Order segmented control: "release_date" | "title" | "manual".
in-out property <string> order-by: "release_date";
// Select-all header checkbox state (tri-state via the two flags).
in property <bool> all-checked: false;
in property <bool> some-checked: false;
// Footer: total checked candidate count + total group count.
in property <int> selected-count: 0;
in property <int> group-count: 0;
// Disables the Create button while a save is in flight.
in property <bool> creating: false;
// The per-row type-override popover open key (`${source}|${source_item_id}`),
// or "" when none is open. One menu open at a time.
in-out property <string> open-type-menu-key: "";
}
export global DiscoBuilderActions {
// Back to the artist page (clears builder state).
callback back();
// Collection-name input edited.
callback name-changed(string);
// Order segmented control: "release_date" | "title" | "manual".
callback set-order(string);
// Toggle one candidate's checked state (group-key + candidate-key).
callback toggle-checked(string /* group-key */, string /* candidate-key */);
// Header tri-state select-all toggle (non-compilation primaries only).
callback toggle-all();
// Open an album (Qobuz album view, or local/Plex local-album view).
callback open-album(string /* source */, string /* source-item-id */);
// Apply a release-type override to a candidate (writes the sidecar).
callback set-type-override(string /* source */, string /* source-item-id */, string /* type */);
// Clear a candidate's override (revert to auto-detected).
callback reset-type-override(string /* source */, string /* source-item-id */);
// Save: create the artist_collection + bulk-add the checked groups.
callback create();
}
// ──────────────────────────── DJ-mix modal ────────────────────────────
// The "Random queue" sampler (TrackMixModal, spec 21 §C / spec 12 §12).
// Opened from the hero DJ-mix CTA. On open the Rust controller resolves the