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 clip_exporter;
|
||||||
pub mod dependency_manager;
|
pub mod dependency_manager;
|
||||||
pub mod download_manager;
|
pub mod download_manager;
|
||||||
|
|||||||
Reference in New Issue
Block a user