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:
5 files changed
+268
No files matched your search
Generated
+1
@@ -5133,6 +5133,7 @@ dependencies = [
|
||||
name = "qbz-offline-cache"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"futures-util",
|
||||
"lofty",
|
||||
"log",
|
||||
|
||||
@@ -24,6 +24,9 @@ futures-util = { workspace = true }
|
||||
# FLAC tag + artwork embedding (legacy-format post-processing)
|
||||
lofty = "0.23"
|
||||
|
||||
# Platform cache dir (default offline-root location)
|
||||
dirs = "6"
|
||||
|
||||
# DTO serialization (camelCase envelopes shared with the frontend)
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -23,13 +23,17 @@ pub mod metadata;
|
||||
pub mod migration;
|
||||
pub mod path_validator;
|
||||
pub mod playback;
|
||||
pub mod purge;
|
||||
pub mod secret_vault;
|
||||
pub mod state;
|
||||
pub mod types;
|
||||
|
||||
pub use db::{CmafBundleRow, OfflineCacheDb};
|
||||
pub use downloader::{spawn_track_cache_download, StreamFetcher};
|
||||
pub use event::{CacheEvent, CacheEventSink, CacheFormat};
|
||||
pub use metadata::{sanitize_filename, CompleteTrackMetadata};
|
||||
pub use purge::purge_all_cached_files;
|
||||
pub use state::OfflineCacheState;
|
||||
pub use migration::{
|
||||
detect_legacy_cached_files, migrate_legacy_cached_files, MigrationError, MigrationStatus,
|
||||
};
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user