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:
7 files changed
+1012
-191
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user