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

+8 -5
View File
@@ -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(),
} }
} }
+40
View File
@@ -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
+60 -19
View File
@@ -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();
+13 -15
View File
@@ -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",
+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. /// 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
} }