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,
};
/// 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`.
///
/// SELECT must be:
/// `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`
/// created_at, last_accessed_at, artwork_path, file_path`
fn row_to_cached_track_info(row: &rusqlite::Row) -> rusqlite::Result<CachedTrackInfo> {
Ok(CachedTrackInfo {
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)?,
created_at: row.get(13)?,
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
pub fn get_track(&self, track_id: u64) -> Result<Option<CachedTrackInfo>, String> {
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",
params![track_id as i64],
row_to_cached_track_info,
@@ -341,7 +343,7 @@ impl OfflineCacheDb {
/// Get all cached tracks
pub fn get_all_tracks(&self) -> Result<Vec<CachedTrackInfo>, String> {
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"
).map_err(|e| format!("Failed to prepare query: {}", e))?;
@@ -364,7 +366,8 @@ impl OfflineCacheDb {
.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
progress_percent, error_message, created_at, last_accessed_at,
artwork_path, file_path
FROM cached_tracks WHERE album_id = ?1",
)
.map_err(|e| format!("Prepare failed: {}", e))?;
@@ -112,6 +112,8 @@ mod tests {
error_message: None,
created_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 created_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