feat(offline-cache): move OfflineCacheState + purge into the crate

Completes the crate extraction (Slice 0 backend half):

- state: OfflineCacheState holder (db/fetcher/cache_dir/limit/semaphore/
  library_db + dir-layout helpers + new/init_at/teardown). apply_persisted_limit
  now takes Option<u64> (the caller reads it from offline_settings) instead of
  borrowing src-tauri's OfflineState, so the struct is frontend-agnostic and
  Slint can own one too.
- purge: purge_all_cached_files takes &Mutex<Option<LibraryDatabase>> directly
  instead of src-tauri's LibraryState wrapper.

The crate is now self-contained (13 modules, no Tauri). Next: re-wire the
Tauri commands onto it with an app.emit-backed CacheEventSink and delete the
old src-tauri/src/offline_cache/ module.

cc
This commit is contained in:
vicrodh committed 2026-05-27 19:50:23 -06:00
1 parent fc6bb7c559
commit b17bbbd442
5 files changed
+268

No files matched your search

+1
View File
@@ -5133,6 +5133,7 @@ dependencies = [
name = "qbz-offline-cache" name = "qbz-offline-cache"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"dirs 6.0.0",
"futures-util", "futures-util",
"lofty", "lofty",
"log", "log",
+3
View File
@@ -24,6 +24,9 @@ futures-util = { workspace = true }
# FLAC tag + artwork embedding (legacy-format post-processing) # FLAC tag + artwork embedding (legacy-format post-processing)
lofty = "0.23" lofty = "0.23"
# Platform cache dir (default offline-root location)
dirs = "6"
# DTO serialization (camelCase envelopes shared with the frontend) # DTO serialization (camelCase envelopes shared with the frontend)
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
+4
View File
@@ -23,13 +23,17 @@ pub mod metadata;
pub mod migration; pub mod migration;
pub mod path_validator; pub mod path_validator;
pub mod playback; pub mod playback;
pub mod purge;
pub mod secret_vault; pub mod secret_vault;
pub mod state;
pub mod types; pub mod types;
pub use db::{CmafBundleRow, OfflineCacheDb}; pub use db::{CmafBundleRow, OfflineCacheDb};
pub use downloader::{spawn_track_cache_download, StreamFetcher}; pub use downloader::{spawn_track_cache_download, StreamFetcher};
pub use event::{CacheEvent, CacheEventSink, CacheFormat}; pub use event::{CacheEvent, CacheEventSink, CacheFormat};
pub use metadata::{sanitize_filename, CompleteTrackMetadata}; pub use metadata::{sanitize_filename, CompleteTrackMetadata};
pub use purge::purge_all_cached_files;
pub use state::OfflineCacheState;
pub use migration::{ pub use migration::{
detect_legacy_cached_files, migrate_legacy_cached_files, MigrationError, MigrationStatus, detect_legacy_cached_files, migrate_legacy_cached_files, MigrationError, MigrationStatus,
}; };
+102
View File
@@ -0,0 +1,102 @@
//! 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::OfflineCacheState;
/// Clear entire offline cache (internal helper).
///
/// `library_db` is the (locked-on-demand) main library connection — the
/// caller passes its `Arc<Mutex<Option<LibraryDatabase>>>` so this helper
/// doesn't depend on any frontend's state-wrapper type.
pub async fn purge_all_cached_files(
cache_state: &OfflineCacheState,
library_db: &tokio::sync::Mutex<Option<qbz_library::LibraryDatabase>>,
) -> 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_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(())
}
+158
View File
@@ -0,0 +1,158 @@
//! The offline-cache state holder (moved from `src-tauri/src/offline_cache/mod.rs`).
//!
//! A plain struct (no Tauri): the open SQLite index, the stream fetcher,
//! the cache-dir path, the size limit, the download concurrency semaphore,
//! and a separate library-DB connection for download post-processing.
//! Both the Tauri frontend (`tauri::State`) and the Slint frontend own one.
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use tokio::sync::{Mutex, Semaphore};
use crate::db::OfflineCacheDb;
use crate::downloader::StreamFetcher;
/// 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 at the platform cache dir.
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 a persisted value (read by the
/// caller from the offline_settings DB) so the user's previously chosen
/// limit survives across restarts. `None` keeps the in-memory default
/// (5 GB) seeded by `new`/`init_at`.
pub async fn apply_persisted_limit(&self, persisted: Option<u64>) {
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");
}
}
}