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
@@ -7,13 +7,13 @@ use crate::types::{
|
|||||||
CachedTrackInfo, OfflineCacheStats, OfflineCacheStatus, ReadyTrackForSync, TrackCacheInfo,
|
CachedTrackInfo, OfflineCacheStats, OfflineCacheStatus, ReadyTrackForSync, TrackCacheInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Maps a `cached_tracks` row (with the canonical 15-column SELECT used by
|
/// Maps a `cached_tracks` row (with the canonical 17-column SELECT used by
|
||||||
/// `get_track`, `get_all_tracks`, and `get_album_tracks`) into a `CachedTrackInfo`.
|
/// `get_track`, `get_all_tracks`, and `get_album_tracks`) into a `CachedTrackInfo`.
|
||||||
///
|
///
|
||||||
/// SELECT must be:
|
/// SELECT must be:
|
||||||
/// `track_id, title, artist, album, album_id, duration_secs, file_size_bytes,
|
/// `track_id, title, artist, album, album_id, duration_secs, file_size_bytes,
|
||||||
/// quality, bit_depth, sample_rate, status, progress_percent, error_message,
|
/// quality, bit_depth, sample_rate, status, progress_percent, error_message,
|
||||||
/// created_at, last_accessed_at`
|
/// created_at, last_accessed_at, artwork_path, file_path`
|
||||||
fn row_to_cached_track_info(row: &rusqlite::Row) -> rusqlite::Result<CachedTrackInfo> {
|
fn row_to_cached_track_info(row: &rusqlite::Row) -> rusqlite::Result<CachedTrackInfo> {
|
||||||
Ok(CachedTrackInfo {
|
Ok(CachedTrackInfo {
|
||||||
track_id: row.get::<_, i64>(0)? as u64,
|
track_id: row.get::<_, i64>(0)? as u64,
|
||||||
@@ -31,6 +31,8 @@ fn row_to_cached_track_info(row: &rusqlite::Row) -> rusqlite::Result<CachedTrack
|
|||||||
error_message: row.get(12)?,
|
error_message: row.get(12)?,
|
||||||
created_at: row.get(13)?,
|
created_at: row.get(13)?,
|
||||||
last_accessed_at: row.get(14)?,
|
last_accessed_at: row.get(14)?,
|
||||||
|
artwork_path: row.get::<_, Option<String>>(15)?,
|
||||||
|
file_path: row.get(16)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +327,7 @@ impl OfflineCacheDb {
|
|||||||
/// Get track info
|
/// Get track info
|
||||||
pub fn get_track(&self, track_id: u64) -> Result<Option<CachedTrackInfo>, String> {
|
pub fn get_track(&self, track_id: u64) -> Result<Option<CachedTrackInfo>, String> {
|
||||||
let result = self.conn.query_row(
|
let result = self.conn.query_row(
|
||||||
"SELECT track_id, title, artist, album, album_id, duration_secs, file_size_bytes, quality, bit_depth, sample_rate, status, progress_percent, error_message, created_at, last_accessed_at
|
"SELECT track_id, title, artist, album, album_id, duration_secs, file_size_bytes, quality, bit_depth, sample_rate, status, progress_percent, error_message, created_at, last_accessed_at, artwork_path, file_path
|
||||||
FROM cached_tracks WHERE track_id = ?1",
|
FROM cached_tracks WHERE track_id = ?1",
|
||||||
params![track_id as i64],
|
params![track_id as i64],
|
||||||
row_to_cached_track_info,
|
row_to_cached_track_info,
|
||||||
@@ -341,7 +343,7 @@ impl OfflineCacheDb {
|
|||||||
/// Get all cached tracks
|
/// Get all cached tracks
|
||||||
pub fn get_all_tracks(&self) -> Result<Vec<CachedTrackInfo>, String> {
|
pub fn get_all_tracks(&self) -> Result<Vec<CachedTrackInfo>, String> {
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
"SELECT track_id, title, artist, album, album_id, duration_secs, file_size_bytes, quality, bit_depth, sample_rate, status, progress_percent, error_message, created_at, last_accessed_at
|
"SELECT track_id, title, artist, album, album_id, duration_secs, file_size_bytes, quality, bit_depth, sample_rate, status, progress_percent, error_message, created_at, last_accessed_at, artwork_path, file_path
|
||||||
FROM cached_tracks ORDER BY last_accessed_at DESC"
|
FROM cached_tracks ORDER BY last_accessed_at DESC"
|
||||||
).map_err(|e| format!("Failed to prepare query: {}", e))?;
|
).map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||||
|
|
||||||
@@ -364,7 +366,8 @@ impl OfflineCacheDb {
|
|||||||
.prepare(
|
.prepare(
|
||||||
"SELECT track_id, title, artist, album, album_id, duration_secs,
|
"SELECT track_id, title, artist, album, album_id, duration_secs,
|
||||||
file_size_bytes, quality, bit_depth, sample_rate, status,
|
file_size_bytes, quality, bit_depth, sample_rate, status,
|
||||||
progress_percent, error_message, created_at, last_accessed_at
|
progress_percent, error_message, created_at, last_accessed_at,
|
||||||
|
artwork_path, file_path
|
||||||
FROM cached_tracks WHERE album_id = ?1",
|
FROM cached_tracks WHERE album_id = ?1",
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("Prepare failed: {}", e))?;
|
.map_err(|e| format!("Prepare failed: {}", e))?;
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ mod tests {
|
|||||||
error_message: None,
|
error_message: None,
|
||||||
created_at: "".into(),
|
created_at: "".into(),
|
||||||
last_accessed_at: "".into(),
|
last_accessed_at: "".into(),
|
||||||
|
artwork_path: None,
|
||||||
|
file_path: "".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,46 @@ pub struct CachedTrackInfo {
|
|||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub last_accessed_at: String,
|
pub last_accessed_at: String,
|
||||||
|
/// The index's `artwork_path` column. Historically never backfilled by
|
||||||
|
/// the downloaders (they record the cover path in library.db only), so
|
||||||
|
/// treat it as a hint — [`Self::resolve_cover_path`] falls back to the
|
||||||
|
/// on-disk layouts when it is unset.
|
||||||
|
pub artwork_path: Option<String>,
|
||||||
|
/// The audio path: the organized FLAC for v1 rows, the CMAF segments
|
||||||
|
/// path (inside `tracks-cmaf/<id>/`) for v2 rows.
|
||||||
|
pub file_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CachedTrackInfo {
|
||||||
|
/// Resolve this row's on-disk cover thumbnail, if any:
|
||||||
|
/// 1. the index's `artwork_path`, when set and still on disk;
|
||||||
|
/// 2. the v2 CMAF bundle's `<cache_path>/tracks-cmaf/<id>/cover.jpg`;
|
||||||
|
/// 3. the `cover.jpg` sibling of `file_path` — v1 rows save the cover
|
||||||
|
/// next to the organized FLAC without backfilling `artwork_path`.
|
||||||
|
pub fn resolve_cover_path(&self, cache_path: &str) -> Option<String> {
|
||||||
|
use std::path::Path;
|
||||||
|
if let Some(p) = self.artwork_path.as_deref().filter(|p| !p.is_empty()) {
|
||||||
|
if Path::new(p).is_file() {
|
||||||
|
return Some(p.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cmaf = Path::new(cache_path)
|
||||||
|
.join("tracks-cmaf")
|
||||||
|
.join(self.track_id.to_string())
|
||||||
|
.join("cover.jpg");
|
||||||
|
if cmaf.is_file() {
|
||||||
|
return Some(cmaf.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
let fp = Path::new(&self.file_path);
|
||||||
|
let folder = if fp.is_dir() { Some(fp) } else { fp.parent() };
|
||||||
|
if let Some(dir) = folder {
|
||||||
|
let sibling = dir.join("cover.jpg");
|
||||||
|
if sibling.is_file() {
|
||||||
|
return Some(sibling.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal track info for syncing to library
|
/// Minimal track info for syncing to library
|
||||||
|
|||||||
@@ -210,6 +210,9 @@ pub enum RowItem {
|
|||||||
duration_secs: u64,
|
duration_secs: u64,
|
||||||
bit_depth: Option<u32>,
|
bit_depth: Option<u32>,
|
||||||
sample_rate: Option<f64>,
|
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 file resolved from library.db by path.
|
||||||
Local(Box<qbz_library::LocalTrack>),
|
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();
|
let mut cached: HashMap<u64, RowItem> = HashMap::new();
|
||||||
if !missing.is_empty() {
|
if !missing.is_empty() {
|
||||||
if let Some(off) = crate::offline::get().await {
|
if let Some(off) = crate::offline::get().await {
|
||||||
|
let cache_path = off.get_cache_path();
|
||||||
let guard = off.db.lock().await;
|
let guard = off.db.lock().await;
|
||||||
if let Some(db) = guard.as_ref() {
|
if let Some(db) = guard.as_ref() {
|
||||||
for tid in &missing {
|
for tid in &missing {
|
||||||
if let Ok(Some(info)) = db.get_track(*tid) {
|
if let Ok(Some(info)) = db.get_track(*tid) {
|
||||||
if matches!(info.status, qbz_offline_cache::OfflineCacheStatus::Ready) {
|
if matches!(info.status, qbz_offline_cache::OfflineCacheStatus::Ready) {
|
||||||
|
let artwork_path = info.resolve_cover_path(&cache_path);
|
||||||
cached.insert(
|
cached.insert(
|
||||||
*tid,
|
*tid,
|
||||||
RowItem::Cached {
|
RowItem::Cached {
|
||||||
@@ -309,6 +314,7 @@ pub async fn load(runtime: &Runtime, playlist_id: &str) -> Option<LocalPlaylistD
|
|||||||
duration_secs: info.duration_secs,
|
duration_secs: info.duration_secs,
|
||||||
bit_depth: info.bit_depth,
|
bit_depth: info.bit_depth,
|
||||||
sample_rate: info.sample_rate,
|
sample_rate: info.sample_rate,
|
||||||
|
artwork_path,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -424,6 +430,7 @@ fn row_queue_track(item: &RowItem) -> Option<QueueTrack> {
|
|||||||
duration_secs,
|
duration_secs,
|
||||||
bit_depth,
|
bit_depth,
|
||||||
sample_rate,
|
sample_rate,
|
||||||
|
..
|
||||||
} => Some(QueueTrack {
|
} => Some(QueueTrack {
|
||||||
id: *track_id,
|
id: *track_id,
|
||||||
title: title.clone(),
|
title: title.clone(),
|
||||||
@@ -462,6 +469,7 @@ fn row_item(item: &RowItem, queue: Option<&QueueTrack>) -> TrackItem {
|
|||||||
duration_secs,
|
duration_secs,
|
||||||
bit_depth,
|
bit_depth,
|
||||||
sample_rate,
|
sample_rate,
|
||||||
|
artwork_path,
|
||||||
} => TrackItem {
|
} => TrackItem {
|
||||||
id: track_id.to_string().into(),
|
id: track_id.to_string().into(),
|
||||||
number: "".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(),
|
quality_detail: crate::quality::detail(*bit_depth, *sample_rate).into(),
|
||||||
explicit: false,
|
explicit: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
artwork_url: "".into(),
|
artwork_url: artwork_path.clone().unwrap_or_default().into(),
|
||||||
artwork: slint::Image::default(),
|
artwork: slint::Image::default(),
|
||||||
is_favorite: crate::fav_cache::is_favorite(&track_id.to_string()),
|
is_favorite: crate::fav_cache::is_favorite(&track_id.to_string()),
|
||||||
artist_id: "".into(),
|
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) ────────────────────────
|
// ──────────────────────── Upload to Qobuz (D8) ────────────────────────
|
||||||
|
|
||||||
/// Convert a non-offline-only local playlist into a real Qobuz playlist:
|
/// 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
|
/// create it, add the Qobuz-source rows, attach local rows via the existing
|
||||||
/// mixed-playlist sidecar (`playlist_local_tracks`), then delete the local
|
/// mixed-playlist sidecar (`playlist_local_tracks`) and Plex rows via the
|
||||||
/// entity. Plex rows have no sidecar write path here yet — they are dropped
|
/// plex sidecar (`playlist_plex_tracks`, the same table Tauri's
|
||||||
/// with a log (deferred). Never reached for offline-only playlists (the UI
|
/// `v2_playlist_add_plex_track` writes), then delete the local entity. On
|
||||||
/// hides the action and this guards again).
|
/// 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(
|
pub fn upload_to_qobuz(
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
weak: slint::Weak<AppWindow>,
|
weak: slint::Weak<AppWindow>,
|
||||||
@@ -1082,19 +1102,16 @@ pub fn upload_to_qobuz(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Local rows -> the existing mixed-playlist sidecar, positioned
|
// Local rows -> the existing mixed-playlist sidecar, positioned
|
||||||
// after the Qobuz block (Tauri's append convention). Plex rows are
|
// after the Qobuz block (Tauri's append convention). Plex rows ->
|
||||||
// deferred (no Slint sidecar write path yet) — logged + dropped.
|
// 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 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();
|
let plex_keys: Vec<String> = rows.iter().filter_map(|r| r.plex_key.clone()).collect();
|
||||||
if plex_count > 0 {
|
|
||||||
log::warn!(
|
|
||||||
"[qbz-slint] upload to Qobuz: {plex_count} Plex row(s) dropped (sidecar attach deferred)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let qobuz_count = qobuz_ids.len();
|
let qobuz_count = qobuz_ids.len();
|
||||||
let id_for_delete = id.clone();
|
let id_for_delete = id.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
let attached = tokio::task::spawn_blocking(move || {
|
||||||
crate::library_db::with_db(|db| {
|
let ok = crate::library_db::with_db(|db| {
|
||||||
for (i, path) in local_paths.iter().enumerate() {
|
for (i, path) in local_paths.iter().enumerate() {
|
||||||
match db.get_track_by_path(path)? {
|
match db.get_track_by_path(path)? {
|
||||||
Some(track) => {
|
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(())
|
Ok(())
|
||||||
});
|
})
|
||||||
delete_blocking(&id_for_delete);
|
.is_some();
|
||||||
|
if ok {
|
||||||
|
delete_blocking(&id_for_delete);
|
||||||
|
}
|
||||||
|
ok
|
||||||
})
|
})
|
||||||
.await
|
.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");
|
crate::toast::success_weak(&weak, "Playlist uploaded to Qobuz");
|
||||||
let weak2 = weak.clone();
|
let weak2 = weak.clone();
|
||||||
|
|||||||
@@ -77,19 +77,14 @@ fn album_size(group: &[CachedTrackInfo]) -> u64 {
|
|||||||
group.iter().map(|t| t.file_size_bytes).sum()
|
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),
|
/// Path of an album's on-disk cover thumbnail, or "" when none exists.
|
||||||
/// or "" when it doesn't exist. Computed on the worker (a `String` is `Send`);
|
/// Resolution order (B5): the index's `artwork_path` when set, the CMAF
|
||||||
/// the image itself is loaded on the UI thread (`slint::Image` is NOT `Send`).
|
/// bundle's `tracks-cmaf/<id>/cover.jpg`, then the `cover.jpg` sibling of
|
||||||
fn cover_path(cache_path: &str, track_id: u64) -> String {
|
/// the audio file (v1-format rows). Computed on the worker (a `String` is
|
||||||
let p = std::path::Path::new(cache_path)
|
/// `Send`); the image itself is loaded on the UI thread (`slint::Image` is
|
||||||
.join("tracks-cmaf")
|
/// NOT `Send`).
|
||||||
.join(track_id.to_string())
|
fn cover_path(cache_path: &str, track: &CachedTrackInfo) -> String {
|
||||||
.join("cover.jpg");
|
track.resolve_cover_path(cache_path).unwrap_or_default()
|
||||||
if p.exists() {
|
|
||||||
p.to_string_lossy().to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a cover image from a path on the UI thread (empty path / missing -> 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 {
|
} else {
|
||||||
0
|
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
|
let cover_path = group
|
||||||
.first()
|
.iter()
|
||||||
.map(|t| cover_path(&cache_path, t.track_id))
|
.map(|t| cover_path(&cache_path, t))
|
||||||
|
.find(|p| !p.is_empty())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
rows.push(RowData {
|
rows.push(RowData {
|
||||||
kind: "album",
|
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.
|
/// Whether a playlist passes the search + visibility + folder filters.
|
||||||
/// `current_folder` is None for the flat / root view (we never enter a
|
/// `current_folder` is None for the flat / root view (we never enter a
|
||||||
/// folder in the Slint port — folder navigation is via the tree).
|
/// 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
|
// Filtered + sorted playlists for the grid / list. While OFFLINE only
|
||||||
// the MIXED playlists (>= 1 local sidecar track) stay (D11.b).
|
// the MIXED playlists (>= 1 local sidecar track) stay (D11.b).
|
||||||
let offline = crate::offline_mode::engine().is_offline();
|
let offline = crate::offline_mode::engine().is_offline();
|
||||||
let mut filtered: Vec<PmPlaylist> = data
|
let filtered: Vec<PmPlaylist> = data
|
||||||
.playlists
|
.playlists
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| !offline || p.local_count > 0)
|
.filter(|p| !offline || p.local_count > 0)
|
||||||
.filter(|p| passes(p, &query, &filter, folder_mode, &view_mode))
|
.filter(|p| passes(p, &query, &filter, folder_mode, &view_mode))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
sort_playlists(&mut filtered, &sort);
|
// LOCAL playlists (library.db, D7) interleave into the SAME sort as the
|
||||||
let mut playlist_items: Vec<PmPlaylistItem> = filtered.iter().map(playlist_item).collect();
|
// Qobuz set (B4) — see `PmEntry` for the missing-stat sort rules.
|
||||||
// LOCAL playlists (library.db, D7) — appended after the Qobuz set,
|
let mut entries: Vec<PmEntry> = filtered.iter().map(PmEntry::Qobuz).collect();
|
||||||
// name-sorted, honoring the search query. They have no hidden flag, so
|
entries.extend(local_entries(&data, &query, &filter).into_iter().map(PmEntry::Local));
|
||||||
// the "hidden" filter excludes them; folder filtering N/A (root-only).
|
sort_entries(&mut entries, &sort);
|
||||||
if filter != "hidden" {
|
let playlist_items: Vec<PmPlaylistItem> = entries.iter().map(|e| e.item()).collect();
|
||||||
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)));
|
|
||||||
}
|
|
||||||
let visible_count = playlist_items.len();
|
let visible_count = playlist_items.len();
|
||||||
|
|
||||||
// Tree rows (folder headers + nested + root playlists). Built only
|
// 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).
|
// Root playlists (no folder), with the LOCAL playlists (never in
|
||||||
let mut root: Vec<PmPlaylist> = data
|
// folders) interleaved into the SAME sort (B4) — see `PmEntry` for the
|
||||||
|
// missing-stat sort rules.
|
||||||
|
let root: Vec<PmPlaylist> = data
|
||||||
.playlists
|
.playlists
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| p.folder_id.is_none())
|
.filter(|p| p.folder_id.is_none())
|
||||||
.filter(|p| matches(p))
|
.filter(|p| matches(p))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
sort_playlists(&mut root, sort);
|
let mut entries: Vec<PmEntry> = root.iter().map(PmEntry::Qobuz).collect();
|
||||||
for p in &root {
|
entries.extend(local_entries(data, query, filter).into_iter().map(PmEntry::Local));
|
||||||
|
sort_entries(&mut entries, sort);
|
||||||
|
for e in &entries {
|
||||||
rows.push(PmTreeRow {
|
rows.push(PmTreeRow {
|
||||||
kind: "playlist".into(),
|
kind: "playlist".into(),
|
||||||
expanded: false,
|
expanded: false,
|
||||||
folder: PmFolderItem::default(),
|
folder: PmFolderItem::default(),
|
||||||
playlist: playlist_item(p),
|
playlist: e.item(),
|
||||||
indent: false,
|
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
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user