yt_dlp/cache/backend/redb/
file.rs1use 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#[derive(Debug, Clone)]
17pub struct RedbFileCache {
18 db: Arc<Database>,
19 cache_dir: PathBuf,
20 ttl: u64,
21}
22
23impl RedbFileCache {
24 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}