#![allow(dead_code)]
use crate::error::{AppError, AppResult};
use crate::provider::SubtitleFormat;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub const DEFAULT_TTL_HOURS: u64 = 24;
pub const SECONDS_PER_HOUR: u64 = 3600;
pub fn ttl_from_hours(hours: u64) -> Duration {
Duration::from_secs(hours * SECONDS_PER_HOUR)
}
const CACHE_QUALIFIER_KEY: &str = "cache.qualifier";
const TMP_SUFFIX: &str = ".tmp";
static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
#[tracing::instrument(level = "debug", err, skip(video_id, lang, format), fields(video_id, lang, format, ttl_secs = ttl.as_secs()))]
pub fn cache_path(video_id: &str, lang: &str, format: &str, ttl: Duration) -> AppResult<PathBuf> {
if video_id.is_empty() || lang.is_empty() || format.is_empty() {
return Err(AppError::InvalidInput(
"cache_path requires non-empty video_id, lang, and format".to_string(),
));
}
if ttl.is_zero() {
return Err(AppError::InvalidInput(
"cache_path requires a non-zero ttl".to_string(),
));
}
let dir = cache_root()?
.join("subtitles")
.join(sanitize(video_id)?)
.join(sanitize(lang)?);
std::fs::create_dir_all(&dir)
.map_err(|e| AppError::Io(std::io::Error::other(format!("creating cache dir: {e}"))))?;
Ok(dir.join(format!("{}.bin", sanitize(format)?)))
}
fn cache_root() -> AppResult<PathBuf> {
let proj = crate::config::project_dirs()
.ok_or_else(|| AppError::Internal("could not determine cache directory".to_string()))?;
Ok(qualified_root(
proj.cache_dir(),
configured_qualifier().as_deref(),
))
}
fn configured_qualifier() -> Option<String> {
let value = crate::config::tuning_string(CACHE_QUALIFIER_KEY)?;
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn qualified_root(root: &Path, qualifier: Option<&str>) -> PathBuf {
match qualifier.map(sanitize_qualifier) {
Some(segment) if !segment.is_empty() => root.join(segment),
_ => root.to_path_buf(),
}
}
fn sanitize_qualifier(input: &str) -> String {
input
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
c
} else {
'_'
}
})
.collect()
}
fn sanitize(input: &str) -> AppResult<String> {
if input
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
{
Ok(input.to_string())
} else {
Err(AppError::InvalidInput(format!(
"invalid path component: {input}"
)))
}
}
#[tracing::instrument(level = "debug", err, skip(path), fields(path = %path.display(), ttl_secs = ttl.as_secs()))]
pub async fn read_cache(path: &PathBuf, ttl: Duration) -> AppResult<Option<Vec<u8>>> {
let metadata = match tokio::fs::metadata(path).await {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(AppError::Io(e)),
};
let modified = metadata
.modified()
.map_err(|e| AppError::Io(std::io::Error::other(e.to_string())))?;
let elapsed = modified
.duration_since(UNIX_EPOCH)
.map_err(|e| AppError::Io(std::io::Error::other(e.to_string())))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| AppError::Io(std::io::Error::other(e.to_string())))?;
if now.saturating_sub(elapsed) > ttl {
let _ = tokio::fs::remove_file(path).await;
return Ok(None);
}
let bytes = tokio::fs::read(path).await.map_err(AppError::Io)?;
Ok(Some(bytes))
}
#[tracing::instrument(level = "debug", err, skip(path), fields(path = %path.display(), ttl_secs = ttl.as_secs()))]
pub async fn read_cache_with_hint(
path: &PathBuf,
ttl: Duration,
) -> AppResult<Option<(Vec<u8>, SubtitleFormat)>> {
let bytes = match read_cache(path, ttl).await? {
Some(b) => b,
None => return Ok(None),
};
let hint_path = hint_path_for(path);
let hint = match tokio::fs::read(&hint_path).await {
Ok(s) => match std::str::from_utf8(&s) {
Ok(s) => parse_hint(s).unwrap_or(SubtitleFormat::Srt),
Err(_) => SubtitleFormat::Srt,
},
Err(_) => SubtitleFormat::Srt,
};
Ok(Some((bytes, hint)))
}
#[tracing::instrument(level = "debug", err, skip(path, content), fields(path = %path.display(), bytes = content.len()))]
pub async fn write_cache(path: &Path, content: &[u8]) -> AppResult<()> {
write_atomic(path, content).await
}
async fn write_atomic(path: &Path, content: &[u8]) -> AppResult<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(AppError::Io)?;
}
let tmp = temp_path_for(path);
if let Err(e) = tokio::fs::write(&tmp, content).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(AppError::Io(e));
}
if let Err(e) = tokio::fs::rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(AppError::Io(e));
}
Ok(())
}
fn temp_path_for(path: &Path) -> PathBuf {
let pid = std::process::id();
let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
let mut s = path.as_os_str().to_owned();
s.push(format!(".{pid}.{seq}{TMP_SUFFIX}"));
PathBuf::from(s)
}
#[tracing::instrument(level = "debug", err, skip(path, content, format_hint), fields(path = %path.display(), bytes = content.len()))]
pub async fn write_cache_with_hint(
path: &Path,
content: &[u8],
format_hint: SubtitleFormat,
) -> AppResult<()> {
let hint_path = hint_path_for(path);
write_atomic(&hint_path, format_hint.as_str().as_bytes()).await?;
write_atomic(path, content).await?;
Ok(())
}
fn hint_path_for(path: &Path) -> PathBuf {
let mut s = path.as_os_str().to_owned();
s.push(".hint");
PathBuf::from(s)
}
fn parse_hint(s: &str) -> Option<SubtitleFormat> {
match s.trim() {
"srt" => Some(SubtitleFormat::Srt),
"noteey-transcript" => Some(SubtitleFormat::NoteeyTranscript),
_ => None,
}
}
#[tracing::instrument(level = "debug", err, skip(path), fields(path = %path.display()))]
pub async fn invalidate_cache(path: &PathBuf) -> AppResult<()> {
if path.exists() {
tokio::fs::remove_file(path).await.map_err(AppError::Io)?;
}
Ok(())
}
#[tracing::instrument(level = "debug")]
pub fn default_ttl() -> Duration {
ttl_from_hours(DEFAULT_TTL_HOURS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_ttl_is_24_hours() {
assert_eq!(default_ttl(), Duration::from_secs(24 * 3600));
}
#[test]
fn unconfigured_qualifier_adds_no_segment() {
let root = Path::new("base").join("cache");
assert_eq!(qualified_root(&root, None), root);
}
#[test]
fn configured_qualifier_adds_one_segment() {
let root = Path::new("base").join("cache");
assert_eq!(qualified_root(&root, Some("acme")), root.join("acme"));
}
#[test]
fn configured_qualifier_cannot_traverse_directories() {
let root = Path::new("base").join("cache");
assert_eq!(
qualified_root(&root, Some("../etc a")),
root.join(".._etc_a")
);
}
#[test]
fn config_cache_and_session_share_one_project_dirs() {
let Some(dirs) = crate::config::project_dirs() else {
return;
};
assert_eq!(
crate::config::config_dir().expect("config dir resolves"),
dirs.config_dir()
);
assert!(
cache_root()
.expect("cache root resolves")
.starts_with(dirs.cache_dir()),
"the cache must live under the shared cache directory"
);
assert!(
crate::net::session::CookieJar::default_path()
.expect("session path resolves")
.starts_with(dirs.data_dir()),
"the session jar must live under the shared data directory"
);
}
#[test]
fn qualifier_sanitizes_invalid_chars() {
let s = sanitize_qualifier("hello world/foo");
assert_eq!(s, "hello_world_foo");
}
#[test]
fn cache_path_rejects_zero_ttl() {
let res = cache_path("vid12345678", "en", "txt", Duration::ZERO);
assert!(matches!(res, Err(AppError::InvalidInput(_))));
}
#[test]
fn cache_path_rejects_empty_components() {
let res = cache_path("", "en", "txt", default_ttl());
assert!(matches!(res, Err(AppError::InvalidInput(_))));
}
#[test]
fn sanitize_accepts_safe_chars() {
assert_eq!(sanitize("video_123-abc.txt").unwrap(), "video_123-abc.txt");
}
#[test]
fn sanitize_rejects_unsafe_chars() {
assert!(sanitize("../etc/passwd").is_err());
assert!(sanitize("with space").is_err());
}
fn scratch_dir(tag: &str) -> PathBuf {
let pid = std::process::id();
let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("ylc-cache-test-{tag}-{pid}-{seq}"));
std::fs::create_dir_all(&dir).expect("scratch dir is creatable");
dir
}
fn leftover_staging_files(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.to_string_lossy().ends_with(TMP_SUFFIX))
.collect()
}
#[tokio::test]
async fn atomic_write_round_trips_and_leaves_no_staging_file() {
let dir = scratch_dir("atomic");
let path = dir.join("body.bin");
write_cache(&path, b"hello").await.expect("write succeeds");
let read = read_cache(&path, default_ttl())
.await
.expect("read succeeds");
assert_eq!(read.as_deref(), Some(&b"hello"[..]));
assert!(
leftover_staging_files(&dir).is_empty(),
"atomic write must not leave staging files behind"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn atomic_write_replaces_previous_body_in_full() {
let dir = scratch_dir("replace");
let path = dir.join("body.bin");
write_cache(&path, b"first-and-longer")
.await
.expect("first write succeeds");
write_cache(&path, b"second")
.await
.expect("rewrite succeeds");
let read = read_cache(&path, default_ttl())
.await
.expect("read succeeds");
assert_eq!(read.as_deref(), Some(&b"second"[..]));
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn hint_sidecar_round_trips_and_body_is_committed_last() {
let dir = scratch_dir("hint");
let path = dir.join("body.bin");
write_cache_with_hint(&path, b"cue", SubtitleFormat::NoteeyTranscript)
.await
.expect("write succeeds");
assert!(hint_path_for(&path).exists(), "hint must exist with body");
let (bytes, hint) = read_cache_with_hint(&path, default_ttl())
.await
.expect("read succeeds")
.expect("entry is fresh");
assert_eq!(bytes, b"cue");
assert!(matches!(hint, SubtitleFormat::NoteeyTranscript));
assert!(
leftover_staging_files(&dir).is_empty(),
"atomic write must not leave staging files behind"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn read_cache_reports_missing_entry_as_none() {
let dir = scratch_dir("missing");
let path = dir.join("absent.bin");
let read = read_cache(&path, default_ttl())
.await
.expect("missing entry is not an error");
assert!(read.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn missing_hint_falls_back_to_srt() {
let dir = scratch_dir("nohint");
let path = dir.join("body.bin");
write_cache(&path, b"legacy").await.expect("write succeeds");
let (bytes, hint) = read_cache_with_hint(&path, default_ttl())
.await
.expect("read succeeds")
.expect("entry is fresh");
assert_eq!(bytes, b"legacy");
assert!(matches!(hint, SubtitleFormat::Srt));
let _ = std::fs::remove_dir_all(&dir);
}
}