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"
|
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",
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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