feat(slint): plex rows attach on upload, manager sort interleaving, offline cover chain

Upload to Qobuz now attaches Plex sidecar rows via the existing
add_plex_track_to_playlist helper (positions after the local block), and
the local entity is deleted only when the sidecar attach succeeds - a DB
failure keeps the playlist with an upload-incomplete toast instead of
silently dropping it.

Playlist manager: local playlists interleave into the active sort with the
same comparators as the Qobuz set (name/tracks real interleave; playcount
sorts locals as zero, custom as after-the-positioned-set, recent after the
API order - documented). u64-keyed mutators verified safe with local: ids.

Offline covers: cached_tracks.artwork_path turned out to have zero writers
in history, so CachedTrackInfo gained artwork_path + file_path and a
frontend-agnostic resolve_cover_path chain (index path, CMAF cover.jpg,
v1 sibling cover.jpg). The offline manager album rows and the local-
playlist cached rows now resolve covers through it.

cc
This commit is contained in:
vicrodh committed 2026-06-10 01:51:51 -06:00
1 parent 09cd3be874
commit 63e0e386ba
6 files changed
+225 -79

No files matched your search

+60 -19
View File
@@ -210,6 +210,9 @@ pub enum RowItem {
duration_secs: u64,
bit_depth: Option<u32>,
sample_rate: Option<f64>,
/// On-disk cover thumb (B5: index `artwork_path` / CMAF `cover.jpg`),
/// loaded through the local-file artwork path like Local rows.
artwork_path: Option<String>,
},
/// Local file resolved from library.db by path.
Local(Box<qbz_library::LocalTrack>),
@@ -294,11 +297,13 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
let mut cached: HashMap<u64, RowItem> = HashMap::new();
if !missing.is_empty() {
if let Some(off) = crate::offline::get().await {
let cache_path = off.get_cache_path();
let guard = off.db.lock().await;
if let Some(db) = guard.as_ref() {
for tid in &missing {
if let Ok(Some(info)) = db.get_track(*tid) {
if matches!(info.status, qbz_offline_cache::OfflineCacheStatus::Ready) {
let artwork_path = info.resolve_cover_path(&cache_path);
cached.insert(
*tid,
RowItem::Cached {
@@ -309,6 +314,7 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
duration_secs: info.duration_secs,
bit_depth: info.bit_depth,
sample_rate: info.sample_rate,
artwork_path,
},
);
}
@@ -424,6 +430,7 @@ fn row_queue_track(item: &RowItem) -> Option<QueueTrack> {
duration_secs,
bit_depth,
sample_rate,
..
} => Some(QueueTrack {
id: *track_id,
title: title.clone(),
@@ -462,6 +469,7 @@ fn row_item(item: &RowItem, queue: Option<&QueueTrack>) -> TrackItem {
duration_secs,
bit_depth,
sample_rate,
artwork_path,
} => TrackItem {
id: track_id.to_string().into(),
number: "".into(),
@@ -478,7 +486,7 @@ fn row_item(item: &RowItem, queue: Option<&QueueTrack>) -> TrackItem {
quality_detail: crate::quality::detail(*bit_depth, *sample_rate).into(),
explicit: false,
selected: false,
artwork_url: "".into(),
artwork_url: artwork_path.clone().unwrap_or_default().into(),
artwork: slint::Image::default(),
is_favorite: crate::fav_cache::is_favorite(&track_id.to_string()),
artist_id: "".into(),
@@ -647,6 +655,16 @@ pub fn artwork_jobs(rows: &[LoadedRow]) -> (Vec<ArtworkJob>, Vec<ArtworkJob>) {
});
}
}
// Offline-resolved Qobuz rows: the cached cover.jpg loads through
// the same local-file path as Local rows (B5).
RowItem::Cached { artwork_path, .. } => {
if let Some(path) = artwork_path.clone().filter(|p| !p.is_empty()) {
local.push(ArtworkJob {
url: path,
target: ArtworkTarget::PlaylistTrack { index },
});
}
}
_ => {}
}
}
@@ -1022,11 +1040,13 @@ pub fn remove_selected(
// ──────────────────────── Upload to Qobuz (D8) ────────────────────────
/// Convert a non-offline-only local playlist into a real Qobuz playlist:
/// create it, add the Qobuz-source rows, attach local rows as the existing
/// mixed-playlist sidecar (`playlist_local_tracks`), then delete the local
/// entity. Plex rows have no sidecar write path here yet — they are dropped
/// with a log (deferred). Never reached for offline-only playlists (the UI
/// hides the action and this guards again).
/// create it, add the Qobuz-source rows, attach local rows via the existing
/// mixed-playlist sidecar (`playlist_local_tracks`) and Plex rows via the
/// plex sidecar (`playlist_plex_tracks`, the same table Tauri's
/// `v2_playlist_add_plex_track` writes), then delete the local entity. On
/// any attach failure the local entity is KEPT so the user can retry.
/// Never reached for offline-only playlists (the UI hides the action and
/// this guards again).
pub fn upload_to_qobuz(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
@@ -1082,19 +1102,16 @@ pub fn upload_to_qobuz(
}
// Local rows -> the existing mixed-playlist sidecar, positioned
// after the Qobuz block (Tauri's append convention). Plex rows are
// deferred (no Slint sidecar write path yet) — logged + dropped.
// after the Qobuz block (Tauri's append convention). Plex rows ->
// the plex sidecar, after the local block, relative order preserved
// (B1). The local entity is deleted ONLY when the sidecar attach
// succeeds — on a DB failure it stays so the user can retry.
let local_paths: Vec<String> = rows.iter().filter_map(|r| r.local_path.clone()).collect();
let plex_count = rows.iter().filter(|r| r.plex_key.is_some()).count();
if plex_count > 0 {
log::warn!(
"[qbz-slint] upload to Qobuz: {plex_count} Plex row(s) dropped (sidecar attach deferred)"
);
}
let plex_keys: Vec<String> = rows.iter().filter_map(|r| r.plex_key.clone()).collect();
let qobuz_count = qobuz_ids.len();
let id_for_delete = id.clone();
tokio::task::spawn_blocking(move || {
crate::library_db::with_db(|db| {
let attached = tokio::task::spawn_blocking(move || {
let ok = crate::library_db::with_db(|db| {
for (i, path) in local_paths.iter().enumerate() {
match db.get_track_by_path(path)? {
Some(track) => {
@@ -1111,12 +1128,36 @@ pub fn upload_to_qobuz(
}
}
}
for (i, key) in plex_keys.iter().enumerate() {
db.add_plex_track_to_playlist(
new_id,
key,
(qobuz_count + local_paths.len() + i) as i32,
)?;
}
Ok(())
});
delete_blocking(&id_for_delete);
})
.is_some();
if ok {
delete_blocking(&id_for_delete);
}
ok
})
.await
.ok();
.unwrap_or(false);
if !attached {
// The Qobuz playlist exists with its Qobuz tracks, but the
// local/Plex sidecar rows didn't attach — keep the local entity.
log::error!("[qbz-slint] upload to Qobuz: sidecar attach failed — local playlist kept");
crate::toast::error_weak(&weak, "Upload incomplete — local playlist kept");
let weak2 = weak.clone();
let r2 = runtime.clone();
let h2 = handle.clone();
let _ = weak.upgrade_in_event_loop(move |_w| {
crate::load_sidebar_playlists(r2, weak2, &h2);
});
return;
}
crate::toast::success_weak(&weak, "Playlist uploaded to Qobuz");
let weak2 = weak.clone();
+13 -15
View File
@@ -77,19 +77,14 @@ fn album_size(group: &[CachedTrackInfo]) -> u64 {
group.iter().map(|t| t.file_size_bytes).sum()
}
/// Path of an album's on-disk `cover.jpg` (next to one track's CMAF bundle),
/// or "" when it doesn't exist. Computed on the worker (a `String` is `Send`);
/// the image itself is loaded on the UI thread (`slint::Image` is NOT `Send`).
fn cover_path(cache_path: &str, track_id: u64) -> String {
let p = std::path::Path::new(cache_path)
.join("tracks-cmaf")
.join(track_id.to_string())
.join("cover.jpg");
if p.exists() {
p.to_string_lossy().to_string()
} else {
String::new()
}
/// Path of an album's on-disk cover thumbnail, or "" when none exists.
/// Resolution order (B5): the index's `artwork_path` when set, the CMAF
/// bundle's `tracks-cmaf/<id>/cover.jpg`, then the `cover.jpg` sibling of
/// the audio file (v1-format rows). Computed on the worker (a `String` is
/// `Send`); the image itself is loaded on the UI thread (`slint::Image` is
/// NOT `Send`).
fn cover_path(cache_path: &str, track: &CachedTrackInfo) -> String {
track.resolve_cover_path(cache_path).unwrap_or_default()
}
/// Load a cover image from a path on the UI thread (empty path / missing -> default).
@@ -214,9 +209,12 @@ pub async fn rebuild(weak: slint::Weak<AppWindow>) {
} else {
0
};
// First track whose cover resolves — within an album only some
// tracks may carry one (per-track CMAF folders, mixed v1/v2 rows).
let cover_path = group
.first()
.map(|t| cover_path(&cache_path, t.track_id))
.iter()
.map(|t| cover_path(&cache_path, t))
.find(|p| !p.is_empty())
.unwrap_or_default();
rows.push(RowData {
kind: "album",
+102 -40
View File
@@ -239,6 +239,92 @@ fn sort_playlists(list: &mut Vec<PmPlaylist>, sort: &str) {
}
}
/// Display-stage union of a Qobuz playlist and a LOCAL (library.db)
/// playlist, so locals INTERLEAVE into the active sort instead of being
/// appended after the Qobuz set (B4). Display-only by design: PmPlaylist's
/// u64-keyed mutators (reorder/settings) are untouched — they parse the
/// model ids and skip `local:` ones.
///
/// Sort keys locals don't have:
/// - playcount: no local play stat — locals sort as ZERO, which under the
/// descending playcount sort puts them last (after any played Qobuz set);
/// - custom: positions are a Qobuz-side concept — locals sort as MAX, i.e.
/// after the positioned Qobuz set;
/// - recent: no recency signal — kept after the API-ordered Qobuz set.
/// Ties keep the pre-sort order (stable sort): API order for Qobuz rows,
/// name order among the locals.
enum PmEntry<'a> {
Qobuz(&'a PmPlaylist),
Local(&'a PmLocalPlaylist),
}
impl PmEntry<'_> {
fn name_lower(&self) -> String {
match self {
Self::Qobuz(p) => p.name.to_lowercase(),
Self::Local(p) => p.name.to_lowercase(),
}
}
fn total_count(&self) -> u32 {
match self {
Self::Qobuz(p) => p.total_count(),
Self::Local(p) => p.track_count,
}
}
fn play_count(&self) -> u32 {
match self {
Self::Qobuz(p) => p.play_count,
Self::Local(_) => 0,
}
}
fn position(&self) -> i64 {
match self {
Self::Qobuz(p) => p.position as i64,
Self::Local(_) => i64::MAX,
}
}
fn item(&self) -> PmPlaylistItem {
match self {
Self::Qobuz(p) => playlist_item(p),
Self::Local(p) => local_playlist_item(p),
}
}
}
/// `sort_playlists`, over the merged Qobuz + local display set (same
/// comparators; see `PmEntry` for the missing-stat rules).
fn sort_entries(list: &mut [PmEntry], sort: &str) {
match sort {
"name" => list.sort_by_key(|e| e.name_lower()),
"playcount" => list.sort_by(|a, b| b.play_count().cmp(&a.play_count())),
"tracks" => list.sort_by(|a, b| b.total_count().cmp(&a.total_count())),
"custom" => list.sort_by_key(|e| e.position()),
// "recent" — Qobuz keeps API order, locals stay after it.
_ => {}
}
}
/// The LOCAL playlists that pass the toolbar filters, name-sorted (their
/// tie/no-stat order inside `sort_entries`). They have no hidden flag, so
/// the "hidden" filter excludes them; folder filtering N/A (root-only).
/// `query` must already be lowercased.
fn local_entries<'a>(data: &'a PmData, query: &str, filter: &str) -> Vec<&'a PmLocalPlaylist> {
if filter == "hidden" {
return Vec::new();
}
let mut locals: Vec<&PmLocalPlaylist> = data
.locals
.iter()
.filter(|p| query.is_empty() || p.name.to_lowercase().contains(query))
.collect();
locals.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
locals
}
/// Whether a playlist passes the search + visibility + folder filters.
/// `current_folder` is None for the flat / root view (we never enter a
/// folder in the Slint port — folder navigation is via the tree).
@@ -414,27 +500,19 @@ pub fn rebuild(window: &AppWindow) {
// Filtered + sorted playlists for the grid / list. While OFFLINE only
// the MIXED playlists (>= 1 local sidecar track) stay (D11.b).
let offline = crate::offline_mode::engine().is_offline();
let mut filtered: Vec<PmPlaylist> = data
let filtered: Vec<PmPlaylist> = data
.playlists
.iter()
.filter(|p| !offline || p.local_count > 0)
.filter(|p| passes(p, &query, &filter, folder_mode, &view_mode))
.cloned()
.collect();
sort_playlists(&mut filtered, &sort);
let mut playlist_items: Vec<PmPlaylistItem> = filtered.iter().map(playlist_item).collect();
// LOCAL playlists (library.db, D7) — appended after the Qobuz set,
// name-sorted, honoring the search query. They have no hidden flag, so
// the "hidden" filter excludes them; folder filtering N/A (root-only).
if filter != "hidden" {
let mut locals: Vec<&PmLocalPlaylist> = data
.locals
.iter()
.filter(|p| query.is_empty() || p.name.to_lowercase().contains(&query))
.collect();
locals.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
playlist_items.extend(locals.iter().map(|p| local_playlist_item(p)));
}
// LOCAL playlists (library.db, D7) interleave into the SAME sort as the
// Qobuz set (B4) — see `PmEntry` for the missing-stat sort rules.
let mut entries: Vec<PmEntry> = filtered.iter().map(PmEntry::Qobuz).collect();
entries.extend(local_entries(&data, &query, &filter).into_iter().map(PmEntry::Local));
sort_entries(&mut entries, &sort);
let playlist_items: Vec<PmPlaylistItem> = entries.iter().map(|e| e.item()).collect();
let visible_count = playlist_items.len();
// Tree rows (folder headers + nested + root playlists). Built only
@@ -559,44 +637,28 @@ fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTre
}
}
}
// Root playlists (no folder).
let mut root: Vec<PmPlaylist> = data
// Root playlists (no folder), with the LOCAL playlists (never in
// folders) interleaved into the SAME sort (B4) — see `PmEntry` for the
// missing-stat sort rules.
let root: Vec<PmPlaylist> = data
.playlists
.iter()
.filter(|p| p.folder_id.is_none())
.filter(|p| matches(p))
.cloned()
.collect();
sort_playlists(&mut root, sort);
for p in &root {
let mut entries: Vec<PmEntry> = root.iter().map(PmEntry::Qobuz).collect();
entries.extend(local_entries(data, query, filter).into_iter().map(PmEntry::Local));
sort_entries(&mut entries, sort);
for e in &entries {
rows.push(PmTreeRow {
kind: "playlist".into(),
expanded: false,
folder: PmFolderItem::default(),
playlist: playlist_item(p),
playlist: e.item(),
indent: false,
});
}
// LOCAL playlists as root tree rows (never in folders), name-sorted,
// honoring the search query (no hidden flag — the hidden filter hides
// them like the grid does).
if filter != "hidden" {
let mut locals: Vec<&PmLocalPlaylist> = data
.locals
.iter()
.filter(|p| !searching || p.name.to_lowercase().contains(query))
.collect();
locals.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
for p in locals {
rows.push(PmTreeRow {
kind: "playlist".into(),
expanded: false,
folder: PmFolderItem::default(),
playlist: local_playlist_item(p),
indent: false,
});
}
}
rows
}