feat(slint): mixed playlists - sidecar interleave in the Qobuz detail (carrete)

The online Qobuz playlist detail now reads the local/plex sidecar tables
and renders the merged list with Tauri's exact absolute-slot interleave
(sidecar rows claim stored positions, Qobuz fills in server order, gaps
collapse, leftovers append, contiguous display numbers), via a shared
reader extracted from the D11 offline branch - online and offline render
identical row identities. Fix-forward over Tauri: same-slot collisions
emit all claimants local-first instead of silently dropping rows, and the
real plex glyph renders (T4).

Source-aware play/shuffle route mixed lists through the merged snapshot
with a Qobuz-only playback context; the QConnect all-or-nothing admission
path was verified untouched. Sidecar adds now position at the merged end
via MAX(position)+1 across both tables (fixes the live 0-based collision
writes from the picker/drag) with on-load collision healing (stable claim
order, only colliding rows renumber, every move logged; 9 new tests).
Multi-select removal splits by namespace - local and plex rows hit their
sidecar removers, Qobuz rows resolve catalog ids to playlist_track_ids
before the API call (fixing the live bulk-remove bug that shipped track
ids in the ptid parameter). Custom order upgrades to (track_id, is_local)
keying with Tauri's plex-rewrite wart replicated and documented. Rows
gain per-source favorite/download gating with a grid-preserving
placeholder column.

cc
This commit is contained in:
vicrodh committed 2026-06-10 11:53:22 -06:00
1 parent 47c315251c
commit 58b464ea71
7 files changed
+1012 -191

No files matched your search

+315
View File
@@ -4368,6 +4368,159 @@ impl LibraryDatabase {
Ok(())
}
// === Sidecar position lifecycle (mixed "carrete" playlists) ===
/// Next append position for a local/Plex sidecar add to a Qobuz playlist.
///
/// Tauri's convention is `qobuz_count + sidecar_count` — append after the
/// whole merged list — but that formula re-issues positions after a
/// removal (stored positions keep their gaps while counts shrink; Tauri
/// bug T3), which collides in the absolute-slot interleave and silently
/// loses rows. The fix-forward rule is the MAX of both worlds:
///
/// `max(qobuz_count + local_count + plex_count, MAX(position) + 1)`
///
/// computed across BOTH sidecar tables, so an add always lands after the
/// merged end AND past every stored position. Batch adds take this once
/// and assign `next + i` per row.
pub fn next_playlist_sidecar_position(
&self,
qobuz_playlist_id: u64,
qobuz_track_count: u32,
) -> Result<i32, LibraryError> {
let local_count = self.get_playlist_local_track_count(qobuz_playlist_id)?;
let plex_count = self.get_playlist_plex_track_count(qobuz_playlist_id)?;
let max_pos: Option<i32> = self
.conn
.query_row(
"SELECT MAX(p) FROM (
SELECT MAX(position) AS p FROM playlist_local_tracks
WHERE qobuz_playlist_id = ?1
UNION ALL
SELECT MAX(position) AS p FROM playlist_plex_tracks
WHERE qobuz_playlist_id = ?1
)",
params![qobuz_playlist_id as i64],
|row| row.get(0),
)
.map_err(|e| {
LibraryError::Database(format!("Failed to read max sidecar position: {}", e))
})?;
let count_based = (qobuz_track_count + local_count + plex_count) as i32;
Ok(count_based.max(max_pos.map(|p| p + 1).unwrap_or(0)))
}
/// One-shot healing for sidecar position collisions (mixed playlists).
///
/// Positions are absolute slots in the merged interleave and have no
/// UNIQUE constraint; the legacy Slint picker/drag wrote them 0-based per
/// batch, and Tauri's create-and-add writes local AND plex rows 0-based
/// in parallel — both produce duplicate positions, which a Map-based
/// merge collapses (silent row loss, edges E1/E2). This walks both
/// tables in stable order (local table first, then plex; within a table
/// position ASC, added_at ASC, rowid ASC — the first claimant of a
/// contested slot keeps it, matching the merge's local-first emit) and
/// renumbers every LATER claimant into the append region:
/// `max(qobuz_track_count + sidecar_count, MAX(position) + 1)` onward.
///
/// Non-colliding rows are NEVER touched — drift is normal (edge E7);
/// this is collision repair, not renormalization. Returns one
/// "kind ref: old -> new" description per moved row for the caller to
/// log; empty = nothing healed. Idempotent.
pub fn heal_playlist_sidecar_positions(
&self,
qobuz_playlist_id: u64,
qobuz_track_count: u32,
) -> Result<Vec<String>, LibraryError> {
// (kind, rowid, ref-description, position) in stable claim order.
let mut rows: Vec<(&'static str, i64, String, i32)> = Vec::new();
{
let mut stmt = self
.conn
.prepare(
"SELECT id, local_track_id, position FROM playlist_local_tracks
WHERE qobuz_playlist_id = ?1
ORDER BY position ASC, added_at ASC, id ASC",
)
.map_err(|e| {
LibraryError::Database(format!("Failed to prepare heal query: {}", e))
})?;
let mapped = stmt
.query_map(params![qobuz_playlist_id as i64], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i32>(2)?,
))
})
.map_err(|e| LibraryError::Database(format!("Failed to query heal rows: {}", e)))?;
for r in mapped {
let (rowid, track, pos) = r.map_err(|e| {
LibraryError::Database(format!("Failed to read heal row: {}", e))
})?;
rows.push(("local", rowid, track.to_string(), pos));
}
}
{
let mut stmt = self
.conn
.prepare(
"SELECT id, plex_rating_key, position FROM playlist_plex_tracks
WHERE qobuz_playlist_id = ?1
ORDER BY position ASC, added_at ASC, id ASC",
)
.map_err(|e| {
LibraryError::Database(format!("Failed to prepare heal query: {}", e))
})?;
let mapped = stmt
.query_map(params![qobuz_playlist_id as i64], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i32>(2)?,
))
})
.map_err(|e| LibraryError::Database(format!("Failed to query heal rows: {}", e)))?;
for r in mapped {
let (rowid, key, pos) = r.map_err(|e| {
LibraryError::Database(format!("Failed to read heal row: {}", e))
})?;
rows.push(("plex", rowid, key, pos));
}
}
if rows.is_empty() {
return Ok(Vec::new());
}
let max_pos = rows.iter().map(|r| r.3).max().unwrap_or(-1);
let sidecar_total = rows.len();
let mut seen = std::collections::HashSet::new();
let mut moves: Vec<(&'static str, i64, String, i32)> = Vec::new();
for row in rows {
if !seen.insert(row.3) {
moves.push(row);
}
}
if moves.is_empty() {
return Ok(Vec::new());
}
let mut next =
((qobuz_track_count as i32) + sidecar_total as i32).max(max_pos + 1);
let mut healed = Vec::with_capacity(moves.len());
for (kind, rowid, reference, old) in moves {
let sql = if kind == "local" {
"UPDATE playlist_local_tracks SET position = ?1 WHERE id = ?2"
} else {
"UPDATE playlist_plex_tracks SET position = ?1 WHERE id = ?2"
};
self.conn.execute(sql, params![next, rowid]).map_err(|e| {
LibraryError::Database(format!("Failed to heal sidecar position: {}", e))
})?;
healed.push(format!("{kind} {reference}: {old} -> {next}"));
next += 1;
}
Ok(healed)
}
// === Playlist Custom Track Order ===
/// Get custom track order for a playlist
@@ -6134,3 +6287,165 @@ mod folder_tree_tests {
);
}
}
#[cfg(test)]
mod sidecar_position_tests {
use super::*;
use tempfile::TempDir;
fn fresh_db() -> (TempDir, LibraryDatabase) {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("library.db");
let db = LibraryDatabase::open(&path).unwrap();
(tmp, db)
}
/// Real `local_tracks` rows for the FK on `playlist_local_tracks`.
/// Returns the library row ids in insertion order.
fn seed_local_tracks(db: &LibraryDatabase, count: usize) -> Vec<i64> {
(0..count)
.map(|i| {
let mut t = LocalTrack::default();
t.file_path = format!("/t/track{i}.flac");
t.title = format!("T{i}");
t.artist = "A".into();
t.album = "B".into();
db.insert_track(&t).unwrap()
})
.collect()
}
fn local_positions(db: &LibraryDatabase, pid: u64) -> Vec<(i64, i32)> {
let mut stmt = db
.conn
.prepare(
"SELECT local_track_id, position FROM playlist_local_tracks
WHERE qobuz_playlist_id = ?1 ORDER BY local_track_id ASC",
)
.unwrap();
stmt.query_map(params![pid as i64], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
}
fn plex_positions(db: &LibraryDatabase, pid: u64) -> Vec<(String, i32)> {
let mut stmt = db
.conn
.prepare(
"SELECT plex_rating_key, position FROM playlist_plex_tracks
WHERE qobuz_playlist_id = ?1 ORDER BY plex_rating_key ASC",
)
.unwrap();
stmt.query_map(params![pid as i64], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
}
#[test]
fn next_position_empty_sidecar_appends_after_qobuz_block() {
let (_tmp, db) = fresh_db();
assert_eq!(db.next_playlist_sidecar_position(7, 50).unwrap(), 50);
assert_eq!(db.next_playlist_sidecar_position(7, 0).unwrap(), 0);
}
#[test]
fn next_position_dense_positions_match_count_formula() {
let (_tmp, db) = fresh_db();
let ids = seed_local_tracks(&db, 1);
db.add_local_track_to_playlist(7, ids[0], 50).unwrap();
db.add_plex_track_to_playlist(7, "k1", 51).unwrap();
// count-based 50+1+1 == max+1 == 52.
assert_eq!(db.next_playlist_sidecar_position(7, 50).unwrap(), 52);
}
#[test]
fn next_position_gapped_positions_clear_the_stored_max() {
let (_tmp, db) = fresh_db();
// T3 regression: positions keep gaps after removals; the count
// formula alone would re-issue 52 while 80 is still stored.
let ids = seed_local_tracks(&db, 2);
db.add_local_track_to_playlist(7, ids[0], 50).unwrap();
db.add_local_track_to_playlist(7, ids[1], 80).unwrap();
assert_eq!(db.next_playlist_sidecar_position(7, 50).unwrap(), 81);
}
#[test]
fn next_position_legacy_low_positions_fall_back_to_counts() {
let (_tmp, db) = fresh_db();
// Legacy 0-based rows: max+1 == 2, but the merged list is 52 long.
let ids = seed_local_tracks(&db, 1);
db.add_local_track_to_playlist(7, ids[0], 0).unwrap();
db.add_plex_track_to_playlist(7, "k1", 1).unwrap();
assert_eq!(db.next_playlist_sidecar_position(7, 50).unwrap(), 52);
}
#[test]
fn next_position_scoped_per_playlist() {
let (_tmp, db) = fresh_db();
let ids = seed_local_tracks(&db, 1);
db.add_local_track_to_playlist(7, ids[0], 99).unwrap();
assert_eq!(db.next_playlist_sidecar_position(8, 10).unwrap(), 10);
}
#[test]
fn heal_without_collisions_is_a_noop() {
let (_tmp, db) = fresh_db();
let ids = seed_local_tracks(&db, 2);
db.add_local_track_to_playlist(7, ids[0], 0).unwrap();
db.add_local_track_to_playlist(7, ids[1], 5).unwrap();
db.add_plex_track_to_playlist(7, "k1", 9).unwrap();
let healed = db.heal_playlist_sidecar_positions(7, 50).unwrap();
assert!(healed.is_empty(), "drift is normal (E7): {healed:?}");
assert_eq!(local_positions(&db, 7), vec![(ids[0], 0), (ids[1], 5)]);
assert_eq!(plex_positions(&db, 7), vec![("k1".into(), 9)]);
}
#[test]
fn heal_within_table_collision_moves_the_later_claimant() {
let (_tmp, db) = fresh_db();
// Two legacy 0-based batches: 0,1 then 0 again (E1).
let ids = seed_local_tracks(&db, 3);
db.add_local_track_to_playlist(7, ids[0], 0).unwrap();
db.add_local_track_to_playlist(7, ids[1], 1).unwrap();
db.add_local_track_to_playlist(7, ids[2], 0).unwrap();
let healed = db.heal_playlist_sidecar_positions(7, 10).unwrap();
assert_eq!(healed.len(), 1, "{healed:?}");
// First claimant (rowid order on the added_at tie) keeps slot 0;
// the later one moves to the append region: max(10+3, 1+1) = 13.
assert_eq!(
local_positions(&db, 7),
vec![(ids[0], 0), (ids[1], 1), (ids[2], 13)]
);
}
#[test]
fn heal_cross_table_collision_keeps_local_moves_plex() {
let (_tmp, db) = fresh_db();
// Tauri create-and-add writes local AND plex 0-based (E2).
let ids = seed_local_tracks(&db, 2);
db.add_local_track_to_playlist(7, ids[0], 0).unwrap();
db.add_local_track_to_playlist(7, ids[1], 1).unwrap();
db.add_plex_track_to_playlist(7, "k1", 0).unwrap();
db.add_plex_track_to_playlist(7, "k2", 1).unwrap();
let healed = db.heal_playlist_sidecar_positions(7, 0).unwrap();
assert_eq!(healed.len(), 2, "{healed:?}");
assert_eq!(local_positions(&db, 7), vec![(ids[0], 0), (ids[1], 1)]);
// Plex rows append: max(0+4, 1+1) = 4 onward, stable order.
assert_eq!(
plex_positions(&db, 7),
vec![("k1".into(), 4), ("k2".into(), 5)]
);
}
#[test]
fn heal_is_idempotent() {
let (_tmp, db) = fresh_db();
let ids = seed_local_tracks(&db, 2);
db.add_local_track_to_playlist(7, ids[0], 0).unwrap();
db.add_local_track_to_playlist(7, ids[1], 0).unwrap();
assert!(!db.heal_playlist_sidecar_positions(7, 5).unwrap().is_empty());
assert!(db.heal_playlist_sidecar_positions(7, 5).unwrap().is_empty());
}
}
+147 -58
View File
@@ -385,7 +385,7 @@ fn mmss(secs: u64) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
fn total_duration_label(rows: &[LoadedRow]) -> String {
pub(crate) fn total_duration_label(rows: &[LoadedRow]) -> String {
let secs: u64 = rows
.iter()
.map(|r| match &r.item {
@@ -404,6 +404,128 @@ fn total_duration_label(rows: &[LoadedRow]) -> String {
}
}
/// Read + resolve a QOBUZ playlist's SIDECAR rows (`playlist_local_tracks`
/// + `playlist_plex_tracks`) with their stored absolute positions —
/// the shared reader behind the offline mixed detail
/// ([`navigate_qobuz_offline`]) and the ONLINE mixed detail
/// (`playlist::load`). Runs the one-shot position healing first (Seam C:
/// collided slots — the legacy 0-based picker/drag writes, Tauri's
/// create-and-add parallel 0-based local+plex rows — renumber stably into
/// the append region; drift alone is never touched, E7). Plex refs resolve
/// from the Plex cache in one bulk lookup; misses render the honest
/// `Unresolved` row (E8, fix-forward from 47c31525) instead of vanishing.
/// Returned rows are local-table-first then plex, each position ASC — the
/// stable claim order the interleave's same-slot emit relies on (E1/E2).
/// Blocking — run on a worker thread.
pub fn read_sidecar_rows_blocking(
playlist_id: u64,
qobuz_track_count: u32,
include_plex: bool,
) -> Vec<LoadedRow> {
let (mut rows, plex_refs) = crate::library_db::with_db(|db| {
match db.heal_playlist_sidecar_positions(playlist_id, qobuz_track_count) {
Ok(healed) => {
for entry in &healed {
log::warn!(
"[qbz-slint] playlist {playlist_id}: healed sidecar position collision — {entry}"
);
}
}
Err(e) => {
// Healing is best-effort; the merge tolerates collisions
// (same-slot rows all emit) so reading still proceeds.
log::warn!("[qbz-slint] playlist {playlist_id}: sidecar healing failed: {e}");
}
}
let rows: Vec<LoadedRow> = db
.get_playlist_local_tracks_with_position(playlist_id)?
.into_iter()
.map(|r| LoadedRow {
position: r.playlist_position,
item: RowItem::Local(Box::new(r.track)),
})
.collect();
let plex_refs: Vec<(String, i32)> = if include_plex {
db.get_playlist_plex_tracks_with_position(playlist_id)?
} else {
Vec::new()
};
Ok((rows, plex_refs))
})
.unwrap_or_default();
if !plex_refs.is_empty() {
let keys: Vec<String> = plex_refs.iter().map(|(key, _)| key.clone()).collect();
let resolved: HashMap<String, qbz_library::LocalTrack> =
match qbz_plex::plex_cache_get_cached_tracks_by_keys(&keys) {
Ok(list) => list
.into_iter()
.map(crate::local_library::map_plex_cached_to_local_track)
.map(|t| (t.file_path.clone(), t))
.collect(),
Err(e) => {
log::warn!(
"[qbz-slint] playlist {playlist_id}: plex cache resolve failed: {e}"
);
HashMap::new()
}
};
rows.extend(plex_refs.into_iter().map(|(key, position)| LoadedRow {
position,
item: match resolved.get(&key) {
Some(track) => RowItem::Plex(Box::new(track.clone())),
None => {
log::warn!(
"[qbz-slint] playlist {playlist_id}: plex key {key:?} not in the Plex cache — rendered as unavailable"
);
RowItem::Unresolved {
kind: "plex",
reference: key,
}
}
},
}));
}
rows
}
/// Adopt the ONLINE mixed Qobuz detail's merged queue snapshot into the
/// open-detail statics this module owns (CURRENT_QUEUE / CURRENT_META /
/// ROW_POSITIONS), so `play_from_visible` / `play_all` /
/// `plex_key_for_row` / `local_picker_ref_for_row` / drag work over the
/// merged rows exactly like the LOCAL and offline details (row identity
/// E11). `offline_only` is always false here — a real Qobuz playlist never
/// stamps the D8 guard; the QConnect queue-push exclusion of the local/plex
/// rows happens per-track at admission (`QueueTrack.source`). UI thread.
pub fn set_open_mixed_snapshot(
playlist_id: &str,
queue: Vec<QueueTrack>,
positions: HashMap<String, i32>,
) {
if let Ok(mut cur) = CURRENT_QUEUE.lock() {
*cur = queue;
}
if let Ok(mut meta) = CURRENT_META.lock() {
*meta = Some((playlist_id.to_string(), false));
}
if let Ok(mut pos) = ROW_POSITIONS.lock() {
*pos = positions;
}
}
/// Clear the open-detail snapshot (pure-Qobuz detail / navigation reset) so
/// stale local rows from a previously open detail can never resolve.
pub fn clear_open_snapshot() {
if let Ok(mut cur) = CURRENT_QUEUE.lock() {
cur.clear();
}
if let Ok(mut meta) = CURRENT_META.lock() {
*meta = None;
}
if let Ok(mut pos) = ROW_POSITIONS.lock() {
pos.clear();
}
}
/// Load + resolve a local playlist off the UI thread. Qobuz rows resolve
/// via `get_tracks_batch` when online, via the offline-cache index when
/// offline (or when the batch fails); local rows via library.db by path;
@@ -864,9 +986,10 @@ fn row_item(item: &RowItem, queue: Option<&QueueTrack>) -> TrackItem {
}
/// Build the queue snapshot + display rows + id->position map for a resolved
/// row list. Shared by the LOCAL detail [`apply`] and the offline MIXED
/// detail ([`apply_qobuz_offline`]).
fn build_row_models(
/// row list. Shared by the LOCAL detail [`apply`], the offline MIXED detail
/// ([`apply_qobuz_offline`]) and the ONLINE mixed detail
/// (`playlist::apply`) — one row-identity contract for all three (E11).
pub(crate) fn build_row_models(
rows: &[LoadedRow],
) -> (Vec<QueueTrack>, Vec<TrackItem>, HashMap<String, i32>) {
let mut queue: Vec<QueueTrack> = Vec::new();
@@ -1076,64 +1199,30 @@ pub fn navigate_qobuz_offline(
== qbz_app::offline_mode::OfflineMode::InducedOffline;
let (sidecar_rows, custom_artwork_path, playable_ids, snapshot_name) =
tokio::task::spawn_blocking(move || {
let (mut rows, plex_refs, custom) = crate::library_db::with_db(|db| {
let rows: Vec<LoadedRow> = db
.get_playlist_local_tracks_with_position(playlist_id)?
.into_iter()
.map(|r| LoadedRow {
position: r.playlist_position,
item: RowItem::Local(Box::new(r.track)),
// Healing base: the best offline guess at the Qobuz block
// size (sidebar session cache, else the B7 snapshot count).
let qobuz_count = crate::sidebar::playlist_track_count(playlist_id)
.or_else(|| {
crate::playlist_snapshot::headers_blocking()
.get(&playlist_id)
.and_then(|(_, count)| *count)
})
.collect();
let plex_refs: Vec<(String, i32)> = if plex_allowed {
db.get_playlist_plex_tracks_with_position(playlist_id)?
} else {
Vec::new()
};
let custom = db
.unwrap_or(0);
// Shared sidecar reader (heals positions, resolves Plex from
// cache, honest Unresolved fallbacks). Plex rows only under
// INDUCED offline (availability rule, E13).
let mut rows =
read_sidecar_rows_blocking(playlist_id, qobuz_count, plex_allowed);
// Sidecar block in one position order (stable: local rows
// stay before plex on a tie, the merge's claim order).
rows.sort_by_key(|r| r.position);
let custom = crate::library_db::with_db(|db| {
Ok(db
.get_playlist_settings(playlist_id)?
.and_then(|s| s.custom_artwork_path)
.filter(|p| !p.is_empty());
Ok((rows, plex_refs, custom))
.filter(|p| !p.is_empty()))
})
.unwrap_or_default();
if !plex_refs.is_empty() {
// Resolve the sidecar keys from the Plex cache DB (same
// bulk getter the LOCAL detail uses) — full metadata,
// playable; misses render the honest unavailable row.
let keys: Vec<String> =
plex_refs.iter().map(|(key, _)| key.clone()).collect();
let resolved: HashMap<String, qbz_library::LocalTrack> =
match qbz_plex::plex_cache_get_cached_tracks_by_keys(&keys) {
Ok(list) => list
.into_iter()
.map(crate::local_library::map_plex_cached_to_local_track)
.map(|t| (t.file_path.clone(), t))
.collect(),
Err(e) => {
log::warn!(
"[qbz-slint] playlist {playlist_id}: plex cache resolve failed: {e}"
);
HashMap::new()
}
};
rows.extend(plex_refs.into_iter().map(|(key, position)| LoadedRow {
position,
item: match resolved.get(&key) {
Some(track) => RowItem::Plex(Box::new(track.clone())),
None => {
log::warn!(
"[qbz-slint] playlist {playlist_id}: plex key {key:?} not in the Plex cache — rendered as unavailable"
);
RowItem::Unresolved {
kind: "plex",
reference: key,
}
}
},
}));
rows.sort_by_key(|r| r.position);
}
.flatten();
(
rows,
custom,
+191 -48
View File
@@ -1592,15 +1592,98 @@ fn navigate_playlist(
w.global::<NavState>().set_view(ContentView::Playlist);
});
if let Some(data) = playlist::load(&runtime, id).await {
let jobs = playlist::artwork_jobs(&data);
// Mixed rows split across loaders like the LOCAL detail:
// Qobuz rows = http covers, local sidecar rows = file paths,
// plex rows = tokenized Plex thumbs.
let (http_jobs, local_jobs, plex_jobs) = playlist::artwork_jobs(&data);
let pid = data.id.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
playlist::apply(&w, data);
let owned = sidebar::contains(&w, &pid);
w.global::<PlaylistState>().set_is_owner(owned);
});
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
if !http_jobs.is_empty() {
artwork::spawn_loads(http_jobs, weak.clone(), image_cache.clone());
}
if !local_jobs.is_empty() {
artwork::spawn_local_loads(local_jobs, weak.clone(), image_cache.clone());
}
if !plex_jobs.is_empty() {
let plex = plex_settings::get();
artwork::spawn_local_or_plex_loads(
plex_jobs,
plex.base_url,
plex.token,
weak.clone(),
image_cache.clone(),
);
}
}
});
}
/// Namespace-split removal from the ONLINE Qobuz playlist detail (Seam D):
/// Qobuz rows go to the Qobuz API as `playlist_track_id`s (resolved through
/// the loaded detail — fixing the old bulk path that shipped TRACK ids),
/// local rows to `remove_local_track_from_playlist`, plex rows to
/// `remove_plex_track_from_playlist`; then the detail reloads (re-merge).
/// The bulk bar calls this with the selection; the per-row "Remove from
/// playlist" menu entry (follow-up) calls it with a single row.
fn playlist_remove_rows(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: artwork::ImageCache,
pid: u64,
rows: Vec<playlist::SelectedRow>,
) {
// Resolve on the UI thread: ptids from the loaded Track cache, plex
// keys from the open queue snapshot.
let split = playlist::split_for_removal(&rows);
if split.playlist_track_ids.is_empty()
&& split.local_track_ids.is_empty()
&& split.plex_keys.is_empty()
{
log::warn!("[qbz-slint] playlist {pid}: nothing resolvable in the removal selection");
return;
}
handle.clone().spawn(async move {
let local_ids = split.local_track_ids;
let plex_keys = split.plex_keys;
if !local_ids.is_empty() || !plex_keys.is_empty() {
let _ = tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| {
for rid in &local_ids {
db.remove_local_track_from_playlist(pid, *rid)?;
}
for key in &plex_keys {
db.remove_plex_track_from_playlist(pid, key)?;
}
Ok(())
})
})
.await;
}
if !split.playlist_track_ids.is_empty() {
if let Err(e) = runtime
.core()
.remove_tracks_from_playlist(pid, &split.playlist_track_ids)
.await
{
log::error!("[qbz-slint] remove tracks from playlist failed: {e}");
}
}
// Reload + leave edit mode (the reload re-merges the sidecar).
let _ = weak.upgrade_in_event_loop(|w| {
playlist::set_multi_select(&w, false);
});
navigate_playlist(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
pid.to_string(),
);
});
}
@@ -4892,11 +4975,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Only consult the local-playlist queue snapshot while
// its detail is the OPEN view — a stale snapshot row id
// could collide with a genuine catalog id from a Qobuz
// surface (both are small integers).
// surface (both are small integers). The ONLINE mixed
// Qobuz detail shares the snapshot (E11), so its
// local/plex rows type their refs the same way.
let in_local_detail = w.global::<NavState>().get_view()
== ContentView::Playlist
&& (w.global::<PlaylistState>().get_is_local()
|| w.global::<PlaylistState>().get_offline_subset());
|| w.global::<PlaylistState>().get_offline_subset()
|| playlist::is_mixed());
let local_ref: Option<String> = if id.starts_with("plex:") {
// Unresolved Plex row in a playlist detail — the id
// already carries the rating key.
@@ -5297,11 +5383,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
("playlist", "play-all") => {
// LOCAL playlist detail — its own queue snapshot +
// offline-only stamp (D8); the offline sidecar view of
// a MIXED playlist (D11.a) shares that snapshot; the
// Qobuz path is unchanged below.
// a MIXED playlist (D11.a) AND the ONLINE mixed detail
// (Seam B: source-aware merged queue) share that
// snapshot; the pure-Qobuz path is unchanged below.
if let Some(w) = weak.upgrade() {
let ps = w.global::<PlaylistState>();
if ps.get_is_local() || ps.get_offline_subset() {
if ps.get_is_local()
|| ps.get_offline_subset()
|| playlist::is_mixed()
{
local_playlist::play_all(
&w,
runtime.clone(),
@@ -5321,9 +5411,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
("playlist", "shuffle") => {
// Mixed pool shuffles as ONE list, local/plex rows as
// equals (E9); the context stays the playlist id.
if let Some(w) = weak.upgrade() {
let ps = w.global::<PlaylistState>();
if ps.get_is_local() || ps.get_offline_subset() {
if ps.get_is_local()
|| ps.get_offline_subset()
|| playlist::is_mixed()
{
local_playlist::play_all(
&w,
runtime.clone(),
@@ -5445,38 +5540,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
return;
}
// QOBUZ detail (pure or mixed): split by row
// namespace — qobuz rows resolve to ptids, local
// rows to the local sidecar delete, plex rows to
// the plex sidecar delete (Seam D).
let pid = w.global::<PlaylistState>().get_id().to_string();
let ids = playlist::selected_ids(&w);
if let (Ok(pid), false) = (pid.parse::<u64>(), ids.is_empty()) {
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
let image_cache = image_cache.clone();
handle.clone().spawn(async move {
match runtime
.core()
.remove_tracks_from_playlist(pid, &ids)
.await
{
Ok(()) => {
// Reload the playlist + leave edit mode.
let _ = weak.upgrade_in_event_loop(|w| {
playlist::set_multi_select(&w, false);
});
navigate_playlist(
let rows = playlist::selected_rows(&w);
if let (Ok(pid), false) = (pid.parse::<u64>(), rows.is_empty()) {
playlist_remove_rows(
runtime.clone(),
weak.clone(),
&handle,
handle.clone(),
image_cache.clone(),
pid.to_string(),
pid,
rows,
);
}
Err(e) => log::error!(
"[qbz-slint] remove tracks from playlist failed: {e}"
),
}
});
}
}
}
("playlist", "set-artwork") => {
@@ -8487,6 +8566,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<PlaylistPickerActions>()
.on_pick(move |playlist_id| {
@@ -8559,20 +8639,51 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if refs.is_empty() {
return;
}
// Seam C: append after the whole merged list AND past
// any stored position (the old 0-based `enumerate`
// write collided slots -> silent row loss in the
// interleave). Base = the Qobuz block size from the
// sidebar's session cache; re-adding an existing ref
// MOVES it to the append slot (INSERT OR REPLACE, E4).
let qobuz_count = sidebar::playlist_track_count(pid).unwrap_or(0);
let runtime = runtime.clone();
let weak = weak.clone();
let handle2 = handle.clone();
let image_cache = image_cache.clone();
handle.spawn(async move {
let _ = tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| {
for (pos, r) in refs.iter().enumerate() {
let mut next =
db.next_playlist_sidecar_position(pid, qobuz_count)?;
for r in refs.iter() {
if let Some(key) = r.strip_prefix("plex:") {
db.add_plex_track_to_playlist(pid, key, pos as i32)?;
db.add_plex_track_to_playlist(pid, key, next)?;
next += 1;
} else if let Ok(lid) = r.parse::<i64>() {
db.add_local_track_to_playlist(pid, lid, pos as i32)?;
db.add_local_track_to_playlist(pid, lid, next)?;
next += 1;
}
}
Ok(())
})
})
.await;
// E12: the open detail re-merges so the rows show
// up immediately.
let _ = weak.clone().upgrade_in_event_loop(move |w| {
if w.global::<NavState>().get_view() == ContentView::Playlist
&& w.global::<PlaylistState>().get_id().to_string()
== pid.to_string()
{
navigate_playlist(
runtime,
weak,
&handle2,
image_cache,
pid.to_string(),
);
}
});
});
return;
}
@@ -8644,6 +8755,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window.global::<DragActions>().on_end(move || {
let Some(w) = weak.upgrade() else { return };
let ds = w.global::<DragState>();
@@ -8684,6 +8796,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
let runtime = runtime.clone();
let weak = weak.clone();
let handle2 = handle.clone();
let image_cache = image_cache.clone();
handle.spawn(async move {
let mut added = 0usize;
if !qobuz.is_empty() {
@@ -8694,18 +8809,26 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
}
if !local_rows.is_empty() || !plex.is_empty() {
let sidecar_added = !local_rows.is_empty() || !plex.is_empty();
if sidecar_added {
// Seam C: append after the merged list / past any
// stored position — never 0-based. The Qobuz block
// size includes the rows the SAME drop just added
// (the sidebar cache hasn't seen them yet).
let qobuz_count = sidebar::playlist_track_count(pid)
.unwrap_or(0)
+ qobuz.len() as u32;
let n = tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| {
for (pos, rid) in local_rows.iter().enumerate() {
db.add_local_track_to_playlist(pid, *rid, pos as i32)?;
let mut next =
db.next_playlist_sidecar_position(pid, qobuz_count)?;
for rid in local_rows.iter() {
db.add_local_track_to_playlist(pid, *rid, next)?;
next += 1;
}
for (pos, key) in plex.iter().enumerate() {
db.add_plex_track_to_playlist(
pid,
key,
(local_rows.len() + pos) as i32,
)?;
for key in plex.iter() {
db.add_plex_track_to_playlist(pid, key, next)?;
next += 1;
}
Ok(local_rows.len() + plex.len())
})
@@ -8720,6 +8843,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"[qbz-slint] dropped {added} track(s) onto playlist {pid}"
);
}
if sidecar_added {
// E12: re-merge the open detail after a sidecar
// write to the same playlist.
let _ = weak.clone().upgrade_in_event_loop(move |w| {
if w.global::<NavState>().get_view() == ContentView::Playlist
&& w.global::<PlaylistState>().get_id().to_string()
== pid.to_string()
{
navigate_playlist(
runtime,
weak,
&handle2,
image_cache,
pid.to_string(),
);
}
});
}
});
}
});
@@ -8749,11 +8890,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if field.as_str() == "custom" {
let pid = w.global::<PlaylistState>().get_id().to_string();
if let Ok(pid) = pid.parse::<u64>() {
let ids = playlist::current_track_ids();
// Seed keys carry (id, is_local) — Qobuz rows then
// local sidecar rows, plex excluded (Tauri parity).
let seed = playlist::custom_seed_keys();
let weak = weak.clone();
handle.spawn(async move {
let orders = tokio::task::spawn_blocking(move || {
playlist::load_or_init_custom(pid, ids)
playlist::load_or_init_custom(pid, seed)
})
.await
.unwrap_or_default();
+8 -1
View File
@@ -2047,9 +2047,16 @@ pub fn play_track_in_context(
// LOCAL playlist detail (id "local:<uuid>") — queue from its
// own resolved snapshot + the D8 offline-only stamp. The
// offline sidecar rendering of a MIXED playlist (D11.a) plays
// from the same snapshot (its rows resolve locally).
// from the same snapshot (its rows resolve locally), and so
// does the ONLINE mixed detail (Seam B: source-aware
// QueueTracks; QConnect admission rejects the non-Qobuz rows
// per-track at push time). The now-playing context stays
// ("playlist", id) — anything Qobuz-bound that reads it
// re-resolves Qobuz membership, so sidecar rows are excluded
// from the context by construction (Tauri :1825 parity).
if window.global::<PlaylistState>().get_is_local()
|| window.global::<PlaylistState>().get_offline_subset()
|| crate::playlist::is_mixed()
{
if crate::local_playlist::play_from_visible(
window,
+326 -83
View File
@@ -6,6 +6,7 @@
//! play, and an artwork-jobs pass resolves the row covers + header
//! cover off-thread.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, Mutex};
use qbz_app::shell::AppRuntime;
@@ -14,11 +15,26 @@ use qbz_models::Track;
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::artwork::{ArtworkJob, ArtworkTarget};
use crate::local_playlist::{LoadedRow, RowItem};
use crate::{AppWindow, PlaylistState, TrackItem};
/// The currently-loaded playlist tracks, for play-all / per-track play.
/// The currently-loaded playlist's QOBUZ tracks (server order), for
/// play-all / per-track play of pure-Qobuz details AND for resolving a
/// catalog id to its `playlist_track_id` (removal) — the full `Track`
/// keeps what the `TrackItem` row model drops.
static CURRENT: LazyLock<Mutex<Vec<Track>>> = LazyLock::new(|| Mutex::new(Vec::new()));
/// True while the open ONLINE Qobuz detail carries sidecar (local/Plex)
/// rows — the play/shuffle/per-row-play paths then route through
/// `local_playlist`'s merged queue snapshot instead of the Qobuz-only
/// `CURRENT` cache. Set in `apply`, cleared in `reset`.
static MIXED: AtomicBool = AtomicBool::new(false);
/// Whether the open ONLINE Qobuz detail is a mixed ("carrete") playlist.
pub fn is_mixed() -> bool {
MIXED.load(Ordering::Relaxed)
}
thread_local! {
/// The full, original-order row list — the canonical source the
/// search + sort derive the visible list from. UI thread only.
@@ -28,12 +44,33 @@ thread_local! {
/// Active sort: (field, ascending). field "default" = playlist order.
static SORT: std::cell::RefCell<(String, bool)> =
std::cell::RefCell::new(("default".to_string(), true));
/// Custom order positions: track id -> position. Empty until the
/// custom sort is entered (loaded/initialized from library.db).
static CUSTOM_ORDER: std::cell::RefCell<std::collections::HashMap<u64, i32>> =
/// Custom order positions keyed `(track id, is_local)` — the same
/// keying `playlist_track_custom_order` uses (Seam E), so local/plex
/// rows of a mixed playlist can hold an order without colliding with
/// Qobuz catalog ids. Empty until the custom sort is entered
/// (loaded/initialized from library.db).
static CUSTOM_ORDER: std::cell::RefCell<std::collections::HashMap<(u64, bool), i32>> =
std::cell::RefCell::new(std::collections::HashMap::new());
}
/// The `(track_id, is_local)` custom-order key of a row, derived from the
/// row's source the way Tauri derives it from `isLocal` (§1.3):
/// - Qobuz rows -> `(catalog id, false)`.
/// - Local sidecar rows -> `(library row id, true)` — same value Tauri
/// stores (`local_tracks.id`, `is_local=1`).
/// - Plex rows -> `(synthetic display id, true)` — Tauri's abs(display
/// id):true wart replicated for table compatibility (E5); note the two
/// frontends derive DIFFERENT display ids for the same rating key, so
/// plex entries are read tolerantly and simply fall to the end when
/// unmapped (E6, Slint end-of-list rule).
/// - Rows without a stable numeric id (`plex:<key>` unresolved,
/// `file:`/`broken:` fallbacks) -> None: excluded from the order,
/// sorted to the end.
fn custom_key(item: &TrackItem) -> Option<(u64, bool)> {
let is_local = matches!(item.source.as_str(), "local" | "plex");
item.id.parse::<u64>().ok().map(|id| (id, is_local))
}
/// "m:ss" / "h:mm:ss" -> seconds, for duration sorting.
fn duration_secs(s: &str) -> u32 {
s.split(':')
@@ -59,12 +96,15 @@ fn refresh_view(window: &AppWindow) {
.collect()
});
if field == "custom" {
// Order by the local custom positions; tracks not yet in the
// map fall back to their natural index (newly-added tracks).
// Order by the local custom positions; rows not in the map sort
// to the END in their natural relative order (E6 — deliberate
// Slint rule; Tauri's addedIndex-0 fallback floats them to the
// top by accident).
let order = CUSTOM_ORDER.with(|c| c.borrow().clone());
view.sort_by_key(|t| {
let id = t.id.parse::<u64>().unwrap_or(0);
order.get(&id).copied().unwrap_or(i32::MAX)
custom_key(t)
.and_then(|k| order.get(&k).copied())
.unwrap_or(i32::MAX)
});
} else if field != "default" {
view.sort_by(|a, b| {
@@ -143,20 +183,35 @@ pub fn set_sort(window: &AppWindow, field: &str) {
// ==================== Custom (manual) order ====================
/// The current full track ids in natural (playlist) order — used to
/// seed the custom order on first entry.
pub fn current_track_ids() -> Vec<u64> {
/// Custom-order SEED keys on first entry: Qobuz rows in natural order,
/// then LOCAL sidecar rows — Tauri parity (§1.3
/// `initCustomOrderFromCurrentTracks` covers `tracks` + `localTracks`
/// only; plex rows are NOT seeded — they only enter the table through an
/// explicit reorder write). Offline-copy sidecar rows render source
/// "qobuz" with the REAL catalog id here (Slint's queue-id row identity),
/// so they seed as Qobuz keys — a documented divergence from Tauri's
/// `(local_tracks.id, 1)`; unmapped rows just sort to the end (E6).
pub fn custom_seed_keys() -> Vec<(i64, bool)> {
FULL_ITEMS.with(|cell| {
cell.borrow()
.iter()
.filter_map(|t| t.id.parse::<u64>().ok())
.collect()
let items = cell.borrow();
let mut out: Vec<(i64, bool)> = Vec::new();
for item in items.iter().filter(|t| t.source.as_str() == "qobuz") {
if let Ok(id) = item.id.parse::<i64>() {
out.push((id, false));
}
}
for item in items.iter().filter(|t| t.source.as_str() == "local") {
if let Ok(id) = item.id.parse::<i64>() {
out.push((id, true));
}
}
out
})
}
/// The FULL (unfiltered, natural-order) row ids as strings. The LOCAL
/// detail's reorder works over these — its Plex rows (`plex:<key>`) don't
/// parse as u64, so `current_track_ids` can't serve it. UI thread.
/// parse as u64, so the keyed custom-order helpers can't serve it. UI thread.
pub fn full_item_ids() -> Vec<String> {
FULL_ITEMS.with(|cell| cell.borrow().iter().map(|t| t.id.to_string()).collect())
}
@@ -176,37 +231,45 @@ pub fn swap_full_items(window: &AppWindow, a: usize, b: usize) {
}
/// Load the playlist's custom order from library.db, seeding it from
/// `current_ids` if none exists. Blocking — run on a worker thread.
pub fn load_or_init_custom(playlist_id: u64, current_ids: Vec<u64>) -> Vec<(u64, i32)> {
/// `seed` keys (see [`custom_seed_keys`]) if none exists. Returns
/// `((track_id, is_local), position)` rows — `is_local` is kept (Seam E;
/// the old reader dropped it, which collides once mixed rows exist).
/// Blocking — run on a worker thread.
pub fn load_or_init_custom(
playlist_id: u64,
seed: Vec<(i64, bool)>,
) -> Vec<((u64, bool), i32)> {
crate::library_db::with_db(|db| {
let has = db.has_playlist_custom_order(playlist_id)?;
if !has {
let seed: Vec<(i64, bool)> = current_ids.iter().map(|&id| (id as i64, false)).collect();
db.init_playlist_custom_order(playlist_id, &seed)?;
}
db.get_playlist_custom_order(playlist_id)
})
.unwrap_or_default()
.into_iter()
.map(|(id, _is_local, pos)| (id as u64, pos))
.map(|(id, is_local, pos)| ((id as u64, is_local), pos))
.collect()
}
/// Persist the full custom order (DELETE + INSERT — self-healing).
/// Blocking.
pub fn persist_custom(playlist_id: u64, orders: Vec<(u64, i32)>) {
let rows: Vec<(i64, bool, i32)> =
orders.into_iter().map(|(id, pos)| (id as i64, false, pos)).collect();
/// Persist the full custom order (DELETE + INSERT — self-healing),
/// `is_local` per row (Seam E — bidirectionally compatible with Tauri's
/// `playlist_track_custom_order`). Blocking.
pub fn persist_custom(playlist_id: u64, orders: Vec<(u64, bool, i32)>) {
let rows: Vec<(i64, bool, i32)> = orders
.into_iter()
.map(|(id, is_local, pos)| (id as i64, is_local, pos))
.collect();
crate::library_db::with_db(|db| db.set_playlist_custom_order(playlist_id, &rows));
}
/// Store a freshly-loaded custom order + re-render. UI thread.
pub fn apply_custom_order(window: &AppWindow, orders: Vec<(u64, i32)>) {
pub fn apply_custom_order(window: &AppWindow, orders: Vec<((u64, bool), i32)>) {
CUSTOM_ORDER.with(|c| {
let mut m = c.borrow_mut();
m.clear();
for (id, pos) in orders {
m.insert(id, pos);
for (key, pos) in orders {
m.insert(key, pos);
}
});
refresh_view(window);
@@ -214,21 +277,26 @@ pub fn apply_custom_order(window: &AppWindow, orders: Vec<(u64, i32)>) {
/// Move a track one slot up/down in the custom order. Rebuilds the
/// whole order with clean 0..N-1 positions (self-healing), re-renders,
/// and returns the new orders to persist. UI thread.
pub fn move_track(window: &AppWindow, track_id: &str, up: bool) -> Vec<(u64, i32)> {
let Ok(target) = track_id.parse::<u64>() else {
return Vec::new();
};
// Current visible custom order of ids.
let order = CUSTOM_ORDER.with(|c| c.borrow().clone());
let mut ids: Vec<u64> = FULL_ITEMS.with(|cell| {
/// and returns the new `(id, is_local, position)` rows to persist.
/// Like Tauri's `moveTrack` rewrite, the persisted set DOES include plex
/// rows (typed is_local=1 — the E5 wart, see [`custom_key`]); rows with
/// no stable key can't participate and stay at the end. UI thread.
pub fn move_track(window: &AppWindow, track_id: &str, up: bool) -> Vec<(u64, bool, i32)> {
let target = FULL_ITEMS.with(|cell| {
cell.borrow()
.iter()
.filter_map(|t| t.id.parse::<u64>().ok())
.collect()
.find(|t| t.id.as_str() == track_id)
.and_then(custom_key)
});
ids.sort_by_key(|id| order.get(id).copied().unwrap_or(i32::MAX));
let Some(idx) = ids.iter().position(|&id| id == target) else {
let Some(target) = target else {
return Vec::new();
};
// Current visible custom order of keyed rows.
let order = CUSTOM_ORDER.with(|c| c.borrow().clone());
let mut keys: Vec<(u64, bool)> =
FULL_ITEMS.with(|cell| cell.borrow().iter().filter_map(custom_key).collect());
keys.sort_by_key(|key| order.get(key).copied().unwrap_or(i32::MAX));
let Some(idx) = keys.iter().position(|&key| key == target) else {
return Vec::new();
};
let swap = if up {
@@ -237,23 +305,23 @@ pub fn move_track(window: &AppWindow, track_id: &str, up: bool) -> Vec<(u64, i32
}
idx - 1
} else {
if idx + 1 >= ids.len() {
if idx + 1 >= keys.len() {
return Vec::new();
}
idx + 1
};
ids.swap(idx, swap);
keys.swap(idx, swap);
// Rebuild contiguous positions.
let orders: Vec<(u64, i32)> = ids
let orders: Vec<(u64, bool, i32)> = keys
.iter()
.enumerate()
.map(|(i, &id)| (id, i as i32))
.map(|(i, &(id, is_local))| (id, is_local, i as i32))
.collect();
CUSTOM_ORDER.with(|c| {
let mut m = c.borrow_mut();
m.clear();
for &(id, pos) in &orders {
m.insert(id, pos);
for &(id, is_local, pos) in &orders {
m.insert((id, is_local), pos);
}
});
refresh_view(window);
@@ -271,7 +339,65 @@ pub struct PlaylistData {
/// Local custom artwork path (from playlist_settings), if the user
/// set one — overrides the collage / server image.
pub custom_artwork_path: Option<String>,
pub tracks: Vec<Track>,
/// The MERGED row list (Qobuz tracks interleaved with the local/Plex
/// sidecar rows at their absolute slots — Seam A) in display order.
/// Pure-Qobuz playlists are simply all `RowItem::Qobuz`.
pub rows: Vec<LoadedRow>,
}
/// Tauri's absolute-slot interleave — the `displayTracks` contract
/// (spec §1.2): sidecar rows claim their STORED positions as slots in the
/// merged list; Qobuz tracks fill the remaining slots in server order;
/// `total = max(sum of rows, max stored position + 1)` so stale high slots
/// still render (E3); unclaimed slots with no Qobuz track left are skipped
/// (never a blank); leftover Qobuz tracks append. Same-slot collisions emit
/// ALL claimants — local first, then plex, in stable claim order — instead
/// of Tauri's Map collapse (E1/E2 fix-forward; healing repairs the stored
/// data separately). Display numbering is the emit order (contiguous).
fn interleave_rows(qobuz: Vec<Track>, sidecar: Vec<LoadedRow>) -> Vec<LoadedRow> {
let qobuz_to_row = |(i, t): (usize, Track)| LoadedRow {
position: i as i32,
item: RowItem::Qobuz(Box::new(t)),
};
if sidecar.is_empty() {
return qobuz.into_iter().enumerate().map(qobuz_to_row).collect();
}
let sidecar_len = sidecar.len();
let mut max_pos: i32 = -1;
let mut buckets: std::collections::HashMap<i32, Vec<LoadedRow>> =
std::collections::HashMap::new();
for row in sidecar {
// Corrupt negative positions claim slot 0 rather than vanishing.
let pos = row.position.max(0);
max_pos = max_pos.max(pos);
buckets.entry(pos).or_default().push(row);
}
let total = (qobuz.len() + sidecar_len).max((max_pos + 1) as usize);
let mut out: Vec<LoadedRow> = Vec::with_capacity(qobuz.len() + sidecar_len);
let mut qobuz_iter = qobuz.into_iter();
for pos in 0..total as i32 {
if let Some(rows) = buckets.remove(&pos) {
out.extend(rows);
} else if let Some(track) = qobuz_iter.next() {
out.push(LoadedRow {
position: pos,
item: RowItem::Qobuz(Box::new(track)),
});
}
// else: an unclaimed slot past the Qobuz tracks — a gap, skipped.
}
for track in qobuz_iter {
out.push(LoadedRow {
position: 0,
item: RowItem::Qobuz(Box::new(track)),
});
}
// Positions in the merged output are the contiguous display slots; the
// stored sidecar positions did their job claiming the order.
for (i, row) in out.iter_mut().enumerate() {
row.position = i as i32;
}
out
}
pub async fn load<A>(runtime: &AppRuntime<A>, playlist_id: u64) -> Option<PlaylistData>
@@ -317,12 +443,24 @@ where
// list — full-replace the playlist's snapshot membership, detached (the
// render never waits). No-ops for playlists outside the user's listed
// set (no snapshot header), so merely-viewed public playlists stay out.
// Qobuz membership only — sidecar rows never enter the snapshot (E10).
crate::playlist_snapshot::record_detail_detached(
playlist_id,
pl.name.clone(),
pl.owner.name.clone(),
tracks.iter().map(|t| t.id).collect(),
);
// Seam A (merge-on-load): read the sidecar rows (healing + Plex cache
// resolve inside the shared reader) and interleave them with the Qobuz
// tracks at their absolute slots. Plex rows are always included online
// (availability is connectivity-based, E13).
let qobuz_count = tracks.len() as u32;
let sidecar = tokio::task::spawn_blocking(move || {
crate::local_playlist::read_sidecar_rows_blocking(playlist_id, qobuz_count, true)
})
.await
.unwrap_or_default();
let rows = interleave_rows(tracks, sidecar);
Some(PlaylistData {
id: pl.id.to_string(),
name: pl.name,
@@ -331,7 +469,7 @@ where
description_short: truncate_words(&description, 160),
cover_url,
custom_artwork_path,
tracks,
rows,
})
}
@@ -350,16 +488,6 @@ fn mmss(secs: u32) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
fn total_duration(tracks: &[Track]) -> String {
let secs: u64 = tracks.iter().map(|t| t.duration as u64).sum();
let mins = secs / 60;
if mins >= 60 {
format!("{} h {} min", mins / 60, mins % 60)
} else {
format!("{} min", mins)
}
}
pub(crate) fn to_item(track: &Track) -> TrackItem {
let mut title = track.title.clone();
if let Some(v) = track.version.as_ref().filter(|v| !v.is_empty()) {
@@ -430,6 +558,10 @@ pub fn reset(window: &AppWindow) {
QUERY.with(|q| q.borrow_mut().clear());
SORT.with(|s| *s.borrow_mut() = ("default".to_string(), true));
CUSTOM_ORDER.with(|c| c.borrow_mut().clear());
MIXED.store(false, Ordering::Relaxed);
// Drop the previous detail's queue snapshot — the local/offline/mixed
// applies repopulate it after this shared reset.
crate::local_playlist::clear_open_snapshot();
let state = window.global::<PlaylistState>();
state.set_tracks(ModelRc::new(VecModel::from(Vec::<TrackItem>::new())));
state.set_track_count(0);
@@ -447,12 +579,36 @@ pub fn reset(window: &AppWindow) {
}
pub fn apply(window: &AppWindow, data: PlaylistData) {
let items: Vec<TrackItem> = data.tracks.iter().map(to_item).collect();
let count = data.tracks.len() as i32;
let duration = total_duration(&data.tracks);
// One row-identity contract with the LOCAL/offline details (E11):
// Qobuz rows keep catalog ids, local rows their library row id, plex
// rows their synthetic id / `plex:<key>` — built by the shared
// `build_row_models` so selection, drag, picker refs and scroll
// restore behave identically across the connectivity flip.
let (queue, items, positions) = crate::local_playlist::build_row_models(&data.rows);
let qobuz_tracks: Vec<Track> = data
.rows
.iter()
.filter_map(|r| match &r.item {
RowItem::Qobuz(track) => Some((**track).clone()),
_ => None,
})
.collect();
let mixed = data.rows.len() != qobuz_tracks.len();
// Merged header counts (Tauri shows qobuz + local + plex combined).
let count = items.len() as i32;
let duration = crate::local_playlist::total_duration_label(&data.rows);
FULL_ITEMS.with(|cell| *cell.borrow_mut() = items.clone());
if let Ok(mut cur) = CURRENT.lock() {
*cur = data.tracks;
*cur = qobuz_tracks;
}
MIXED.store(mixed, Ordering::Relaxed);
if mixed {
// Seam B: the mixed detail plays through local_playlist's queue
// snapshot (source-aware QueueTracks); pure-Qobuz details keep the
// CURRENT-cache path unchanged.
crate::local_playlist::set_open_mixed_snapshot(&data.id, queue, positions);
} else {
crate::local_playlist::clear_open_snapshot();
}
// Custom artwork overrides the collage / server image. Load the
// local file directly (it lives in the artwork cache on disk).
@@ -499,31 +655,21 @@ pub fn apply_local_items(window: &AppWindow, items: Vec<TrackItem>) {
}
/// Artwork jobs for the loaded playlist — one per row plus the header
/// cover (resolved into PlaylistState.cover).
pub fn artwork_jobs(data: &PlaylistData) -> Vec<ArtworkJob> {
let mut jobs: Vec<ArtworkJob> = data
.tracks
.iter()
.enumerate()
.filter_map(|(i, t)| {
t.album
.as_ref()
.and_then(|a| a.image.smallest().cloned())
.map(|url| ArtworkJob {
url,
target: ArtworkTarget::PlaylistTrack { index: i },
})
})
.collect();
/// cover (resolved into PlaylistState.cover). Returns (http, local-file,
/// plex) job sets: Qobuz rows carry http URLs, local sidecar rows file
/// paths, plex rows raw `/library/...` thumb paths — the same loader split
/// the LOCAL detail uses.
pub fn artwork_jobs(data: &PlaylistData) -> (Vec<ArtworkJob>, Vec<ArtworkJob>, Vec<ArtworkJob>) {
let (mut http, local, plex) = crate::local_playlist::artwork_jobs(&data.rows);
// Skip the server-cover job when a local custom artwork is set
// (it's already loaded in apply and cover_url holds a file path).
if data.custom_artwork_path.is_none() && !data.cover_url.is_empty() {
jobs.push(ArtworkJob {
http.push(ArtworkJob {
url: data.cover_url.clone(),
target: ArtworkTarget::PlaylistCover,
});
}
jobs
(http, local, plex)
}
pub fn current_tracks() -> Vec<Track> {
@@ -633,12 +779,109 @@ pub fn select_all(window: &AppWindow) {
recount_selected(window);
}
/// The ids of the currently-selected rows.
pub fn selected_ids(window: &AppWindow) -> Vec<u64> {
// ==================== Namespace-split removal (Seam D) ====================
/// A row reference for removal: the display id + the row's source — the
/// id namespace is source-dependent (catalog id / library row id /
/// synthetic plex id / `plex:<key>`). Built from the selection (bulk) or a
/// single row (the per-row "Remove from playlist" menu entry rides this
/// same seam when it lands).
pub struct SelectedRow {
pub id: String,
pub source: String,
}
/// The currently-selected rows with their sources.
pub fn selected_rows(window: &AppWindow) -> Vec<SelectedRow> {
let model = window.global::<PlaylistState>().get_tracks();
(0..model.row_count())
.filter_map(|i| model.row_data(i))
.filter(|t| t.selected)
.filter_map(|t| t.id.parse::<u64>().ok())
.map(|t| SelectedRow {
id: t.id.to_string(),
source: t.source.to_string(),
})
.collect()
}
/// The removal split of a row set, by id namespace.
#[derive(Default)]
pub struct RemovalSplit {
/// Qobuz rows resolved to `playlist_track_id`s — what the
/// `playlist/deleteTracks` API actually takes. ALL instances of a
/// selected catalog id resolve (duplicates removed together — Tauri
/// behavior).
pub playlist_track_ids: Vec<u64>,
/// Local sidecar rows: `local_tracks.id` (the row's display id).
pub local_track_ids: Vec<i64>,
/// Plex sidecar rows: rating keys (from the `plex:<key>` id of
/// unresolved rows, or recovered from the open queue snapshot for
/// resolved rows — the synthetic display id itself is useless).
pub plex_keys: Vec<String>,
}
/// Split rows for removal by id namespace (Seam D — port Tauri's intent,
/// not its T1 plex-falls-into-the-Qobuz-call bug). Qobuz catalog ids
/// resolve to `playlist_track_id` through the `CURRENT` Track cache (the
/// loaded detail keeps it there; `TrackItem` drops it) — never ship a
/// TRACK id to `remove_tracks_from_playlist` (its parameter is
/// playlist_track_ids; the old bulk path did exactly that and silently
/// failed). Call on the UI thread while the detail is open (the plex key
/// recovery reads the open snapshot).
pub fn split_for_removal(rows: &[SelectedRow]) -> RemovalSplit {
let mut split = RemovalSplit::default();
let mut qobuz_ids: Vec<u64> = Vec::new();
for row in rows {
match row.source.as_str() {
"local" => match row.id.parse::<i64>() {
Ok(rid) => split.local_track_ids.push(rid),
Err(_) => {
log::warn!("[qbz-slint] remove: unresolvable local row id {}", row.id)
}
},
"plex" => {
if let Some(key) = row.id.strip_prefix("plex:") {
split.plex_keys.push(key.to_string());
} else if let Some(key) = crate::local_playlist::plex_key_for_row(&row.id) {
split.plex_keys.push(key);
} else {
log::warn!("[qbz-slint] remove: no rating key for plex row {}", row.id);
}
}
_ => match row.id.parse::<u64>() {
Ok(tid) => qobuz_ids.push(tid),
Err(_) => {
log::warn!("[qbz-slint] remove: unresolvable row id {}", row.id)
}
},
}
}
if !qobuz_ids.is_empty() {
let id_set: std::collections::HashSet<u64> = qobuz_ids.iter().copied().collect();
let mut resolved: std::collections::HashSet<u64> = std::collections::HashSet::new();
if let Ok(cur) = CURRENT.lock() {
for track in cur.iter().filter(|t| id_set.contains(&t.id)) {
match track.playlist_track_id {
Some(ptid) => {
split.playlist_track_ids.push(ptid);
resolved.insert(track.id);
}
None => {
log::warn!(
"[qbz-slint] remove: track {} has no playlist_track_id",
track.id
);
}
}
}
}
for tid in qobuz_ids {
if !resolved.contains(&tid) {
log::warn!(
"[qbz-slint] remove: track {tid} not resolvable to a playlist_track_id — skipped"
);
}
}
}
split
}
@@ -665,7 +665,20 @@ export component PlaylistView inherits Rectangle {
index: i;
show-artwork: true;
show-album: true;
show-favorite: true;
// Mixed "carrete" rows (Tauri parity): the heart
// + offline-cloud columns are Qobuz-only
// (hideFavorite/hideDownload — a local row's id
// is a library row id, not a catalog id; wiring
// those columns to it mis-types the call).
// Local/plex rows show the per-source glyph in
// the cloud column's place instead — incl. the
// REAL Plex glyph (Tauri quirk T4 fixed
// forward). The reserved heart slot keeps one
// duration/quality grid across row types.
show-favorite: track.source == "qobuz";
reserve-favorite: track.source != "qobuz";
show-download: track.source == "qobuz";
show-source: track.source != "qobuz" && track.source != "";
// Custom sort (Qobuz sidecar order) — or, on a LOCAL
// playlist, the Default sort: its natural order IS the
// editable repo order (B2), written via repo::reorder.
@@ -33,6 +33,12 @@ export component TrackRow inherits Rectangle {
in property <bool> show-artwork: true;
in property <bool> show-album: false;
in property <bool> show-favorite: false;
// Keep the favorite column's 28px slot when the heart is hidden, so a
// surface mixing heart rows WITH heart-less rows (the playlist detail:
// Qobuz rows have it, local/plex sidecar rows don't) keeps one
// duration/quality grid — Tauri renders a placeholder for the same
// reason (TrackRow.svelte favorite-placeholder).
in property <bool> reserve-favorite: false;
// On by default: every Qobuz track row carries the offline cloud/check
// button. A non-Qobuz surface (future Local Library / Plex rows, which are
// already local) should set this false.
@@ -461,6 +467,11 @@ export component TrackRow inherits Rectangle {
}
}
}
// Heart-slot placeholder (column-grid alignment, see the
// reserve-favorite doc above).
if !root.show-favorite && root.reserve-favorite: Rectangle {
width: 28px;
}
// Offline cache — a stateful affordance reflecting cache-status:
// 0 none cloud-download (click -> cache)
// 1/2 q'd/dl'ing loader (accent) (click -> cache; re-queues)