refactor(offline-cache): re-wire Tauri onto qbz-offline-cache; delete old module

Completes Slice 0. src-tauri now depends on the qbz-offline-cache crate; the
old src-tauri/src/offline_cache/ implementation files are deleted. What remains
in that module is a thin shim: a re-export of the crate plus the Tauri glue.

- offline_cache/mod.rs: pub use qbz_offline_cache::* + tauri_cache_sink (maps
  CacheEvent -> the exact legacy offline:caching_*/offline:unlock_* app.emit
  shapes, so the Svelte frontend is unchanged) + core_client (resolves the core
  qbz_qobuz::QobuzClient from the CoreBridge for the download pipeline).
- Cache/redownload/migration commands: drop the legacy crate::api::QobuzClient
  (state.client) + the CoreBridge double-plumb; pass one core client + an
  app.emit-backed CacheEventSink. Unused `state: State<AppState>` params dropped.
- Playback + library cached-resolution call sites: pass &tauri_cache_sink(..)
  to load_cmaf_bundle_with_ui_events instead of &app_handle.
- session_lifecycle: apply_persisted_limit now reads the persisted value here;
  purge takes &library.db directly.
- migration: qobuz_client is the Option-wrapped core client (consistent with the
  downloader); None mid-loop is recorded as a per-track failure.

Full src-tauri cargo check passes; behaviour-preserving for the Svelte frontend.
Backend extraction (ADR-006) done — next is Slice 1 (shared tier-walk in core).

cc
This commit is contained in:
vicrodh committed 2026-05-27 20:09:09 -06:00
1 parent b17bbbd442
commit c8158bf419
19 files changed
+213 -3359

No files matched your search

+8 -10
View File
@@ -896,7 +896,6 @@ pub async fn v2_cache_track_for_offline(
quality: String,
bit_depth: Option<u32>,
sample_rate: Option<f64>,
state: State<'_, AppState>,
bridge: State<'_, crate::core_bridge::CoreBridgeState>,
cache_state: State<'_, OfflineCacheState>,
app_handle: tauri::AppHandle,
@@ -933,16 +932,16 @@ pub async fn v2_cache_track_for_offline(
db.insert_track(&track_info, &file_path_str)?;
}
let core_client = crate::offline_cache::core_client(&bridge).await?;
spawn_track_cache_download(
track_id,
file_path,
state.client.clone(),
bridge.0.clone(),
core_client,
cache_state.fetcher.clone(),
cache_state.db.clone(),
cache_state.get_cache_path(),
cache_state.library_db.clone(),
app_handle.clone(),
crate::offline_cache::tauri_cache_sink(app_handle.clone()),
cache_state.cache_semaphore.clone(),
);
@@ -966,7 +965,6 @@ pub struct BatchTrackInfo {
#[tauri::command]
pub async fn v2_cache_tracks_batch_for_offline(
tracks: Vec<BatchTrackInfo>,
state: State<'_, AppState>,
bridge: State<'_, crate::core_bridge::CoreBridgeState>,
cache_state: State<'_, OfflineCacheState>,
app_handle: tauri::AppHandle,
@@ -1016,18 +1014,18 @@ pub async fn v2_cache_tracks_batch_for_offline(
}
// Spawn download tasks for each track
let core_client = crate::offline_cache::core_client(&bridge).await?;
for track in &tracks {
let file_path = cache_state.track_file_path(track.id, "flac");
spawn_track_cache_download(
track.id,
file_path,
state.client.clone(),
bridge.0.clone(),
core_client.clone(),
cache_state.fetcher.clone(),
cache_state.db.clone(),
cache_state.get_cache_path(),
cache_state.library_db.clone(),
app_handle.clone(),
crate::offline_cache::tauri_cache_sink(app_handle.clone()),
cache_state.cache_semaphore.clone(),
);
}
@@ -1037,7 +1035,7 @@ pub async fn v2_cache_tracks_batch_for_offline(
#[tauri::command]
pub async fn v2_start_legacy_migration(
state: State<'_, AppState>,
bridge: State<'_, crate::core_bridge::CoreBridgeState>,
cache_state: State<'_, OfflineCacheState>,
library_state: State<'_, crate::library::LibraryState>,
app_handle: tauri::AppHandle,
@@ -1051,7 +1049,7 @@ pub async fn v2_start_legacy_migration(
}
let offline_root = cache_state.get_cache_path();
let qobuz_client = state.client.clone();
let qobuz_client = crate::offline_cache::core_client(&bridge).await?;
let library_db = library_state.db.clone();
let app_complete = app_handle.clone();
+1 -1
View File
@@ -924,7 +924,7 @@ pub async fn v2_library_play_track(
// CMAF id = qobuz track id (what the bundle is keyed by).
let audio_data =
crate::offline_cache::playback::load_cmaf_bundle_with_ui_events(
&app_handle,
&crate::offline_cache::tauri_cache_sink(app_handle.clone()),
track_id as u64,
qobuz_track_id as u64,
row.clone(),
+6 -9
View File
@@ -9,7 +9,6 @@ use crate::core_bridge::CoreBridgeState;
use crate::offline_cache::{
downloader::spawn_track_cache_download, maintenance, OfflineCacheState, OfflineCacheStatus,
};
use crate::AppState;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
@@ -49,7 +48,6 @@ pub async fn v2_redownload_cached_album(
album_id: String,
failed_only: bool,
cache_state: State<'_, OfflineCacheState>,
state: State<'_, AppState>,
bridge: State<'_, CoreBridgeState>,
app_handle: tauri::AppHandle,
) -> Result<RedownloadStartedReport, String> {
@@ -72,18 +70,18 @@ pub async fn v2_redownload_cached_album(
ids
};
let core_client = crate::offline_cache::core_client(&bridge).await?;
for track_id in &queued_ids {
let file_path = cache_state.track_file_path(*track_id, "flac");
spawn_track_cache_download(
*track_id,
file_path,
state.client.clone(),
bridge.0.clone(),
core_client.clone(),
cache_state.fetcher.clone(),
cache_state.db.clone(),
offline_root_string.clone(),
cache_state.library_db.clone(),
app_handle.clone(),
crate::offline_cache::tauri_cache_sink(app_handle.clone()),
cache_state.cache_semaphore.clone(),
);
}
@@ -98,7 +96,6 @@ pub async fn v2_redownload_cached_album(
pub async fn v2_redownload_cached_track(
track_id: u64,
cache_state: State<'_, OfflineCacheState>,
state: State<'_, AppState>,
bridge: State<'_, CoreBridgeState>,
app_handle: tauri::AppHandle,
) -> Result<(), String> {
@@ -119,16 +116,16 @@ pub async fn v2_redownload_cached_track(
}
let file_path = cache_state.track_file_path(track_id, "flac");
let core_client = crate::offline_cache::core_client(&bridge).await?;
spawn_track_cache_download(
track_id,
file_path,
state.client.clone(),
bridge.0.clone(),
core_client,
cache_state.fetcher.clone(),
cache_state.db.clone(),
offline_root_string,
cache_state.library_db.clone(),
app_handle,
crate::offline_cache::tauri_cache_sink(app_handle),
cache_state.cache_semaphore.clone(),
);
+3 -3
View File
@@ -658,7 +658,7 @@ pub async fn v2_play_next_gapless(
let cache_path = offline_cache.get_cache_path();
let decrypted =
crate::offline_cache::playback::load_cmaf_bundle_with_ui_events(
&app_handle,
&crate::offline_cache::tauri_cache_sink(app_handle.clone()),
track_id,
track_id,
row.clone(),
@@ -741,7 +741,7 @@ pub async fn v2_play_next_gapless(
let cache_path = offline_cache.get_cache_path();
let decrypted =
crate::offline_cache::playback::load_cmaf_bundle_with_ui_events(
&app_handle,
&crate::offline_cache::tauri_cache_sink(app_handle.clone()),
track_id, // display: library row id
qid as u64, // cmaf/bundle: qobuz id
row.clone(),
@@ -1123,7 +1123,7 @@ pub async fn v2_play_track(
let audio_data_opt: Option<Vec<u8>> = if row.cache_format == 2 {
let cache_path = offline_cache.get_cache_path();
crate::offline_cache::playback::load_cmaf_bundle_with_ui_events(
&app_handle,
&crate::offline_cache::tauri_cache_sink(app_handle.clone()),
track_id,
track_id,
row.clone(),
-238
View File
@@ -1,238 +0,0 @@
//! 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(())
}
-750
View File
@@ -1,750 +0,0 @@
//! SQLite database for offline cache index
use rusqlite::{params, Connection};
use std::path::Path;
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,
}
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());
}
}
-735
View File
@@ -1,735 +0,0 @@
//! Stream fetcher for caching tracks to disk
use std::io::Write;
use std::path::Path;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
use super::{CacheProgress, OfflineCacheStatus};
/// Maximum number of download retry attempts
const MAX_RETRIES: u32 = 3;
/// Backoff durations for each retry attempt
const RETRY_BACKOFFS: [Duration; 3] = [
Duration::from_secs(1),
Duration::from_secs(3),
Duration::from_secs(5),
];
/// StreamFetcher handles fetching audio streams and caching them to disk.
///
/// Creates a fresh HTTP client per download to avoid HTTP/2 connection pool
/// poisoning: when a CDN connection breaks mid-transfer, a persistent client's
/// pool can keep reusing the dead connection, causing all subsequent downloads
/// to fail after 1 byte. Ephemeral clients guarantee a clean connection pool.
pub struct StreamFetcher;
impl StreamFetcher {
pub fn new() -> Self {
Self
}
/// Build a fresh reqwest::Client for a single download.
///
/// Each download gets its own client to prevent HTTP/2 connection pool
/// poisoning from affecting subsequent downloads.
fn build_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.timeout(Duration::from_secs(300)) // 5 minute timeout for large files
.connect_timeout(Duration::from_secs(15))
.use_native_tls()
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))
}
/// Fetch a stream and cache it to disk with progress updates.
///
/// Retries up to MAX_RETRIES times with exponential backoff on transient
/// failures (connection reset, EOF, timeout). Each retry creates a fresh
/// HTTP client to avoid reusing a poisoned connection pool.
pub async fn fetch_to_file(
&self,
url: &str,
dest_path: &Path,
track_id: u64,
app_handle: Option<&AppHandle>,
) -> Result<u64, String> {
log::info!("Caching track {} to {:?}", track_id, dest_path);
// Create parent directories if needed
if let Some(parent) = dest_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {}", e))?;
}
let temp_path = dest_path.with_extension("tmp");
let mut last_error = String::new();
for attempt in 0..=MAX_RETRIES {
if attempt > 0 {
let backoff = RETRY_BACKOFFS[(attempt - 1) as usize];
log::info!(
"[Offline] Retry {}/{} for track {} after {}s",
attempt,
MAX_RETRIES,
track_id,
backoff.as_secs()
);
tokio::time::sleep(backoff).await;
}
// Fresh client per attempt — prevents connection pool poisoning
let client = Self::build_client()?;
match self
.try_download(&client, url, &temp_path, track_id, app_handle)
.await
{
Ok(size) => {
// Move temp file to final destination
std::fs::rename(&temp_path, dest_path)
.map_err(|e| format!("Failed to move temp file: {}", e))?;
log::info!("Caching complete for track {}: {} bytes", track_id, size);
return Ok(size);
}
Err(e) => {
last_error = e;
// Clean up partial temp file before retry
let _ = std::fs::remove_file(&temp_path);
if attempt < MAX_RETRIES {
log::warn!(
"[Offline] Download attempt {} failed for track {}: {}",
attempt + 1,
track_id,
last_error
);
}
}
}
}
Err(last_error)
}
/// Single download attempt: stream response body to a temp file.
async fn try_download(
&self,
client: &reqwest::Client,
url: &str,
temp_path: &Path,
track_id: u64,
app_handle: Option<&AppHandle>,
) -> Result<u64, String> {
let response = client
.get(url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|e| format!("Failed to start fetch: {}", e))?;
if !response.status().is_success() {
return Err(format!("HTTP error: {}", response.status()));
}
let total_size = response.content_length();
log::info!(
"Caching started for track {}, total size: {:?} bytes",
track_id,
total_size
);
let mut file = std::fs::File::create(temp_path)
.map_err(|e| format!("Failed to create temp file: {}", e))?;
let mut cached: u64 = 0;
let mut last_progress: u8 = 0;
let mut last_emit_time = Instant::now();
const MIN_EMIT_INTERVAL: Duration = Duration::from_millis(200);
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| {
use std::error::Error as _;
let mut msg = format!("Fetch error: {}", e);
let mut source = e.source();
while let Some(cause) = source {
msg.push_str(&format!(" | caused by: {}", cause));
source = cause.source();
}
log::error!(
"[Offline] Download error for track {} after {} bytes: {}",
track_id,
cached,
msg
);
msg
})?;
file.write_all(&chunk)
.map_err(|e| format!("Failed to write chunk: {}", e))?;
cached += chunk.len() as u64;
// Calculate progress
let progress = if let Some(total) = total_size {
((cached as f64 / total as f64) * 100.0) as u8
} else {
0
};
// Emit progress event every 2% change AND at least 200ms apart (always emit 100%)
let elapsed = last_emit_time.elapsed();
if progress != last_progress
&& (progress - last_progress >= 2 || progress == 100)
&& (elapsed >= MIN_EMIT_INTERVAL || progress == 100)
{
last_progress = progress;
last_emit_time = Instant::now();
if let Some(app) = app_handle {
let _ = app.emit(
"offline:caching_progress",
CacheProgress {
track_id,
progress_percent: progress,
bytes_downloaded: cached,
total_bytes: total_size,
status: OfflineCacheStatus::Downloading,
},
);
}
log::debug!(
"Caching progress for track {}: {}% ({}/{:?} bytes)",
track_id,
progress,
cached,
total_size
);
}
}
// Ensure all data is written
file.flush()
.map_err(|e| format!("Failed to flush file: {}", e))?;
drop(file);
Ok(cached)
}
/// Fetch to memory (for smaller files or streaming)
pub async fn fetch_to_memory(&self, url: &str) -> Result<Vec<u8>, String> {
let client = Self::build_client()?;
let response = client
.get(url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|e| format!("Failed to fetch: {}", e))?;
if !response.status().is_success() {
return Err(format!("HTTP error: {}", response.status()));
}
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read bytes: {}", e))?;
Ok(bytes.to_vec())
}
}
impl Default for StreamFetcher {
fn default() -> Self {
Self::new()
}
}
/// Shared helper: spawn the download task for a single track.
/// CMAF-first offline download path (v2 format).
///
/// On success: the encrypted CMAF bundle is persisted under
/// `<offline_root>/tracks-cmaf/<track_id>/`, the per-track AES content key
/// + session infos are wrapped via `qbz-secrets` and stored on the DB row,
/// `cache_format` flips to 2, `mark_complete` fires, and the library row
/// is populated with the same metadata the legacy path would populate
/// (title/artist/album, etc.).
///
/// Returns `Err` for any failure that makes CMAF unusable — the caller
/// falls back to the legacy plain-FLAC path.
pub(crate) async fn try_cmaf_offline_download(
track_id: u64,
bridge: &std::sync::Arc<tokio::sync::RwLock<Option<crate::core_bridge::CoreBridge>>>,
db: &std::sync::Arc<tokio::sync::Mutex<Option<crate::offline_cache::OfflineCacheDb>>>,
offline_root: &str,
library_db: &std::sync::Arc<tokio::sync::Mutex<Option<qbz_library::LibraryDatabase>>>,
client: &std::sync::Arc<tokio::sync::RwLock<crate::api::QobuzClient>>,
app: &tauri::AppHandle,
) -> Result<(), String> {
use qbz_models::Quality;
let offline_root_path = std::path::PathBuf::from(offline_root);
// Progress callback: emit the same `offline:caching_progress` event
// shape the legacy StreamFetcher fires, so the UI's progress ring
// doesn't care whether the bytes came from CMAF or legacy.
//
// Note: one 'started' event here up front so the frontend sees
// 'downloading' status immediately (the ring starts empty); actual
// percentage updates arrive per completed segment.
let _ = app.emit(
"offline:caching_progress",
serde_json::json!({
"trackId": track_id,
"progressPercent": 0u32,
"bytesDownloaded": 0u64,
"totalBytes": serde_json::Value::Null,
"status": "downloading",
}),
);
let app_for_cb = app.clone();
let progress_cb: qbz_qobuz::CmafProgressCallback = std::sync::Arc::new(
move |update: qbz_qobuz::CmafProgressUpdate| {
let percent = if update.n_segments > 0 {
(update.segments_completed as f64 / update.n_segments as f64 * 100.0)
.round()
.clamp(0.0, 100.0) as u32
} else {
0u32
};
let _ = app_for_cb.emit(
"offline:caching_progress",
serde_json::json!({
"trackId": track_id,
"progressPercent": percent,
"bytesDownloaded": update.bytes_this_segment,
"totalBytes": serde_json::Value::Null,
"status": "downloading",
}),
);
},
);
// Fetch the raw CMAF bundle. Requires an initialized CoreBridge →
// QobuzClient; if either is missing, bail so the legacy path runs.
let bundle = {
let bridge_guard = bridge.read().await;
let bridge = bridge_guard
.as_ref()
.ok_or_else(|| "CoreBridge not initialized".to_string())?;
let client_arc = bridge.core().client();
let client_guard = client_arc.read().await;
let qobuz_client = client_guard
.as_ref()
.ok_or_else(|| "QobuzClient not initialized".to_string())?;
qbz_qobuz::cmaf::download_raw_with_progress(
qobuz_client,
track_id,
Quality::UltraHiRes,
Some(progress_cb),
)
.await?
};
// Open (or lazily init) the secret vault and wrap the keying material
// before it touches the filesystem.
let vault = crate::offline_cache::secret_vault::get_or_init(&offline_root_path)
.map_err(|e| format!("SecretBox init failed: {}", e))?;
let content_key_wrapped = vault
.wrap(&bundle.content_key)
.map_err(|e| format!("Failed to wrap content_key: {}", e))?;
let infos_wrapped = vault
.wrap(bundle.infos.as_bytes())
.map_err(|e| format!("Failed to wrap infos: {}", e))?;
// Persist the encrypted bundle to disk.
let (layout, total_bytes) =
crate::offline_cache::cmaf_store::persist_bundle(&offline_root_path, track_id, &bundle)?;
// Flip the DB row to v2 and store the wrapped keying material.
{
let db_guard = db.lock().await;
let db_ref = db_guard
.as_ref()
.ok_or_else(|| "Offline cache DB not open".to_string())?;
db_ref.set_cmaf_bundle(
track_id,
layout.segments_path.to_string_lossy().as_ref(),
layout.init_path.to_string_lossy().as_ref(),
&content_key_wrapped,
&infos_wrapped,
bundle.format_id,
bundle.n_segments as u32,
total_bytes,
)?;
db_ref
.mark_complete(track_id, total_bytes)
.map_err(|e| format!("Failed to mark_complete: {}", e))?;
}
// Fetch metadata for the library row (same source the legacy path uses).
// We don't write FLAC tags or embed artwork INSIDE the encrypted blob
// — that would corrupt it. But we DO save a cover.jpg next to the
// bundle directory so the library UI has artwork to display.
let metadata = {
let qobuz_client = client.read().await;
crate::offline_cache::metadata::fetch_complete_metadata(track_id, &*qobuz_client).await
};
if let Ok(metadata) = metadata {
// Download and save album artwork alongside the bundle, same as
// the legacy path does next to the FLAC file. cover.jpg lives at
// <offline_root>/tracks-cmaf/<track_id>/cover.jpg — set as the
// library row's artwork_path so the UI picks it up.
let artwork_path: Option<String> = if let Some(artwork_url) = metadata.artwork_url.as_deref() {
match crate::offline_cache::metadata::save_album_artwork(&layout.track_dir, artwork_url).await {
Ok(()) => {
let cover = layout.track_dir.join("cover.jpg");
if cover.exists() {
Some(cover.to_string_lossy().to_string())
} else {
None
}
}
Err(e) => {
log::warn!("[Offline/CMAF] Track {} artwork save failed: {}", track_id, e);
None
}
}
} else {
None
};
let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist);
let album_group_key = format!("{}|{}", metadata.album, album_artist);
let lib_opt = library_db.lock().await;
if let Some(lib_guard) = lib_opt.as_ref() {
let _ = lib_guard.insert_qobuz_cached_track_with_grouping(
track_id,
&metadata.title,
&metadata.artist,
Some(&metadata.album),
metadata.album_artist.as_deref(),
metadata.track_number,
metadata.disc_number,
metadata.year,
metadata.duration_secs,
// For v2 bundles the "playable path" in the library index
// is the track directory; the player resolves it through
// the DB's cache_format=2 branch anyway.
layout.track_dir.to_string_lossy().as_ref(),
&album_group_key,
&metadata.album,
bundle.bit_depth,
bundle.sampling_rate.map(|r| r as f64),
artwork_path.as_deref(),
);
}
} else if let Err(e) = metadata {
log::warn!(
"[Offline/CMAF] Track {} post-metadata fetch failed: {} (bundle already persisted)",
track_id,
e
);
}
log::info!(
"[Offline/CMAF] Track {} cached as v2 bundle: {:.2} MB under {:?}",
track_id,
total_bytes as f64 / (1024.0 * 1024.0),
layout.track_dir
);
let _ = app.emit(
"offline:caching_completed",
serde_json::json!({
"trackId": track_id,
"size": total_bytes,
"format": "cmaf",
}),
);
let _ = app.emit(
"offline:caching_processed",
serde_json::json!({
"trackId": track_id,
"path": layout.track_dir.to_string_lossy(),
"format": "cmaf",
}),
);
Ok(())
}
/// Used by both v2_cache_track_for_offline (single) and v2_cache_tracks_batch_for_offline (batch).
#[allow(clippy::too_many_arguments)]
pub(crate) fn spawn_track_cache_download(
track_id: u64,
file_path: std::path::PathBuf,
client: std::sync::Arc<tokio::sync::RwLock<crate::api::QobuzClient>>,
bridge: std::sync::Arc<tokio::sync::RwLock<Option<crate::core_bridge::CoreBridge>>>,
fetcher: std::sync::Arc<crate::offline_cache::StreamFetcher>,
db: std::sync::Arc<tokio::sync::Mutex<Option<crate::offline_cache::OfflineCacheDb>>>,
offline_root: String,
library_db: std::sync::Arc<tokio::sync::Mutex<Option<qbz_library::LibraryDatabase>>>,
app: tauri::AppHandle,
semaphore: std::sync::Arc<tokio::sync::Semaphore>,
) {
tokio::spawn(async move {
let _permit = match semaphore.acquire_owned().await {
Ok(permit) => permit,
Err(err) => {
log::error!(
"Failed to acquire cache slot for track {}: {}",
track_id,
err
);
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_status(
track_id,
crate::offline_cache::OfflineCacheStatus::Failed,
Some("Failed to start caching"),
);
}
let _ = app.emit(
"offline:caching_failed",
serde_json::json!({
"trackId": track_id,
"error": "Failed to acquire cache slot"
}),
);
return;
}
};
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_status(
track_id,
crate::offline_cache::OfflineCacheStatus::Downloading,
None,
);
}
let _ = app.emit(
"offline:caching_started",
serde_json::json!({ "trackId": track_id }),
);
// === CMAF-first offline download (v2 format) ===
//
// Stores bit-identical encrypted segments + wrapped content key.
// Falls through to the legacy path below if any step fails (no
// CoreBridge yet, /file/url returns a non-CMAF response, network
// flake, vault init failure, etc.). The legacy fallback keeps
// existing users unblocked while we validate the new path.
match try_cmaf_offline_download(
track_id,
&bridge,
&db,
&offline_root,
&library_db,
&client,
&app,
)
.await
{
Ok(()) => return,
Err(e) => {
log::warn!(
"[Offline/CMAF] Track {} — CMAF path failed ({}), falling back to legacy /track/getFileUrl",
track_id,
e
);
}
}
let stream_url = {
let client_guard = client.read().await;
client_guard
.get_stream_url_with_fallback(track_id, crate::api::models::Quality::UltraHiRes)
.await
};
let url = match stream_url {
Ok(s) => s.url,
Err(e) => {
log::error!("Failed to get stream URL for track {}: {}", track_id, e);
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_status(
track_id,
crate::offline_cache::OfflineCacheStatus::Failed,
Some(&format!("Failed to get stream URL: {}", e)),
);
}
let _ = app.emit(
"offline:caching_failed",
serde_json::json!({
"trackId": track_id,
"error": e.to_string()
}),
);
return;
}
};
match fetcher
.fetch_to_file(&url, &file_path, track_id, Some(&app))
.await
{
Ok(size) => {
log::info!("Caching complete for track {}: {} bytes", track_id, size);
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.mark_complete(track_id, size);
}
let _ = app.emit(
"offline:caching_completed",
serde_json::json!({
"trackId": track_id,
"size": size
}),
);
// Post-processing kept in V2 to avoid command->command delegation.
let file_path_str = file_path.to_string_lossy().to_string();
let qobuz_client = client.read().await;
let metadata = match crate::offline_cache::metadata::fetch_complete_metadata(
track_id,
&*qobuz_client,
)
.await
{
Ok(m) => m,
Err(e) => {
log::warn!(
"Post-processing metadata fetch failed for {}: {}",
track_id,
e
);
return;
}
};
if let Err(e) =
crate::offline_cache::metadata::write_flac_tags(&file_path_str, &metadata)
{
log::warn!("Failed to write tags for {}: {}", track_id, e);
}
if let Some(artwork_url) = &metadata.artwork_url {
if let Err(e) =
crate::offline_cache::metadata::embed_artwork(&file_path_str, artwork_url)
.await
{
log::warn!("Failed to embed artwork for {}: {}", track_id, e);
}
}
let new_path = match crate::offline_cache::metadata::organize_cached_file(
track_id,
&file_path_str,
&offline_root,
&metadata,
) {
Ok(p) => p,
Err(e) => {
log::warn!("Failed to organize cached file {}: {}", track_id, e);
return;
}
};
// Save cover.jpg next to the organized FLAC so the library
// UI has artwork to display.
let artwork_path_v1: Option<String> =
if let Some(artwork_url) = metadata.artwork_url.as_deref() {
if let Some(parent_dir) = std::path::Path::new(&new_path).parent() {
match crate::offline_cache::metadata::save_album_artwork(
parent_dir,
artwork_url,
)
.await
{
Ok(()) => {
let cover = parent_dir.join("cover.jpg");
if cover.exists() {
Some(cover.to_string_lossy().to_string())
} else {
None
}
}
Err(_) => None,
}
} else {
None
}
} else {
None
};
let (bit_depth_detected, sample_rate_detected) =
match lofty::read_from_path(&new_path) {
Ok(tagged_file) => {
use lofty::prelude::*;
let properties = tagged_file.properties();
(
properties.bit_depth().map(|bd| bd as u32),
properties.sample_rate().map(|sr| sr as f64),
)
}
Err(_) => (None, None),
};
let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist);
let album_group_key = format!("{}|{}", metadata.album, album_artist);
let lib_opt = library_db.lock().await;
if let Some(lib_guard) = lib_opt.as_ref() {
let _ = lib_guard.insert_qobuz_cached_track_with_grouping(
track_id,
&metadata.title,
&metadata.artist,
Some(&metadata.album),
metadata.album_artist.as_deref(),
metadata.track_number,
metadata.disc_number,
metadata.year,
metadata.duration_secs,
&new_path,
&album_group_key,
&metadata.album,
bit_depth_detected,
sample_rate_detected,
artwork_path_v1.as_deref(),
);
}
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_file_path(track_id, &new_path);
}
let _ = app.emit(
"offline:caching_processed",
serde_json::json!({
"trackId": track_id,
"path": new_path
}),
);
}
Err(e) => {
log::error!("Caching failed for track {}: {}", track_id, e);
if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_status(
track_id,
crate::offline_cache::OfflineCacheStatus::Failed,
Some(&e),
);
}
let _ = app.emit(
"offline:caching_failed",
serde_json::json!({
"trackId": track_id,
"error": e
}),
);
}
}
});
}
-142
View File
@@ -1,142 +0,0 @@
//! 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 super::cmaf_store::BundleLayout;
use super::{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::offline_cache::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]);
}
}
-367
View File
@@ -1,367 +0,0 @@
//! Metadata fetching and FLAC tagging for cached tracks
//!
//! Handles complete metadata retrieval from Qobuz API and writing to FLAC tags.
use lofty::config::WriteOptions;
use lofty::picture::{MimeType, Picture, PictureType};
use lofty::prelude::*;
use lofty::tag::{ItemKey, Tag};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompleteTrackMetadata {
pub track_id: u64,
pub title: String,
pub artist: String,
pub album: String,
pub album_artist: Option<String>,
pub track_number: Option<u32>,
pub disc_number: Option<u32>,
pub year: Option<u32>,
pub genre: Option<String>,
pub isrc: Option<String>,
pub label: Option<String>,
pub copyright: Option<String>,
pub composer: Option<String>,
pub duration_secs: u64,
pub artwork_url: Option<String>,
}
/// Fetch complete metadata for a track from Qobuz API
pub async fn fetch_complete_metadata(
track_id: u64,
qobuz_client: &crate::api::QobuzClient,
) -> Result<CompleteTrackMetadata, String> {
log::info!("Fetching complete metadata for track {}", track_id);
let track = qobuz_client
.get_track(track_id)
.await
.map_err(|e| format!("Failed to fetch track: {}", e))?;
let album = if let Some(album_obj) = &track.album {
qobuz_client.get_album(&album_obj.id).await.ok()
} else {
None
};
let album_artist = album
.as_ref()
.map(|a| a.artist.name.clone())
.or_else(|| track.performer.as_ref().map(|p| p.name.clone()));
let genre = album
.as_ref()
.and_then(|a| a.genre.as_ref())
.map(|g| g.name.clone());
let label = album
.as_ref()
.and_then(|a| a.label.as_ref())
.map(|l| l.name.clone());
let year = album
.as_ref()
.and_then(|a| a.release_date_original.as_ref())
.and_then(|date_str| {
// Parse YYYY-MM-DD or YYYY format
date_str
.split('-')
.next()
.and_then(|year_str| year_str.parse::<u32>().ok())
});
let artwork_url = album
.as_ref()
.and_then(|a| a.image.large.clone())
.or_else(|| track.album.as_ref().and_then(|a| a.image.large.clone()));
Ok(CompleteTrackMetadata {
track_id,
title: track.title,
artist: track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default(),
album: track
.album
.as_ref()
.map(|a| a.title.clone())
.unwrap_or_default(),
album_artist,
track_number: Some(track.track_number),
disc_number: track.media_number,
year,
genre,
isrc: track.isrc.clone(),
label,
copyright: None, // Qobuz API doesn't provide copyright in album model
composer: None, // Qobuz API doesn't provide composer in track model
duration_secs: track.duration as u64,
artwork_url,
})
}
/// Write metadata tags to a FLAC file
pub fn write_flac_tags(file_path: &str, metadata: &CompleteTrackMetadata) -> Result<(), String> {
log::info!("Writing FLAC tags to: {}", file_path);
let path = Path::new(file_path);
let mut tagged_file =
lofty::read_from_path(path).map_err(|e| format!("Failed to read FLAC file: {}", e))?;
let tag = match tagged_file.primary_tag_mut() {
Some(primary_tag) => primary_tag,
None => {
let tag_type = tagged_file.primary_tag_type();
tagged_file.insert_tag(Tag::new(tag_type));
tagged_file.primary_tag_mut().unwrap()
}
};
// Clear existing tags
tag.clear();
// Write standard Vorbis comments
tag.set_title(metadata.title.clone());
tag.set_artist(metadata.artist.clone());
tag.set_album(metadata.album.clone());
if let Some(album_artist) = &metadata.album_artist {
tag.insert_text(ItemKey::AlbumArtist, album_artist.clone());
}
if let Some(track_number) = metadata.track_number {
tag.set_track(track_number);
}
if let Some(disc_number) = metadata.disc_number {
tag.set_disk(disc_number);
}
if let Some(year) = metadata.year {
tag.set_date(lofty::tag::items::Timestamp {
year: year as u16,
..Default::default()
});
}
if let Some(genre) = &metadata.genre {
tag.set_genre(genre.clone());
}
if let Some(isrc) = &metadata.isrc {
tag.insert_text(ItemKey::Isrc, isrc.clone());
}
if let Some(label) = &metadata.label {
tag.insert_text(ItemKey::Label, label.clone());
}
if let Some(copyright) = &metadata.copyright {
tag.insert_text(ItemKey::CopyrightMessage, copyright.clone());
}
if let Some(composer) = &metadata.composer {
tag.insert_text(ItemKey::Composer, composer.clone());
}
// Save tags
tagged_file
.save_to_path(path, WriteOptions::default())
.map_err(|e| format!("Failed to save tags: {}", e))?;
Ok(())
}
/// Download and embed artwork in FLAC file
pub async fn embed_artwork(file_path: &str, artwork_url: &str) -> Result<(), String> {
log::info!("Embedding artwork from: {}", artwork_url);
// Download artwork
let response = reqwest::get(artwork_url)
.await
.map_err(|e| format!("Failed to download artwork: {}", e))?;
let artwork_bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read artwork bytes: {}", e))?;
// Determine MIME type from URL
let mime_type = if artwork_url.contains(".jpg") || artwork_url.contains(".jpeg") {
MimeType::Jpeg
} else if artwork_url.contains(".png") {
MimeType::Png
} else {
MimeType::Jpeg // Default to JPEG
};
// Create picture
let picture = Picture::unchecked(artwork_bytes.to_vec())
.pic_type(PictureType::CoverFront)
.mime_type(mime_type)
.build();
// Read file
let path = Path::new(file_path);
let mut tagged_file =
lofty::read_from_path(path).map_err(|e| format!("Failed to read FLAC file: {}", e))?;
// Add picture to primary tag
if let Some(tag) = tagged_file.primary_tag_mut() {
tag.push_picture(picture);
} else {
let tag_type = tagged_file.primary_tag_type();
let mut tag = Tag::new(tag_type);
tag.push_picture(picture);
tagged_file.insert_tag(tag);
}
// Save
tagged_file
.save_to_path(path, WriteOptions::default())
.map_err(|e| format!("Failed to save artwork: {}", e))?;
Ok(())
}
/// Sanitize filename to be ASCII-safe and filesystem-compatible
pub fn sanitize_filename(name: &str) -> String {
// Remove or replace invalid characters
let invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
let mut sanitized = name
.chars()
.map(|c| {
if invalid_chars.contains(&c) {
'-'
} else if c.is_ascii() || c.is_alphanumeric() {
c
} else {
'-'
}
})
.collect::<String>();
// Replace multiple consecutive dashes with single dash
while sanitized.contains("--") {
sanitized = sanitized.replace("--", "-");
}
// Trim dashes and whitespace from ends
sanitized = sanitized.trim_matches('-').trim().to_string();
// Limit length to 200 chars (leaving room for extension and path)
if sanitized.len() > 200 {
sanitized.truncate(200);
sanitized = sanitized.trim_matches('-').trim().to_string();
}
// If empty after sanitization, use fallback
if sanitized.is_empty() {
sanitized = "track".to_string();
}
sanitized
}
/// Download and save album cover art as a file
pub async fn save_album_artwork(album_dir: &Path, artwork_url: &str) -> Result<(), String> {
log::info!("Downloading album artwork to: {:?}", album_dir);
let cover_path = album_dir.join("cover.jpg");
// Skip if cover already exists
if cover_path.exists() {
log::debug!("Cover art already exists at {:?}", cover_path);
return Ok(());
}
// Download artwork
let response = reqwest::get(artwork_url)
.await
.map_err(|e| format!("Failed to download album artwork: {}", e))?;
let artwork_bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read album artwork bytes: {}", e))?;
// Write to file
std::fs::write(&cover_path, artwork_bytes)
.map_err(|e| format!("Failed to write cover.jpg: {}", e))?;
log::info!("Album artwork saved to: {:?}", cover_path);
Ok(())
}
/// Organize cached file into proper folder structure
pub fn organize_cached_file(
track_id: u64,
temp_path: &str,
root_dir: &str,
metadata: &CompleteTrackMetadata,
) -> Result<String, String> {
log::info!("Organizing cached file for track {}", track_id);
let temp = Path::new(temp_path);
let root = Path::new(root_dir);
// Build target path: <root>/<artist>/<album>/[Disc N/]NN - Title.flac
let artist_dir = sanitize_filename(&metadata.album_artist.as_ref().unwrap_or(&metadata.artist));
let album_dir = sanitize_filename(&metadata.album);
let mut target_dir = root.join(&artist_dir).join(&album_dir);
// Add disc subfolder if multi-disc
if let Some(disc) = metadata.disc_number {
if disc > 1 {
target_dir = target_dir.join(format!("Disc {}", disc));
}
}
// Create directory structure
std::fs::create_dir_all(&target_dir)
.map_err(|e| format!("Failed to create directories: {}", e))?;
// Build filename: NN - Title.flac
let track_num = metadata.track_number.unwrap_or(0);
let title_clean = sanitize_filename(&metadata.title);
let filename = if track_num > 0 {
format!("{:02} - {}.flac", track_num, title_clean)
} else {
format!("{}.flac", title_clean)
};
let target_path = target_dir.join(&filename);
// Handle filename conflicts
let final_path = if target_path.exists() {
let mut counter = 2;
loop {
let alt_filename = if track_num > 0 {
format!("{:02} - {} ({}).flac", track_num, title_clean, counter)
} else {
format!("{} ({}).flac", title_clean, counter)
};
let alt_path = target_dir.join(&alt_filename);
if !alt_path.exists() {
break alt_path;
}
counter += 1;
if counter > 100 {
return Err("Too many filename conflicts".to_string());
}
}
} else {
target_path
};
// Move file
std::fs::rename(temp, &final_path).map_err(|e| format!("Failed to move file: {}", e))?;
Ok(final_path.to_string_lossy().to_string())
}
-252
View File
@@ -1,252 +0,0 @@
//! Legacy cached file migration service
//!
//! Handles migration of old numeric-named FLAC files to new organized structure
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use super::metadata::{
embed_artwork, fetch_complete_metadata, organize_cached_file, save_album_artwork,
write_flac_tags,
};
use crate::api::QobuzClient;
use crate::library::database::LibraryDatabase;
#[derive(Default, Serialize, Clone, Debug)]
pub struct MigrationStatus {
pub has_legacy_files: bool,
pub total_tracks: usize,
pub processed: usize,
pub successful: usize,
pub failed: usize,
pub in_progress: bool,
pub completed: bool,
pub errors: Vec<MigrationError>,
}
#[derive(Serialize, Clone, Debug)]
pub struct MigrationError {
pub track_id: u64,
pub error_message: String,
}
/// Detect legacy cached files (numeric FLAC files in tracks/ folder)
pub fn detect_legacy_cached_files(tracks_dir: &Path) -> Result<Vec<u64>, String> {
log::info!(
"Scanning for legacy cached files in: {}",
tracks_dir.display()
);
if !tracks_dir.exists() {
return Ok(Vec::new());
}
let mut track_ids = Vec::new();
match std::fs::read_dir(tracks_dir) {
Ok(entries) => {
for entry in entries.flatten() {
let path = entry.path();
// Only process FLAC files
if path.extension().and_then(|s| s.to_str()) != Some("flac") {
continue;
}
// Check if filename is purely numeric (track_id)
if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
if let Ok(track_id) = filename.parse::<u64>() {
track_ids.push(track_id);
log::debug!("Found legacy track: {}", track_id);
}
}
}
}
Err(e) => {
return Err(format!("Failed to read tracks directory: {}", e));
}
}
log::info!("Found {} legacy cached files", track_ids.len());
Ok(track_ids)
}
/// Migrate a single legacy cached track
async fn migrate_single_track(
track_id: u64,
legacy_path: PathBuf,
offline_root: &str,
qobuz_client: &QobuzClient,
library_db: Arc<Mutex<Option<LibraryDatabase>>>,
) -> Result<String, String> {
log::info!("Migrating track {}", track_id);
// 1. Fetch complete metadata from Qobuz
let metadata = fetch_complete_metadata(track_id, qobuz_client).await?;
// 2. Write FLAC tags
let legacy_path_str = legacy_path.to_string_lossy().to_string();
write_flac_tags(&legacy_path_str, &metadata)
.map_err(|e| format!("Failed to write tags: {}", e))?;
// 3. Embed artwork if available
if let Some(artwork_url) = &metadata.artwork_url {
if let Err(e) = embed_artwork(&legacy_path_str, artwork_url).await {
log::warn!("Failed to embed artwork for track {}: {}", track_id, e);
}
}
// 4. Organize file into artist/album structure
let new_path = organize_cached_file(track_id, &legacy_path_str, offline_root, &metadata)?;
// 5. Save album artwork as cover.jpg
let artwork_path = if let Some(artwork_url) = &metadata.artwork_url {
if let Some(parent_dir) = std::path::Path::new(&new_path).parent() {
match save_album_artwork(parent_dir, artwork_url).await {
Ok(_) => Some(parent_dir.join("cover.jpg").to_string_lossy().to_string()),
Err(e) => {
log::warn!("Failed to save album artwork for track {}: {}", track_id, e);
None
}
}
} else {
None
}
} else {
None
};
// 6. Extract audio properties from FLAC file
use lofty::prelude::*;
let (bit_depth, sample_rate) = match lofty::read_from_path(&new_path) {
Ok(tagged_file) => {
let properties = tagged_file.properties();
let bit_depth = properties.bit_depth().map(|bd| bd as u32);
let sample_rate = properties.sample_rate().map(|sr| sr as f64);
(bit_depth, sample_rate)
}
Err(e) => {
log::warn!(
"Failed to read audio properties for track {}: {}",
track_id,
e
);
(None, None)
}
};
// 7. Insert into local library DB
let lib_opt__ = library_db.lock().await;
let lib_guard = lib_opt__
.as_ref()
.ok_or("No active session - please log in")?;
let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist);
let album_group_key = format!("{}|{}", metadata.album, album_artist);
lib_guard
.insert_qobuz_cached_track_with_grouping(
track_id,
&metadata.title,
&metadata.artist,
Some(&metadata.album),
metadata.album_artist.as_deref(),
metadata.track_number,
metadata.disc_number,
metadata.year,
metadata.duration_secs,
&new_path,
&album_group_key,
&metadata.album,
bit_depth,
sample_rate,
artwork_path.as_deref(),
)
.map_err(|e| format!("Failed to insert to library DB: {}", e))?;
log::info!("Track {} migrated successfully to: {}", track_id, new_path);
Ok(new_path)
}
/// Migrate all legacy cached files
pub async fn migrate_legacy_cached_files(
track_ids: Vec<u64>,
tracks_dir: PathBuf,
offline_root: String,
qobuz_client: Arc<RwLock<QobuzClient>>,
library_db: Arc<Mutex<Option<LibraryDatabase>>>,
) -> MigrationStatus {
let total = track_ids.len();
let mut status = MigrationStatus {
has_legacy_files: true,
total_tracks: total,
in_progress: true,
..Default::default()
};
for track_id in track_ids {
let legacy_path = tracks_dir.join(format!("{}.flac", track_id));
if !legacy_path.exists() {
log::warn!("Legacy file not found: {}", legacy_path.display());
status.processed += 1;
continue;
}
// Lock client for this migration
let client_guard = qobuz_client.read().await;
match migrate_single_track(
track_id,
legacy_path.clone(),
&offline_root,
&*client_guard,
library_db.clone(),
)
.await
{
Ok(_) => {
status.successful += 1;
// Delete legacy file after successful migration
if let Err(e) = std::fs::remove_file(&legacy_path) {
log::warn!(
"Failed to delete legacy file {}: {}",
legacy_path.display(),
e
);
}
}
Err(e) => {
status.failed += 1;
status.errors.push(MigrationError {
track_id,
error_message: e,
});
log::error!(
"Failed to migrate track {}: {}",
track_id,
status.errors.last().unwrap().error_message
);
}
}
drop(client_guard);
status.processed += 1;
}
status.in_progress = false;
status.completed = true;
log::info!(
"Migration complete: {}/{} successful, {} failed",
status.successful,
total,
status.failed
);
status
}
+97 -290
View File
@@ -1,298 +1,105 @@
//! Offline cache module for temporary playback data
//!
//! Provides persistent disk-based caching for audio tracks:
//! - SQLite index for track metadata
//! - File-based storage for audio data
//! - LRU eviction with configurable limits
//! - Progress events for UI updates
//! Offline cache — the implementation lives in the frontend-agnostic
//! `qbz-offline-cache` crate (ADR-006). This module re-exports it and adds
//! the Tauri-specific glue: a `CacheEventSink` that re-emits the crate's
//! `CacheEvent`s as the exact legacy `offline:caching_*` / `offline:unlock_*`
//! Tauri events the Svelte frontend listens for (1:1 IPC shapes preserved).
pub mod cmaf_store;
pub mod db;
pub mod downloader;
pub mod maintenance;
pub mod metadata;
pub mod migration;
pub mod path_validator;
pub mod playback;
pub mod purge;
pub mod secret_vault;
pub use qbz_offline_cache::*;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use tokio::sync::{Mutex, Semaphore};
use tauri::{Emitter, Runtime};
pub use db::OfflineCacheDb;
pub use downloader::StreamFetcher;
pub use metadata::{sanitize_filename, CompleteTrackMetadata};
pub use migration::{
detect_legacy_cached_files, migrate_legacy_cached_files, MigrationError, MigrationStatus,
};
pub use path_validator::{is_offline_root_available, validate_path, PathStatus};
/// 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,
/// Resolve the core Qobuz client handle from the `CoreBridge` for the
/// download pipeline. The CMAF download, metadata fetch, and legacy
/// stream-url fallback all use this single core client. Errs when no
/// session/bridge is active.
pub async fn core_client(
bridge: &crate::core_bridge::CoreBridgeState,
) -> Result<
std::sync::Arc<tokio::sync::RwLock<Option<qbz_qobuz::QobuzClient>>>,
String,
> {
let guard = bridge.0.read().await;
guard
.as_ref()
.map(|b| b.core().client())
.ok_or_else(|| "CoreBridge not initialized".to_string())
}
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>,
}
/// Offline cache state manager
pub struct OfflineCacheState {
pub db: Arc<Mutex<Option<OfflineCacheDb>>>,
pub fetcher: Arc<StreamFetcher>,
pub cache_dir: Arc<RwLock<PathBuf>>,
/// Cache limit in bytes (None = unlimited)
pub limit_bytes: Arc<Mutex<Option<u64>>>,
pub cache_semaphore: Arc<Semaphore>,
/// Separate library DB connection for download post-processing writes.
/// This avoids contending with the main library DB mutex used by UI queries.
pub library_db: Arc<Mutex<Option<qbz_library::LibraryDatabase>>>,
}
impl OfflineCacheState {
/// Initialize the offline cache
pub fn new() -> Result<Self, String> {
let cache_dir = dirs::cache_dir()
.ok_or("Could not determine cache directory")?
.join("qbz")
.join("audio");
// Create directories
std::fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
std::fs::create_dir_all(cache_dir.join("tracks"))
.map_err(|e| format!("Failed to create tracks directory: {}", e))?;
std::fs::create_dir_all(cache_dir.join("artwork"))
.map_err(|e| format!("Failed to create artwork directory: {}", e))?;
let db_path = cache_dir.join("index.db");
let db = OfflineCacheDb::new(&db_path)?;
// Default limit: 5GB
let default_limit = Some(5 * 1024 * 1024 * 1024u64);
let state = Self {
db: Arc::new(Mutex::new(Some(db))),
fetcher: Arc::new(StreamFetcher::new()),
cache_dir: Arc::new(RwLock::new(cache_dir.clone())),
limit_bytes: Arc::new(Mutex::new(default_limit)),
cache_semaphore: Arc::new(Semaphore::new(3)),
library_db: Arc::new(Mutex::new(None)),
};
log::info!("Offline cache initialized at: {:?}", cache_dir);
Ok(state)
}
pub fn new_empty() -> Self {
let cache_dir = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("qbz")
.join("audio");
Self {
db: Arc::new(Mutex::new(None)),
fetcher: Arc::new(StreamFetcher::new()),
cache_dir: Arc::new(RwLock::new(cache_dir)),
limit_bytes: Arc::new(Mutex::new(Some(5 * 1024 * 1024 * 1024u64))),
cache_semaphore: Arc::new(Semaphore::new(3)),
library_db: Arc::new(Mutex::new(None)),
}
}
pub async fn init_at(&self, cache_base_dir: &std::path::Path) -> Result<(), String> {
let cache_dir = cache_base_dir.join("audio");
std::fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
std::fs::create_dir_all(cache_dir.join("tracks"))
.map_err(|e| format!("Failed to create tracks directory: {}", e))?;
std::fs::create_dir_all(cache_dir.join("artwork"))
.map_err(|e| format!("Failed to create artwork directory: {}", e))?;
let db_path = cache_dir.join("index.db");
let new_db = OfflineCacheDb::new(&db_path)?;
let mut guard = self.db.lock().await;
*guard = Some(new_db);
// Update cache_dir to user-scoped path
if let Ok(mut dir_guard) = self.cache_dir.write() {
*dir_guard = cache_dir.clone();
}
log::info!("Offline cache initialized at: {:?}", cache_dir);
Ok(())
}
/// Open a separate library DB connection for download post-processing.
/// Must be called after library.init_at() so the schema exists.
pub async fn init_library_connection(&self, data_dir: &std::path::Path) -> Result<(), String> {
let db_path = data_dir.join("library.db");
let lib_db = qbz_library::LibraryDatabase::open(&db_path)
.map_err(|e| format!("Failed to open download library connection: {}", e))?;
let mut guard = self.library_db.lock().await;
*guard = Some(lib_db);
log::info!(
"Offline cache: separate library DB connection opened at {:?}",
db_path
);
Ok(())
}
pub async fn teardown(&self) {
// Close library connection first (before main teardown)
{
let mut lib_guard = self.library_db.lock().await;
*lib_guard = None;
}
let mut guard = self.db.lock().await;
*guard = None;
}
/// Get the path for a track's audio file
pub fn track_file_path(&self, track_id: u64, format: &str) -> PathBuf {
let dir = self.cache_dir.read().unwrap();
dir.join("tracks").join(format!("{}.{}", track_id, format))
}
/// Get the path for an album's artwork
pub fn artwork_path(&self, album_id: &str) -> PathBuf {
let dir = self.cache_dir.read().unwrap();
dir.join("artwork").join(format!("{}.jpg", album_id))
}
/// Get the cache directory path
pub fn get_cache_path(&self) -> String {
let dir = self.cache_dir.read().unwrap();
dir.to_string_lossy().to_string()
}
/// Seed the in-memory `limit_bytes` from the persisted offline_settings DB.
///
/// Called at session activation (after `init_at`) so the user's previously
/// chosen limit survives across restarts. When the persisted value is
/// missing (legacy installs with NULL column), the default 5 GB seeded by
/// `init_at`/`new` is preserved.
pub async fn apply_persisted_limit(
&self,
offline_state: &crate::offline::OfflineState,
) -> Result<(), String> {
let persisted: Option<u64> = {
let guard = offline_state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
match guard.as_ref() {
Some(store) => store.get_cache_limit_bytes()?,
None => return Ok(()),
}
};
if let Some(bytes) = persisted {
let mut limit = self.limit_bytes.lock().await;
*limit = Some(bytes);
log::info!(
"Offline cache: applied persisted size limit ({} bytes)",
bytes
);
} else {
log::info!(
"Offline cache: no persisted size limit, keeping in-memory default"
/// Build a [`qbz_offline_cache::CacheEventSink`] that re-emits the crate's
/// `CacheEvent`s as the legacy Tauri events. Cheap to build per call (an
/// `Arc`'d closure capturing the `AppHandle`).
pub fn tauri_cache_sink<R: Runtime>(
app: tauri::AppHandle<R>,
) -> qbz_offline_cache::CacheEventSink {
use qbz_offline_cache::{CacheEvent, CacheFormat};
std::sync::Arc::new(move |ev: CacheEvent| match ev {
CacheEvent::Started { track_id } => {
let _ = app.emit(
"offline:caching_started",
serde_json::json!({ "trackId": track_id }),
);
}
Ok(())
}
CacheEvent::Progress {
track_id,
progress_percent,
bytes_downloaded,
total_bytes,
} => {
let _ = app.emit(
"offline:caching_progress",
serde_json::json!({
"trackId": track_id,
"progressPercent": progress_percent,
"bytesDownloaded": bytes_downloaded,
"totalBytes": total_bytes,
"status": "downloading",
}),
);
}
CacheEvent::Completed {
track_id,
size,
format,
} => {
// Legacy path omitted `format`; CMAF path set "cmaf". Preserve both.
let payload = match format {
CacheFormat::Cmaf => {
serde_json::json!({ "trackId": track_id, "size": size, "format": "cmaf" })
}
CacheFormat::Flac => serde_json::json!({ "trackId": track_id, "size": size }),
};
let _ = app.emit("offline:caching_completed", payload);
}
CacheEvent::Processed {
track_id,
path,
format,
} => {
let payload = match format {
CacheFormat::Cmaf => {
serde_json::json!({ "trackId": track_id, "path": path, "format": "cmaf" })
}
CacheFormat::Flac => serde_json::json!({ "trackId": track_id, "path": path }),
};
let _ = app.emit("offline:caching_processed", payload);
}
CacheEvent::Failed { track_id, error } => {
let _ = app.emit(
"offline:caching_failed",
serde_json::json!({ "trackId": track_id, "error": error }),
);
}
CacheEvent::UnlockStart { track_id } => {
let _ = app.emit(
"offline:unlock_start",
serde_json::json!({ "trackId": track_id }),
);
}
CacheEvent::UnlockEnd { track_id, success } => {
let _ = app.emit(
"offline:unlock_end",
serde_json::json!({ "trackId": track_id, "success": success }),
);
}
})
}
@@ -1,236 +0,0 @@
//! 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)
}
-169
View File
@@ -1,169 +0,0 @@
//! Offline-cache → audio-bytes bridge for playback.
//!
//! When a track is played back from the offline cache (either via the
//! main playback path or via the Local Library path when the track is a
//! Qobuz-cached offline entry), this module converts the stored row
//! into a `Vec<u8>` ready for `player.play_data`.
//!
//! For `cache_format = 2` (v2 CMAF bundle) this means:
//! 1. Read init.mp4 + segments.bin + manifest.json from disk
//! 2. Unwrap the content_key via the secret vault
//! 3. Decrypt the encrypted frames and prepend the FLAC header
//!
//! For `cache_format = 1` (legacy plain FLAC) the caller should just
//! `std::fs::read(file_path)` directly — this module doesn't handle v1
//! since v1 needs no extra work.
use std::path::Path;
use tauri::Emitter;
use super::cmaf_store::{self, BundleLayout};
use super::db::CmafBundleRow;
use super::secret_vault;
/// Run `load_cmaf_bundle` on the blocking pool and emit
/// `offline:unlock_start` / `offline:unlock_end` around it so the
/// frontend can show an "unlocking" animation on the track row.
///
/// `display_track_id` is what the frontend knows this track as — for
/// Qobuz flow it's the Qobuz track id, for Local Library it's the
/// library row id. The events carry THIS id so whatever UI is looking
/// at the track can key off it.
///
/// `cmaf_track_id` is the key used inside load_cmaf_bundle for logs
/// (always the Qobuz track id, since that's what the bundle is
/// identified by on disk + in the offline cache DB).
pub async fn load_cmaf_bundle_with_ui_events<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
display_track_id: u64,
cmaf_track_id: u64,
row: CmafBundleRow,
cache_path: String,
) -> Option<Vec<u8>> {
let _ = app.emit(
"offline:unlock_start",
serde_json::json!({ "trackId": display_track_id }),
);
let result = tokio::task::spawn_blocking(move || {
load_cmaf_bundle(cmaf_track_id, &row, Path::new(&cache_path))
})
.await
.ok()
.flatten();
let _ = app.emit(
"offline:unlock_end",
serde_json::json!({
"trackId": display_track_id,
"success": result.is_some(),
}),
);
result
}
/// Decrypt a v2 CMAF bundle row into plain FLAC bytes ready for
/// `player.play_data`. Returns `None` on any failure (missing init,
/// wrong-size unwrapped key, corrupt manifest, decrypt error). The
/// caller should treat `None` as a cache miss — continue to the next
/// tier or the network.
///
/// `offline_root_path` is only used to locate the secret vault's
/// install UUID file; it must match the path used at download time.
/// Passing `OfflineCacheState::get_cache_path()` is correct.
pub fn load_cmaf_bundle(
track_id: u64,
row: &CmafBundleRow,
offline_root_path: &Path,
) -> Option<Vec<u8>> {
if row.cache_format != 2 {
return None;
}
let init_path = row.init_path.as_ref().or_else(|| {
log::warn!(
"[OfflineCache/Play] Track {} cache_format=2 but init_path is null",
track_id
);
None
})?;
let content_key_wrapped = row.content_key_wrapped.as_ref().or_else(|| {
log::warn!(
"[OfflineCache/Play] Track {} cache_format=2 but content_key_wrapped is null",
track_id
);
None
})?;
let segments_path = std::path::PathBuf::from(&row.segments_path);
let track_dir = segments_path.parent()?.to_path_buf();
let layout = BundleLayout {
track_dir,
init_path: std::path::PathBuf::from(init_path),
segments_path: segments_path.clone(),
manifest_path: segments_path.with_file_name("manifest.json"),
};
let loaded = match cmaf_store::read_bundle(&layout) {
Ok(lb) => lb,
Err(e) => {
log::warn!(
"[OfflineCache/Play] Track {} failed to read CMAF bundle: {}",
track_id,
e
);
return None;
}
};
let vault = match secret_vault::get_or_init(offline_root_path) {
Ok(v) => v,
Err(e) => {
log::warn!(
"[OfflineCache/Play] Track {} SecretBox init failed: {}",
track_id,
e
);
return None;
}
};
let unwrapped = match vault.unwrap(content_key_wrapped) {
Ok(k) => k,
Err(e) => {
log::warn!(
"[OfflineCache/Play] Track {} content_key unwrap failed: {}",
track_id,
e
);
return None;
}
};
if unwrapped.len() != 16 {
log::warn!(
"[OfflineCache/Play] Track {} unwrapped content_key wrong size ({} bytes)",
track_id,
unwrapped.len()
);
return None;
}
let mut content_key = [0u8; 16];
content_key.copy_from_slice(&unwrapped);
match loaded.decrypt_to_flac(&content_key) {
Ok(flac_bytes) => {
log::info!(
"[OfflineCache/Play] Track {} unwrapped + decrypted ({:.2} MB FLAC)",
track_id,
flac_bytes.len() as f64 / (1024.0 * 1024.0)
);
Some(flac_bytes)
}
Err(e) => {
log::warn!(
"[OfflineCache/Play] Track {} CMAF decrypt failed: {}",
track_id,
e
);
None
}
}
}
-99
View File
@@ -1,99 +0,0 @@
//! Offline cache purge helper.
//!
//! Used by `session_lifecycle` to clear all cached audio when the active
//! session is torn down. Not a Tauri command — pure helper.
use crate::library::LibraryState;
use crate::offline_cache::OfflineCacheState;
/// Clear entire offline cache (internal helper)
pub async fn purge_all_cached_files(
cache_state: &OfflineCacheState,
library_state: &LibraryState,
) -> Result<(), String> {
let paths = {
let guard__ = cache_state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.clear_all()?
};
// Delete all files. For v1 entries `file_path` is the plain FLAC;
// for v2 entries it's `<dir>/segments.bin` — we remove the enclosing
// track directory so init.mp4 + manifest.json + segments.bin all go
// in one step. Plain files still work because remove_dir_all on a
// file path fails silently and we fall through to remove_file.
for path in paths {
let p = std::path::Path::new(&path);
if !p.exists() {
continue;
}
// Heuristic: the v2 layout puts everything inside `tracks-cmaf/<id>/`.
// If the parent directory matches that shape, remove the directory.
let looks_like_v2 = p
.parent()
.and_then(|parent| parent.parent())
.and_then(|root| root.file_name())
.and_then(|n| n.to_str())
== Some("tracks-cmaf");
if looks_like_v2 {
if let Some(track_dir) = p.parent() {
let _ = std::fs::remove_dir_all(track_dir);
continue;
}
}
let _ = std::fs::remove_file(p);
}
// Also clear the tracks directory (legacy unorganized files)
let cache_dir = cache_state.cache_dir.read().unwrap().clone();
let tracks_dir = cache_dir.join("tracks");
if tracks_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&tracks_dir) {
for entry in entries.flatten() {
let _ = std::fs::remove_file(entry.path());
}
}
}
// And the tracks-cmaf directory (v2 bundles) — belt-and-suspenders
// so orphan bundles from corrupt DB rows get cleaned up too.
let tracks_cmaf_dir = cache_dir.join("tracks-cmaf");
if tracks_cmaf_dir.exists() {
let _ = std::fs::remove_dir_all(&tracks_cmaf_dir);
log::info!("[OfflineCache/Purge] Removed tracks-cmaf/ directory");
}
// Clear organized artist/album folders
// Look for any subdirectories in cache_dir that are not "tracks" or system files
if let Ok(entries) = std::fs::read_dir(&cache_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
// Skip the tracks directory and database files
if name != "tracks" && !name.ends_with(".db") && !name.ends_with(".db-journal") {
// This is likely an artist folder, delete it recursively
if let Err(e) = std::fs::remove_dir_all(&path) {
log::warn!("Failed to remove folder {:?}: {}", path, e);
} else {
log::info!("Removed artist folder: {:?}", path);
}
}
}
}
}
// Remove all Qobuz cached tracks from library
let guard__ = library_state.db.lock().await;
let library_db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let removed_count = library_db
.remove_all_qobuz_cached_tracks()
.map_err(|e| format!("Failed to remove cached tracks from library: {}", e))?;
log::info!("Removed {} Qobuz cached tracks from library", removed_count);
Ok(())
}
@@ -1,35 +0,0 @@
//! 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)
}
+7 -7
View File
@@ -217,12 +217,12 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
// both `offline.init_at` (above) and `offline_cache.init_at` so the DB
// handle is available. Falls back silently to the 5 GB default when
// unset.
if let Err(e) = offline_cache.apply_persisted_limit(&offline).await {
log::warn!(
"[SessionLifecycle] Failed to apply persisted offline cache limit: {} (using default)",
e
);
}
let persisted_limit = offline.store.lock().ok().and_then(|guard| {
guard
.as_ref()
.and_then(|s| s.get_cache_limit_bytes().ok().flatten())
});
offline_cache.apply_persisted_limit(persisted_limit).await;
lyrics.init_at(&cache_dir).await?;
// Run deferred subscription purge check
@@ -245,7 +245,7 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
log::warn!("[SessionLifecycle] Subscription invalid beyond the grace window. Purging offline cache.");
if let Err(e) = crate::offline_cache::purge::purge_all_cached_files(
offline_cache.inner(),
library.inner(),
&library.inner().db,
)
.await
{