feat(offline-cache): scaffold qbz-offline-cache crate with the tauri-free core
Slice 0 of the offline-cache port (extraction per ADR-006). Creates the new qbz-offline-cache workspace crate and moves the tauri-free leaf modules out of src-tauri/src/offline_cache/ verbatim: - types: the shared serde DTOs (was inline in offline_cache/mod.rs) - db: the SQLite cached_tracks index (super:: -> crate::types) - secret_vault, cmaf_store, path_validator: moved verbatim - maintenance: super:: refs re-pointed to crate:: src-tauri is untouched and keeps its own copy, so the production build stays green; the re-point + delete happens at the end of Slice 0. Crate compiles in isolation and all 5 unit tests pass. Remaining for Slice 0: playback pure-fn split, metadata/migration/purge (re-point crate::api/crate::library -> qbz_qobuz/qbz_library), the downloader + CacheEvent/CacheEventSink abstraction, the OfflineCacheState struct, then re-wire the Tauri commands and delete the old module. cc
This commit is contained in:
10 files changed
+1577
No files matched your search
Generated
+14
@@ -5129,6 +5129,20 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-offline-cache"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"qbz-cmaf",
|
||||
"qbz-qobuz",
|
||||
"qbz-secrets",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-player"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -15,6 +15,7 @@ members = [
|
||||
"qbz-cast",
|
||||
"qbz-cmaf",
|
||||
"qbz-secrets",
|
||||
"qbz-offline-cache",
|
||||
"qconnect-core",
|
||||
"qconnect-protocol",
|
||||
"qconnect-transport-ws",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "qbz-offline-cache"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Offline cache for QBZ: download-for-offline store, CMAF bundle vault, SQLite index, cached playback resolution (frontend-agnostic)"
|
||||
|
||||
[dependencies]
|
||||
# Sibling crates: the network + crypto + init-parse already live here.
|
||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||
qbz-cmaf = { path = "../qbz-cmaf" }
|
||||
qbz-secrets = { path = "../qbz-secrets" }
|
||||
|
||||
# SQLite index (match qbz-library's version/features)
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
|
||||
# DTO serialization (camelCase envelopes shared with the frontend)
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Logging
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,238 @@
|
||||
//! On-disk layout and I/O for v2 CMAF-bundle offline cache entries.
|
||||
//!
|
||||
//! Layout under `<offline_root>/tracks-cmaf/<track_id>/`:
|
||||
//!
|
||||
//! ```text
|
||||
//! init.mp4 — init segment (unencrypted container + FLAC header)
|
||||
//! segments.bin — concatenated encrypted audio segments (s=1..=n)
|
||||
//! manifest.json — small recovery manifest (segment offsets + n_segments)
|
||||
//! ```
|
||||
//!
|
||||
//! The `manifest.json` is a belt-and-suspenders convenience: SQLite is the
|
||||
//! authoritative source of truth, but if the DB is ever lost we can still
|
||||
//! tell the caller how to slice `segments.bin` back into per-segment
|
||||
//! buffers from the manifest. It's cheap to write and cheap to read.
|
||||
//!
|
||||
//! Everything here is intentionally I/O-only — no network, no crypto. The
|
||||
//! CMAF download itself happens in qbz-qobuz; this module just persists
|
||||
//! the bytes in a format the playback path can read back efficiently.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use qbz_qobuz::cmaf::CmafRawBundle;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const SUBDIR: &str = "tracks-cmaf";
|
||||
const INIT_FILENAME: &str = "init.mp4";
|
||||
const SEGMENTS_FILENAME: &str = "segments.bin";
|
||||
const MANIFEST_FILENAME: &str = "manifest.json";
|
||||
|
||||
/// Lightweight sidecar manifest saved next to the bundle. If the SQLite
|
||||
/// index is ever lost or an integrity check fails, this is enough to
|
||||
/// reconstruct the per-segment slicing so the decrypt path can still
|
||||
/// iterate the concatenated `segments.bin`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BundleManifest {
|
||||
pub version: u8,
|
||||
pub track_id: u64,
|
||||
pub format_id: u32,
|
||||
pub n_segments: u32,
|
||||
/// Offset in bytes of each encrypted segment inside `segments.bin`.
|
||||
/// `segment_offsets[i]` = start of segment `i+1`; `segment_offsets[n]`
|
||||
/// is the total size. Length = `n_segments + 1`.
|
||||
pub segment_offsets: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Where the v2 bundle for a given track id lives on disk. Callers use
|
||||
/// this to build DB rows and to locate existing bundles at playback.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BundleLayout {
|
||||
pub track_dir: PathBuf,
|
||||
pub init_path: PathBuf,
|
||||
pub segments_path: PathBuf,
|
||||
pub manifest_path: PathBuf,
|
||||
}
|
||||
|
||||
impl BundleLayout {
|
||||
pub fn new(offline_root: &Path, track_id: u64) -> Self {
|
||||
let track_dir = offline_root.join(SUBDIR).join(track_id.to_string());
|
||||
Self {
|
||||
init_path: track_dir.join(INIT_FILENAME),
|
||||
segments_path: track_dir.join(SEGMENTS_FILENAME),
|
||||
manifest_path: track_dir.join(MANIFEST_FILENAME),
|
||||
track_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a freshly-downloaded [`CmafRawBundle`] to disk under the track
|
||||
/// directory, returning the layout + total size of the persisted bytes.
|
||||
///
|
||||
/// Note: this does NOT write any key material. The caller is responsible
|
||||
/// for wrapping `bundle.content_key` / `bundle.infos` via `qbz-secrets`
|
||||
/// and persisting those blobs to the SQLite row.
|
||||
pub fn persist_bundle(
|
||||
offline_root: &Path,
|
||||
track_id: u64,
|
||||
bundle: &CmafRawBundle,
|
||||
) -> Result<(BundleLayout, u64), String> {
|
||||
let layout = BundleLayout::new(offline_root, track_id);
|
||||
fs::create_dir_all(&layout.track_dir)
|
||||
.map_err(|e| format!("Failed to create bundle dir {:?}: {}", layout.track_dir, e))?;
|
||||
|
||||
// Init segment (unencrypted, small)
|
||||
write_atomic(&layout.init_path, &bundle.init_bytes)
|
||||
.map_err(|e| format!("Failed to write init: {}", e))?;
|
||||
|
||||
// Audio segments: concatenated into a single file, with offsets tracked
|
||||
// for the manifest so playback can slice them back apart.
|
||||
let segments_tmp = layout.segments_path.with_extension("tmp");
|
||||
let file = fs::File::create(&segments_tmp)
|
||||
.map_err(|e| format!("Failed to create segments file: {}", e))?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
let mut offsets: Vec<u64> = Vec::with_capacity(bundle.segments.len() + 1);
|
||||
let mut cursor: u64 = 0;
|
||||
offsets.push(cursor);
|
||||
for seg in &bundle.segments {
|
||||
writer
|
||||
.write_all(seg)
|
||||
.map_err(|e| format!("Failed to write segment: {}", e))?;
|
||||
cursor += seg.len() as u64;
|
||||
offsets.push(cursor);
|
||||
}
|
||||
writer
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush segments: {}", e))?;
|
||||
let file = writer
|
||||
.into_inner()
|
||||
.map_err(|e| format!("Failed to finalize writer: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Failed to fsync segments: {}", e))?;
|
||||
drop(file);
|
||||
fs::rename(&segments_tmp, &layout.segments_path)
|
||||
.map_err(|e| format!("Failed to rename segments: {}", e))?;
|
||||
|
||||
let manifest = BundleManifest {
|
||||
version: 1,
|
||||
track_id,
|
||||
format_id: bundle.format_id,
|
||||
n_segments: bundle.n_segments as u32,
|
||||
segment_offsets: offsets.clone(),
|
||||
};
|
||||
let manifest_json = serde_json::to_vec_pretty(&manifest)
|
||||
.map_err(|e| format!("Failed to serialize manifest: {}", e))?;
|
||||
write_atomic(&layout.manifest_path, &manifest_json)
|
||||
.map_err(|e| format!("Failed to write manifest: {}", e))?;
|
||||
|
||||
let total_bytes = bundle.init_bytes.len() as u64 + cursor + manifest_json.len() as u64;
|
||||
log::info!(
|
||||
"[OfflineCache/CMAF] Persisted bundle for track {}: init={}B, segments={}B ({} files), manifest={}B, total={:.2} MB",
|
||||
track_id,
|
||||
bundle.init_bytes.len(),
|
||||
cursor,
|
||||
bundle.segments.len(),
|
||||
manifest_json.len(),
|
||||
total_bytes as f64 / (1024.0 * 1024.0),
|
||||
);
|
||||
Ok((layout, total_bytes))
|
||||
}
|
||||
|
||||
/// Load a bundle back from disk for playback. Returns the init bytes and
|
||||
/// the per-segment slices of `segments.bin` in order.
|
||||
pub fn read_bundle(layout: &BundleLayout) -> Result<LoadedBundle, String> {
|
||||
let init_bytes = fs::read(&layout.init_path)
|
||||
.map_err(|e| format!("Failed to read init {:?}: {}", layout.init_path, e))?;
|
||||
let segments_blob = fs::read(&layout.segments_path)
|
||||
.map_err(|e| format!("Failed to read segments {:?}: {}", layout.segments_path, e))?;
|
||||
let manifest_bytes = fs::read(&layout.manifest_path)
|
||||
.map_err(|e| format!("Failed to read manifest {:?}: {}", layout.manifest_path, e))?;
|
||||
let manifest: BundleManifest = serde_json::from_slice(&manifest_bytes)
|
||||
.map_err(|e| format!("Failed to parse manifest: {}", e))?;
|
||||
|
||||
let n = manifest.n_segments as usize;
|
||||
if manifest.segment_offsets.len() != n + 1 {
|
||||
return Err(format!(
|
||||
"Manifest offsets length {} doesn't match n_segments {}+1",
|
||||
manifest.segment_offsets.len(),
|
||||
n
|
||||
));
|
||||
}
|
||||
let mut segments: Vec<Vec<u8>> = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let start = manifest.segment_offsets[i] as usize;
|
||||
let end = manifest.segment_offsets[i + 1] as usize;
|
||||
if end > segments_blob.len() {
|
||||
return Err(format!(
|
||||
"Segment {} offset {}..{} past blob size {}",
|
||||
i + 1,
|
||||
start,
|
||||
end,
|
||||
segments_blob.len()
|
||||
));
|
||||
}
|
||||
segments.push(segments_blob[start..end].to_vec());
|
||||
}
|
||||
|
||||
Ok(LoadedBundle {
|
||||
init_bytes,
|
||||
segments,
|
||||
manifest,
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove a bundle from disk (called by eviction / re-download).
|
||||
pub fn remove_bundle(layout: &BundleLayout) {
|
||||
if layout.track_dir.exists() {
|
||||
if let Err(e) = fs::remove_dir_all(&layout.track_dir) {
|
||||
log::warn!(
|
||||
"[OfflineCache/CMAF] Failed to remove bundle dir {:?}: {}",
|
||||
layout.track_dir,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A freshly-loaded bundle ready to be decrypted + played. Owned buffers
|
||||
/// so the caller can feed the player without holding any file locks.
|
||||
pub struct LoadedBundle {
|
||||
pub init_bytes: Vec<u8>,
|
||||
pub segments: Vec<Vec<u8>>,
|
||||
pub manifest: BundleManifest,
|
||||
}
|
||||
|
||||
impl LoadedBundle {
|
||||
/// Decrypt the bundle into a complete, playable FLAC byte stream.
|
||||
///
|
||||
/// The layout is `flac_header || decrypted_frames` — identical to
|
||||
/// what the streaming playback path produces, so the player can
|
||||
/// consume it via `play_data` exactly like a cached plain FLAC.
|
||||
pub fn decrypt_to_flac(&self, content_key: &[u8; 16]) -> Result<Vec<u8>, String> {
|
||||
let init_info = qbz_cmaf::parse_init_segment(&self.init_bytes)
|
||||
.map_err(|e| format!("Failed to parse init segment: {}", e))?;
|
||||
|
||||
let total = init_info.flac_header.len()
|
||||
+ init_info
|
||||
.segment_table
|
||||
.iter()
|
||||
.map(|s| s.byte_len as usize)
|
||||
.sum::<usize>();
|
||||
let mut out = Vec::with_capacity(total);
|
||||
out.extend_from_slice(&init_info.flac_header);
|
||||
qbz_qobuz::cmaf::decrypt_segments_into(&self.segments, content_key, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
let tmp = path.with_extension("tmp");
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)?;
|
||||
f.write_all(data)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,750 @@
|
||||
//! SQLite database for offline cache index
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::types::{
|
||||
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,
|
||||
}
|
||||
|
||||
impl OfflineCacheDb {
|
||||
/// Open or create the database
|
||||
pub fn new(path: &Path) -> Result<Self, String> {
|
||||
let conn = Connection::open(path)
|
||||
.map_err(|e| format!("Failed to open offline cache database: {}", e))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to enable WAL for offline cache database: {}", e))?;
|
||||
|
||||
let db = Self { conn };
|
||||
db.init_schema()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Get reference to the connection (for direct queries)
|
||||
pub fn conn(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
|
||||
/// Initialize database schema
|
||||
fn init_schema(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS cached_tracks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
track_id INTEGER UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
album TEXT,
|
||||
album_id TEXT,
|
||||
duration_secs INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
format TEXT NOT NULL DEFAULT 'flac',
|
||||
quality TEXT,
|
||||
bit_depth INTEGER,
|
||||
sample_rate REAL,
|
||||
artwork_path TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
progress_percent INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_accessed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_track_id ON cached_tracks(track_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_status ON cached_tracks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_last_accessed ON cached_tracks(last_accessed_at);
|
||||
",
|
||||
)
|
||||
.map_err(|e| format!("Failed to initialize database schema: {}", e))?;
|
||||
|
||||
self.migrate_v2_cmaf_columns()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Additive migration for the v2 offline format.
|
||||
///
|
||||
/// Adds columns for bit-identical CMAF storage:
|
||||
/// - `cache_format`: 1 = legacy plain FLAC, 2 = raw CMAF bundle
|
||||
/// - `init_path`: path to the init.mp4 (contains FLAC header + table)
|
||||
/// - `content_key_wrapped`: AES content key wrapped with qbz-secrets
|
||||
/// - `infos_wrapped`: session infos salt wrapped with qbz-secrets
|
||||
/// - `format_id`: Qobuz format id (e.g. 5/6/7/27)
|
||||
/// - `n_segments`: number of audio segments (s=1..=n)
|
||||
///
|
||||
/// Existing rows keep `cache_format=1` so playback continues to read
|
||||
/// the legacy plain-FLAC `file_path` for them. New downloads go to
|
||||
/// `cache_format=2`. We never rewrite v1 rows into v2 — the two
|
||||
/// formats coexist until the v1 rows naturally expire via the
|
||||
/// subscription-lapse cache wipe or user-triggered re-download.
|
||||
fn migrate_v2_cmaf_columns(&self) -> Result<(), String> {
|
||||
let existing = self.existing_columns("cached_tracks")?;
|
||||
let add = |col: &str, ddl: &str| -> Result<(), String> {
|
||||
if !existing.iter().any(|c| c == col) {
|
||||
let sql = format!("ALTER TABLE cached_tracks ADD COLUMN {}", ddl);
|
||||
self.conn
|
||||
.execute(&sql, [])
|
||||
.map_err(|e| format!("Failed to add column {}: {}", col, e))?;
|
||||
log::info!(
|
||||
"[OfflineCache/MIGRATE] Added column {} to cached_tracks",
|
||||
col
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
add("cache_format", "cache_format INTEGER NOT NULL DEFAULT 1")?;
|
||||
add("init_path", "init_path TEXT")?;
|
||||
add("content_key_wrapped", "content_key_wrapped BLOB")?;
|
||||
add("infos_wrapped", "infos_wrapped BLOB")?;
|
||||
add("format_id", "format_id INTEGER")?;
|
||||
add("n_segments", "n_segments INTEGER")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn existing_columns(&self, table: &str) -> Result<Vec<String>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(&format!("PRAGMA table_info({})", table))
|
||||
.map_err(|e| format!("Failed to prepare PRAGMA: {}", e))?;
|
||||
let cols = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.map_err(|e| format!("Failed to read PRAGMA: {}", e))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Failed to iterate PRAGMA: {}", e))?;
|
||||
Ok(cols)
|
||||
}
|
||||
|
||||
/// Insert a new track to cache for offline
|
||||
pub fn insert_track(&self, info: &TrackCacheInfo, file_path: &str) -> Result<(), String> {
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO cached_tracks
|
||||
(track_id, title, artist, album, album_id, duration_secs, file_path, quality, bit_depth, sample_rate, status, progress_percent, created_at, last_accessed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'queued', 0, datetime('now'), datetime('now'))",
|
||||
params![
|
||||
info.track_id as i64,
|
||||
info.title,
|
||||
info.artist,
|
||||
info.album,
|
||||
info.album_id,
|
||||
info.duration_secs as i64,
|
||||
file_path,
|
||||
info.quality,
|
||||
info.bit_depth.map(|v| v as i64),
|
||||
info.sample_rate,
|
||||
],
|
||||
).map_err(|e| format!("Failed to insert track: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert multiple tracks in a single transaction (batch queuing)
|
||||
pub fn insert_tracks_batch(&self, tracks: &[(&TrackCacheInfo, String)]) -> Result<(), String> {
|
||||
let tx = self
|
||||
.conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT OR REPLACE INTO cached_tracks
|
||||
(track_id, title, artist, album, album_id, duration_secs, file_path, quality, bit_depth, sample_rate, status, progress_percent, created_at, last_accessed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'queued', 0, datetime('now'), datetime('now'))"
|
||||
).map_err(|e| format!("Failed to prepare batch insert: {}", e))?;
|
||||
|
||||
for (info, file_path) in tracks {
|
||||
stmt.execute(params![
|
||||
info.track_id as i64,
|
||||
info.title,
|
||||
info.artist,
|
||||
info.album,
|
||||
info.album_id,
|
||||
info.duration_secs as i64,
|
||||
file_path,
|
||||
info.quality,
|
||||
info.bit_depth.map(|v| v as i64),
|
||||
info.sample_rate,
|
||||
])
|
||||
.map_err(|e| format!("Failed to insert track {}: {}", info.track_id, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.map_err(|e| format!("Failed to commit batch insert: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update track status
|
||||
pub fn update_status(
|
||||
&self,
|
||||
track_id: u64,
|
||||
status: OfflineCacheStatus,
|
||||
error: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE cached_tracks SET status = ?1, error_message = ?2 WHERE track_id = ?3",
|
||||
params![status.as_str(), error, track_id as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update status: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update caching progress
|
||||
pub fn update_progress(
|
||||
&self,
|
||||
track_id: u64,
|
||||
progress: u8,
|
||||
size_bytes: u64,
|
||||
) -> Result<(), String> {
|
||||
self.conn.execute(
|
||||
"UPDATE cached_tracks SET progress_percent = ?1, file_size_bytes = ?2 WHERE track_id = ?3",
|
||||
params![progress as i64, size_bytes as i64, track_id as i64],
|
||||
).map_err(|e| format!("Failed to update progress: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark caching as complete
|
||||
pub fn mark_complete(&self, track_id: u64, file_size: u64) -> Result<(), String> {
|
||||
self.conn.execute(
|
||||
"UPDATE cached_tracks SET status = 'ready', progress_percent = 100, file_size_bytes = ?1, last_accessed_at = datetime('now') WHERE track_id = ?2",
|
||||
params![file_size as i64, track_id as i64],
|
||||
).map_err(|e| format!("Failed to mark complete: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update last accessed time (for LRU)
|
||||
pub fn touch(&self, track_id: u64) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE cached_tracks SET last_accessed_at = datetime('now') WHERE track_id = ?1",
|
||||
params![track_id as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update access time: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a track is cached and ready
|
||||
pub fn is_cached(&self, track_id: u64) -> Result<bool, String> {
|
||||
let count: i64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM cached_tracks WHERE track_id = ?1 AND status = 'ready'",
|
||||
params![track_id as i64],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to check cache: {}", e))?;
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Get file path for a cached track
|
||||
pub fn get_file_path(&self, track_id: u64) -> Result<Option<String>, String> {
|
||||
let result = self.conn.query_row(
|
||||
"SELECT file_path FROM cached_tracks WHERE track_id = ?1 AND status = 'ready'",
|
||||
params![track_id as i64],
|
||||
|row| row.get(0),
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(path) => Ok(Some(path)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get file path: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all ready (cached) tracks with their file paths for syncing to library
|
||||
pub fn get_ready_tracks_for_sync(&self) -> Result<Vec<ReadyTrackForSync>, String> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT track_id, title, artist, album, duration_secs, file_path, bit_depth, sample_rate
|
||||
FROM cached_tracks WHERE status = 'ready'"
|
||||
).map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||
|
||||
let tracks = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(ReadyTrackForSync {
|
||||
track_id: row.get::<_, i64>(0)? as u64,
|
||||
title: row.get(1)?,
|
||||
artist: row.get(2)?,
|
||||
album: row.get(3)?,
|
||||
duration_secs: row.get::<_, i64>(4)? as u64,
|
||||
file_path: row.get(5)?,
|
||||
bit_depth: row.get::<_, Option<i64>>(6)?.map(|v| v as u32),
|
||||
sample_rate: row.get(7)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query tracks: {}", e))?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for track in tracks {
|
||||
result.push(track.map_err(|e| format!("Failed to read track: {}", e))?);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 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
|
||||
FROM cached_tracks WHERE track_id = ?1",
|
||||
params![track_id as i64],
|
||||
row_to_cached_track_info,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(info) => Ok(Some(info)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to get track: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
FROM cached_tracks ORDER BY last_accessed_at DESC"
|
||||
).map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||
|
||||
let tracks = stmt
|
||||
.query_map([], row_to_cached_track_info)
|
||||
.map_err(|e| format!("Failed to query tracks: {}", e))?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for track in tracks {
|
||||
result.push(track.map_err(|e| format!("Failed to read track: {}", e))?);
|
||||
}
|
||||
|
||||
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 = 'queued', 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
|
||||
let file_path: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT file_path FROM cached_tracks WHERE track_id = ?1",
|
||||
params![track_id as i64],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM cached_tracks WHERE track_id = ?1",
|
||||
params![track_id as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to delete track: {}", e))?;
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// Get statistics
|
||||
pub fn get_stats(
|
||||
&self,
|
||||
cache_path: &str,
|
||||
limit_bytes: Option<u64>,
|
||||
) -> Result<OfflineCacheStats, String> {
|
||||
let total_tracks: i64 = self
|
||||
.conn
|
||||
.query_row("SELECT COUNT(*) FROM cached_tracks", [], |row| row.get(0))
|
||||
.map_err(|e| format!("Failed to count tracks: {}", e))?;
|
||||
|
||||
let ready_tracks: i64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM cached_tracks WHERE status = 'ready'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to count ready tracks: {}", e))?;
|
||||
|
||||
let downloading_tracks: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM cached_tracks WHERE status = 'downloading' OR status = 'queued'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).map_err(|e| format!("Failed to count downloading tracks: {}", e))?;
|
||||
|
||||
let failed_tracks: i64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM cached_tracks WHERE status = 'failed'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Failed to count failed tracks: {}", e))?;
|
||||
|
||||
let total_size: i64 = self.conn.query_row(
|
||||
"SELECT COALESCE(SUM(file_size_bytes), 0) FROM cached_tracks WHERE status = 'ready'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).map_err(|e| format!("Failed to sum sizes: {}", e))?;
|
||||
|
||||
Ok(OfflineCacheStats {
|
||||
total_tracks: total_tracks as usize,
|
||||
ready_tracks: ready_tracks as usize,
|
||||
downloading_tracks: downloading_tracks as usize,
|
||||
failed_tracks: failed_tracks as usize,
|
||||
total_size_bytes: total_size as u64,
|
||||
limit_bytes,
|
||||
cache_path: cache_path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get tracks to evict (LRU order) to free up space
|
||||
pub fn get_tracks_for_eviction(
|
||||
&self,
|
||||
bytes_to_free: u64,
|
||||
) -> Result<Vec<(u64, String)>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT track_id, file_path, file_size_bytes FROM cached_tracks
|
||||
WHERE status = 'ready'
|
||||
ORDER BY last_accessed_at ASC",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare eviction query: {}", e))?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut freed = 0u64;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)? as u64,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, i64>(2)? as u64,
|
||||
))
|
||||
})
|
||||
.map_err(|e| format!("Failed to query for eviction: {}", e))?;
|
||||
|
||||
for row in rows {
|
||||
if freed >= bytes_to_free {
|
||||
break;
|
||||
}
|
||||
let (track_id, file_path, size) =
|
||||
row.map_err(|e| format!("Failed to read row: {}", e))?;
|
||||
result.push((track_id, file_path));
|
||||
freed += size;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Clear all entries
|
||||
pub fn clear_all(&self) -> Result<Vec<String>, String> {
|
||||
// Get all file paths first
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT file_path FROM cached_tracks")
|
||||
.map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||
|
||||
let paths: Vec<String> = stmt
|
||||
.query_map([], |row| row.get(0))
|
||||
.map_err(|e| format!("Failed to query paths: {}", e))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
self.conn
|
||||
.execute("DELETE FROM cached_tracks", [])
|
||||
.map_err(|e| format!("Failed to clear database: {}", e))?;
|
||||
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// Update file path for a track (after organizing)
|
||||
pub fn update_file_path(&self, track_id: u64, new_path: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE cached_tracks SET file_path = ?1 WHERE track_id = ?2",
|
||||
params![new_path, track_id as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update file path: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update artwork path for a track
|
||||
pub fn update_artwork_path(&self, track_id: u64, artwork_path: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE cached_tracks SET artwork_path = ?1 WHERE track_id = ?2",
|
||||
params![artwork_path, track_id as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to update artwork path: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== v2 CMAF bundle fields ====================
|
||||
|
||||
/// Persist the CMAF-specific columns for a track after it was
|
||||
/// successfully downloaded as a raw encrypted bundle.
|
||||
///
|
||||
/// `file_path` here is the concatenated-segments file (or primary
|
||||
/// segment file, depending on how the caller lays out the bundle on
|
||||
/// disk). `init_path` is the init.mp4. Both keys are already wrapped
|
||||
/// by the caller via `qbz-secrets::SecretBox::wrap`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_cmaf_bundle(
|
||||
&self,
|
||||
track_id: u64,
|
||||
segments_path: &str,
|
||||
init_path: &str,
|
||||
content_key_wrapped: &[u8],
|
||||
infos_wrapped: &[u8],
|
||||
format_id: u32,
|
||||
n_segments: u32,
|
||||
total_bytes: u64,
|
||||
) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE cached_tracks
|
||||
SET cache_format = 2,
|
||||
file_path = ?1,
|
||||
init_path = ?2,
|
||||
content_key_wrapped = ?3,
|
||||
infos_wrapped = ?4,
|
||||
format_id = ?5,
|
||||
n_segments = ?6,
|
||||
file_size_bytes = ?7
|
||||
WHERE track_id = ?8",
|
||||
params![
|
||||
segments_path,
|
||||
init_path,
|
||||
content_key_wrapped,
|
||||
infos_wrapped,
|
||||
format_id as i64,
|
||||
n_segments as i64,
|
||||
total_bytes as i64,
|
||||
track_id as i64,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to write CMAF bundle fields: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read back the bundle fields for a track, for offline playback.
|
||||
pub fn get_cmaf_bundle(&self, track_id: u64) -> Result<Option<CmafBundleRow>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT cache_format, file_path, init_path, content_key_wrapped,
|
||||
infos_wrapped, format_id, n_segments
|
||||
FROM cached_tracks
|
||||
WHERE track_id = ?1",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare CMAF bundle select: {}", e))?;
|
||||
let row: Result<CmafBundleRow, _> = stmt.query_row(params![track_id as i64], |row| {
|
||||
Ok(CmafBundleRow {
|
||||
cache_format: row.get::<_, i64>(0)? as u8,
|
||||
segments_path: row.get(1)?,
|
||||
init_path: row.get::<_, Option<String>>(2)?,
|
||||
content_key_wrapped: row.get::<_, Option<Vec<u8>>>(3)?,
|
||||
infos_wrapped: row.get::<_, Option<Vec<u8>>>(4)?,
|
||||
format_id: row.get::<_, Option<i64>>(5)?.map(|v| v as u32),
|
||||
n_segments: row.get::<_, Option<i64>>(6)?.map(|v| v as u32),
|
||||
})
|
||||
});
|
||||
match row {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to read CMAF bundle: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes all rows for the given album_id in a single transaction.
|
||||
/// Returns (deleted track_ids, total file_size_bytes freed).
|
||||
pub fn delete_album_tracks(&self, album_id: &str) -> Result<(Vec<u64>, u64), String> {
|
||||
let tx = self
|
||||
.conn()
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| format!("Failed to begin tx: {}", e))?;
|
||||
|
||||
let ids: Vec<u64> = {
|
||||
let mut stmt = tx
|
||||
.prepare("SELECT track_id FROM cached_tracks WHERE album_id = ?1")
|
||||
.map_err(|e| format!("Prepare failed: {}", e))?;
|
||||
let rows = stmt
|
||||
.query_map([album_id], |row| row.get::<_, i64>(0).map(|v| v as u64))
|
||||
.map_err(|e| format!("Query failed: {}", e))?;
|
||||
rows.collect::<rusqlite::Result<Vec<u64>>>()
|
||||
.map_err(|e| format!("Row decode failed: {}", e))?
|
||||
};
|
||||
|
||||
let bytes: i64 = tx
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(file_size_bytes), 0) FROM cached_tracks WHERE album_id = ?1",
|
||||
[album_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| format!("Sum failed: {}", e))?;
|
||||
|
||||
tx.execute("DELETE FROM cached_tracks WHERE album_id = ?1", [album_id])
|
||||
.map_err(|e| format!("Delete failed: {}", e))?;
|
||||
|
||||
tx.commit()
|
||||
.map_err(|e| format!("Commit failed: {}", e))?;
|
||||
|
||||
Ok((ids, bytes as u64))
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw snapshot of the v2 bundle columns for a cached track.
|
||||
///
|
||||
/// `cache_format` tells the caller how to interpret the rest:
|
||||
/// - `1` — legacy plain FLAC at `segments_path`; other fields empty.
|
||||
/// - `2` — raw CMAF bundle; all fields populated.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CmafBundleRow {
|
||||
pub cache_format: u8,
|
||||
pub segments_path: String,
|
||||
pub init_path: Option<String>,
|
||||
pub content_key_wrapped: Option<Vec<u8>>,
|
||||
pub infos_wrapped: Option<Vec<u8>>,
|
||||
pub format_id: Option<u32>,
|
||||
pub n_segments: Option<u32>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod maintenance_tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fresh_db() -> (TempDir, OfflineCacheDb) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("idx.db");
|
||||
let db = OfflineCacheDb::new(&path).unwrap();
|
||||
(tmp, db)
|
||||
}
|
||||
|
||||
fn sample_track(id: u64, album_id: Option<&str>) -> TrackCacheInfo {
|
||||
TrackCacheInfo {
|
||||
track_id: id,
|
||||
title: format!("t{}", id),
|
||||
artist: "A".into(),
|
||||
album: Some("Alb".into()),
|
||||
album_id: album_id.map(String::from),
|
||||
duration_secs: 100,
|
||||
quality: "lossless".into(),
|
||||
bit_depth: Some(16),
|
||||
sample_rate: Some(44100.0),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_album_tracks_returns_deleted_ids_and_freed_bytes() {
|
||||
let (_tmp, db) = fresh_db();
|
||||
db.insert_track(&sample_track(1, Some("alb1")), "/p/1").unwrap();
|
||||
db.insert_track(&sample_track(2, Some("alb1")), "/p/2").unwrap();
|
||||
db.insert_track(&sample_track(3, Some("alb2")), "/p/3").unwrap();
|
||||
db.mark_complete(1, 1000).unwrap();
|
||||
db.mark_complete(2, 2000).unwrap();
|
||||
db.mark_complete(3, 9999).unwrap();
|
||||
|
||||
let (ids, bytes) = db.delete_album_tracks("alb1").unwrap();
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert!(ids.contains(&1) && ids.contains(&2));
|
||||
assert_eq!(bytes, 3000);
|
||||
|
||||
// alb2 untouched
|
||||
let remaining = db.get_all_tracks().unwrap();
|
||||
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::Queued));
|
||||
assert_eq!(track.progress_percent, 0);
|
||||
assert!(track.error_message.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Offline cache for QBZ — frontend-agnostic.
|
||||
//!
|
||||
//! Downloading Qobuz content for offline playback and managing the local
|
||||
//! store: the on-disk CMAF bundle store, the per-track secret vault, the
|
||||
//! SQLite index, maintenance/purge/migration, and the pure cached-playback
|
||||
//! resolution.
|
||||
//!
|
||||
//! Extracted out of `src-tauri/src/offline_cache/` so both the Tauri and
|
||||
//! the Slint frontends share ONE implementation (ADR-006). The download
|
||||
//! pipeline emits progress through a `CacheEventSink` callback instead of
|
||||
//! Tauri events, so this crate has no Tauri dependency.
|
||||
//!
|
||||
//! Migration status (Slice 0 — extraction): modules are moved here
|
||||
//! incrementally; `src-tauri` keeps its own copy until the re-point step,
|
||||
//! so the workspace stays green throughout.
|
||||
|
||||
pub mod cmaf_store;
|
||||
pub mod db;
|
||||
pub mod maintenance;
|
||||
pub mod path_validator;
|
||||
pub mod secret_vault;
|
||||
pub mod types;
|
||||
|
||||
pub use db::{CmafBundleRow, OfflineCacheDb};
|
||||
pub use path_validator::{is_offline_root_available, validate_path, PathStatus};
|
||||
pub use types::{
|
||||
CacheProgress, CachedTrackInfo, OfflineCacheStats, OfflineCacheStatus, ReadyTrackForSync,
|
||||
TrackCacheInfo,
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Maintenance operations on the offline cache: bulk delete and re-download.
|
||||
//! Pure logic — no Tauri state. Callable from any future TUI or headless binary.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::cmaf_store::BundleLayout;
|
||||
use crate::{CachedTrackInfo, OfflineCacheDb, OfflineCacheStatus};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AlbumRemovalReport {
|
||||
pub album_id: String,
|
||||
pub removed_track_ids: Vec<u64>,
|
||||
pub freed_bytes: u64,
|
||||
}
|
||||
|
||||
/// Removes all cached tracks of an album: SQLite rows + on-disk CMAF bundles.
|
||||
/// Filesystem errors per-track are logged and not propagated; SQLite is the
|
||||
/// source of truth and the bundle directories are best-effort cleanup.
|
||||
pub fn remove_album_cached_tracks(
|
||||
db: &OfflineCacheDb,
|
||||
offline_root: &Path,
|
||||
album_id: &str,
|
||||
) -> Result<AlbumRemovalReport, String> {
|
||||
let (ids, bytes) = db.delete_album_tracks(album_id)?;
|
||||
for &track_id in &ids {
|
||||
let layout = BundleLayout::new(offline_root, track_id);
|
||||
if layout.track_dir.exists() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&layout.track_dir) {
|
||||
log::warn!(
|
||||
"Failed to remove CMAF dir for track {}: {} (continuing)",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(AlbumRemovalReport {
|
||||
album_id: album_id.to_string(),
|
||||
removed_track_ids: ids,
|
||||
freed_bytes: bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pre-flight cache-limit check executed before queuing new downloads.
|
||||
///
|
||||
/// Returns `Ok(())` when:
|
||||
/// - `limit_bytes` is `None` (no limit configured); OR
|
||||
/// - the current `total_size_bytes` from the cache stats is strictly below the
|
||||
/// configured limit.
|
||||
///
|
||||
/// Returns `Err(message)` when the cache has already met or exceeded the
|
||||
/// configured limit. The message is suitable for surfacing to end users via a
|
||||
/// toast.
|
||||
///
|
||||
/// Intentionally simple: it does not predict the new track's size. Sufficient
|
||||
/// for v1 — the user gets feedback the moment they hit the limit and can free
|
||||
/// space or raise the limit through the offline cache manager.
|
||||
pub fn check_cache_limit(
|
||||
db: &OfflineCacheDb,
|
||||
offline_root: &Path,
|
||||
limit_bytes: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
let limit = match limit_bytes {
|
||||
Some(value) => value,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let stats = db.get_stats(&offline_root.to_string_lossy(), Some(limit))?;
|
||||
if stats.total_size_bytes >= limit {
|
||||
return Err(
|
||||
"Offline cache limit reached. Free space or raise the limit in the offline cache manager."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Filters tracks targeted by re-download: skip in-flight Downloading,
|
||||
/// optionally restrict to Failed only.
|
||||
pub fn select_redownload_targets(
|
||||
tracks: &[CachedTrackInfo],
|
||||
failed_only: bool,
|
||||
) -> Vec<&CachedTrackInfo> {
|
||||
tracks
|
||||
.iter()
|
||||
.filter(|track| match track.status {
|
||||
OfflineCacheStatus::Downloading => false,
|
||||
OfflineCacheStatus::Failed => true,
|
||||
_ => !failed_only,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::OfflineCacheStatus;
|
||||
|
||||
fn track_with_status(id: u64, status: OfflineCacheStatus) -> CachedTrackInfo {
|
||||
CachedTrackInfo {
|
||||
track_id: id,
|
||||
title: format!("t{}", id),
|
||||
artist: "A".into(),
|
||||
album: None,
|
||||
album_id: None,
|
||||
duration_secs: 0,
|
||||
file_size_bytes: 0,
|
||||
quality: "lossless".into(),
|
||||
bit_depth: None,
|
||||
sample_rate: None,
|
||||
status,
|
||||
progress_percent: 0,
|
||||
error_message: None,
|
||||
created_at: "".into(),
|
||||
last_accessed_at: "".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redownload_targets_full_skips_only_downloading() {
|
||||
let tracks = vec![
|
||||
track_with_status(1, OfflineCacheStatus::Ready),
|
||||
track_with_status(2, OfflineCacheStatus::Downloading),
|
||||
track_with_status(3, OfflineCacheStatus::Failed),
|
||||
track_with_status(4, OfflineCacheStatus::Queued),
|
||||
];
|
||||
let picked = select_redownload_targets(&tracks, false);
|
||||
let ids: Vec<u64> = picked.iter().map(|track| track.track_id).collect();
|
||||
assert_eq!(ids, vec![1, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redownload_targets_failed_only_returns_failed() {
|
||||
let tracks = vec![
|
||||
track_with_status(1, OfflineCacheStatus::Ready),
|
||||
track_with_status(2, OfflineCacheStatus::Failed),
|
||||
track_with_status(3, OfflineCacheStatus::Downloading),
|
||||
];
|
||||
let picked = select_redownload_targets(&tracks, true);
|
||||
let ids: Vec<u64> = picked.iter().map(|track| track.track_id).collect();
|
||||
assert_eq!(ids, vec![2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Offline cache path validation and management
|
||||
//!
|
||||
//! Handles path validation, permission checking, and mount status verification.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PathStatus {
|
||||
Valid,
|
||||
DoesNotExist,
|
||||
NotADirectory,
|
||||
NoWritePermission,
|
||||
NotMounted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PathValidationResult {
|
||||
pub status: PathStatus,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Validate an offline cache path
|
||||
pub fn validate_path(path: &str) -> Result<PathValidationResult, String> {
|
||||
let path_obj = Path::new(path);
|
||||
|
||||
if !path_obj.exists() {
|
||||
return Ok(PathValidationResult {
|
||||
status: PathStatus::DoesNotExist,
|
||||
message: format!("Path does not exist: {}", path),
|
||||
});
|
||||
}
|
||||
|
||||
if !path_obj.is_dir() {
|
||||
return Ok(PathValidationResult {
|
||||
status: PathStatus::NotADirectory,
|
||||
message: format!("Path is not a directory: {}", path),
|
||||
});
|
||||
}
|
||||
|
||||
if !check_mount_status(path)? {
|
||||
return Ok(PathValidationResult {
|
||||
status: PathStatus::NotMounted,
|
||||
message: "Storage device is not mounted".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if !check_permissions(path)? {
|
||||
return Ok(PathValidationResult {
|
||||
status: PathStatus::NoWritePermission,
|
||||
message: "No write permission for this directory".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(PathValidationResult {
|
||||
status: PathStatus::Valid,
|
||||
message: "Path is valid and writable".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if we have write permissions on a path
|
||||
pub fn check_permissions(path: &str) -> Result<bool, String> {
|
||||
let path_obj = Path::new(path);
|
||||
let test_file = path_obj.join(".qbz_write_test");
|
||||
|
||||
match fs::write(&test_file, b"test") {
|
||||
Ok(_) => {
|
||||
let _ = fs::remove_file(&test_file);
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(format!("Failed to check permissions: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the path is on a mounted filesystem
|
||||
pub fn check_mount_status(path: &str) -> Result<bool, String> {
|
||||
let path_obj = Path::new(path);
|
||||
|
||||
// Try to canonicalize the path
|
||||
match path_obj.canonicalize() {
|
||||
Ok(canonical) => {
|
||||
// If we can read the metadata, the mount is accessible
|
||||
match canonical.metadata() {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(format!("Failed to check mount status: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(format!("Failed to canonicalize path: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MoveReport {
|
||||
pub total_files: usize,
|
||||
pub moved_count: usize,
|
||||
pub failed_files: Vec<String>,
|
||||
}
|
||||
|
||||
/// Move cached files from old path to new path
|
||||
pub fn move_cached_files_to_new_path(old_root: &str, new_root: &str) -> Result<MoveReport, String> {
|
||||
let old_path = Path::new(old_root);
|
||||
let new_path = Path::new(new_root);
|
||||
|
||||
// Validate new path
|
||||
let validation = validate_path(new_root)?;
|
||||
if !matches!(validation.status, PathStatus::Valid) {
|
||||
return Err(format!("New path is not valid: {}", validation.message));
|
||||
}
|
||||
|
||||
// Create new root if it doesn't exist
|
||||
fs::create_dir_all(new_path).map_err(|e| format!("Failed to create new directory: {}", e))?;
|
||||
|
||||
let mut report = MoveReport {
|
||||
total_files: 0,
|
||||
moved_count: 0,
|
||||
failed_files: Vec::new(),
|
||||
};
|
||||
|
||||
// Collect all FLAC files recursively
|
||||
let files = collect_flac_files(old_path)?;
|
||||
report.total_files = files.len();
|
||||
|
||||
for old_file in files {
|
||||
// Get relative path from old root
|
||||
let relative_path = old_file
|
||||
.strip_prefix(old_path)
|
||||
.map_err(|e| format!("Failed to get relative path: {}", e))?;
|
||||
|
||||
let new_file = new_path.join(relative_path);
|
||||
|
||||
// Create parent directories in new location
|
||||
if let Some(parent) = new_file.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
}
|
||||
|
||||
// Move file
|
||||
match fs::rename(&old_file, &new_file) {
|
||||
Ok(_) => {
|
||||
report.moved_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to move file {:?}: {}", old_file, e);
|
||||
report
|
||||
.failed_files
|
||||
.push(old_file.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Recursively collect all FLAC files in a directory
|
||||
fn collect_flac_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
if !dir.is_dir() {
|
||||
return Ok(files);
|
||||
}
|
||||
|
||||
let entries = fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {}", e))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
// Recurse into subdirectories
|
||||
files.extend(collect_flac_files(&path)?);
|
||||
} else if path.extension().and_then(|s| s.to_str()) == Some("flac") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
// Mount status cache
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
struct MountStatusCache {
|
||||
path: String,
|
||||
is_mounted: bool,
|
||||
last_check: SystemTime,
|
||||
}
|
||||
|
||||
static MOUNT_CACHE: Mutex<Option<MountStatusCache>> = Mutex::new(None);
|
||||
const CACHE_DURATION: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Check mount status with caching (30s cache)
|
||||
pub fn is_offline_root_available(path: &str) -> Result<bool, String> {
|
||||
let mut cache = MOUNT_CACHE
|
||||
.lock()
|
||||
.map_err(|e| format!("Cache lock error: {}", e))?;
|
||||
|
||||
if let Some(cached) = cache.as_ref() {
|
||||
if cached.path == path {
|
||||
if let Ok(elapsed) = SystemTime::now().duration_since(cached.last_check) {
|
||||
if elapsed < CACHE_DURATION {
|
||||
return Ok(cached.is_mounted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or expired, check mount status
|
||||
let is_mounted = check_mount_status(path)?;
|
||||
|
||||
*cache = Some(MountStatusCache {
|
||||
path: path.to_string(),
|
||||
is_mounted,
|
||||
last_check: SystemTime::now(),
|
||||
});
|
||||
|
||||
Ok(is_mounted)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Lazy singleton access to the `qbz-secrets::SecretBox` for the offline
|
||||
//! cache.
|
||||
//!
|
||||
//! The vault is opened on first use (with the OS-keyring + KDF-fallback
|
||||
//! selection happening inside `qbz-secrets`) and then shared across all
|
||||
//! offline-cache calls. The storage directory is the app config dir — the
|
||||
//! install UUID for the KDF fallback lives there so it survives upgrades.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use qbz_secrets::{SecretBox, SecretError};
|
||||
|
||||
const SERVICE_NAME: &str = "qbz";
|
||||
|
||||
static VAULT: OnceLock<std::sync::Mutex<Option<SecretBox>>> = OnceLock::new();
|
||||
|
||||
/// Get-or-init the vault. `storage_dir` is used only on the first call;
|
||||
/// subsequent calls return the cached handle regardless.
|
||||
pub fn get_or_init(storage_dir: &Path) -> Result<SecretBox, SecretError> {
|
||||
let cell = VAULT.get_or_init(|| std::sync::Mutex::new(None));
|
||||
let mut guard = cell
|
||||
.lock()
|
||||
.map_err(|_| SecretError::Other("secret vault mutex poisoned".to_string()))?;
|
||||
if let Some(existing) = guard.as_ref() {
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
let vault = SecretBox::open(SERVICE_NAME, storage_dir)?;
|
||||
log::info!(
|
||||
"[OfflineCache/Vault] Opened SecretBox backend={:?}",
|
||||
vault.backend_kind()
|
||||
);
|
||||
*guard = Some(vault.clone());
|
||||
Ok(vault)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Shared DTOs for the offline cache (moved verbatim from
|
||||
//! `src-tauri/src/offline_cache/mod.rs`). Pure serde — no Tauri, no I/O.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cache status for a track in offline storage
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OfflineCacheStatus {
|
||||
Queued,
|
||||
Downloading,
|
||||
Ready,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl OfflineCacheStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Queued => "queued",
|
||||
Self::Downloading => "downloading",
|
||||
Self::Ready => "ready",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"queued" => Self::Queued,
|
||||
"downloading" => Self::Downloading,
|
||||
"ready" => Self::Ready,
|
||||
"failed" => Self::Failed,
|
||||
_ => Self::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a cached track
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CachedTrackInfo {
|
||||
pub track_id: u64,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub album_id: Option<String>,
|
||||
pub duration_secs: u64,
|
||||
pub file_size_bytes: u64,
|
||||
pub quality: String,
|
||||
pub bit_depth: Option<u32>,
|
||||
pub sample_rate: Option<f64>,
|
||||
pub status: OfflineCacheStatus,
|
||||
pub progress_percent: u8,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at: String,
|
||||
pub last_accessed_at: String,
|
||||
}
|
||||
|
||||
/// Minimal track info for syncing to library
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReadyTrackForSync {
|
||||
pub track_id: u64,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub duration_secs: u64,
|
||||
pub file_path: String,
|
||||
pub bit_depth: Option<u32>,
|
||||
pub sample_rate: Option<f64>,
|
||||
}
|
||||
|
||||
/// Statistics about the offline cache
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OfflineCacheStats {
|
||||
pub total_tracks: usize,
|
||||
pub ready_tracks: usize,
|
||||
pub downloading_tracks: usize,
|
||||
pub failed_tracks: usize,
|
||||
pub total_size_bytes: u64,
|
||||
pub limit_bytes: Option<u64>,
|
||||
pub cache_path: String,
|
||||
}
|
||||
|
||||
/// Progress update for caching a track
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CacheProgress {
|
||||
pub track_id: u64,
|
||||
pub progress_percent: u8,
|
||||
pub bytes_downloaded: u64,
|
||||
pub total_bytes: Option<u64>,
|
||||
pub status: OfflineCacheStatus,
|
||||
}
|
||||
|
||||
/// Track metadata for initiating offline caching
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackCacheInfo {
|
||||
pub track_id: u64,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub album_id: Option<String>,
|
||||
pub duration_secs: u64,
|
||||
pub quality: String,
|
||||
pub bit_depth: Option<u32>,
|
||||
pub sample_rate: Option<f64>,
|
||||
}
|
||||
Reference in new issue
Block a user