Skip to main content

yt_dlp/cache/stores/
files.rs

1//! Download file cache wrapper with tiered L1 (Moka) + L2 (persistent) lookup.
2//!
3//! Provides the `DownloadCache` which manages cached download files, thumbnails,
4//! and subtitles with higher-level convenience methods built on top of the
5//! `FileBackend` trait.
6
7use 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
25/// Guesses a MIME type from a file extension.
26fn 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/// Download file cache manager with tiered L1 (Moka) + L2 (persistent) lookup.
49///
50/// On `get_*`: L1 → miss → L2 → backfill L1.
51/// On `put_*`: write to both layers.
52#[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    /// Create a new DownloadCache with default TTL.
62    ///
63    /// # Arguments
64    ///
65    /// * `config` - The cache configuration specifying directories, TTLs, and backend settings.
66    /// * `ttl` - Time-to-live for cache entries in seconds (optional).
67    ///
68    /// # Returns
69    ///
70    /// A new `DownloadCache` instance with default TTL (7 days).
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if the backend initialization fails or the backend is ambiguous.
75    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    /// Retrieve a file by its content hash.
87    ///
88    /// # Arguments
89    ///
90    /// * `hash` - The SHA-256 hash of the file content.
91    ///
92    /// # Returns
93    ///
94    /// The cached file metadata and its path, or `None` if not found.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if the underlying cache backend fails.
99    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        // L1: Moka
103        #[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        // L2: persistent
110        #[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            // Backfill L1
115            #[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    /// Retrieve a file by video ID and format ID.
128    ///
129    /// # Arguments
130    ///
131    /// * `video_id` - The video identifier.
132    /// * `format_id` - The format identifier.
133    ///
134    /// # Returns
135    ///
136    /// The cached file metadata and its path, or `None` if not found.
137    ///
138    /// # Errors
139    ///
140    /// Returns an error if the underlying cache backend fails.
141    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        // L1: Moka
153        #[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        // L2: persistent
164        #[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    /// Retrieve a file by video ID and quality/codec preferences.
185    ///
186    /// # Arguments
187    ///
188    /// * `video_id` - The video identifier.
189    /// * `preferences` - The format preferences to match against.
190    ///
191    /// # Returns
192    ///
193    /// The cached file metadata and its path, or `None` if no match.
194    ///
195    /// # Errors
196    ///
197    /// Returns an error if the underlying cache backend fails.
198    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        // L1: Moka
206        #[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        // L2: persistent
213        #[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    /// Retrieve a thumbnail by video ID.
234    ///
235    /// # Arguments
236    ///
237    /// * `video_id` - The video identifier.
238    ///
239    /// # Returns
240    ///
241    /// The cached thumbnail metadata and its path, or `None` if not found.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if the underlying cache backend fails.
246    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        // L1: Moka
250        #[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        // L2: persistent
257        #[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    /// Retrieve a subtitle by video ID and language code.
274    ///
275    /// # Arguments
276    ///
277    /// * `video_id` - The video identifier.
278    /// * `language` - The language code (e.g., "en", "es").
279    ///
280    /// # Returns
281    ///
282    /// The cached subtitle file metadata and its path, or `None` if not found.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if the underlying cache backend fails.
287    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        // L1: Moka
299        #[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        // L2: persistent
310        #[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    /// Store a file in the cache (both layers).
331    ///
332    /// # Arguments
333    ///
334    /// * `source_path` - Path to the source file.
335    /// * `filename` - Display name for the cached file.
336    /// * `video_id` - Optional video ID association.
337    /// * `format` - Optional format metadata to store alongside the file.
338    ///
339    /// # Errors
340    ///
341    /// Returns an error if the file cannot be hashed or stored.
342    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    /// Store a file in the cache with quality/codec preferences (both layers).
354    ///
355    /// # Arguments
356    ///
357    /// * `source_path` - Path to the source file.
358    /// * `filename` - Display name for the cached file.
359    /// * `video_id` - Optional video ID association.
360    /// * `format` - Optional format metadata.
361    /// * `preferences` - The format preferences used for selection.
362    ///
363    /// # Errors
364    ///
365    /// Returns an error if the file cannot be hashed or stored.
366    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    /// Store a thumbnail in the cache (both layers).
385    ///
386    /// # Arguments
387    ///
388    /// * `source_path` - Path to the source thumbnail file.
389    /// * `filename` - Display name for the cached thumbnail.
390    /// * `video_id` - The video ID associated with this thumbnail.
391    ///
392    /// # Errors
393    ///
394    /// Returns an error if the thumbnail cannot be stored.
395    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    /// Store a subtitle file in the cache (both layers).
424    ///
425    /// # Arguments
426    ///
427    /// * `source_path` - Path to the source subtitle file.
428    /// * `filename` - Display name for the cached subtitle.
429    /// * `video_id` - The video ID associated with this subtitle.
430    /// * `language_code` - The language code (e.g., "en", "es").
431    ///
432    /// # Errors
433    ///
434    /// Returns an error if the subtitle cannot be stored.
435    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    /// Remove a file from the cache (both layers).
471    ///
472    /// # Arguments
473    ///
474    /// * `id` - The unique identifier of the cached file.
475    ///
476    /// # Errors
477    ///
478    /// Returns an error if the removal operation fails.
479    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    /// Clean expired entries (both layers).
492    ///
493    /// # Errors
494    ///
495    /// Returns an error if the cleanup operation fails.
496    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    /// Calculate the SHA-256 hash of a file.
509    ///
510    /// # Arguments
511    ///
512    /// * `path` - The path to the file to hash.
513    ///
514    /// # Returns
515    ///
516    /// A hex-encoded SHA-256 hash string.
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if the file cannot be read.
521    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    /// Collect file metadata into a `CachedFile` struct.
542    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(), // Will be set after hashing
557            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    /// Internal helper: put a CachedFile to both layers.
575    async fn put_cached_file(&self, mut file: CachedFile, source_path: &Path) -> Result<PathBuf> {
576        // Calculate hash if not already set
577        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        // L1: Moka (metadata only)
584        #[cfg(feature = "cache-memory")]
585        let _ = self.memory.put(file.clone(), source_path).await?;
586
587        // L2: persistent (may copy actual file content)
588        #[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    /// Internal helper: put a CachedThumbnail to both layers.
597    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}