use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use tokio::io::AsyncReadExt;
use crate::cache::FormatPreferences;
use crate::cache::backend::FileBackend;
#[cfg(persistent_cache)]
use crate::cache::backend::PersistentFileBackend;
#[cfg(feature = "cache-memory")]
use crate::cache::backend::memory::MokaFileCache;
use crate::cache::config::CacheConfig;
use crate::cache::video::{CachedFile, CachedThumbnail, CachedType};
use crate::error::Result;
use crate::model::format::Format;
use crate::model::utils;
use crate::utils::current_timestamp;
fn guess_mime(path: &Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("mp4") | Some("m4v") => "video/mp4",
Some("mkv") => "video/x-matroska",
Some("webm") => "video/webm",
Some("mp3") => "audio/mpeg",
Some("m4a") => "audio/mp4",
Some("ogg") | Some("oga") => "audio/ogg",
Some("opus") => "audio/opus",
Some("flac") => "audio/flac",
Some("wav") => "audio/wav",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("srt") => "text/plain",
Some("vtt") => "text/vtt",
Some("ass") | Some("ssa") => "text/x-ssa",
Some("json") => "application/json",
_ => "application/octet-stream",
}
}
#[derive(Debug)]
pub struct DownloadCache {
#[cfg(feature = "cache-memory")]
memory: MokaFileCache,
#[cfg(persistent_cache)]
persistent: PersistentFileBackend,
}
impl DownloadCache {
pub async fn new(config: &CacheConfig, ttl: Option<u64>) -> Result<Self> {
tracing::debug!(cache_dir = ?config.cache_dir, ttl = ?ttl, "⚙️ Creating download cache");
Ok(Self {
#[cfg(feature = "cache-memory")]
memory: MokaFileCache::new(config.cache_dir.clone(), ttl).await?,
#[cfg(persistent_cache)]
persistent: PersistentFileBackend::new(config, ttl).await?,
})
}
pub async fn get_by_hash(&self, hash: &str) -> Result<Option<(CachedFile, PathBuf)>> {
tracing::debug!(hash = hash, "🔍 Looking up file by hash");
#[cfg(feature = "cache-memory")]
if let Some(result) = self.memory.get_by_hash(hash).await? {
tracing::debug!(hash = hash, "✅ File cache hit (L1 memory)");
return Ok(Some(result));
}
#[cfg(persistent_cache)]
if let Some(result) = self.persistent.get_by_hash(hash).await? {
tracing::debug!(hash = hash, "✅ File cache hit (L2 persistent)");
#[cfg(feature = "cache-memory")]
{
let path = std::path::Path::new(&result.0.relative_path);
let _ = self.memory.put(result.0.clone(), path).await;
}
return Ok(Some(result));
}
Ok(None)
}
pub async fn get_by_video_and_format(
&self,
video_id: &str,
format_id: &str,
) -> Result<Option<(CachedFile, PathBuf)>> {
tracing::debug!(
video_id = video_id,
format_id = format_id,
"🔍 Looking up file by video and format"
);
#[cfg(feature = "cache-memory")]
if let Some(result) = self.memory.get_by_video_and_format(video_id, format_id).await? {
tracing::debug!(
video_id = video_id,
format_id = format_id,
"✅ File cache hit (L1 memory)"
);
return Ok(Some(result));
}
#[cfg(persistent_cache)]
if let Some(result) = self.persistent.get_by_video_and_format(video_id, format_id).await? {
tracing::debug!(
video_id = video_id,
format_id = format_id,
"✅ File cache hit (L2 persistent)"
);
#[cfg(feature = "cache-memory")]
{
let path = std::path::Path::new(&result.0.relative_path);
let _ = self.memory.put(result.0.clone(), path).await;
}
return Ok(Some(result));
}
Ok(None)
}
pub async fn get_by_video_and_preferences(
&self,
video_id: &str,
preferences: &FormatPreferences,
) -> Result<Option<(CachedFile, PathBuf)>> {
tracing::debug!(video_id = video_id, "🔍 Looking up file by preferences");
#[cfg(feature = "cache-memory")]
if let Some(result) = self.memory.get_by_video_and_preferences(video_id, preferences).await? {
tracing::debug!(video_id = video_id, "✅ File cache hit by preferences (L1 memory)");
return Ok(Some(result));
}
#[cfg(persistent_cache)]
if let Some(result) = self
.persistent
.get_by_video_and_preferences(video_id, preferences)
.await?
{
tracing::debug!(video_id = video_id, "✅ File cache hit by preferences (L2 persistent)");
#[cfg(feature = "cache-memory")]
{
let path = std::path::Path::new(&result.0.relative_path);
let _ = self.memory.put(result.0.clone(), path).await;
}
return Ok(Some(result));
}
Ok(None)
}
pub async fn get_thumbnail_by_video_id(&self, video_id: &str) -> Result<Option<(CachedThumbnail, PathBuf)>> {
tracing::debug!(video_id = video_id, "🔍 Looking up thumbnail by video ID");
#[cfg(feature = "cache-memory")]
if let Some(result) = self.memory.get_thumbnail_by_video_id(video_id).await? {
tracing::debug!(video_id = video_id, "✅ Thumbnail cache hit (L1 memory)");
return Ok(Some(result));
}
#[cfg(persistent_cache)]
if let Some(result) = self.persistent.get_thumbnail_by_video_id(video_id).await? {
tracing::debug!(video_id = video_id, "✅ Thumbnail cache hit (L2 persistent)");
#[cfg(feature = "cache-memory")]
{
let path = std::path::Path::new(&result.0.relative_path);
let _ = self.memory.put_thumbnail(result.0.clone(), path).await;
}
return Ok(Some(result));
}
Ok(None)
}
pub async fn get_subtitle_by_language(
&self,
video_id: &str,
language: &str,
) -> Result<Option<(CachedFile, PathBuf)>> {
tracing::debug!(
video_id = video_id,
language = language,
"🔍 Looking up subtitle by language"
);
#[cfg(feature = "cache-memory")]
if let Some(result) = self.memory.get_subtitle_by_language(video_id, language).await? {
tracing::debug!(
video_id = video_id,
language = language,
"✅ Subtitle cache hit (L1 memory)"
);
return Ok(Some(result));
}
#[cfg(persistent_cache)]
if let Some(result) = self.persistent.get_subtitle_by_language(video_id, language).await? {
tracing::debug!(
video_id = video_id,
language = language,
"✅ Subtitle cache hit (L2 persistent)"
);
#[cfg(feature = "cache-memory")]
{
let path = std::path::Path::new(&result.0.relative_path);
let _ = self.memory.put(result.0.clone(), path).await;
}
return Ok(Some(result));
}
Ok(None)
}
pub async fn put_file(
&self,
source_path: &Path,
filename: impl Into<String>,
video_id: Option<String>,
format: Option<&Format>,
) -> Result<PathBuf> {
let file_info = Self::collect_file_info(source_path, filename.into(), video_id, format)?;
self.put_cached_file(file_info, source_path).await
}
pub async fn put_file_with_preferences(
&self,
source_path: &Path,
filename: impl Into<String>,
video_id: Option<String>,
format: Option<&Format>,
preferences: &FormatPreferences,
) -> Result<PathBuf> {
let mut file_info = Self::collect_file_info(source_path, filename.into(), video_id, format)?;
file_info.video_quality = utils::serde::serialize_json_opt(preferences.video_quality);
file_info.audio_quality = utils::serde::serialize_json_opt(preferences.audio_quality);
file_info.video_codec = utils::serde::serialize_json_opt(preferences.video_codec.clone());
file_info.audio_codec = utils::serde::serialize_json_opt(preferences.audio_codec.clone());
self.put_cached_file(file_info, source_path).await
}
pub async fn put_thumbnail(
&self,
source_path: &Path,
filename: impl Into<String>,
video_id: String,
) -> Result<PathBuf> {
let filename = filename.into();
let hash = Self::calculate_file_hash(source_path).await?;
let size = tokio::fs::metadata(source_path)
.await
.map(|m| m.len() as i64)
.unwrap_or(0);
let thumbnail = CachedThumbnail {
id: hash,
filename,
relative_path: source_path.to_string_lossy().to_string(),
video_id,
filesize: size,
mime_type: guess_mime(source_path).to_string(),
width: None,
height: None,
cached_at: current_timestamp(),
};
self.put_cached_thumbnail(thumbnail, source_path).await
}
pub async fn put_subtitle_file(
&self,
source_path: &Path,
filename: impl Into<String>,
video_id: String,
language_code: String,
) -> Result<PathBuf> {
let filename = filename.into();
let hash = Self::calculate_file_hash(source_path).await?;
let size = tokio::fs::metadata(source_path)
.await
.map(|m| m.len() as i64)
.unwrap_or(0);
let cached_file = CachedFile {
id: hash,
filename,
relative_path: source_path.to_string_lossy().to_string(),
video_id: Some(video_id),
file_type: CachedType::Subtitle.to_string(),
format_id: None,
format_json: None,
video_quality: None,
audio_quality: None,
video_codec: None,
audio_codec: None,
language_code: Some(language_code),
filesize: size,
mime_type: "text/plain".to_string(),
cached_at: current_timestamp(),
};
self.put_cached_file(cached_file, source_path).await
}
pub async fn remove(&self, id: &str) -> Result<()> {
tracing::debug!(file_id = id, "⚙️ Removing file from cache");
#[cfg(feature = "cache-memory")]
self.memory.remove(id).await?;
#[cfg(persistent_cache)]
self.persistent.remove(id).await?;
Ok(())
}
pub async fn clean(&self) -> Result<()> {
tracing::debug!("⚙️ Cleaning download cache");
#[cfg(feature = "cache-memory")]
self.memory.clean().await?;
#[cfg(persistent_cache)]
self.persistent.clean().await?;
Ok(())
}
pub async fn calculate_file_hash(path: &Path) -> Result<String> {
let mut file = tokio::fs::File::open(path).await?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let bytes_read = file.read(&mut buffer).await?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
Ok(hasher.finalize().iter().fold(String::new(), |mut acc, b| {
use std::fmt::Write;
let _ = write!(acc, "{:02x}", b);
acc
}))
}
fn collect_file_info(
source_path: &Path,
filename: String,
video_id: Option<String>,
format: Option<&Format>,
) -> Result<CachedFile> {
let size = std::fs::metadata(source_path).map(|m| m.len() as i64).unwrap_or(0);
let mime = guess_mime(source_path).to_string();
let format_id = format.map(|f| f.format_id.clone());
let format_json = format.and_then(|f| serde_json::to_string(f).ok());
Ok(CachedFile {
id: String::new(), filename,
relative_path: source_path.to_string_lossy().to_string(),
video_id,
file_type: CachedType::Format.to_string(),
format_id,
format_json,
video_quality: None,
audio_quality: None,
video_codec: None,
audio_codec: None,
language_code: None,
filesize: size,
mime_type: mime,
cached_at: current_timestamp(),
})
}
async fn put_cached_file(&self, mut file: CachedFile, source_path: &Path) -> Result<PathBuf> {
if file.id.is_empty() {
file.id = Self::calculate_file_hash(source_path).await?;
}
tracing::debug!(file_id = file.id, filename = file.filename, "⚙️ Storing file in cache");
#[cfg(feature = "cache-memory")]
let _ = self.memory.put(file.clone(), source_path).await?;
#[cfg(persistent_cache)]
let out = self.persistent.put(file, source_path).await?;
#[cfg(not(persistent_cache))]
let out = source_path.to_path_buf();
Ok(out)
}
async fn put_cached_thumbnail(&self, thumbnail: CachedThumbnail, source_path: &Path) -> Result<PathBuf> {
tracing::debug!(
thumbnail_id = thumbnail.id,
video_id = thumbnail.video_id,
"⚙️ Storing thumbnail in cache"
);
#[cfg(feature = "cache-memory")]
let _ = self.memory.put_thumbnail(thumbnail.clone(), source_path).await?;
#[cfg(persistent_cache)]
let out = self.persistent.put_thumbnail(thumbnail, source_path).await?;
#[cfg(not(persistent_cache))]
let out = source_path.to_path_buf();
Ok(out)
}
}