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:
19 files changed
+213
-3359
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user