feat: add cache_manager service for analysis caching
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
207
src-tauri/src/services/cache_manager.rs
Normal file
207
src-tauri/src/services/cache_manager.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
// Future: used by project file save/load and cache Tauri commands.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::models::{ThumbnailSpritesheet, WaveformTiers};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CacheManifest {
|
||||
pub version: u32,
|
||||
pub source_url: String,
|
||||
pub title: String,
|
||||
pub duration: f64,
|
||||
pub fps: f64,
|
||||
pub created_at: String,
|
||||
pub last_accessed_at: String,
|
||||
pub waveform_tiers: Option<WaveformTiers>,
|
||||
pub keyframe_positions: Vec<f64>,
|
||||
pub thumbnail_spritesheets: Vec<ThumbnailSpritesheet>,
|
||||
pub caption_file_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CacheStats {
|
||||
pub entry_count: usize,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
/// First 16 hex characters of the SHA-256 hash of the URL.
|
||||
pub fn url_hash(url: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(url.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
result[..8]
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
pub fn cache_base_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("gui-video-clipper")
|
||||
.join("cache")
|
||||
}
|
||||
|
||||
pub fn cache_dir_for_url(url: &str) -> PathBuf {
|
||||
cache_base_dir().join(url_hash(url))
|
||||
}
|
||||
|
||||
pub fn thumbnail_cache_dir(url: &str) -> PathBuf {
|
||||
cache_dir_for_url(url).join("thumbnails")
|
||||
}
|
||||
|
||||
pub fn caption_cache_dir(url: &str) -> PathBuf {
|
||||
cache_dir_for_url(url).join("captions")
|
||||
}
|
||||
|
||||
fn manifest_path(url: &str) -> PathBuf {
|
||||
cache_dir_for_url(url).join("manifest.json")
|
||||
}
|
||||
|
||||
fn now_iso8601() -> String {
|
||||
// Use a simple UTC timestamp without pulling in chrono
|
||||
let duration = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = duration.as_secs();
|
||||
// Format as ISO 8601 — good enough for cache timestamps
|
||||
format!("{secs}")
|
||||
}
|
||||
|
||||
pub fn load_manifest(url: &str) -> Option<CacheManifest> {
|
||||
let path = manifest_path(url);
|
||||
if !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
let data = std::fs::read_to_string(&path).ok()?;
|
||||
let mut manifest: CacheManifest = serde_json::from_str(&data).ok()?;
|
||||
// Update last-accessed timestamp
|
||||
manifest.last_accessed_at = now_iso8601();
|
||||
if let Ok(json) = serde_json::to_string_pretty(&manifest) {
|
||||
let _ = std::fs::write(&path, json);
|
||||
}
|
||||
Some(manifest)
|
||||
}
|
||||
|
||||
pub fn save_manifest(url: &str, manifest: &CacheManifest) -> Result<(), String> {
|
||||
let dir = cache_dir_for_url(url);
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create cache dir: {e}"))?;
|
||||
let path = dir.join("manifest.json");
|
||||
let json = serde_json::to_string_pretty(manifest)
|
||||
.map_err(|e| format!("Failed to serialize manifest: {e}"))?;
|
||||
std::fs::write(&path, json)
|
||||
.map_err(|e| format!("Failed to write manifest: {e}"))?;
|
||||
eprintln!("[video-clipper:cache] saved manifest to '{}'", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_cache_entry(url: &str) -> Result<(), String> {
|
||||
let dir = cache_dir_for_url(url);
|
||||
if dir.is_dir() {
|
||||
std::fs::remove_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to remove cache entry: {e}"))?;
|
||||
eprintln!("[video-clipper:cache] cleared entry at '{}'", dir.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_all_cache() -> Result<(), String> {
|
||||
let base = cache_base_dir();
|
||||
if base.is_dir() {
|
||||
std::fs::remove_dir_all(&base)
|
||||
.map_err(|e| format!("Failed to remove cache dir: {e}"))?;
|
||||
eprintln!("[video-clipper:cache] cleared all cache at '{}'", base.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_cache_stats() -> Result<CacheStats, String> {
|
||||
let base = cache_base_dir();
|
||||
if !base.is_dir() {
|
||||
return Ok(CacheStats {
|
||||
entry_count: 0,
|
||||
total_bytes: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let mut entry_count = 0usize;
|
||||
let mut total_bytes = 0u64;
|
||||
|
||||
let entries = std::fs::read_dir(&base)
|
||||
.map_err(|e| format!("Failed to read cache dir: {e}"))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
entry_count += 1;
|
||||
total_bytes += dir_size(&path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CacheStats {
|
||||
entry_count,
|
||||
total_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn dir_size(path: &std::path::Path) -> u64 {
|
||||
let mut total = 0u64;
|
||||
if let Ok(entries) = std::fs::read_dir(path) {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_file() {
|
||||
total += std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
|
||||
} else if p.is_dir() {
|
||||
total += dir_size(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_url_hash_length() {
|
||||
let hash = url_hash("https://youtube.com/watch?v=abc123");
|
||||
assert_eq!(hash.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_hash_deterministic() {
|
||||
let url = "https://youtube.com/watch?v=abc123";
|
||||
assert_eq!(url_hash(url), url_hash(url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_hash_different_urls() {
|
||||
let a = url_hash("https://youtube.com/watch?v=abc");
|
||||
let b = url_hash("https://youtube.com/watch?v=xyz");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_dir_for_url_contains_hash() {
|
||||
let url = "https://youtube.com/watch?v=test";
|
||||
let dir = cache_dir_for_url(url);
|
||||
let hash = url_hash(url);
|
||||
assert!(dir.to_string_lossy().contains(&hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thumbnail_cache_dir_is_subdir() {
|
||||
let url = "https://example.com/video";
|
||||
let base = cache_dir_for_url(url);
|
||||
let thumbs = thumbnail_cache_dir(url);
|
||||
assert!(thumbs.starts_with(&base));
|
||||
assert!(thumbs.to_string_lossy().ends_with("thumbnails"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod cache_manager;
|
||||
pub mod clip_exporter;
|
||||
pub mod dependency_manager;
|
||||
pub mod download_manager;
|
||||
|
||||
Reference in New Issue
Block a user