1use std::path::{Path, PathBuf};
8
9use sha2::{Digest, Sha256};
10use tokio::io::AsyncReadExt;
11
12use crate::cache::FormatPreferences;
13use crate::cache::backend::FileBackend;
14#[cfg(persistent_cache)]
15use crate::cache::backend::PersistentFileBackend;
16#[cfg(feature = "cache-memory")]
17use crate::cache::backend::memory::MokaFileCache;
18use crate::cache::config::CacheConfig;
19use crate::cache::video::{CachedFile, CachedThumbnail, CachedType};
20use crate::error::Result;
21use crate::model::format::Format;
22use crate::model::utils;
23use crate::utils::current_timestamp;
24
25fn guess_mime(path: &Path) -> &'static str {
27 match path.extension().and_then(|e| e.to_str()) {
28 Some("mp4") | Some("m4v") => "video/mp4",
29 Some("mkv") => "video/x-matroska",
30 Some("webm") => "video/webm",
31 Some("mp3") => "audio/mpeg",
32 Some("m4a") => "audio/mp4",
33 Some("ogg") | Some("oga") => "audio/ogg",
34 Some("opus") => "audio/opus",
35 Some("flac") => "audio/flac",
36 Some("wav") => "audio/wav",
37 Some("jpg") | Some("jpeg") => "image/jpeg",
38 Some("png") => "image/png",
39 Some("webp") => "image/webp",
40 Some("srt") => "text/plain",
41 Some("vtt") => "text/vtt",
42 Some("ass") | Some("ssa") => "text/x-ssa",
43 Some("json") => "application/json",
44 _ => "application/octet-stream",
45 }
46}
47
48#[derive(Debug)]
53pub struct DownloadCache {
54 #[cfg(feature = "cache-memory")]
55 memory: MokaFileCache,
56 #[cfg(persistent_cache)]
57 persistent: PersistentFileBackend,
58}
59
60impl DownloadCache {
61 pub async fn new(config: &CacheConfig, ttl: Option<u64>) -> Result<Self> {
76 tracing::debug!(cache_dir = ?config.cache_dir, ttl = ?ttl, "⚙️ Creating download cache");
77
78 Ok(Self {
79 #[cfg(feature = "cache-memory")]
80 memory: MokaFileCache::new(config.cache_dir.clone(), ttl).await?,
81 #[cfg(persistent_cache)]
82 persistent: PersistentFileBackend::new(config, ttl).await?,
83 })
84 }
85
86 pub async fn get_by_hash(&self, hash: &str) -> Result<Option<(CachedFile, PathBuf)>> {
100 tracing::debug!(hash = hash, "🔍 Looking up file by hash");
101
102 #[cfg(feature = "cache-memory")]
104 if let Some(result) = self.memory.get_by_hash(hash).await? {
105 tracing::debug!(hash = hash, "✅ File cache hit (L1 memory)");
106 return Ok(Some(result));
107 }
108
109 #[cfg(persistent_cache)]
111 if let Some(result) = self.persistent.get_by_hash(hash).await? {
112 tracing::debug!(hash = hash, "✅ File cache hit (L2 persistent)");
113
114 #[cfg(feature = "cache-memory")]
116 {
117 let path = std::path::Path::new(&result.0.relative_path);
118 let _ = self.memory.put(result.0.clone(), path).await;
119 }
120
121 return Ok(Some(result));
122 }
123
124 Ok(None)
125 }
126
127 pub async fn get_by_video_and_format(
142 &self,
143 video_id: &str,
144 format_id: &str,
145 ) -> Result<Option<(CachedFile, PathBuf)>> {
146 tracing::debug!(
147 video_id = video_id,
148 format_id = format_id,
149 "🔍 Looking up file by video and format"
150 );
151
152 #[cfg(feature = "cache-memory")]
154 if let Some(result) = self.memory.get_by_video_and_format(video_id, format_id).await? {
155 tracing::debug!(
156 video_id = video_id,
157 format_id = format_id,
158 "✅ File cache hit (L1 memory)"
159 );
160 return Ok(Some(result));
161 }
162
163 #[cfg(persistent_cache)]
165 if let Some(result) = self.persistent.get_by_video_and_format(video_id, format_id).await? {
166 tracing::debug!(
167 video_id = video_id,
168 format_id = format_id,
169 "✅ File cache hit (L2 persistent)"
170 );
171
172 #[cfg(feature = "cache-memory")]
173 {
174 let path = std::path::Path::new(&result.0.relative_path);
175 let _ = self.memory.put(result.0.clone(), path).await;
176 }
177
178 return Ok(Some(result));
179 }
180
181 Ok(None)
182 }
183
184 pub async fn get_by_video_and_preferences(
199 &self,
200 video_id: &str,
201 preferences: &FormatPreferences,
202 ) -> Result<Option<(CachedFile, PathBuf)>> {
203 tracing::debug!(video_id = video_id, "🔍 Looking up file by preferences");
204
205 #[cfg(feature = "cache-memory")]
207 if let Some(result) = self.memory.get_by_video_and_preferences(video_id, preferences).await? {
208 tracing::debug!(video_id = video_id, "✅ File cache hit by preferences (L1 memory)");
209 return Ok(Some(result));
210 }
211
212 #[cfg(persistent_cache)]
214 if let Some(result) = self
215 .persistent
216 .get_by_video_and_preferences(video_id, preferences)
217 .await?
218 {
219 tracing::debug!(video_id = video_id, "✅ File cache hit by preferences (L2 persistent)");
220
221 #[cfg(feature = "cache-memory")]
222 {
223 let path = std::path::Path::new(&result.0.relative_path);
224 let _ = self.memory.put(result.0.clone(), path).await;
225 }
226
227 return Ok(Some(result));
228 }
229
230 Ok(None)
231 }
232
233 pub async fn get_thumbnail_by_video_id(&self, video_id: &str) -> Result<Option<(CachedThumbnail, PathBuf)>> {
247 tracing::debug!(video_id = video_id, "🔍 Looking up thumbnail by video ID");
248
249 #[cfg(feature = "cache-memory")]
251 if let Some(result) = self.memory.get_thumbnail_by_video_id(video_id).await? {
252 tracing::debug!(video_id = video_id, "✅ Thumbnail cache hit (L1 memory)");
253 return Ok(Some(result));
254 }
255
256 #[cfg(persistent_cache)]
258 if let Some(result) = self.persistent.get_thumbnail_by_video_id(video_id).await? {
259 tracing::debug!(video_id = video_id, "✅ Thumbnail cache hit (L2 persistent)");
260
261 #[cfg(feature = "cache-memory")]
262 {
263 let path = std::path::Path::new(&result.0.relative_path);
264 let _ = self.memory.put_thumbnail(result.0.clone(), path).await;
265 }
266
267 return Ok(Some(result));
268 }
269
270 Ok(None)
271 }
272
273 pub async fn get_subtitle_by_language(
288 &self,
289 video_id: &str,
290 language: &str,
291 ) -> Result<Option<(CachedFile, PathBuf)>> {
292 tracing::debug!(
293 video_id = video_id,
294 language = language,
295 "🔍 Looking up subtitle by language"
296 );
297
298 #[cfg(feature = "cache-memory")]
300 if let Some(result) = self.memory.get_subtitle_by_language(video_id, language).await? {
301 tracing::debug!(
302 video_id = video_id,
303 language = language,
304 "✅ Subtitle cache hit (L1 memory)"
305 );
306 return Ok(Some(result));
307 }
308
309 #[cfg(persistent_cache)]
311 if let Some(result) = self.persistent.get_subtitle_by_language(video_id, language).await? {
312 tracing::debug!(
313 video_id = video_id,
314 language = language,
315 "✅ Subtitle cache hit (L2 persistent)"
316 );
317
318 #[cfg(feature = "cache-memory")]
319 {
320 let path = std::path::Path::new(&result.0.relative_path);
321 let _ = self.memory.put(result.0.clone(), path).await;
322 }
323
324 return Ok(Some(result));
325 }
326
327 Ok(None)
328 }
329
330 pub async fn put_file(
343 &self,
344 source_path: &Path,
345 filename: impl Into<String>,
346 video_id: Option<String>,
347 format: Option<&Format>,
348 ) -> Result<PathBuf> {
349 let file_info = Self::collect_file_info(source_path, filename.into(), video_id, format)?;
350 self.put_cached_file(file_info, source_path).await
351 }
352
353 pub async fn put_file_with_preferences(
367 &self,
368 source_path: &Path,
369 filename: impl Into<String>,
370 video_id: Option<String>,
371 format: Option<&Format>,
372 preferences: &FormatPreferences,
373 ) -> Result<PathBuf> {
374 let mut file_info = Self::collect_file_info(source_path, filename.into(), video_id, format)?;
375
376 file_info.video_quality = utils::serde::serialize_json_opt(preferences.video_quality);
377 file_info.audio_quality = utils::serde::serialize_json_opt(preferences.audio_quality);
378 file_info.video_codec = utils::serde::serialize_json_opt(preferences.video_codec.clone());
379 file_info.audio_codec = utils::serde::serialize_json_opt(preferences.audio_codec.clone());
380
381 self.put_cached_file(file_info, source_path).await
382 }
383
384 pub async fn put_thumbnail(
396 &self,
397 source_path: &Path,
398 filename: impl Into<String>,
399 video_id: String,
400 ) -> Result<PathBuf> {
401 let filename = filename.into();
402 let hash = Self::calculate_file_hash(source_path).await?;
403 let size = tokio::fs::metadata(source_path)
404 .await
405 .map(|m| m.len() as i64)
406 .unwrap_or(0);
407
408 let thumbnail = CachedThumbnail {
409 id: hash,
410 filename,
411 relative_path: source_path.to_string_lossy().to_string(),
412 video_id,
413 filesize: size,
414 mime_type: guess_mime(source_path).to_string(),
415 width: None,
416 height: None,
417 cached_at: current_timestamp(),
418 };
419
420 self.put_cached_thumbnail(thumbnail, source_path).await
421 }
422
423 pub async fn put_subtitle_file(
436 &self,
437 source_path: &Path,
438 filename: impl Into<String>,
439 video_id: String,
440 language_code: String,
441 ) -> Result<PathBuf> {
442 let filename = filename.into();
443 let hash = Self::calculate_file_hash(source_path).await?;
444 let size = tokio::fs::metadata(source_path)
445 .await
446 .map(|m| m.len() as i64)
447 .unwrap_or(0);
448
449 let cached_file = CachedFile {
450 id: hash,
451 filename,
452 relative_path: source_path.to_string_lossy().to_string(),
453 video_id: Some(video_id),
454 file_type: CachedType::Subtitle.to_string(),
455 format_id: None,
456 format_json: None,
457 video_quality: None,
458 audio_quality: None,
459 video_codec: None,
460 audio_codec: None,
461 language_code: Some(language_code),
462 filesize: size,
463 mime_type: "text/plain".to_string(),
464 cached_at: current_timestamp(),
465 };
466
467 self.put_cached_file(cached_file, source_path).await
468 }
469
470 pub async fn remove(&self, id: &str) -> Result<()> {
480 tracing::debug!(file_id = id, "⚙️ Removing file from cache");
481
482 #[cfg(feature = "cache-memory")]
483 self.memory.remove(id).await?;
484
485 #[cfg(persistent_cache)]
486 self.persistent.remove(id).await?;
487
488 Ok(())
489 }
490
491 pub async fn clean(&self) -> Result<()> {
497 tracing::debug!("⚙️ Cleaning download cache");
498
499 #[cfg(feature = "cache-memory")]
500 self.memory.clean().await?;
501
502 #[cfg(persistent_cache)]
503 self.persistent.clean().await?;
504
505 Ok(())
506 }
507
508 pub async fn calculate_file_hash(path: &Path) -> Result<String> {
522 let mut file = tokio::fs::File::open(path).await?;
523 let mut hasher = Sha256::new();
524 let mut buffer = [0u8; 8192];
525
526 loop {
527 let bytes_read = file.read(&mut buffer).await?;
528 if bytes_read == 0 {
529 break;
530 }
531 hasher.update(&buffer[..bytes_read]);
532 }
533
534 Ok(hasher.finalize().iter().fold(String::new(), |mut acc, b| {
535 use std::fmt::Write;
536 let _ = write!(acc, "{:02x}", b);
537 acc
538 }))
539 }
540
541 fn collect_file_info(
543 source_path: &Path,
544 filename: String,
545 video_id: Option<String>,
546 format: Option<&Format>,
547 ) -> Result<CachedFile> {
548 let size = std::fs::metadata(source_path).map(|m| m.len() as i64).unwrap_or(0);
549
550 let mime = guess_mime(source_path).to_string();
551
552 let format_id = format.map(|f| f.format_id.clone());
553 let format_json = format.and_then(|f| serde_json::to_string(f).ok());
554
555 Ok(CachedFile {
556 id: String::new(), filename,
558 relative_path: source_path.to_string_lossy().to_string(),
559 video_id,
560 file_type: CachedType::Format.to_string(),
561 format_id,
562 format_json,
563 video_quality: None,
564 audio_quality: None,
565 video_codec: None,
566 audio_codec: None,
567 language_code: None,
568 filesize: size,
569 mime_type: mime,
570 cached_at: current_timestamp(),
571 })
572 }
573
574 async fn put_cached_file(&self, mut file: CachedFile, source_path: &Path) -> Result<PathBuf> {
576 if file.id.is_empty() {
578 file.id = Self::calculate_file_hash(source_path).await?;
579 }
580
581 tracing::debug!(file_id = file.id, filename = file.filename, "⚙️ Storing file in cache");
582
583 #[cfg(feature = "cache-memory")]
585 let _ = self.memory.put(file.clone(), source_path).await?;
586
587 #[cfg(persistent_cache)]
589 let out = self.persistent.put(file, source_path).await?;
590 #[cfg(not(persistent_cache))]
591 let out = source_path.to_path_buf();
592
593 Ok(out)
594 }
595
596 async fn put_cached_thumbnail(&self, thumbnail: CachedThumbnail, source_path: &Path) -> Result<PathBuf> {
598 tracing::debug!(
599 thumbnail_id = thumbnail.id,
600 video_id = thumbnail.video_id,
601 "⚙️ Storing thumbnail in cache"
602 );
603
604 #[cfg(feature = "cache-memory")]
605 let _ = self.memory.put_thumbnail(thumbnail.clone(), source_path).await?;
606
607 #[cfg(persistent_cache)]
608 let out = self.persistent.put_thumbnail(thumbnail, source_path).await?;
609 #[cfg(not(persistent_cache))]
610 let out = source_path.to_path_buf();
611
612 Ok(out)
613 }
614}