feat(offline_cache): add get_album_tracks and reset_track_for_redownload

get_album_tracks returns rows of any status (needed for re-download
which targets ready+failed but skips downloading). reset_track_for_redownload
clears progress and error so the downloader can restart cleanly.

Also adds OfflineCacheStatus::Pending (serializes/parses as 'pending') so
reset_track_for_redownload has a target state distinct from Queued and
Downloading. The frontend type union (per plan) already includes 'pending'.

Factors out row_to_cached_track_info as a private free function so
get_track, get_all_tracks, and the new get_album_tracks share the same
15-column row decoder.

cc
This commit is contained in:
vicrodh committed 2026-05-06 07:21:15 -06:00
1 parent f6eaa4ba6b
commit c47a5558ad
2 files changed
+89 -38

No files matched your search

+86 -38
View File
@@ -7,6 +7,33 @@ use super::{
CachedTrackInfo, OfflineCacheStats, OfflineCacheStatus, ReadyTrackForSync, TrackCacheInfo,
};
/// Maps a `cached_tracks` row (with the canonical 15-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`
fn row_to_cached_track_info(row: &rusqlite::Row) -> rusqlite::Result<CachedTrackInfo> {
Ok(CachedTrackInfo {
track_id: row.get::<_, i64>(0)? as u64,
title: row.get(1)?,
artist: row.get(2)?,
album: row.get(3)?,
album_id: row.get(4)?,
duration_secs: row.get::<_, i64>(5)? as u64,
file_size_bytes: row.get::<_, i64>(6)? as u64,
quality: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
bit_depth: row.get::<_, Option<i64>>(8)?.map(|v| v as u32),
sample_rate: row.get(9)?,
status: OfflineCacheStatus::from_str(&row.get::<_, String>(10)?),
progress_percent: row.get::<_, i64>(11)? as u8,
error_message: row.get(12)?,
created_at: row.get(13)?,
last_accessed_at: row.get(14)?,
})
}
/// Database wrapper for cached tracks index
pub struct OfflineCacheDb {
conn: Connection,
@@ -301,25 +328,7 @@ impl OfflineCacheDb {
"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
FROM cached_tracks WHERE track_id = ?1",
params![track_id as i64],
|row| {
Ok(CachedTrackInfo {
track_id: row.get::<_, i64>(0)? as u64,
title: row.get(1)?,
artist: row.get(2)?,
album: row.get(3)?,
album_id: row.get(4)?,
duration_secs: row.get::<_, i64>(5)? as u64,
file_size_bytes: row.get::<_, i64>(6)? as u64,
quality: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
bit_depth: row.get::<_, Option<i64>>(8)?.map(|v| v as u32),
sample_rate: row.get(9)?,
status: OfflineCacheStatus::from_str(&row.get::<_, String>(10)?),
progress_percent: row.get::<_, i64>(11)? as u8,
error_message: row.get(12)?,
created_at: row.get(13)?,
last_accessed_at: row.get(14)?,
})
},
row_to_cached_track_info,
);
match result {
@@ -337,25 +346,7 @@ impl OfflineCacheDb {
).map_err(|e| format!("Failed to prepare query: {}", e))?;
let tracks = stmt
.query_map([], |row| {
Ok(CachedTrackInfo {
track_id: row.get::<_, i64>(0)? as u64,
title: row.get(1)?,
artist: row.get(2)?,
album: row.get(3)?,
album_id: row.get(4)?,
duration_secs: row.get::<_, i64>(5)? as u64,
file_size_bytes: row.get::<_, i64>(6)? as u64,
quality: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
bit_depth: row.get::<_, Option<i64>>(8)?.map(|v| v as u32),
sample_rate: row.get(9)?,
status: OfflineCacheStatus::from_str(&row.get::<_, String>(10)?),
progress_percent: row.get::<_, i64>(11)? as u8,
error_message: row.get(12)?,
created_at: row.get(13)?,
last_accessed_at: row.get(14)?,
})
})
.query_map([], row_to_cached_track_info)
.map_err(|e| format!("Failed to query tracks: {}", e))?;
let mut result = Vec::new();
@@ -366,6 +357,38 @@ impl OfflineCacheDb {
Ok(result)
}
/// Returns all cached track rows for a given album_id (any status).
pub fn get_album_tracks(&self, album_id: &str) -> 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
FROM cached_tracks WHERE album_id = ?1",
)
.map_err(|e| format!("Prepare failed: {}", e))?;
let rows = stmt
.query_map([album_id], row_to_cached_track_info)
.map_err(|e| format!("Query failed: {}", e))?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| format!("Row decode failed: {}", e))
}
/// Resets a track row to Pending state for re-download.
/// Clears progress_percent and error_message.
pub fn reset_track_for_redownload(&self, track_id: u64) -> Result<(), String> {
self.conn()
.execute(
"UPDATE cached_tracks
SET status = 'pending', progress_percent = 0, error_message = NULL
WHERE track_id = ?1",
[track_id as i64],
)
.map_err(|e| format!("Update failed: {}", e))?;
Ok(())
}
/// Delete a track from cache
pub fn delete_track(&self, track_id: u64) -> Result<Option<String>, String> {
// Get file path before deleting
@@ -699,4 +722,29 @@ mod maintenance_tests {
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].track_id, 3);
}
#[test]
fn get_album_tracks_returns_only_matching_album() {
let (_tmp, db) = fresh_db();
db.insert_track(&sample_track(1, Some("alb1")), "/p/1").unwrap();
db.insert_track(&sample_track(2, Some("alb2")), "/p/2").unwrap();
let alb1 = db.get_album_tracks("alb1").unwrap();
assert_eq!(alb1.len(), 1);
assert_eq!(alb1[0].track_id, 1);
}
#[test]
fn reset_track_for_redownload_clears_progress_and_error() {
let (_tmp, db) = fresh_db();
db.insert_track(&sample_track(1, Some("alb1")), "/p/1").unwrap();
db.update_status(1, OfflineCacheStatus::Failed, Some("boom")).unwrap();
db.reset_track_for_redownload(1).unwrap();
let track = db.get_track(1).unwrap().unwrap();
assert!(matches!(track.status, OfflineCacheStatus::Pending));
assert_eq!(track.progress_percent, 0);
assert!(track.error_message.is_none());
}
}
+3
View File
@@ -34,6 +34,7 @@ pub use path_validator::{is_offline_root_available, validate_path, PathStatus};
#[serde(rename_all = "lowercase")]
pub enum OfflineCacheStatus {
Queued,
Pending,
Downloading,
Ready,
Failed,
@@ -43,6 +44,7 @@ impl OfflineCacheStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Pending => "pending",
Self::Downloading => "downloading",
Self::Ready => "ready",
Self::Failed => "failed",
@@ -52,6 +54,7 @@ impl OfflineCacheStatus {
pub fn from_str(s: &str) -> Self {
match s {
"queued" => Self::Queued,
"pending" => Self::Pending,
"downloading" => Self::Downloading,
"ready" => Self::Ready,
"failed" => Self::Failed,