Skip to main content

yt_dlp/cache/backend/redb/
file.rs

1//! Redb file cache backend.
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use redb::{Database, ReadableDatabase, ReadableTable};
7
8use super::{DEFAULT_FILE_TTL, FILES, THUMBNAILS, clean_redb_table, copy_to_cache};
9use crate::cache::backend::FileBackend;
10use crate::cache::video::{CachedFile, CachedThumbnail};
11use crate::error::Result;
12use crate::model::selector::FormatPreferences;
13use crate::utils::is_expired;
14
15/// Redb-backed file cache.
16#[derive(Debug, Clone)]
17pub struct RedbFileCache {
18    db: Arc<Database>,
19    cache_dir: PathBuf,
20    ttl: u64,
21}
22
23impl RedbFileCache {
24    /// Creates a new redb file cache.
25    pub async fn new(cache_dir: PathBuf, ttl: Option<u64>) -> Result<Self> {
26        if !cache_dir.exists() {
27            tokio::fs::create_dir_all(&cache_dir).await?;
28        }
29
30        let db_path = cache_dir.join("files.redb");
31        let db = tokio::task::spawn_blocking(move || Database::create(db_path))
32            .await
33            .map_err(|e| crate::error::Error::runtime("redb open", e))?
34            .map_err(|e| crate::error::Error::database("open files.redb", e))?;
35
36        let db = Arc::new(db);
37        let db_init = db.clone();
38        tokio::task::spawn_blocking(move || {
39            let txn = db_init.begin_write()?;
40            {
41                let _ = txn.open_table(FILES)?;
42            }
43            {
44                let _ = txn.open_table(THUMBNAILS)?;
45            }
46            txn.commit()?;
47            Ok::<_, redb::Error>(())
48        })
49        .await
50        .map_err(|e| crate::error::Error::runtime("redb init", e))?
51        .map_err(|e| crate::error::Error::database("init files/thumbnails tables", e))?;
52
53        Ok(Self {
54            db,
55            cache_dir,
56            ttl: ttl.unwrap_or(DEFAULT_FILE_TTL),
57        })
58    }
59}
60
61impl FileBackend for RedbFileCache {
62    async fn get_by_hash(&self, hash: &str) -> Result<Option<(CachedFile, PathBuf)>> {
63        tracing::debug!(hash = hash, "🔍 Looking for file in redb cache by hash");
64
65        let db = self.db.clone();
66        let hash_owned = hash.to_string();
67        let cache_dir = self.cache_dir.clone();
68        let ttl = self.ttl;
69
70        tokio::task::spawn_blocking(move || {
71            let txn = db
72                .begin_read()
73                .map_err(|e| crate::error::Error::database("read file by hash", e))?;
74            let table = txn
75                .open_table(FILES)
76                .map_err(|e| crate::error::Error::database("open files table", e))?;
77
78            if let Some(entry) = table
79                .get(hash_owned.as_str())
80                .map_err(|e| crate::error::Error::database("get file by hash", e))?
81            {
82                let bytes = entry.value();
83                if let Ok(cached) = serde_json::from_slice::<CachedFile>(bytes)
84                    && !is_expired(cached.cached_at, ttl)
85                {
86                    let path = cache_dir.join(&cached.relative_path);
87                    return Ok(Some((cached, path)));
88                }
89            }
90            Ok(None)
91        })
92        .await
93        .map_err(|e| crate::error::Error::runtime("redb get file by hash", e))?
94    }
95
96    async fn get_by_video_and_format(&self, video_id: &str, format_id: &str) -> Result<Option<(CachedFile, PathBuf)>> {
97        tracing::debug!(
98            video_id = video_id,
99            format_id = format_id,
100            "🔍 Looking for file by video and format in redb cache"
101        );
102
103        let db = self.db.clone();
104        let vid = video_id.to_string();
105        let fid = format_id.to_string();
106        let cache_dir = self.cache_dir.clone();
107        let ttl = self.ttl;
108
109        tokio::task::spawn_blocking(move || {
110            let txn = db
111                .begin_read()
112                .map_err(|e| crate::error::Error::database("read file by video+format", e))?;
113            let table = txn
114                .open_table(FILES)
115                .map_err(|e| crate::error::Error::database("open files table", e))?;
116
117            let iter = table
118                .iter()
119                .map_err(|e| crate::error::Error::database("iterate files table", e))?;
120            for (_key, val) in iter.flatten() {
121                let bytes = val.value();
122                if let Ok(cached) = serde_json::from_slice::<CachedFile>(bytes)
123                    && cached.video_id.as_deref() == Some(&vid)
124                    && cached.format_id.as_deref() == Some(&fid)
125                    && !is_expired(cached.cached_at, ttl)
126                {
127                    let path = cache_dir.join(&cached.relative_path);
128                    return Ok(Some((cached, path)));
129                }
130            }
131            Ok(None)
132        })
133        .await
134        .map_err(|e| crate::error::Error::runtime("redb get file by video+format", e))?
135    }
136
137    async fn get_by_video_and_preferences(
138        &self,
139        video_id: &str,
140        preferences: &FormatPreferences,
141    ) -> Result<Option<(CachedFile, PathBuf)>> {
142        tracing::debug!(video_id = video_id, "🔍 Looking for file by preferences in redb cache");
143
144        let db = self.db.clone();
145        let vid = video_id.to_string();
146        let cache_dir = self.cache_dir.clone();
147        let ttl = self.ttl;
148        let prefs = preferences.clone();
149
150        tokio::task::spawn_blocking(move || {
151            let txn = db
152                .begin_read()
153                .map_err(|e| crate::error::Error::database("read file by preferences", e))?;
154            let table = txn
155                .open_table(FILES)
156                .map_err(|e| crate::error::Error::database("open files table", e))?;
157
158            let iter = table
159                .iter()
160                .map_err(|e| crate::error::Error::database("iterate files table", e))?;
161            for (_key, val) in iter.flatten() {
162                let bytes = val.value();
163                if let Ok(cached) = serde_json::from_slice::<CachedFile>(bytes)
164                    && cached.video_id.as_deref() == Some(&vid)
165                    && cached.matches_preferences(&prefs)
166                    && !is_expired(cached.cached_at, ttl)
167                {
168                    let path = cache_dir.join(&cached.relative_path);
169                    return Ok(Some((cached, path)));
170                }
171            }
172            Ok(None)
173        })
174        .await
175        .map_err(|e| crate::error::Error::runtime("redb get file by preferences", e))?
176    }
177
178    async fn put(&self, file: CachedFile, source_path: &Path) -> Result<PathBuf> {
179        tracing::debug!(
180            file_id = file.id,
181            filename = file.filename,
182            "⚙️ Caching file to redb backend"
183        );
184
185        let dest_path = copy_to_cache(&self.cache_dir, &file.relative_path, source_path).await?;
186
187        let db = self.db.clone();
188        let ret_path = dest_path.clone();
189        tokio::task::spawn_blocking(move || {
190            let bytes = serde_json::to_vec(&file)?;
191            let txn = db
192                .begin_write()
193                .map_err(|e| crate::error::Error::database("write file", e))?;
194            {
195                let mut table = txn
196                    .open_table(FILES)
197                    .map_err(|e| crate::error::Error::database("open files table", e))?;
198                table
199                    .insert(file.id.as_str(), bytes.as_slice())
200                    .map_err(|e| crate::error::Error::database("insert file", e))?;
201            }
202            txn.commit()
203                .map_err(|e| crate::error::Error::database("commit file", e))?;
204            Ok::<_, crate::error::Error>(())
205        })
206        .await
207        .map_err(|e| crate::error::Error::runtime("redb put file", e))??;
208
209        Ok(ret_path)
210    }
211
212    async fn remove(&self, id: &str) -> Result<()> {
213        tracing::debug!(file_id = id, "⚙️ Removing file from redb cache");
214
215        let db = self.db.clone();
216        let id_owned = id.to_string();
217        let cache_dir = self.cache_dir.clone();
218
219        tokio::task::spawn_blocking(move || {
220            let txn = db
221                .begin_write()
222                .map_err(|e| crate::error::Error::database("write file remove", e))?;
223            {
224                let mut table = txn
225                    .open_table(FILES)
226                    .map_err(|e| crate::error::Error::database("open files table", e))?;
227
228                if let Some(entry) = table
229                    .get(id_owned.as_str())
230                    .map_err(|e| crate::error::Error::database("get file for remove", e))?
231                {
232                    let bytes = entry.value();
233                    if let Ok(cached) = serde_json::from_slice::<CachedFile>(bytes) {
234                        let path = cache_dir.join(&cached.relative_path);
235                        let _ = std::fs::remove_file(path);
236                    }
237                }
238
239                table
240                    .remove(id_owned.as_str())
241                    .map_err(|e| crate::error::Error::database("remove file", e))?;
242            }
243            txn.commit()
244                .map_err(|e| crate::error::Error::database("commit file remove", e))?;
245            Ok(())
246        })
247        .await
248        .map_err(|e| crate::error::Error::runtime("redb remove file", e))?
249    }
250
251    async fn clean(&self) -> Result<()> {
252        let db = self.db.clone();
253        let ttl = self.ttl;
254        let cache_dir = self.cache_dir.clone();
255
256        tokio::task::spawn_blocking(move || {
257            clean_redb_table(&db, FILES, ttl, &cache_dir, "file")?;
258            clean_redb_table(&db, THUMBNAILS, ttl, &cache_dir, "thumbnail")?;
259
260            Ok(())
261        })
262        .await
263        .map_err(|e| crate::error::Error::runtime("redb clean files", e))?
264    }
265
266    async fn get_thumbnail_by_video_id(&self, video_id: &str) -> Result<Option<(CachedThumbnail, PathBuf)>> {
267        tracing::debug!(
268            video_id = video_id,
269            "🔍 Looking for thumbnail by video ID in redb cache"
270        );
271
272        let db = self.db.clone();
273        let vid = video_id.to_string();
274        let cache_dir = self.cache_dir.clone();
275        let ttl = self.ttl;
276
277        tokio::task::spawn_blocking(move || {
278            let txn = db
279                .begin_read()
280                .map_err(|e| crate::error::Error::database("read thumbnail by video", e))?;
281            let table = txn
282                .open_table(THUMBNAILS)
283                .map_err(|e| crate::error::Error::database("open thumbnails table", e))?;
284
285            let iter = table
286                .iter()
287                .map_err(|e| crate::error::Error::database("iterate thumbnails table", e))?;
288            for (_key, val) in iter.flatten() {
289                let bytes = val.value();
290                if let Ok(cached) = serde_json::from_slice::<CachedThumbnail>(bytes)
291                    && cached.video_id == vid
292                    && !is_expired(cached.cached_at, ttl)
293                {
294                    let path = cache_dir.join(&cached.relative_path);
295                    return Ok(Some((cached, path)));
296                }
297            }
298            Ok(None)
299        })
300        .await
301        .map_err(|e| crate::error::Error::runtime("redb get thumbnail by video", e))?
302    }
303
304    async fn put_thumbnail(&self, thumbnail: CachedThumbnail, source_path: &Path) -> Result<PathBuf> {
305        tracing::debug!(
306            thumbnail_id = thumbnail.id,
307            video_id = thumbnail.video_id,
308            "⚙️ Caching thumbnail to redb backend"
309        );
310
311        let dest_path = copy_to_cache(&self.cache_dir, &thumbnail.relative_path, source_path).await?;
312
313        let db = self.db.clone();
314        let ret_path = dest_path.clone();
315        tokio::task::spawn_blocking(move || {
316            let bytes = serde_json::to_vec(&thumbnail)?;
317            let txn = db
318                .begin_write()
319                .map_err(|e| crate::error::Error::database("write thumbnail", e))?;
320            {
321                let mut table = txn
322                    .open_table(THUMBNAILS)
323                    .map_err(|e| crate::error::Error::database("open thumbnails table", e))?;
324                table
325                    .insert(thumbnail.id.as_str(), bytes.as_slice())
326                    .map_err(|e| crate::error::Error::database("insert thumbnail", e))?;
327            }
328            txn.commit()
329                .map_err(|e| crate::error::Error::database("commit thumbnail", e))?;
330            Ok::<_, crate::error::Error>(())
331        })
332        .await
333        .map_err(|e| crate::error::Error::runtime("redb put thumbnail", e))??;
334
335        Ok(ret_path)
336    }
337
338    async fn get_subtitle_by_language(&self, video_id: &str, language: &str) -> Result<Option<(CachedFile, PathBuf)>> {
339        tracing::debug!(
340            video_id = video_id,
341            language = language,
342            "🔍 Looking for subtitle in redb cache"
343        );
344
345        let db = self.db.clone();
346        let vid = video_id.to_string();
347        let lang = language.to_string();
348        let cache_dir = self.cache_dir.clone();
349        let ttl = self.ttl;
350
351        tokio::task::spawn_blocking(move || {
352            let txn = db
353                .begin_read()
354                .map_err(|e| crate::error::Error::database("read subtitle by language", e))?;
355            let table = txn
356                .open_table(FILES)
357                .map_err(|e| crate::error::Error::database("open files table", e))?;
358
359            let iter = table
360                .iter()
361                .map_err(|e| crate::error::Error::database("iterate files table", e))?;
362            for (_key, val) in iter.flatten() {
363                let bytes = val.value();
364                if let Ok(cached) = serde_json::from_slice::<CachedFile>(bytes)
365                    && cached.video_id.as_deref() == Some(&vid)
366                    && cached.language_code.as_deref() == Some(&lang)
367                    && !is_expired(cached.cached_at, ttl)
368                {
369                    let path = cache_dir.join(&cached.relative_path);
370                    return Ok(Some((cached, path)));
371                }
372            }
373            Ok(None)
374        })
375        .await
376        .map_err(|e| crate::error::Error::runtime("redb get subtitle by language", e))?
377    }
378}