1use super::{Segment, Storage};
11use crate::error::Result;
12#[cfg(feature = "recursive-http")]
13use crate::types::TrackedRecursiveJob;
14use crate::types::{DownloadId, DownloadStatus};
15use async_trait::async_trait;
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20#[derive(Debug, Default, Serialize, Deserialize)]
22struct DownloadRecord {
23 #[serde(default)]
24 status: Option<DownloadStatus>,
25 #[serde(default)]
26 segments: Vec<Segment>,
27 #[serde(default)]
28 runtime_metadata: Option<String>,
29}
30
31#[derive(Debug)]
44pub struct FileStorage {
45 dir: PathBuf,
46 write_lock: tokio::sync::Mutex<()>,
48}
49
50const RECURSIVE_PREFIX: &str = "recursive-";
51
52impl FileStorage {
53 pub async fn new(dir: impl Into<PathBuf>) -> Result<Self> {
55 let dir = dir.into();
56 tokio::fs::create_dir_all(&dir).await?;
57 Ok(Self {
58 dir,
59 write_lock: tokio::sync::Mutex::new(()),
60 })
61 }
62
63 pub fn dir(&self) -> &Path {
65 &self.dir
66 }
67
68 fn record_path(&self, id: DownloadId) -> PathBuf {
71 self.dir.join(format!("{}.json", id.as_uuid()))
72 }
73
74 fn torrent_path(&self, id: DownloadId) -> PathBuf {
75 self.dir.join(format!("{}.torrent", id.as_uuid()))
76 }
77
78 #[cfg(feature = "recursive-http")]
79 fn recursive_path(&self, id: uuid::Uuid) -> PathBuf {
80 self.dir.join(format!("{RECURSIVE_PREFIX}{id}.json"))
81 }
82
83 async fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
84 match tokio::fs::read(path).await {
85 Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
86 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
87 Err(e) => Err(e.into()),
88 }
89 }
90
91 async fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
92 let bytes = serde_json::to_vec_pretty(value)?;
93 Self::write_atomic(path, &bytes).await
94 }
95
96 async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
97 let mut tmp = path.as_os_str().to_owned();
98 tmp.push(".tmp");
99 let tmp = PathBuf::from(tmp);
100 tokio::fs::write(&tmp, bytes).await?;
101 tokio::fs::rename(&tmp, path).await?;
102 Ok(())
103 }
104
105 async fn remove_if_exists(path: &Path) -> Result<()> {
106 match tokio::fs::remove_file(path).await {
107 Ok(()) => Ok(()),
108 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
109 Err(e) => Err(e.into()),
110 }
111 }
112
113 async fn update_record<F>(&self, id: DownloadId, update: F) -> Result<()>
114 where
115 F: FnOnce(&mut DownloadRecord),
116 {
117 let _guard = self.write_lock.lock().await;
118 let path = self.record_path(id);
119 let mut record: DownloadRecord = Self::read_json(&path).await?.unwrap_or_default();
120 update(&mut record);
121 Self::write_json(&path, &record).await
122 }
123
124 async fn record_ids(&self) -> Result<Vec<DownloadId>> {
129 let mut ids = Vec::new();
130 let mut entries = tokio::fs::read_dir(&self.dir).await?;
131 while let Some(entry) = entries.next_entry().await? {
132 let path = entry.path();
133 if path.extension().and_then(|e| e.to_str()) != Some("json") {
134 continue;
135 }
136 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
137 continue;
138 };
139 if stem.starts_with(RECURSIVE_PREFIX) {
140 continue;
141 }
142 if let Ok(uuid) = uuid::Uuid::parse_str(stem) {
143 ids.push(DownloadId::from_uuid(uuid));
144 }
145 }
146 Ok(ids)
147 }
148}
149
150#[async_trait]
151impl Storage for FileStorage {
152 async fn save_download(&self, status: &DownloadStatus) -> Result<()> {
153 let status = status.clone();
154 self.update_record(status.id, move |record| record.status = Some(status))
155 .await
156 }
157
158 async fn load_download(&self, id: DownloadId) -> Result<Option<DownloadStatus>> {
159 let record: Option<DownloadRecord> = Self::read_json(&self.record_path(id)).await?;
160 Ok(record.and_then(|r| r.status))
161 }
162
163 async fn load_all(&self) -> Result<Vec<DownloadStatus>> {
164 let mut statuses = Vec::new();
165 for id in self.record_ids().await? {
166 match Self::read_json::<DownloadRecord>(&self.record_path(id)).await {
167 Ok(Some(record)) => {
168 if let Some(status) = record.status {
169 statuses.push(status);
170 }
171 }
172 Ok(None) => {}
173 Err(e) => {
174 tracing::warn!("Skipping unreadable download record for {}: {}", id, e);
175 }
176 }
177 }
178 Ok(statuses)
179 }
180
181 async fn delete_download(&self, id: DownloadId) -> Result<()> {
182 let _guard = self.write_lock.lock().await;
183 Self::remove_if_exists(&self.record_path(id)).await?;
184 Self::remove_if_exists(&self.torrent_path(id)).await?;
185 Ok(())
186 }
187
188 async fn save_segments(&self, id: DownloadId, segments: &[Segment]) -> Result<()> {
189 let segments = segments.to_vec();
190 self.update_record(id, move |record| record.segments = segments)
191 .await
192 }
193
194 async fn load_segments(&self, id: DownloadId) -> Result<Vec<Segment>> {
195 let record: Option<DownloadRecord> = Self::read_json(&self.record_path(id)).await?;
196 Ok(record.map(|r| r.segments).unwrap_or_default())
197 }
198
199 async fn delete_segments(&self, id: DownloadId) -> Result<()> {
200 let _guard = self.write_lock.lock().await;
201 let path = self.record_path(id);
202 if let Some(mut record) = Self::read_json::<DownloadRecord>(&path).await? {
203 record.segments.clear();
204 Self::write_json(&path, &record).await?;
205 }
206 Ok(())
207 }
208
209 async fn save_torrent_data(&self, id: DownloadId, data: &[u8]) -> Result<()> {
210 let _guard = self.write_lock.lock().await;
211 Self::write_atomic(&self.torrent_path(id), data).await
212 }
213
214 async fn load_torrent_data(&self, id: DownloadId) -> Result<Option<Vec<u8>>> {
215 match tokio::fs::read(self.torrent_path(id)).await {
216 Ok(bytes) => Ok(Some(bytes)),
217 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
218 Err(e) => Err(e.into()),
219 }
220 }
221
222 async fn save_runtime_metadata(&self, id: DownloadId, runtime_json: &str) -> Result<()> {
223 let runtime_json = runtime_json.to_string();
224 self.update_record(id, move |record| {
225 record.runtime_metadata = Some(runtime_json)
226 })
227 .await
228 }
229
230 async fn load_runtime_metadata(&self, id: DownloadId) -> Result<Option<String>> {
231 let record: Option<DownloadRecord> = Self::read_json(&self.record_path(id)).await?;
232 Ok(record.and_then(|r| r.runtime_metadata))
233 }
234
235 async fn load_all_runtime_metadata(&self) -> Result<HashMap<DownloadId, String>> {
236 let mut metadata = HashMap::new();
237 for id in self.record_ids().await? {
238 if let Some(json) = self.load_runtime_metadata(id).await? {
239 metadata.insert(id, json);
240 }
241 }
242 Ok(metadata)
243 }
244
245 #[cfg(feature = "recursive-http")]
246 async fn save_recursive_job(&self, job: &TrackedRecursiveJob) -> Result<()> {
247 let _guard = self.write_lock.lock().await;
248 Self::write_json(&self.recursive_path(job.id), job).await
249 }
250
251 #[cfg(feature = "recursive-http")]
252 async fn load_recursive_jobs(&self) -> Result<Vec<TrackedRecursiveJob>> {
253 let mut jobs: Vec<TrackedRecursiveJob> = Vec::new();
254 let mut entries = tokio::fs::read_dir(&self.dir).await?;
255 while let Some(entry) = entries.next_entry().await? {
256 let path = entry.path();
257 if path.extension().and_then(|e| e.to_str()) != Some("json") {
258 continue;
259 }
260 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
261 continue;
262 };
263 if !stem.starts_with(RECURSIVE_PREFIX) {
264 continue;
265 }
266 match Self::read_json::<TrackedRecursiveJob>(&path).await {
267 Ok(Some(job)) => jobs.push(job),
268 Ok(None) => {}
269 Err(e) => {
270 tracing::warn!("Skipping unreadable recursive job record {:?}: {}", path, e);
271 }
272 }
273 }
274 jobs.sort_by_key(|job| std::cmp::Reverse(job.created_at));
275 Ok(jobs)
276 }
277
278 #[cfg(feature = "recursive-http")]
279 async fn delete_recursive_job(&self, id: uuid::Uuid) -> Result<()> {
280 let _guard = self.write_lock.lock().await;
281 Self::remove_if_exists(&self.recursive_path(id)).await
282 }
283
284 async fn health_check(&self) -> Result<()> {
285 tokio::fs::create_dir_all(&self.dir).await?;
287 let probe = self.dir.join(".health-check.tmp");
288 tokio::fs::write(&probe, b"ok").await?;
289 Self::remove_if_exists(&probe).await
290 }
291
292 async fn compact(&self) -> Result<()> {
293 let mut entries = tokio::fs::read_dir(&self.dir).await?;
295 while let Some(entry) = entries.next_entry().await? {
296 let path = entry.path();
297 if path.extension().and_then(|e| e.to_str()) == Some("tmp") {
298 Self::remove_if_exists(&path).await?;
299 }
300 }
301 Ok(())
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::storage::SegmentState;
309 use crate::types::{DownloadKind, DownloadMetadata, DownloadProgress, DownloadState};
310 use chrono::Utc;
311
312 fn create_test_status() -> DownloadStatus {
313 DownloadStatus {
314 id: DownloadId::new(),
315 kind: DownloadKind::Http,
316 state: DownloadState::Paused,
317 priority: crate::priority_queue::DownloadPriority::Normal,
318 progress: DownloadProgress::default(),
319 metadata: DownloadMetadata {
320 name: "test.zip".to_string(),
321 url: Some("https://example.com/test.zip".to_string()),
322 magnet_uri: None,
323 info_hash: None,
324 save_dir: PathBuf::from("/tmp"),
325 filename: Some("test.zip".to_string()),
326 user_agent: None,
327 referer: None,
328 headers: vec![],
329 cookies: Vec::new(),
330 checksum: None,
331 mirrors: Vec::new(),
332 etag: None,
333 last_modified: None,
334 },
335 torrent_info: None,
336 peers: None,
337 created_at: Utc::now(),
338 completed_at: None,
339 }
340 }
341
342 #[tokio::test]
343 async fn test_download_round_trip() {
344 let dir = tempfile::tempdir().unwrap();
345 let storage = FileStorage::new(dir.path()).await.unwrap();
346 let status = create_test_status();
347 let id = status.id;
348
349 storage.save_download(&status).await.unwrap();
350
351 let loaded = storage.load_download(id).await.unwrap().unwrap();
352 assert_eq!(loaded.id, id);
353 assert_eq!(loaded.state, DownloadState::Paused);
354
355 let all = storage.load_all().await.unwrap();
356 assert_eq!(all.len(), 1);
357
358 storage.delete_download(id).await.unwrap();
359 assert!(storage.load_download(id).await.unwrap().is_none());
360 assert!(storage.load_all().await.unwrap().is_empty());
361 }
362
363 #[tokio::test]
364 async fn test_segments_round_trip() {
365 let dir = tempfile::tempdir().unwrap();
366 let storage = FileStorage::new(dir.path()).await.unwrap();
367 let id = DownloadId::new();
368
369 let mut segments = vec![
370 Segment::new(0, 0, 999),
371 Segment::new(1, 1000, 1999),
372 Segment::new(2, 2000, 2999),
373 ];
374 segments[0].downloaded = 1000;
375 segments[0].state = SegmentState::Completed;
376 segments[1].downloaded = 512;
377 segments[1].state = SegmentState::Failed {
378 error: "boom".to_string(),
379 retries: 2,
380 };
381
382 storage.save_segments(id, &segments).await.unwrap();
383
384 let loaded = storage.load_segments(id).await.unwrap();
385 assert_eq!(loaded.len(), 3);
386 assert_eq!(loaded[0].downloaded, 1000);
387 assert_eq!(loaded[0].state, SegmentState::Completed);
388 assert_eq!(
389 loaded[1].state,
390 SegmentState::Failed {
391 error: "boom".to_string(),
392 retries: 2
393 }
394 );
395 assert_eq!(loaded[2].state, SegmentState::Pending);
396
397 storage.delete_segments(id).await.unwrap();
398 assert!(storage.load_segments(id).await.unwrap().is_empty());
399 }
400
401 #[tokio::test]
402 async fn test_torrent_data_round_trip() {
403 let dir = tempfile::tempdir().unwrap();
404 let storage = FileStorage::new(dir.path()).await.unwrap();
405 let id = DownloadId::new();
406
407 assert!(storage.load_torrent_data(id).await.unwrap().is_none());
408
409 let data = b"d8:announce3:url4:infod4:name4:teste".to_vec();
410 storage.save_torrent_data(id, &data).await.unwrap();
411 assert_eq!(storage.load_torrent_data(id).await.unwrap(), Some(data));
412
413 storage.delete_download(id).await.unwrap();
414 assert!(storage.load_torrent_data(id).await.unwrap().is_none());
415 }
416
417 #[tokio::test]
418 async fn test_runtime_metadata_round_trip() {
419 let dir = tempfile::tempdir().unwrap();
420 let storage = FileStorage::new(dir.path()).await.unwrap();
421 let status = create_test_status();
422 let id = status.id;
423
424 storage.save_download(&status).await.unwrap();
425 storage
426 .save_runtime_metadata(id, r#"{"recursive_child":{"fail_fast":true}}"#)
427 .await
428 .unwrap();
429
430 assert_eq!(
431 storage.load_runtime_metadata(id).await.unwrap().as_deref(),
432 Some(r#"{"recursive_child":{"fail_fast":true}}"#)
433 );
434 assert!(storage
435 .load_all_runtime_metadata()
436 .await
437 .unwrap()
438 .contains_key(&id));
439
440 assert!(storage.load_download(id).await.unwrap().is_some());
442
443 storage.delete_download(id).await.unwrap();
444 assert!(storage.load_runtime_metadata(id).await.unwrap().is_none());
445 }
446
447 #[cfg(feature = "recursive-http")]
448 #[tokio::test]
449 async fn test_recursive_job_round_trip() {
450 let dir = tempfile::tempdir().unwrap();
451 let storage = FileStorage::new(dir.path()).await.unwrap();
452 let job = TrackedRecursiveJob {
453 id: uuid::Uuid::new_v4(),
454 root_url: "https://example.com/pub/".to_string(),
455 child_ids: vec![DownloadId::new(), DownloadId::new()],
456 created_at: Utc::now(),
457 };
458
459 storage.save_recursive_job(&job).await.unwrap();
460 assert_eq!(
461 storage.load_recursive_jobs().await.unwrap(),
462 vec![job.clone()]
463 );
464
465 assert!(storage.load_all().await.unwrap().is_empty());
467
468 storage.delete_recursive_job(job.id).await.unwrap();
469 assert!(storage.load_recursive_jobs().await.unwrap().is_empty());
470 }
471
472 #[tokio::test]
473 async fn test_persists_across_instances() {
474 let dir = tempfile::tempdir().unwrap();
475 let status = create_test_status();
476 let id = status.id;
477
478 {
479 let storage = FileStorage::new(dir.path()).await.unwrap();
480 storage.save_download(&status).await.unwrap();
481 storage
482 .save_segments(id, &[Segment::new(0, 0, 999)])
483 .await
484 .unwrap();
485 }
486
487 let storage = FileStorage::new(dir.path()).await.unwrap();
488 assert!(storage.load_download(id).await.unwrap().is_some());
489 assert_eq!(storage.load_segments(id).await.unwrap().len(), 1);
490 }
491
492 #[tokio::test]
493 async fn test_health_check_and_compact() {
494 let dir = tempfile::tempdir().unwrap();
495 let storage = FileStorage::new(dir.path()).await.unwrap();
496 storage.health_check().await.unwrap();
497
498 let leftover = dir.path().join("garbage.json.tmp");
500 tokio::fs::write(&leftover, b"partial").await.unwrap();
501 storage.compact().await.unwrap();
502 assert!(!leftover.exists());
503 }
504}