feat(myqbz): AddToMixtapeModal + app-wide add-to-collection entry points

Phase-2 Slice 8. Adds "Add to Mixtape/Collection" everywhere "Add to playlist"
lives, so collections can be populated:

- ui/myqbz/AddToMixtapeModal.slint: the picker (search + collection list with
  kind icon/name/count + "Already added" via item_exists + kind tag) and the
  create-new-inline panel (name + kind), per spec 21. Mounted once at root.
- src/myqbz_add.rs: list_collections (kind-restricted: track/playlist->mixtapes,
  album->mixtapes+collections, never artist_collection; recency-sorted); add via
  add_item_with(allow_duplicate=false) counting added/skipped-duplicates + toast;
  create_and_add reuses create_collection. All via with_db -> with_connection.
- Entry points wired: per-track (universal via TrackContextMenu/TrackRow -> search,
  favorites, album rows, local tracks, now-playing), Qobuz album, local/plex album,
  playlist (PM), bulk local tracks. Replaced the PlaylistManager add-to-mixtape no-op.
  Qobuz bulk omitted by design (spec 50 negative-space).

Note: adversarial reviewers were blocked by a session limit; verified manually
(cargo green, confined to qbz-slint, backend untouched, ADR-005/006, coverage per
spec 50).

cc
This commit is contained in:
vicrodh committed 2026-06-09 06:19:35 -06:00
1 parent 2a72878a1d
commit 5da3b8bfd5
9 files changed
+1337 -5

No files matched your search

+372 -3
View File
@@ -36,6 +36,7 @@ mod location_view;
mod mix;
mod musician;
mod myqbz;
mod myqbz_add;
mod myqbz_cover;
mod myqbz_detail;
mod myqbz_edit;
@@ -386,6 +387,47 @@ fn set_row_favorite(window: &AppWindow, track_id: &str, favorite: bool) {
}
}
/// Look up the display name of an "Add to Mixtape/Collection" picker row by id
/// (for the post-add toast). Returns "" if not found.
fn myqbz_add_row_name(window: &AppWindow, collection_id: &str) -> String {
use slint::Model;
let model = window.global::<MyQbzAddState>().get_rows();
(0..model.row_count())
.filter_map(|i| model.row_data(i))
.find(|r| r.id == collection_id)
.map(|r| r.name.to_string())
.unwrap_or_default()
}
/// Open the global "Add to Mixtape/Collection" picker for `items` (mirrors
/// Tauri's `openAddToMixtape`). Hops onto the event loop to show the modal,
/// then loads the picker rows (kind-restricted + recency-sorted +
/// `item_exists`-resolved) on a blocking worker. Empty `items` is a no-op
/// (the controller guards too). Callable from any thread.
fn open_add_to_mixtape(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
items: Vec<myqbz_add::AddItem>,
) {
if items.is_empty() {
return;
}
let restrict = items.iter().any(|it| it.item_type != "album");
let items_for_open = items.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
myqbz_add::open(&w, items_for_open);
});
handle.spawn(async move {
let rows =
tokio::task::spawn_blocking(move || myqbz_add::load_rows(restrict, &items))
.await
.unwrap_or_default();
let _ = weak.upgrade_in_event_loop(move |w| {
myqbz_add::apply_rows(&w, rows);
});
});
}
/// Update the offline cache-status (+ progress) of every visible row matching
/// `track_id`. Mirrors `set_row_favorite`. status: 0 none / 1 queued / 2
/// downloading / 3 ready / 4 failed; `progress` is 0.0..1.0.
@@ -1556,12 +1598,36 @@ fn wire_playlist_manager(
});
}
{
// Add-to-mixtape — the mixtape system is not yet ported to Slint;
// log and no-op (matches the deferred mixtape surface).
// Add a whole playlist to a Mixtape/Collection (callsite O). Builds the
// `playlist` payload from the PM grid row (id / name / track count /
// first cover); the owner subtitle isn't carried in the PM model, so it
// is omitted (optional in the contract).
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlaylistManagerActions>()
.on_add_to_mixtape(move |id| {
log::info!("[qbz-slint] add-to-mixtape not yet implemented (playlist {id})");
use slint::Model;
let Some(w) = weak.upgrade() else { return };
let model = w.global::<PlaylistManagerState>().get_playlists();
let Some(row) = (0..model.row_count())
.filter_map(|i| model.row_data(i))
.find(|it| it.id == id)
else {
return;
};
let artwork = row.url1.to_string();
let item = myqbz_add::AddItem {
item_type: "playlist".into(),
source: "qobuz".into(),
source_item_id: id.to_string(),
title: row.name.to_string(),
subtitle: None,
artwork_url: (!artwork.is_empty()).then_some(artwork),
year: None,
track_count: (row.total_count > 0).then_some(row.total_count),
};
open_add_to_mixtape(weak.clone(), handle.clone(), vec![item]);
});
}
@@ -1984,6 +2050,132 @@ fn wire_myqbz(
});
}
// --- Add to Mixtape/Collection picker (global singleton) ------------
{
// close — clear the pending payload + hide.
let weak = window.as_weak();
window.global::<MyQbzAddActions>().on_close(move || {
if let Some(w) = weak.upgrade() {
myqbz_add::close(&w);
}
});
}
{
// search — re-filter the loaded rows client-side.
let weak = window.as_weak();
window
.global::<MyQbzAddActions>()
.on_search_changed(move |_query| {
if let Some(w) = weak.upgrade() {
myqbz_add::rebuild(&w);
}
});
}
{
// show-create — open the create sub-panel preset to a kind.
let weak = window.as_weak();
window
.global::<MyQbzAddActions>()
.on_show_create(move |kind| {
if let Some(w) = weak.upgrade() {
let st = w.global::<MyQbzAddState>();
st.set_create_kind(kind);
st.set_create_name("".into());
st.set_creating(true);
}
});
}
{
// create-back — return to the picker list.
let weak = window.as_weak();
window.global::<MyQbzAddActions>().on_create_back(move || {
if let Some(w) = weak.upgrade() {
w.global::<MyQbzAddState>().set_creating(false);
}
});
}
{
// pick — add the pending items to the chosen collection.
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<MyQbzAddActions>()
.on_pick(move |collection_id| {
let Some(w) = weak.upgrade() else { return };
let st = w.global::<MyQbzAddState>();
if st.get_busy_id() != "" {
return;
}
st.set_busy_id(collection_id.clone());
// The chosen collection's display name (for the toast).
let name = myqbz_add_row_name(&w, collection_id.as_str());
let items = myqbz_add::take_pending();
let cid = collection_id.to_string();
let weak = weak.clone();
handle.spawn(async move {
let outcome = tokio::task::spawn_blocking(move || {
myqbz_add::add_items(&cid, &items)
})
.await
.unwrap_or(myqbz_add::AddOutcome { added: 0, skipped: 0 });
let _ = weak.upgrade_in_event_loop(move |w| {
myqbz_add::toast_outcome(&w, &name, &outcome);
myqbz_add::close(&w);
});
});
});
}
{
// create-and-add — create a new collection then add the items.
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<MyQbzAddActions>().on_create_and_add(move || {
let Some(w) = weak.upgrade() else { return };
let st = w.global::<MyQbzAddState>();
let name = st.get_create_name().trim().to_string();
if name.is_empty() || st.get_create_busy() {
return;
}
let kind = st.get_create_kind().to_string();
st.set_create_busy(true);
let items = myqbz_add::take_pending();
let weak = weak.clone();
handle.spawn(async move {
let created = {
let kind = kind.clone();
let name = name.clone();
tokio::task::spawn_blocking(move || {
myqbz_add::create_collection(&kind, &name)
})
.await
.ok()
.flatten()
};
match created {
Some((cid, cname)) => {
let outcome = tokio::task::spawn_blocking(move || {
myqbz_add::add_items(&cid, &items)
})
.await
.unwrap_or(myqbz_add::AddOutcome { added: 0, skipped: 0 });
let _ = weak.upgrade_in_event_loop(move |w| {
myqbz_add::toast_outcome(&w, &cname, &outcome);
myqbz_add::close(&w);
});
}
None => {
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<MyQbzAddState>().set_create_busy(false);
crate::toast::error(&w, "Failed to create");
});
}
}
});
});
}
// --- Mixtapes toolbar -----------------------------------------------
{
let weak = window.as_weak();
@@ -3241,6 +3433,79 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// for folder-grouped local albums).
tag_editor::open_tag_editor(weak.clone(), handle.clone(), id.clone(), id);
}
("album", "add-to-mixtape") => {
// The cassette button on the album header. Local albums
// (incl. Plex, stored as source "local") build the payload
// from AlbumState + the loaded tracks; Qobuz albums resolve
// via get_album (the proven fail-safe resolver).
let Some(w) = weak.upgrade() else { return };
let st = w.global::<AlbumState>();
if st.get_is_local() {
let item = myqbz_add::AddItem {
item_type: "album".into(),
source: "local".into(),
source_item_id: st.get_id().to_string(),
title: st.get_title().to_string(),
subtitle: {
let a = st.get_artist().to_string();
(!a.is_empty()).then_some(a)
},
artwork_url: None, // local albums omit artwork_url (1:1 PSD)
year: None,
track_count: {
use slint::Model;
let n = st.get_tracks().row_count();
(n > 0).then_some(n as i32)
},
};
open_add_to_mixtape(weak.clone(), handle.clone(), vec![item]);
} else {
let runtime = runtime.clone();
let weak = weak.clone();
let handle2 = handle.clone();
let album_id = id.clone();
handle.spawn(async move {
let item = match runtime.core().get_album(&album_id).await {
Ok(album) => {
let artwork_url = album
.image
.thumbnail
.clone()
.or_else(|| album.image.small.clone());
let year = album
.release_date_original
.as_deref()
.and_then(|d| d.get(0..4))
.and_then(|y| y.parse::<i32>().ok());
let track_count = album
.tracks_count
.or(album.track_count)
.map(|n| n as i32);
myqbz_add::AddItem {
item_type: "album".into(),
source: "qobuz".into(),
source_item_id: album.id.clone(),
title: album.title.clone(),
subtitle: {
let a = album.artist.name.clone();
(!a.is_empty()).then_some(a)
},
artwork_url,
year,
track_count,
}
}
Err(e) => {
log::warn!(
"[qbz-slint] add-to-mixtape: get_album {album_id} failed: {e}"
);
return;
}
};
open_add_to_mixtape(weak, handle2, vec![item]);
});
}
}
("album", "radio") => playback::play_album_radio(
runtime.clone(),
weak.clone(),
@@ -3360,6 +3625,60 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
});
}
("track", "add-to-mixtape") => {
// The menu only carries the track id; resolve the Qobuz
// track (this entry is gated to Qobuz/offline in the menu)
// to build the AddToMixtape payload, then open the picker.
if let Ok(track_id) = id.parse::<u64>() {
let runtime = runtime.clone();
let weak = weak.clone();
let handle2 = handle.clone();
handle.spawn(async move {
let item = match runtime.core().get_track(track_id).await {
Ok(track) => {
let artist = track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default();
let album = track
.album
.as_ref()
.map(|a| a.title.clone())
.unwrap_or_default();
let subtitle = [artist, album]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" · ");
let artwork_url = track.album.as_ref().and_then(|a| {
a.image
.thumbnail
.clone()
.or_else(|| a.image.small.clone())
});
myqbz_add::AddItem {
item_type: "track".into(),
source: "qobuz".into(),
source_item_id: track_id.to_string(),
title: track.title.clone(),
subtitle: (!subtitle.is_empty()).then_some(subtitle),
artwork_url,
year: None,
track_count: None,
}
}
Err(e) => {
log::warn!(
"[qbz-slint] add-to-mixtape: get_track {track_id} failed: {e}"
);
return;
}
};
open_add_to_mixtape(weak, handle2, vec![item]);
});
}
}
("track", "share-qobuz") => {
share::copy_to_clipboard(share::qobuz_track_url(&id));
log::info!("[qbz-slint] copied Qobuz link for track {id}");
@@ -5619,6 +5938,37 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
}
{
// Add the whole local/Plex album to a Mixtape/Collection. Builds the
// `album` payload (source "local", no artwork_url — 1:1 PSD) from the
// LocalAlbumState header + the current version's track count.
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<LocalAlbumActions>().on_add_to_mixtape(move || {
if let Some(w) = weak.upgrade() {
let st = w.global::<LocalAlbumState>();
let id = st.get_id().to_string();
if id.is_empty() {
return;
}
let tracks = local_library::current_album_version_tracks(&w);
let item = myqbz_add::AddItem {
item_type: "album".into(),
source: "local".into(),
source_item_id: id,
title: st.get_title().to_string(),
subtitle: {
let a = st.get_artist().to_string();
(!a.is_empty()).then_some(a)
},
artwork_url: None,
year: None,
track_count: (!tracks.is_empty()).then_some(tracks.len() as i32),
};
open_add_to_mixtape(weak.clone(), handle.clone(), vec![item]);
}
});
}
{
let weak = window.as_weak();
window.global::<LocalAlbumActions>().on_select_version(move |i| {
@@ -5913,6 +6263,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
}
"add-to-mixtape" => {
// All selected tracks (Plex INCLUDED — Plex rows are
// stored as source "local" in the mixtape contract).
let rows = local_library::selected_local_tracks(&w);
let items = myqbz_add::track_items_from_local(&rows);
if !items.is_empty() {
open_add_to_mixtape(weak.clone(), handle.clone(), items);
local_library::clear_tracks_selection(&w);
}
}
_ => {}
}
});
@@ -6011,6 +6371,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
}
"add-to-mixtape" => {
// All selected tracks (Plex included — stored as "local").
let rows = local_library::tree_selected_snapshot();
let items = myqbz_add::track_items_from_local(&rows);
if !items.is_empty() {
open_add_to_mixtape(weak.clone(), handle.clone(), items);
local_library::tree_clear_selection(&w);
}
}
_ => {}
}
});
+375
View File
@@ -0,0 +1,375 @@
//! "Add to Mixtape/Collection" controller — the Rust side of the global
//! `AddToMixtapeModal` (spec 21/50). Every app-wide "Add to Mixtape/Collection"
//! trigger builds an [`AddItem`] payload (or a batch) and calls [`open`]; this
//! module holds the pending items in a process-global, loads the picker list
//! (kind-restricted + recency-sorted + `item_exists`-resolved), and on pick /
//! create-and-add writes the items into the chosen collection via the shared
//! `qbz_mixtape::repo` (reached through `crate::library_db::with_db` +
//! `with_connection` — no Tauri command wrappers, ADR-005/006).
//!
//! Dedup is the backend's job: `add_item_with(allow_duplicate=false)` returns
//! `false` for an exact `(collection_id, source, source_item_id)` duplicate
//! (not an error). We count `added` vs `skipped` and surface the outcome via a
//! toast ("Added N to {name}" / "Already in {name}"), mirroring Tauri's
//! `toastBatchResult` + the dup flow's net result.
use std::sync::{LazyLock, Mutex};
use qbz_models::mixtape::{AlbumSource, CollectionKind, ItemType, MixtapeCollection};
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::{AppWindow, MyQbzAddRow, MyQbzAddState, ToastKind};
/// One pending item to add. Built by each callsite from its row/album/playlist
/// data (spec 50 §0.2). `source_item_id` is ALWAYS a string (numeric track ids
/// are stringified by the caller).
#[derive(Clone)]
pub struct AddItem {
/// "album" | "track" | "playlist".
pub item_type: String,
/// "qobuz" | "local" (Plex rows pass "local" — there is no "plex" source).
pub source: String,
pub source_item_id: String,
pub title: String,
pub subtitle: Option<String>,
pub artwork_url: Option<String>,
pub year: Option<i32>,
pub track_count: Option<i32>,
}
/// Pending items for the currently-open picker. Set by [`open`], read by the
/// add/create handlers. Cleared on close.
static PENDING: LazyLock<Mutex<Vec<AddItem>>> = LazyLock::new(|| Mutex::new(Vec::new()));
fn pending_snapshot() -> Vec<AddItem> {
PENDING.lock().map(|p| p.clone()).unwrap_or_default()
}
// ──────────────────────────── enum mapping ────────────────────────────
fn item_type_from_str(s: &str) -> ItemType {
match s {
"track" => ItemType::Track,
"playlist" => ItemType::Playlist,
_ => ItemType::Album,
}
}
fn source_from_str(s: &str) -> AlbumSource {
match s {
"local" => AlbumSource::Local,
_ => AlbumSource::Qobuz,
}
}
// ──────────────────────────── open / payload ──────────────────────────
/// Open the picker for one or more items. Empty input is a no-op (mirrors
/// `openAddToMixtape([])`). Stores the payload, computes the header strings +
/// kind-restriction, marks loading, and shows the modal. UI thread.
///
/// The caller is responsible for spawning the row load afterwards (it needs the
/// tokio handle + a worker thread for the DB read); the wiring in `main.rs`
/// does `open(...)` then spawns [`load_rows`] → [`apply_rows`].
pub fn open(window: &AppWindow, items: Vec<AddItem>) {
if items.is_empty() {
return;
}
let bulk = items.len() > 1;
// restrict to mixtapes if ANY pending item is a track/playlist (collections
// hold whole albums only).
let restrict = items.iter().any(|it| it.item_type != "album");
let first = &items[0];
let header_title: String = if bulk {
format!("{} items", items.len())
} else {
first.title.clone()
};
let header_subtitle: String = if bulk {
// "{first title} + N more" — hardcoded English suffix (1:1 with PSD).
format!("{} + {} more", first.title, items.len() - 1)
} else {
first.subtitle.clone().unwrap_or_default()
};
if let Ok(mut p) = PENDING.lock() {
*p = items;
}
let state = window.global::<MyQbzAddState>();
state.set_rows(ModelRc::new(VecModel::from(Vec::<MyQbzAddRow>::new())));
state.set_header_title(header_title.into());
state.set_header_subtitle(header_subtitle.into());
state.set_bulk_mode(bulk);
state.set_restrict_to_mixtape(restrict);
state.set_search("".into());
state.set_busy_id("".into());
state.set_creating(false);
state.set_create_name("".into());
state.set_create_kind("mixtape".into());
state.set_loading(true);
state.set_open(true);
}
/// Close the picker + clear the pending payload. UI thread.
pub fn close(window: &AppWindow) {
if let Ok(mut p) = PENDING.lock() {
p.clear();
}
let state = window.global::<MyQbzAddState>();
state.set_open(false);
state.set_creating(false);
state.set_busy_id("".into());
}
// ──────────────────────────── row loading ─────────────────────────────
/// A loaded picker row (the collection + whether it already contains every
/// pending item). Built on a worker thread by [`load_rows`].
pub struct LoadedRow {
pub id: String,
pub name: String,
pub kind: CollectionKind,
pub item_count: usize,
/// True when EVERY pending item already exists in this collection.
pub already_has: bool,
}
/// Load the collections offered as targets, kind-restricted + recency-sorted +
/// `item_exists`-resolved. Blocking (DB) — run on a worker thread.
///
/// - `restrict_to_mixtape` → only `kind == mixtape` (excludes collections AND
/// artist_collections, the latter never a user target).
/// - sort = `last_played_at ?? updated_at` DESC (most-recently-played, then
/// most-recently-updated), matching `sortedCollections` in the PSD.
/// - `already_has` = every pending item's `(source, source_item_id)` already in
/// the collection (so the row can show an "already added" hint).
pub fn load_rows(restrict_to_mixtape: bool, items: &[AddItem]) -> Vec<LoadedRow> {
crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| {
let mut cols: Vec<MixtapeCollection> =
qbz_mixtape::repo::list_collections(conn, None).unwrap_or_else(|e| {
log::warn!("[qbz-slint] myqbz_add list_collections failed: {e}");
Vec::new()
});
// Kind restriction.
if restrict_to_mixtape {
cols.retain(|c| c.kind == CollectionKind::Mixtape);
} else {
// Albums can land in a Mixtape or a Collection, but NEVER an
// auto-generated ArtistCollection.
cols.retain(|c| c.kind != CollectionKind::ArtistCollection);
}
// Sort by last_played_at ?? updated_at DESC.
cols.sort_by(|a, b| {
let ra = a.last_played_at.unwrap_or(a.updated_at);
let rb = b.last_played_at.unwrap_or(b.updated_at);
rb.cmp(&ra)
});
cols.into_iter()
.map(|c| {
// already_has = every pending item is already present.
let already_has = !items.is_empty()
&& items.iter().all(|it| {
qbz_mixtape::repo::item_exists(
conn,
&c.id,
source_from_str(&it.source),
&it.source_item_id,
)
.unwrap_or(false)
});
LoadedRow {
id: c.id,
name: c.name,
kind: c.kind,
item_count: c.items.len(),
already_has,
}
})
.collect::<Vec<_>>()
}))
})
.unwrap_or_default()
}
fn kind_icon(kind: CollectionKind) -> &'static str {
match kind {
CollectionKind::Mixtape => "cassette",
CollectionKind::ArtistCollection => "user",
CollectionKind::Collection => "library-big",
}
}
fn kind_label(kind: CollectionKind) -> &'static str {
match kind {
CollectionKind::Mixtape => "MIXTAPE",
CollectionKind::Collection => "COLLECTION",
CollectionKind::ArtistCollection => "ARTIST",
}
}
/// "N albums" / "1 album" (always "album(s)" regardless of item_type — 1:1 PSD).
fn album_count_label(count: usize) -> String {
if count == 1 {
"1 album".to_string()
} else {
format!("{count} albums")
}
}
/// Render loaded rows into `MyQbzAddState`, applying the active search filter.
/// UI thread.
pub fn apply_rows(window: &AppWindow, rows: Vec<LoadedRow>) {
// Stash so a later search re-filters without a DB refetch.
if let Ok(mut c) = ROWS_CACHE.lock() {
*c = rows;
}
rebuild(window);
window.global::<MyQbzAddState>().set_loading(false);
}
/// Last-loaded rows (so search filters client-side, no refetch).
static ROWS_CACHE: LazyLock<Mutex<Vec<LoadedRow>>> = LazyLock::new(|| Mutex::new(Vec::new()));
/// Rebuild the visible row model from the cache honoring the search filter.
pub fn rebuild(window: &AppWindow) {
let state = window.global::<MyQbzAddState>();
let query = state.get_search().trim().to_lowercase();
let cache = ROWS_CACHE.lock();
let items: Vec<MyQbzAddRow> = cache
.as_ref()
.map(|rows| {
rows.iter()
.filter(|r| query.is_empty() || r.name.to_lowercase().contains(&query))
.map(|r| MyQbzAddRow {
id: r.id.clone().into(),
name: r.name.clone().into(),
kind: match r.kind {
CollectionKind::Mixtape => "mixtape",
CollectionKind::Collection => "collection",
CollectionKind::ArtistCollection => "artist_collection",
}
.into(),
icon: kind_icon(r.kind).into(),
kind_label: kind_label(r.kind).into(),
meta: album_count_label(r.item_count).into(),
already_has: r.already_has,
})
.collect()
})
.unwrap_or_default();
state.set_rows(ModelRc::new(VecModel::from(items)));
}
// ──────────────────────────── add / create ────────────────────────────
/// Result of a batch insert: how many were inserted and how many were skipped
/// as duplicates.
pub struct AddOutcome {
pub added: usize,
pub skipped: usize,
}
/// Insert every pending item into `collection_id` with `allow_duplicate=false`.
/// Blocking (DB). Returns the added/skipped tally (a `false` return from the
/// repo = a dedup-rejected duplicate, NOT an error).
pub fn add_items(collection_id: &str, items: &[AddItem]) -> AddOutcome {
let mut added = 0usize;
let mut skipped = 0usize;
crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| {
for it in items {
match qbz_mixtape::repo::add_item_with(
conn,
collection_id,
item_type_from_str(&it.item_type),
source_from_str(&it.source),
&it.source_item_id,
&it.title,
it.subtitle.as_deref(),
it.artwork_url.as_deref(),
it.year,
it.track_count,
false,
) {
Ok(true) => added += 1,
Ok(false) => skipped += 1,
Err(e) => {
log::warn!("[qbz-slint] myqbz_add add_item failed: {e}");
}
}
}
}))
});
AddOutcome { added, skipped }
}
/// Surface the add outcome via a toast (matches Tauri's `toastBatchResult` net
/// behavior). `name` is the collection name.
pub fn toast_outcome(window: &AppWindow, name: &str, outcome: &AddOutcome) {
let msg = if outcome.added == 0 {
// Nothing inserted -> everything was a duplicate ("Already in {name}").
format!("Already in {name}")
} else if outcome.skipped > 0 {
format!(
"Added {} to {name} ({} duplicate(s) skipped)",
outcome.added, outcome.skipped
)
} else {
format!("Added {} to {name}", outcome.added)
};
let kind = if outcome.added == 0 {
ToastKind::Info
} else {
ToastKind::Success
};
crate::toast::show(window, msg, kind);
}
/// Take a snapshot of the pending items (clone). Used by the action handlers in
/// `main.rs` to hand the payload to a blocking worker.
pub fn take_pending() -> Vec<AddItem> {
pending_snapshot()
}
/// Build `track` payloads from a batch of LocalTracks (source "local" — Plex
/// rows included, per spec 50: there is no "plex" source). Subtitle =
/// "artist · album"; no artwork_url / year / track_count (1:1 PSD §R).
pub fn track_items_from_local(tracks: &[qbz_library::LocalTrack]) -> Vec<AddItem> {
tracks
.iter()
.map(|t| {
let subtitle = [t.artist.clone(), t.album.clone()]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" · ");
AddItem {
item_type: "track".into(),
source: "local".into(),
source_item_id: t.id.to_string(),
title: t.title.clone(),
subtitle: (!subtitle.is_empty()).then_some(subtitle),
artwork_url: None,
year: None,
track_count: None,
}
})
.collect()
}
/// Create a new manual collection of `kind` named `name`, returning
/// `(id, name)` on success. Blocking (DB). `kind` is "mixtape" | "collection".
pub fn create_collection(kind: &str, name: &str) -> Option<(String, String)> {
let kind = match kind {
"collection" => CollectionKind::Collection,
_ => CollectionKind::Mixtape,
};
crate::myqbz::create_collection(kind, name).map(|c| (c.id, c.name))
}