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