use crate::engine::hash;
use crate::engine::vision::DetectedObject;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
const VISION_CACHE_DIR: &str = "vision";
const CACHE_SCHEMA_VERSION: u32 = 6;
const HASH_HEX_LEN: usize = 64;
const IMAGE_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "webp", "bmp", "tiff", "tif", "avif", "heic", "heif", "ico",
];
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct CacheEntry {
schema_version: u32,
pub(crate) caption: Option<String>,
pub(crate) objects: Option<Vec<DetectedObject>>,
pub(crate) ocr: Option<String>,
}
impl CacheEntry {
pub(crate) fn new_empty() -> Self {
Self {
schema_version: CACHE_SCHEMA_VERSION,
caption: None,
objects: None,
ocr: None,
}
}
fn is_valid(&self) -> bool {
self.schema_version == CACHE_SCHEMA_VERSION
}
}
fn entry_path(readseek_dir: &Path, hash_hex: &str) -> PathBuf {
readseek_dir
.join(VISION_CACHE_DIR)
.join(&hash_hex[..2])
.join(format!("{}.json", &hash_hex[2..]))
}
pub(crate) fn load(readseek_dir: &Path, hash_hex: &str) -> Option<CacheEntry> {
let path = entry_path(readseek_dir, hash_hex);
if !path.exists() {
return None;
}
let data = match fs::read(&path) {
Ok(data) => data,
Err(error) => {
log::warn!("vision cache read {}: {error}", path.display());
return None;
}
};
let entry: CacheEntry = match serde_json::from_slice(&data) {
Ok(entry) => entry,
Err(error) => {
log::warn!("vision cache parse {}: {error}", path.display());
return None;
}
};
if !entry.is_valid() {
log::warn!(
"vision cache schema/model mismatch in {}, treating as miss",
path.display()
);
return None;
}
Some(entry)
}
pub(crate) fn store(readseek_dir: &Path, hash_hex: &str, entry: &CacheEntry) {
let path = entry_path(readseek_dir, hash_hex);
if let Some(parent) = path.parent()
&& let Err(error) = fs::create_dir_all(parent)
{
log::warn!("vision cache mkdir {}: {error}", parent.display());
return;
}
let data = match serde_json::to_vec(entry) {
Ok(data) => data,
Err(error) => {
log::warn!("vision cache serialize: {error}");
return;
}
};
if let Err(error) = crate::engine::repo::write_atomic(&path, &data) {
log::warn!("vision cache write {}: {error}", path.display());
}
}
fn has_image_extension(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(str::to_ascii_lowercase)
.is_some_and(|ext| IMAGE_EXTENSIONS.contains(&ext.as_str()))
}
pub(crate) fn image_hash(path: &Path) -> Option<String> {
if !has_image_extension(path) {
return None;
}
let bytes = fs::read(path).ok()?;
crate::engine::image::probe(&bytes)?;
Some(hash::hash_bytes(&bytes))
}
pub(crate) fn remove_stale(readseek_dir: &Path, active: &HashSet<String>) -> Result<usize> {
let mut removed = 0;
let vision_root = readseek_dir.join(VISION_CACHE_DIR);
if !vision_root.is_dir() {
return Ok(0);
}
for shard in fs::read_dir(&vision_root)
.map_err(|error| anyhow::anyhow!("read {}: {error}", vision_root.display()))?
{
let shard = shard?;
if !shard.file_type()?.is_dir() {
continue;
}
let prefix = shard.file_name().to_string_lossy().into_owned();
for file_entry in fs::read_dir(shard.path())? {
let file_entry = file_entry?;
let path = file_entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
continue;
};
let hash_hex = format!("{prefix}{stem}");
if hash_hex.len() == HASH_HEX_LEN
&& hex::decode(&hash_hex).is_ok()
&& !active.contains(&hash_hex)
{
fs::remove_file(&path)?;
removed += 1;
}
}
}
Ok(removed)
}