fix(slint): plex rows resolve in local playlists; catalog-id guards

Closes the slice-4 'plex rows render basic' deferral: local-playlist plex
refs bulk-resolve from the plex cache by rating key (new qbz-plex
by-keys getter with the cached-track shape) into full-metadata rows -
real titles, duration, PlexThumb artwork, playable through the existing
plex path (induced offline allowed). Cache misses render an honest
'Unavailable Plex track' fallback and legacy garbage refs (plex synthetic
ids stored as qobuz ids by the pre-typed-drag flow) surface as removable
unavailable rows instead of being D11-hidden forever.

Still-live writers of that garbage are fixed: the shared add-to-playlist
arm routes plex:<key>/local refs through the local picker and refuses
synthetic ids with a toast; row_drag_track no longer falls through plex
rows to the Qobuz arm; and add_qobuz/add_drag blocking writers refuse ids
at or above the plex synthetic floor as a last gate. main.rs also carries
the context-menu mixtape/favorite arms from the previous commit.

cc
This commit is contained in:
vicrodh committed 2026-06-10 10:09:11 -06:00
1 parent 603a2725fb
commit 47c315251c
4 files changed
+586 -95

No files matched your search

+92
View File
@@ -1561,6 +1561,98 @@ pub fn plex_cache_search_tracks(
Ok(tracks) Ok(tracks)
} }
/// Bulk by-rating-key lookup in the `PlexCachedTrack` shape (synthetic
/// playback id + normalized album + content-hash album key — the same
/// row mapping `plex_cache_search_tracks` produces, so a row resolved
/// here is indistinguishable from a Local-Library-merged one). Used by
/// the local-playlist detail to hydrate `plex_key` membership rows.
/// Keys missing from the cache are silently omitted — the caller renders
/// those rows as explicitly unavailable.
pub fn plex_cache_get_cached_tracks_by_keys(
rating_keys: &[String],
) -> Result<Vec<PlexCachedTrack>, String> {
if rating_keys.is_empty() {
return Ok(Vec::new());
}
let conn = open_plex_cache_db()?;
let placeholders = std::iter::repeat("?")
.take(rating_keys.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT rating_key, title, artist, album, duration_ms, container, bit_depth,
sampling_rate_hz, artwork_path, track_number, disc_number, parent_rating_key
FROM plex_cache_tracks
WHERE rating_key IN ({})",
placeholders
);
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("Failed to prepare Plex cache by-keys query: {}", e))?;
let rows = stmt
.query_map(rusqlite::params_from_iter(rating_keys.iter()), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<i64>>(4)?,
row.get::<_, Option<String>>(5)?,
row.get::<_, Option<i64>>(6)?,
row.get::<_, Option<i64>>(7)?,
row.get::<_, Option<String>>(8)?,
row.get::<_, Option<i64>>(9)?,
row.get::<_, Option<i64>>(10)?,
row.get::<_, Option<String>>(11)?,
))
})
.map_err(|e| format!("Failed to query Plex cache tracks by keys: {}", e))?;
let mut tracks = Vec::new();
for row in rows {
let (
rating_key,
title,
artist_opt,
album_opt,
duration_ms_opt,
container_opt,
bit_depth_opt,
sampling_rate_opt,
artwork_path,
track_number_opt,
disc_number_opt,
parent_rating_key,
) = row.map_err(|e| format!("Failed to read Plex cache by-keys row: {}", e))?;
let artist = artist_opt
.map(|v| decode_xml_entities(v.trim()))
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "Unknown Artist".to_string());
let album_raw = album_opt
.map(|v| decode_xml_entities(v.trim()))
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "Unknown Album".to_string());
let album = normalize_album_title(Some(&artist), &album_raw);
tracks.push(PlexCachedTrack {
id: playback_track_id(&rating_key),
rating_key,
title: decode_xml_entities(title.trim()),
artist: artist.clone(),
album: album.clone(),
duration_secs: duration_ms_opt.map(|v| (v as u64) / 1000).unwrap_or(0),
format: container_opt.unwrap_or_else(|| "flac".to_string()),
bit_depth: bit_depth_opt.map(|v| v as u32),
sample_rate: sampling_rate_opt.map(|v| v as u32).unwrap_or(44100),
artwork_path,
source: "plex".to_string(),
album_key: plex_album_key(&artist, &album),
track_number: track_number_opt.map(|v| v as u32),
disc_number: disc_number_opt.map(|v| v as u32),
parent_rating_key,
});
}
Ok(tracks)
}
pub fn plex_cache_save_tracks( pub fn plex_cache_save_tracks(
server_id: Option<String>, server_id: Option<String>,
section_key: String, section_key: String,
+2 -2
View File
@@ -3197,7 +3197,7 @@ fn parse_audio_format(s: &str) -> qbz_library::AudioFormat {
/// local ids (`< 2^40`) AND of ephemeral ids (`>= 2^48 = EPHEMERAL_ID_FLOOR`), /// local ids (`< 2^40`) AND of ephemeral ids (`>= 2^48 = EPHEMERAL_ID_FLOOR`),
/// so `is_ephemeral_id` still returns false. The string rating_key is preserved /// so `is_ephemeral_id` still returns false. The string rating_key is preserved
/// separately in `file_path`, so playback resolution is unaffected. /// separately in `file_path`, so playback resolution is unaffected.
const PLEX_TRACK_ID_FLOOR: u64 = 1 << 40; pub(crate) const PLEX_TRACK_ID_FLOOR: u64 = 1 << 40;
/// Map a Plex-cache track row to the `LocalTrack` shape the album-detail view /// Map a Plex-cache track row to the `LocalTrack` shape the album-detail view
/// and the Tracks tab render. The `file_path` carries the Plex `rating_key` (the /// and the Tracks tab render. The `file_path` carries the Plex `rating_key` (the
@@ -3205,7 +3205,7 @@ const PLEX_TRACK_ID_FLOOR: u64 = 1 << 40;
/// `/library/...` thumb path (tokenized at decode time by the PlexThumb artwork /// `/library/...` thumb path (tokenized at decode time by the PlexThumb artwork
/// arm); `source` is `"plex"` so the UI classifies the row correctly. The `id` /// arm); `source` is `"plex"` so the UI classifies the row correctly. The `id`
/// is namespaced (see `PLEX_TRACK_ID_FLOOR`) to avoid colliding with local rows. /// is namespaced (see `PLEX_TRACK_ID_FLOOR`) to avoid colliding with local rows.
fn map_plex_cached_to_local_track(t: qbz_plex::PlexCachedTrack) -> qbz_library::LocalTrack { pub(crate) fn map_plex_cached_to_local_track(t: qbz_plex::PlexCachedTrack) -> qbz_library::LocalTrack {
qbz_library::LocalTrack { qbz_library::LocalTrack {
id: (PLEX_TRACK_ID_FLOOR | (t.id & (PLEX_TRACK_ID_FLOOR - 1))) as i64, id: (PLEX_TRACK_ID_FLOOR | (t.id & (PLEX_TRACK_ID_FLOOR - 1))) as i64,
file_path: t.rating_key, file_path: t.rating_key,
+271 -46
View File
@@ -115,12 +115,29 @@ pub fn set_hidden_blocking(id: &str, hidden: bool) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// Append Qobuz track ids. Returns inserted count. /// Append Qobuz track ids. Returns inserted count. Ids in the Plex
/// synthetic namespace (>= 2^40 — see `local_library::PLEX_TRACK_ID_FLOOR`)
/// are NOT Qobuz catalog ids; storing one writes a forever-unresolvable
/// row (the field garbage class), so they are refused and logged here,
/// at the last gate before the repo write.
pub fn add_qobuz_tracks_blocking(id: &str, track_ids: &[u64]) -> usize { pub fn add_qobuz_tracks_blocking(id: &str, track_ids: &[u64]) -> usize {
let entries: Vec<repo::LocalPlaylistTrackInput> = track_ids let entries: Vec<repo::LocalPlaylistTrackInput> = track_ids
.iter() .iter()
.filter(|&&tid| {
if tid >= crate::local_library::PLEX_TRACK_ID_FLOOR {
log::warn!(
"[qbz-slint] local playlist add: refused non-catalog id {tid} as a Qobuz ref"
);
false
} else {
true
}
})
.map(|&tid| repo::LocalPlaylistTrackInput::Qobuz(tid)) .map(|&tid| repo::LocalPlaylistTrackInput::Qobuz(tid))
.collect(); .collect();
if entries.is_empty() {
return 0;
}
crate::library_db::with_db(|db| { crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| repo::add_tracks(conn, id, &entries))) Ok(db.with_connection(|conn| repo::add_tracks(conn, id, &entries)))
}) })
@@ -194,6 +211,15 @@ pub fn add_drag_tracks_blocking(id: &str, tracks: &[crate::drag::DragTrack]) ->
for item in tracks { for item in tracks {
match item { match item {
crate::drag::DragTrack::Qobuz(tid) => { crate::drag::DragTrack::Qobuz(tid) => {
if *tid >= crate::local_library::PLEX_TRACK_ID_FLOOR {
// Not a catalog id (Plex synthetic namespace) — a
// mis-typed payload; refuse rather than store a
// forever-unresolvable row.
log::warn!(
"[qbz-slint] local playlist drop: refused non-catalog id {tid} as a Qobuz ref"
);
continue;
}
out.push(repo::LocalPlaylistTrackInput::Qobuz(*tid)); out.push(repo::LocalPlaylistTrackInput::Qobuz(*tid));
} }
crate::drag::DragTrack::LocalRow(rid) => { crate::drag::DragTrack::LocalRow(rid) => {
@@ -264,6 +290,37 @@ static CURRENT_META: LazyLock<Mutex<Option<(String, bool)>>> = LazyLock::new(||
static ROW_POSITIONS: LazyLock<Mutex<HashMap<String, i32>>> = static ROW_POSITIONS: LazyLock<Mutex<HashMap<String, i32>>> =
LazyLock::new(|| Mutex::new(HashMap::new())); LazyLock::new(|| Mutex::new(HashMap::new()));
/// The rating key of an open-detail row by its display id (`TrackItem.id`
/// == queue id). Resolved Plex rows carry a NUMERIC synthetic id in the
/// model; the string rating key only lives in the queue snapshot's
/// `source_item_id_hint` — this is how drag/pick paths recover it instead
/// of mis-typing the numeric id as a Qobuz catalog id.
pub fn plex_key_for_row(id: &str) -> Option<String> {
let queue = CURRENT_QUEUE.lock().ok()?;
queue
.iter()
.find(|q| q.id.to_string() == id)
.filter(|q| q.source.as_deref() == Some("plex"))
.and_then(|q| q.source_item_id_hint.clone())
}
/// Local-mode picker ref for an open-detail row id: `"plex:<key>"` for
/// resolved Plex rows, `"<library row id>"` for local file rows. `None`
/// for Qobuz/offline-copy rows (those ride the catalog-id flow) and for
/// ids not in the open snapshot.
pub fn local_picker_ref_for_row(id: &str) -> Option<String> {
let queue = CURRENT_QUEUE.lock().ok()?;
let q = queue.iter().find(|q| q.id.to_string() == id)?;
match q.source.as_deref() {
Some("plex") => q
.source_item_id_hint
.as_ref()
.map(|key| format!("plex:{key}")),
Some("local") => Some(q.id.to_string()),
_ => None,
}
}
/// One resolved, renderable row (Send — built on the worker). /// One resolved, renderable row (Send — built on the worker).
pub enum RowItem { pub enum RowItem {
/// Full catalog track (online fetch). /// Full catalog track (online fetch).
@@ -289,8 +346,25 @@ pub enum RowItem {
/// unavailable-offline QOBUZ rows, not for a file that is right there). /// unavailable-offline QOBUZ rows, not for a file that is right there).
/// Not playable until the row is back in the library index. /// Not playable until the row is back in the library index.
LocalFile { path: String }, LocalFile { path: String },
/// Plex ref — no detailed resolve in v1 (rendered basic, not playable). /// Plex ref resolved from the Plex cache DB into the same `LocalTrack`
Plex { key: String }, /// shape the LocalLibrary Tracks tab merges (synthetic 2^40-namespaced
/// id, rating key in `file_path`, source `"plex"`) — renders full
/// metadata and plays through the existing Plex playback path.
Plex(Box<qbz_library::LocalTrack>),
/// A ref that cannot resolve right now: a `plex_key` missing from the
/// Plex cache (purged / never synced / a garbage key written by an old
/// mis-typed add), or a `qobuz_track_id` outside the catalog id range
/// (the legacy untyped-drag bug stored Plex synthetic 2^40 row ids as
/// Qobuz ids). Renders an HONEST, selectable (removable) row instead
/// of hiding — D11 hiding is for genuinely-offline Qobuz rows, not for
/// refs that can never heal on their own.
Unresolved {
/// "plex" (cache miss — may heal after a resync) or "qobuz"
/// (out-of-range id — permanent garbage).
kind: &'static str,
/// The raw stored ref, shown so the user knows WHAT is broken.
reference: String,
},
} }
pub struct LoadedRow { pub struct LoadedRow {
@@ -317,9 +391,9 @@ fn total_duration_label(rows: &[LoadedRow]) -> String {
.map(|r| match &r.item { .map(|r| match &r.item {
RowItem::Qobuz(t) => t.duration as u64, RowItem::Qobuz(t) => t.duration as u64,
RowItem::Cached { duration_secs, .. } => *duration_secs, RowItem::Cached { duration_secs, .. } => *duration_secs,
RowItem::Local(t) => t.duration_secs, RowItem::Local(t) | RowItem::Plex(t) => t.duration_secs,
RowItem::LocalFile { .. } => 0, RowItem::LocalFile { .. } => 0,
RowItem::Plex { .. } => 0, RowItem::Unresolved { .. } => 0,
}) })
.sum(); .sum();
let mins = secs / 60; let mins = secs / 60;
@@ -333,10 +407,13 @@ fn total_duration_label(rows: &[LoadedRow]) -> String {
/// Load + resolve a local playlist off the UI thread. Qobuz rows resolve /// Load + resolve a local playlist off the UI thread. Qobuz rows resolve
/// via `get_tracks_batch` when online, via the offline-cache index when /// 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; /// offline (or when the batch fails); local rows via library.db by path;
/// Plex rows stay basic. Unresolvable QOBUZ rows are filtered out (D11); /// Plex rows via the Plex cache DB (bulk by rating key — full metadata,
/// a LOCAL row that misses the index still renders (filename fallback) /// playable). Unresolvable QOBUZ rows are filtered out (D11); a LOCAL row
/// while its file exists, and hides (logged distinctly) only when the /// that misses the index still renders (filename fallback) while its file
/// file itself is gone. /// exists, and hides (logged distinctly) only when the file itself is
/// gone; a Plex key the cache doesn't know — and a `qobuz_track_id` in the
/// Plex synthetic namespace (legacy mis-typed garbage) — render an honest
/// "unavailable" row the user can still select and remove.
pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistData> { pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistData> {
let id = playlist_id.to_string(); let id = playlist_id.to_string();
let (header, tracks) = tokio::task::spawn_blocking({ let (header, tracks) = tokio::task::spawn_blocking({
@@ -400,6 +477,34 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
} }
} }
// Plex rows: ONE bulk cache lookup by rating key, mapped to the same
// `LocalTrack` shape the LocalLibrary Tracks tab merges — so render,
// queue build, and artwork ride the existing source-aware paths.
let plex_keys: Vec<String> = tracks
.iter()
.filter_map(|t| t.plex_key.clone())
.filter(|k| !k.is_empty())
.collect();
let plex_resolved: HashMap<String, qbz_library::LocalTrack> = if plex_keys.is_empty() {
HashMap::new()
} else {
tokio::task::spawn_blocking(move || {
match qbz_plex::plex_cache_get_cached_tracks_by_keys(&plex_keys) {
Ok(rows) => rows
.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] local playlist: plex cache resolve failed: {e}");
HashMap::new()
}
}
})
.await
.unwrap_or_default()
};
// Local rows: resolve library rows by file path (blocking). Paths the // Local rows: resolve library rows by file path (blocking). Paths the
// index doesn't know are stat'ed on the same worker — an existing file // index doesn't know are stat'ed on the same worker — an existing file
// renders as a filename-fallback row instead of hiding (D11 nuance). // renders as a filename-fallback row instead of hiding (D11 nuance).
@@ -436,6 +541,7 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
let mut rows: Vec<LoadedRow> = Vec::new(); let mut rows: Vec<LoadedRow> = Vec::new();
let mut hidden = 0usize; let mut hidden = 0usize;
let mut missing_files = 0usize; let mut missing_files = 0usize;
let mut unresolved = 0usize;
for t in tracks { for t in tracks {
let item = match t.source { let item = match t.source {
repo::LocalPlaylistTrackSource::Qobuz => { repo::LocalPlaylistTrackSource::Qobuz => {
@@ -443,7 +549,20 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
hidden += 1; hidden += 1;
continue; continue;
}; };
if let Some(track) = fetched.remove(&tid) { if tid >= crate::local_library::PLEX_TRACK_ID_FLOOR {
// NOT a Qobuz catalog id — a Plex row's synthetic
// 2^40-namespaced id stored as qobuz_track_id by the
// pre-typed-drag bug. It can never resolve; render it
// honestly (removable) instead of D11-hiding it forever.
unresolved += 1;
log::warn!(
"[qbz-slint] local playlist {id}: qobuz ref {tid} is outside the catalog range (legacy mis-typed row) — rendered as unavailable"
);
RowItem::Unresolved {
kind: "qobuz",
reference: tid.to_string(),
}
} else if let Some(track) = fetched.remove(&tid) {
RowItem::Qobuz(Box::new(track)) RowItem::Qobuz(Box::new(track))
} else if let Some(item) = cached.remove(&tid) { } else if let Some(item) = cached.remove(&tid) {
item item
@@ -474,9 +593,25 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
} }
} }
} }
repo::LocalPlaylistTrackSource::Plex => RowItem::Plex { repo::LocalPlaylistTrackSource::Plex => {
key: t.plex_key.clone().unwrap_or_default(), let key = t.plex_key.clone().unwrap_or_default();
}, match plex_resolved.get(&key) {
Some(track) => RowItem::Plex(Box::new(track.clone())),
None => {
// Cache miss (purged / never synced) OR a garbage key
// (e.g. a file path stored as a plex_key) — honest,
// removable fallback; never the generic placeholder.
unresolved += 1;
log::warn!(
"[qbz-slint] local playlist {id}: plex key {key:?} not in the Plex cache — rendered as unavailable"
);
RowItem::Unresolved {
kind: "plex",
reference: key,
}
}
}
}
}; };
rows.push(LoadedRow { rows.push(LoadedRow {
position: t.position, position: t.position,
@@ -491,6 +626,11 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
"[qbz-slint] local playlist {id}: {missing_files} local file row(s) missing on disk, hidden (D11.local)" "[qbz-slint] local playlist {id}: {missing_files} local file row(s) missing on disk, hidden (D11.local)"
); );
} }
if unresolved > 0 {
log::info!(
"[qbz-slint] local playlist {id}: {unresolved} row(s) with unresolvable refs, rendered as unavailable"
);
}
Some(LocalPlaylistData { Some(LocalPlaylistData {
id: header.id, id: header.id,
@@ -559,11 +699,17 @@ fn row_queue_track(item: &RowItem) -> Option<QueueTrack> {
parental_warning: false, parental_warning: false,
source_item_id_hint: None, source_item_id_hint: None,
}), }),
RowItem::Local(track) => Some(crate::playback::local_queue_track(track)), // `local_queue_track` is source-aware: Plex rows get `source =
// "plex"` + the rating key in `source_item_id_hint` + the raw
// `/library/...` thumb path — the existing Plex playback path
// (offline gating allows plex under induced offline).
RowItem::Local(track) | RowItem::Plex(track) => {
Some(crate::playback::local_queue_track(track))
}
// Filename-fallback rows have no library row to resolve playback // Filename-fallback rows have no library row to resolve playback
// through — render-only until the file is re-indexed. // through — render-only until the file is re-indexed.
RowItem::LocalFile { .. } => None, RowItem::LocalFile { .. } => None,
RowItem::Plex { .. } => None, RowItem::Unresolved { .. } => None,
} }
} }
@@ -608,7 +754,7 @@ fn row_item(item: &RowItem, queue: Option<&QueueTrack>) -> TrackItem {
source: "qobuz".into(), source: "qobuz".into(),
unlocking: false, unlocking: false,
}, },
RowItem::Local(track) => { RowItem::Local(track) | RowItem::Plex(track) => {
let (tier, quality_detail, _) = crate::quality::badge( let (tier, quality_detail, _) = crate::quality::badge(
&track.format.to_string(), &track.format.to_string(),
track.bit_depth, track.bit_depth,
@@ -616,7 +762,8 @@ fn row_item(item: &RowItem, queue: Option<&QueueTrack>) -> TrackItem {
); );
TrackItem { TrackItem {
// The queue id (library row id; the Qobuz id for offline // The queue id (library row id; the Qobuz id for offline
// copies) so visible-order playback resolves this row. // copies; the synthetic 2^40-namespaced id for Plex rows)
// so visible-order playback resolves this row.
id: queue id: queue
.map(|q| q.id.to_string()) .map(|q| q.id.to_string())
.unwrap_or_else(|| track.id.to_string()) .unwrap_or_else(|| track.id.to_string())
@@ -675,12 +822,28 @@ fn row_item(item: &RowItem, queue: Option<&QueueTrack>) -> TrackItem {
unlocking: false, unlocking: false,
} }
} }
RowItem::Plex { key } => TrackItem { // Honest unavailable row: distinct title + the raw stored ref in
id: format!("plex:{key}").into(), // the album column, selectable so multi-select removal can clear
// it. Plex cache-misses keep the "plex:<key>" id (re-dragging one
// still carries the key — it may heal after a resync); mis-typed
// qobuz refs get an unparseable id so no drag/pick path can ever
// re-type them as a catalog id.
RowItem::Unresolved { kind, reference } => TrackItem {
id: if *kind == "plex" {
format!("plex:{reference}")
} else {
format!("broken:{kind}:{reference}")
}
.into(),
number: "".into(), number: "".into(),
title: "Plex track".into(), title: if *kind == "plex" {
artist: "Plex".into(), "Unavailable Plex track"
album: "".into(), } else {
"Unavailable track"
}
.into(),
artist: if *kind == "plex" { "Plex" } else { "Unknown source" }.into(),
album: format!("ref {reference}").into(),
duration: "".into(), duration: "".into(),
quality_tier: "".into(), quality_tier: "".into(),
quality_detail: "".into(), quality_detail: "".into(),
@@ -694,7 +857,7 @@ fn row_item(item: &RowItem, queue: Option<&QueueTrack>) -> TrackItem {
removing: false, removing: false,
cache_status: 0, cache_status: 0,
cache_progress: 0.0, cache_progress: 0.0,
source: "plex".into(), source: if *kind == "plex" { "plex" } else { "" }.into(),
unlocking: false, unlocking: false,
}, },
} }
@@ -770,12 +933,15 @@ pub fn apply(window: &AppWindow, data: LocalPlaylistData) {
crate::playlist::apply_local_items(window, items); crate::playlist::apply_local_items(window, items);
} }
/// Row artwork jobs — Qobuz rows have http URLs, local rows file paths. /// Row artwork jobs — Qobuz rows have http URLs, local rows file paths,
/// Returns (http jobs, local-file jobs) targeting `PlaylistTrack{index}` /// Plex rows raw `/library/...` thumb paths (tokenized by the PlexThumb
/// (the same target the Qobuz detail uses; indexes are FULL_ITEMS order). /// loader — offline-tolerant). Returns (http, local-file, plex) job sets
pub fn artwork_jobs(rows: &[LoadedRow]) -> (Vec<ArtworkJob>, Vec<ArtworkJob>) { /// targeting `PlaylistTrack{index}` (the same target the Qobuz detail
/// uses; indexes are FULL_ITEMS order).
pub fn artwork_jobs(rows: &[LoadedRow]) -> (Vec<ArtworkJob>, Vec<ArtworkJob>, Vec<ArtworkJob>) {
let mut http = Vec::new(); let mut http = Vec::new();
let mut local = Vec::new(); let mut local = Vec::new();
let mut plex = Vec::new();
for (index, row) in rows.iter().enumerate() { for (index, row) in rows.iter().enumerate() {
match &row.item { match &row.item {
RowItem::Qobuz(track) => { RowItem::Qobuz(track) => {
@@ -794,6 +960,14 @@ pub fn artwork_jobs(rows: &[LoadedRow]) -> (Vec<ArtworkJob>, Vec<ArtworkJob>) {
}); });
} }
} }
RowItem::Plex(track) => {
if let Some(path) = track.artwork_path.clone().filter(|p| !p.is_empty()) {
plex.push(ArtworkJob {
url: path,
target: ArtworkTarget::PlaylistTrack { index },
});
}
}
// Offline-resolved Qobuz rows: the cached cover.jpg loads through // Offline-resolved Qobuz rows: the cached cover.jpg loads through
// the same local-file path as Local rows (B5). // the same local-file path as Local rows (B5).
RowItem::Cached { artwork_path, .. } => { RowItem::Cached { artwork_path, .. } => {
@@ -807,7 +981,7 @@ pub fn artwork_jobs(rows: &[LoadedRow]) -> (Vec<ArtworkJob>, Vec<ArtworkJob>) {
_ => {} _ => {}
} }
} }
(http, local) (http, local, plex)
} }
/// Open a local playlist detail (the `local:` branch of /// Open a local playlist detail (the `local:` branch of
@@ -837,7 +1011,7 @@ pub fn navigate(
}); });
return; return;
}; };
let (http_jobs, local_jobs) = artwork_jobs(&data.rows); let (http_jobs, local_jobs, plex_jobs) = artwork_jobs(&data.rows);
let _ = weak.upgrade_in_event_loop(move |w| { let _ = weak.upgrade_in_event_loop(move |w| {
apply(&w, data); apply(&w, data);
}); });
@@ -847,6 +1021,16 @@ pub fn navigate(
if !local_jobs.is_empty() { if !local_jobs.is_empty() {
artwork::spawn_local_loads(local_jobs, weak.clone(), image_cache.clone()); artwork::spawn_local_loads(local_jobs, weak.clone(), image_cache.clone());
} }
if !plex_jobs.is_empty() {
let plex = crate::plex_settings::get();
artwork::spawn_local_or_plex_loads(
plex_jobs,
plex.base_url,
plex.token,
weak.clone(),
image_cache.clone(),
);
}
}); });
} }
@@ -892,8 +1076,8 @@ pub fn navigate_qobuz_offline(
== qbz_app::offline_mode::OfflineMode::InducedOffline; == qbz_app::offline_mode::OfflineMode::InducedOffline;
let (sidecar_rows, custom_artwork_path, playable_ids, snapshot_name) = let (sidecar_rows, custom_artwork_path, playable_ids, snapshot_name) =
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let (rows, custom) = crate::library_db::with_db(|db| { let (mut rows, plex_refs, custom) = crate::library_db::with_db(|db| {
let mut rows: Vec<LoadedRow> = db let rows: Vec<LoadedRow> = db
.get_playlist_local_tracks_with_position(playlist_id)? .get_playlist_local_tracks_with_position(playlist_id)?
.into_iter() .into_iter()
.map(|r| LoadedRow { .map(|r| LoadedRow {
@@ -901,24 +1085,55 @@ pub fn navigate_qobuz_offline(
item: RowItem::Local(Box::new(r.track)), item: RowItem::Local(Box::new(r.track)),
}) })
.collect(); .collect();
if plex_allowed { let plex_refs: Vec<(String, i32)> = if plex_allowed {
rows.extend( db.get_playlist_plex_tracks_with_position(playlist_id)?
db.get_playlist_plex_tracks_with_position(playlist_id)? } else {
.into_iter() Vec::new()
.map(|(key, position)| LoadedRow { };
position,
item: RowItem::Plex { key },
}),
);
rows.sort_by_key(|r| r.position);
}
let custom = db let custom = db
.get_playlist_settings(playlist_id)? .get_playlist_settings(playlist_id)?
.and_then(|s| s.custom_artwork_path) .and_then(|s| s.custom_artwork_path)
.filter(|p| !p.is_empty()); .filter(|p| !p.is_empty());
Ok((rows, custom)) Ok((rows, plex_refs, custom))
}) })
.unwrap_or_default(); .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);
}
( (
rows, rows,
custom, custom,
@@ -969,18 +1184,28 @@ pub fn navigate_qobuz_offline(
let (name, description) = crate::sidebar::playlist_name_desc(playlist_id) let (name, description) = crate::sidebar::playlist_name_desc(playlist_id)
.or_else(|| snapshot_name.map(|n| (n, String::new()))) .or_else(|| snapshot_name.map(|n| (n, String::new())))
.unwrap_or_else(|| ("Playlist".to_string(), String::new())); .unwrap_or_else(|| ("Playlist".to_string(), String::new()));
let (http_jobs, local_jobs) = artwork_jobs(&rows); let (http_jobs, local_jobs, plex_jobs) = artwork_jobs(&rows);
let _ = weak.upgrade_in_event_loop(move |w| { let _ = weak.upgrade_in_event_loop(move |w| {
apply_qobuz_offline(&w, playlist_id, name, description, custom_artwork_path, rows); apply_qobuz_offline(&w, playlist_id, name, description, custom_artwork_path, rows);
}); });
// Sidecar rows carry file paths (local) — the http set stays empty, // Sidecar rows carry file paths (local) or Plex thumb paths — the
// kept for symmetry with the local detail's dispatch. // http set stays empty, kept for symmetry with the local detail.
if !http_jobs.is_empty() { if !http_jobs.is_empty() {
artwork::spawn_loads(http_jobs, weak.clone(), image_cache.clone()); artwork::spawn_loads(http_jobs, weak.clone(), image_cache.clone());
} }
if !local_jobs.is_empty() { if !local_jobs.is_empty() {
artwork::spawn_local_loads(local_jobs, weak.clone(), image_cache.clone()); artwork::spawn_local_loads(local_jobs, weak.clone(), image_cache.clone());
} }
if !plex_jobs.is_empty() {
let plex = crate::plex_settings::get();
artwork::spawn_local_or_plex_loads(
plex_jobs,
plex.base_url,
plex.token,
weak.clone(),
image_cache.clone(),
);
}
}); });
} }
+221 -47
View File
@@ -567,6 +567,56 @@ fn set_row_favorite(window: &AppWindow, track_id: &str, favorite: bool) {
} }
} }
/// Toggle a track favorite by its REAL Qobuz id: offline guard (read-only
/// hearts, spec 4.3), optimistic flip across the visible rows + the shared
/// fav cache, then the network add/remove with rollback on failure. Shared
/// by the Qobuz-surface `("track","favorite")` media-action arm and the
/// library-surface favorite entry (qobuz_download rows resolve their
/// `qobuz_track_id` first — never the local row id, which is Tauri's latent
/// "Add to Library" bug; LocalLibrary track-menu spec §3.2). UI-thread only
/// (upgrades `weak` directly).
fn toggle_track_favorite(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
id: String,
) {
if offline_mode::engine().is_offline() {
if let Some(w) = weak.upgrade() {
toast::info(&w, "Not available offline");
}
return;
}
// Toggle (not just add): read the cached state, flip it optimistically
// across every visible track model + the shared cache, then add/remove
// on the network.
let was_fav = fav_cache::is_favorite(&id);
let make_fav = !was_fav;
if let Ok(track_id) = id.parse::<u64>() {
fav_cache::set(track_id, make_fav);
}
if let Some(w) = weak.upgrade() {
set_row_favorite(&w, &id, make_fav);
}
handle.spawn(async move {
let res = if make_fav {
runtime.core().add_favorite("track", &id).await
} else {
runtime.core().remove_favorite("track", &id).await
};
if let Err(e) = res {
log::error!("[qbz-slint] toggle track favorite failed: {e}");
// Roll the optimistic change back on failure.
if let Ok(tid) = id.parse::<u64>() {
fav_cache::set(tid, was_fav);
}
let _ = weak.upgrade_in_event_loop(move |w| {
set_row_favorite(&w, &id, was_fav);
});
}
});
}
/// Look up the display name of an "Add to Mixtape/Collection" picker row by id /// Look up the display name of an "Add to Mixtape/Collection" picker row by id
/// (for the post-add toast). Returns "" if not found. /// (for the post-add toast). Returns "" if not found.
fn myqbz_add_row_name(window: &AppWindow, collection_id: &str) -> String { fn myqbz_add_row_name(window: &AppWindow, collection_id: &str) -> String {
@@ -1577,15 +1627,25 @@ fn local_picker_ref(track: &qbz_library::LocalTrack) -> String {
} }
/// Type a model row (Playlist / Artist surfaces) for the drag payload. /// Type a model row (Playlist / Artist surfaces) for the drag payload.
/// The LOCAL playlist detail mixes namespaces: "plex:<key>" Plex rows, /// The LOCAL playlist detail mixes namespaces: "plex:<key>" unresolved
/// library row ids on `source == "local"` rows, Qobuz catalog ids on /// Plex rows, NUMERIC synthetic ids on RESOLVED Plex rows (`source ==
/// everything else (incl. offline-cached rows). Render-only rows /// "plex"` — the rating key is recovered from the open detail's queue
/// ("file:<path>" fallbacks) type to None and drop out of the drag. /// snapshot, NEVER typed as a Qobuz id), library row ids on `source ==
/// "local"` rows, Qobuz catalog ids on everything else (incl.
/// offline-cached rows). Render-only rows ("file:"/"broken:" fallbacks)
/// type to None and drop out of the drag.
fn row_drag_track(row: &TrackItem) -> Option<drag::DragTrack> { fn row_drag_track(row: &TrackItem) -> Option<drag::DragTrack> {
let id = row.id.to_string(); let id = row.id.to_string();
if let Some(key) = id.strip_prefix("plex:") { if let Some(key) = id.strip_prefix("plex:") {
return Some(drag::DragTrack::Plex(key.to_string())); return Some(drag::DragTrack::Plex(key.to_string()));
} }
if row.source.as_str() == "plex" {
// Resolved Plex row: numeric display id; the rating key lives in
// the queue snapshot. No key recoverable -> drop from the drag;
// falling through to the Qobuz parse would store the synthetic id
// as a catalog id (the exact garbage class found in the field).
return local_playlist::plex_key_for_row(&id).map(drag::DragTrack::Plex);
}
if row.source.as_str() == "local" { if row.source.as_str() == "local" {
return id.parse::<i64>().ok().map(drag::DragTrack::LocalRow); return id.parse::<i64>().ok().map(drag::DragTrack::LocalRow);
} }
@@ -4779,45 +4839,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
("track", "favorite") => { ("track", "favorite") => {
// Offline = read-only hearts (spec 4.3): refuse the // Offline guard + optimistic toggle + network flip with
// toggle with a toast instead of letting the API fail. // rollback — shared with the library-surface favorite
if offline_mode::engine().is_offline() { // (see toggle_track_favorite).
if let Some(w) = weak.upgrade() { toggle_track_favorite(
toast::info(&w, "Not available offline"); runtime.clone(),
} weak.clone(),
return; handle.clone(),
} id.to_string(),
// Toggle (not just add): read the cached state, flip it );
// optimistically across every visible track model + the
// shared cache, then add/remove on the network.
let was_fav = fav_cache::is_favorite(&id);
let make_fav = !was_fav;
if let Ok(track_id) = id.parse::<u64>() {
fav_cache::set(track_id, make_fav);
}
if let Some(w) = weak.upgrade() {
set_row_favorite(&w, &id, make_fav);
}
let runtime = runtime.clone();
let weak = weak.clone();
let track_id = id.clone();
handle.spawn(async move {
let res = if make_fav {
runtime.core().add_favorite("track", &track_id).await
} else {
runtime.core().remove_favorite("track", &track_id).await
};
if let Err(e) = res {
log::error!("[qbz-slint] toggle track favorite failed: {e}");
// Roll the optimistic change back on failure.
if let Ok(tid) = track_id.parse::<u64>() {
fav_cache::set(tid, was_fav);
}
let _ = weak.upgrade_in_event_loop(move |w| {
set_row_favorite(&w, &track_id, was_fav);
});
}
});
} }
// Offline cache: "download"/"cache" make a track available // Offline cache: "download"/"cache" make a track available
// offline; "uncache" removes the local copy. The row affordance // offline; "uncache" removes the local copy. The row affordance
@@ -4850,11 +4880,51 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
), ),
("track", "add-to-playlist") => { ("track", "add-to-playlist") => {
// Open the global picker for this track + load the // Open the global picker for this track + load the
// user's playlists. // user's playlists. SOURCE-TYPED routing first: this
// shared arm also fires for local/Plex rows (local
// playlist detail, now-playing), whose ids are NOT
// Qobuz catalog ids — the untyped path stored a Plex
// row's synthetic 2^40 id as qobuz_track_id (the field
// garbage class). Type the ref, or refuse.
let Some(w) = weak.upgrade() else { let Some(w) = weak.upgrade() else {
return; return;
}; };
playlist_picker::open(&w, &id); // 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).
let in_local_detail = w.global::<NavState>().get_view()
== ContentView::Playlist
&& (w.global::<PlaylistState>().get_is_local()
|| w.global::<PlaylistState>().get_offline_subset());
let local_ref: Option<String> = if id.starts_with("plex:") {
// Unresolved Plex row in a playlist detail — the id
// already carries the rating key.
Some(id.to_string())
} else if in_local_detail {
// Open local-playlist detail row: the queue snapshot
// knows its source ("plex:<key>" / "<row id>"; None
// for Qobuz rows = catalog flow below).
local_playlist::local_picker_ref_for_row(id.as_str())
} else {
None
};
if let Some(track_ref) = local_ref {
playlist_picker::open_multi(&w, &[track_ref], true);
} else if id
.parse::<u64>()
.is_ok_and(|n| n >= local_library::PLEX_TRACK_ID_FLOOR)
{
// A synthetic (Plex/ephemeral) id with no resolvable
// ref — refuse rather than store a fake Qobuz id.
log::warn!(
"[qbz-slint] add-to-playlist: unresolvable non-catalog id {id} — refused"
);
toast::error(&w, "Couldn't resolve this track");
return;
} else {
playlist_picker::open(&w, &id);
}
let runtime = runtime.clone(); let runtime = runtime.clone();
let weak = weak.clone(); let weak = weak.clone();
handle.spawn(async move { handle.spawn(async move {
@@ -7423,8 +7493,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
{ {
// Per-row context-menu actions on the local album detail (play-next / // Per-row context-menu actions on the local album detail (play-next /
// queue / add-to-playlist) — resolved against the open version's // queue / add-to-playlist / add-to-mixtape / favorite) — resolved
// track cache; "play" stays on LocalAlbumActions.play-track. // against the open version's track cache; "play" stays on
// LocalAlbumActions.play-track.
let runtime = app_runtime.clone(); let runtime = app_runtime.clone();
let weak = window.as_weak(); let weak = window.as_weak();
let handle = tokio_rt.handle().clone(); let handle = tokio_rt.handle().clone();
@@ -7457,6 +7528,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
}); });
} }
"add-to-mixtape" => {
// Single-row Add to Mixtape/Collection on the local
// album detail (spec §3.1) — the row is already
// resolved from the open version's track cache.
let items =
myqbz_add::track_items_from_local(std::slice::from_ref(row));
open_add_to_mixtape(weak.clone(), handle.clone(), items);
}
"favorite" => {
// qobuz_download rows only (the menu gates the entry);
// toggle by the REAL Qobuz id, never the local row id
// (spec §3.2 — Tauri's latent bug, not ported).
match row.qobuz_track_id {
Some(qid) => toggle_track_favorite(
runtime.clone(),
weak.clone(),
handle.clone(),
qid.to_string(),
),
None => log::debug!(
"[qbz-slint] favorite: album row {id} has no qobuz_track_id"
),
}
}
_ => { _ => {
log::debug!( log::debug!(
"[qbz-slint] unhandled local album track action: {id} {action}" "[qbz-slint] unhandled local album track action: {id} {action}"
@@ -7756,6 +7851,85 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
}); });
} }
"add-to-mixtape" => {
// Single-row Add to Mixtape/Collection (Tracks tab +
// folder-detail rows; spec §3.1). Same resolution as
// play-next: loaded cache first (Plex rows included —
// stored as source "local" in the mixtape contract),
// DB fallback off-thread for folder rows.
if let Some(row) = local_library::local_track_by_id(id.as_str()) {
let items = myqbz_add::track_items_from_local(&[row]);
open_add_to_mixtape(weak.clone(), handle.clone(), items);
} else if let Ok(rid) = id.parse::<i64>() {
let weak2 = weak.clone();
let handle2 = handle.clone();
handle.spawn(async move {
let row = tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| db.get_track(rid))
.flatten()
})
.await
.ok()
.flatten();
if let Some(row) = row {
let items = myqbz_add::track_items_from_local(&[row]);
open_add_to_mixtape(weak2, handle2, items);
}
});
}
}
"favorite" => {
// Library-surface favorite: the menu only shows the
// entry on qobuz_download rows (TrackRow gates on
// source == "qobuz"), and the toggle uses the row's
// REAL qobuz_track_id — never the local row id, which
// is what Tauri sends (spec §3.2 latent bug; we port
// the intent, not the bug).
if let Some(row) = local_library::local_track_by_id(id.as_str()) {
match row.qobuz_track_id {
Some(qid) => toggle_track_favorite(
runtime.clone(),
weak.clone(),
handle.clone(),
qid.to_string(),
),
None => log::debug!(
"[qbz-slint] favorite: local row {id} has no qobuz_track_id"
),
}
} else if let Ok(rid) = id.parse::<i64>() {
// Folder rows aren't in the Tracks cache: resolve
// off-thread, then hop back to the UI thread (the
// toggle reads/writes UI models).
let runtime = runtime.clone();
let weak2 = weak.clone();
let handle2 = handle.clone();
handle.spawn(async move {
let row = tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| db.get_track(rid))
.flatten()
})
.await
.ok()
.flatten();
let Some(qid) = row.and_then(|r| r.qobuz_track_id) else {
log::debug!(
"[qbz-slint] favorite: row {rid} has no qobuz_track_id"
);
return;
};
let weak3 = weak2.clone();
let _ = weak2.upgrade_in_event_loop(move |_w| {
toggle_track_favorite(
runtime,
weak3,
handle2,
qid.to_string(),
);
});
});
}
}
_ => { _ => {
log::debug!("[qbz-slint] unhandled local track action: {id} {action}"); log::debug!("[qbz-slint] unhandled local track action: {id} {action}");
} }