Skip to main content

vidsage_core/storage/
manager.rs

1//! Storage manager implementations
2
3use super::{StorageError, StorageManager};
4use crate::{CoreError, Result};
5use flate2::{read::GzDecoder, write::GzEncoder, Compression};
6use serde::{Deserialize, Serialize};
7use std::fs::{self, File};
8use std::io::{Read, Write};
9use std::path::{Path, PathBuf};
10use uuid::Uuid;
11
12/// Storage configuration structure
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct StorageConfig {
15    /// Base directory for storage
16    pub base_dir: PathBuf,
17
18    /// Maximum file size in bytes
19    pub max_file_size: u64,
20
21    /// Enable compression for stored files
22    pub enable_compression: bool,
23
24    /// Compression level (0-9)
25    pub compression_level: u8,
26}
27
28impl Default for StorageConfig {
29    fn default() -> Self {
30        Self {
31            base_dir: PathBuf::from("./storage"),
32            max_file_size: 100 * 1024 * 1024, // 100MB
33            enable_compression: false,
34            compression_level: 5,
35        }
36    }
37}
38
39/// File-based storage implementation
40pub struct FileStorage {
41    config: StorageConfig,
42}
43
44impl FileStorage {
45    /// Create a new FileStorage instance
46    pub fn new(config: StorageConfig) -> Result<Self> {
47        // Create base directories if they don't exist
48        fs::create_dir_all(&config.base_dir).map_err(|e| CoreError::IoError(e.to_string()))?;
49        fs::create_dir_all(config.base_dir.join("videos"))
50            .map_err(|e| CoreError::IoError(e.to_string()))?;
51        fs::create_dir_all(config.base_dir.join("commentaries"))
52            .map_err(|e| CoreError::IoError(e.to_string()))?;
53        fs::create_dir_all(config.base_dir.join("metadata"))
54            .map_err(|e| CoreError::IoError(e.to_string()))?;
55
56        Ok(Self { config })
57    }
58
59    /// Get the path for a video file
60    fn get_video_path(&self, id: &str) -> PathBuf {
61        let base_path = self.config.base_dir.join("videos").join(id);
62        if self.config.enable_compression {
63            base_path.with_extension("gz")
64        } else {
65            base_path
66        }
67    }
68
69    /// Get the path for metadata
70    fn get_metadata_path(&self, id: &str) -> PathBuf {
71        let base_path = self
72            .config
73            .base_dir
74            .join("metadata")
75            .join(format!("{}.json", id));
76        if self.config.enable_compression {
77            base_path.with_extension("json.gz")
78        } else {
79            base_path
80        }
81    }
82
83    /// Get the path for a commentary file
84    fn get_commentary_path(&self, id: &str) -> PathBuf {
85        let base_path = self
86            .config
87            .base_dir
88            .join("commentaries")
89            .join(format!("{}.json", id));
90        if self.config.enable_compression {
91            base_path.with_extension("json.gz")
92        } else {
93            base_path
94        }
95    }
96
97    /// Write data to file with optional compression
98    fn write_file_with_compression(&self, path: &Path, data: &[u8]) -> Result<()> {
99        if self.config.enable_compression {
100            let file = File::create(path).map_err(|e| CoreError::IoError(e.to_string()))?;
101            let compression_level = Compression::new(self.config.compression_level as u32);
102            let mut encoder = GzEncoder::new(file, compression_level);
103            encoder
104                .write_all(data)
105                .map_err(|e| CoreError::IoError(e.to_string()))?;
106            encoder
107                .finish()
108                .map_err(|e| CoreError::IoError(e.to_string()))?;
109        } else {
110            let mut file = File::create(path).map_err(|e| CoreError::IoError(e.to_string()))?;
111            file.write_all(data)
112                .map_err(|e| CoreError::IoError(e.to_string()))?;
113        }
114        Ok(())
115    }
116
117    /// Read data from file with optional decompression
118    fn read_file_with_decompression(&self, path: &Path) -> Result<Vec<u8>> {
119        if self.config.enable_compression {
120            let file = File::open(path).map_err(|e| CoreError::IoError(e.to_string()))?;
121            let mut decoder = GzDecoder::new(file);
122            let mut data = Vec::new();
123            decoder
124                .read_to_end(&mut data)
125                .map_err(|e| CoreError::IoError(e.to_string()))?;
126            Ok(data)
127        } else {
128            fs::read(path).map_err(|e| CoreError::IoError(e.to_string()))
129        }
130    }
131}
132
133#[async_trait::async_trait]
134impl StorageManager for FileStorage {
135    async fn store_video(&self, path: &Path, metadata: &serde_json::Value) -> Result<String> {
136        // Check file size
137        let file_metadata = fs::metadata(path).map_err(|e| CoreError::IoError(e.to_string()))?;
138        if file_metadata.len() > self.config.max_file_size {
139            return Err(CoreError::StorageError(StorageError::FileTooLarge(
140                file_metadata.len(),
141            )));
142        }
143
144        // Generate unique ID
145        let id = Uuid::new_v4().to_string();
146
147        // Read video file content
148        let video_content = fs::read(path).map_err(|e| CoreError::IoError(e.to_string()))?;
149
150        // Store video with optional compression
151        let video_path = self.get_video_path(&id);
152        self.write_file_with_compression(&video_path, &video_content)?;
153
154        // Store metadata with optional compression
155        let metadata_path = self.get_metadata_path(&id);
156        let metadata_json = serde_json::to_string_pretty(metadata)
157            .map_err(|e| CoreError::JsonError(e.to_string()))?;
158        self.write_file_with_compression(&metadata_path, metadata_json.as_bytes())?;
159
160        Ok(id)
161    }
162
163    async fn retrieve_video(&self, id: &str) -> Result<Vec<u8>> {
164        let path = self.get_video_path(id);
165        if !path.exists() {
166            return Err(CoreError::StorageError(StorageError::FileNotFound(
167                id.to_string(),
168            )));
169        }
170
171        self.read_file_with_decompression(&path)
172    }
173
174    async fn get_video_metadata(&self, id: &str) -> Result<serde_json::Value> {
175        let path = self.get_metadata_path(id);
176        if !path.exists() {
177            return Err(CoreError::StorageError(StorageError::FileNotFound(
178                id.to_string(),
179            )));
180        }
181
182        let content_bytes = self.read_file_with_decompression(&path)?;
183        let content =
184            String::from_utf8(content_bytes).map_err(|e| CoreError::IoError(e.to_string()))?;
185        Ok(serde_json::from_str(&content).map_err(|e| CoreError::JsonError(e.to_string()))?)
186    }
187
188    async fn delete_video(&self, id: &str) -> Result<bool> {
189        let video_path = self.get_video_path(id);
190        let metadata_path = self.get_metadata_path(id);
191
192        // Delete video file if it exists
193        if video_path.exists() {
194            fs::remove_file(video_path).map_err(|e| CoreError::IoError(e.to_string()))?;
195        }
196
197        // Delete metadata if it exists
198        if metadata_path.exists() {
199            fs::remove_file(metadata_path).map_err(|e| CoreError::IoError(e.to_string()))?;
200        }
201
202        Ok(true)
203    }
204
205    async fn store_commentary(&self, commentary: &serde_json::Value) -> Result<String> {
206        // Generate unique ID
207        let id = Uuid::new_v4().to_string();
208
209        // Store commentary with optional compression
210        let path = self.get_commentary_path(&id);
211        let content = serde_json::to_string_pretty(commentary)
212            .map_err(|e| CoreError::JsonError(e.to_string()))?;
213        self.write_file_with_compression(&path, content.as_bytes())?;
214
215        Ok(id)
216    }
217
218    async fn retrieve_commentary(&self, id: &str) -> Result<serde_json::Value> {
219        let path = self.get_commentary_path(id);
220        if !path.exists() {
221            return Err(CoreError::StorageError(StorageError::FileNotFound(
222                id.to_string(),
223            )));
224        }
225
226        let content_bytes = self.read_file_with_decompression(&path)?;
227        let content =
228            String::from_utf8(content_bytes).map_err(|e| CoreError::IoError(e.to_string()))?;
229        Ok(serde_json::from_str(&content).map_err(|e| CoreError::JsonError(e.to_string()))?)
230    }
231
232    async fn update_commentary(&self, id: &str, commentary: &serde_json::Value) -> Result<bool> {
233        let path = self.get_commentary_path(id);
234        if !path.exists() {
235            return Err(CoreError::StorageError(StorageError::FileNotFound(
236                id.to_string(),
237            )));
238        }
239
240        // Update commentary with optional compression
241        let content = serde_json::to_string_pretty(commentary)
242            .map_err(|e| CoreError::JsonError(e.to_string()))?;
243        self.write_file_with_compression(&path, content.as_bytes())?;
244
245        Ok(true)
246    }
247
248    async fn delete_commentary(&self, id: &str) -> Result<bool> {
249        let path = self.get_commentary_path(id);
250        if !path.exists() {
251            return Err(CoreError::StorageError(StorageError::FileNotFound(
252                id.to_string(),
253            )));
254        }
255
256        fs::remove_file(path).map_err(|e| CoreError::IoError(e.to_string()))?;
257        Ok(true)
258    }
259
260    async fn list_videos(
261        &self,
262        page: u32,
263        page_size: u32,
264    ) -> Result<Vec<(String, serde_json::Value)>> {
265        let videos_dir = self.config.base_dir.join("videos");
266
267        // Read all video files
268        let mut videos = Vec::new();
269        for entry in fs::read_dir(videos_dir).map_err(|e| CoreError::IoError(e.to_string()))? {
270            let entry = entry.map_err(|e| CoreError::IoError(e.to_string()))?;
271            let file_name = entry.file_name().to_string_lossy().to_string();
272
273            // Extract ID from file name (remove .gz extension if present)
274            let id = if file_name.ends_with(".gz") {
275                file_name[0..file_name.len() - 3].to_string()
276            } else {
277                file_name
278            };
279
280            // Get metadata path with proper extension
281            let metadata_path = self.get_metadata_path(&id);
282            if metadata_path.exists() {
283                // Read metadata with optional decompression
284                let content_bytes = self.read_file_with_decompression(&metadata_path)?;
285                let content = String::from_utf8(content_bytes)
286                    .map_err(|e| CoreError::IoError(e.to_string()))?;
287                let metadata = serde_json::from_str(&content)
288                    .map_err(|e| CoreError::JsonError(e.to_string()))?;
289                videos.push((id, metadata));
290            }
291        }
292
293        // Apply pagination
294        let start = (page - 1) as usize * page_size as usize;
295        let end = start + page_size as usize;
296        let paginated = videos.into_iter().skip(start).take(end).collect();
297
298        Ok(paginated)
299    }
300
301    async fn list_commentaries(&self, video_id: &str) -> Result<Vec<(String, serde_json::Value)>> {
302        let commentaries_dir = self.config.base_dir.join("commentaries");
303
304        // Read all commentary files
305        let mut commentaries = Vec::new();
306        for entry in
307            fs::read_dir(commentaries_dir).map_err(|e| CoreError::IoError(e.to_string()))?
308        {
309            let entry = entry.map_err(|e| CoreError::IoError(e.to_string()))?;
310            let path = entry.path();
311
312            // Read commentary with optional decompression
313            let content_bytes = self.read_file_with_decompression(&path)?;
314            let content =
315                String::from_utf8(content_bytes).map_err(|e| CoreError::IoError(e.to_string()))?;
316            let commentary: serde_json::Value =
317                serde_json::from_str(&content).map_err(|e| CoreError::JsonError(e.to_string()))?;
318
319            // Check if this commentary belongs to the requested video
320            if let Some(vid) = commentary.get("video_id").and_then(|v| v.as_str()) {
321                if vid == video_id {
322                    let file_name = entry.file_name().to_string_lossy().to_string();
323                    // Extract ID from file name (remove .json.gz or .json extension)
324                    let id = if file_name.ends_with(".json.gz") {
325                        file_name[0..file_name.len() - 8].to_string()
326                    } else if file_name.ends_with(".json") {
327                        file_name[0..file_name.len() - 5].to_string()
328                    } else {
329                        file_name
330                    };
331                    commentaries.push((id, commentary));
332                }
333            }
334        }
335
336        Ok(commentaries)
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use serde_json::json;
344    use tempfile::tempdir;
345
346    #[tokio::test]
347    async fn test_file_storage_new() {
348        let temp_dir = tempdir().unwrap();
349        let config = StorageConfig {
350            base_dir: temp_dir.path().to_path_buf(),
351            ..Default::default()
352        };
353
354        let storage = FileStorage::new(config).unwrap();
355        assert_eq!(storage.config.base_dir, temp_dir.path());
356    }
357
358    #[tokio::test]
359    async fn test_store_retrieve_video() {
360        let temp_dir = tempdir().unwrap();
361        let config = StorageConfig {
362            base_dir: temp_dir.path().to_path_buf(),
363            ..Default::default()
364        };
365
366        let storage = FileStorage::new(config).unwrap();
367
368        // Create a test video file
369        let test_video_path = temp_dir.path().join("test_video.mp4");
370        std::fs::write(&test_video_path, "test video content").unwrap();
371
372        // Store video
373        let metadata = json!({"title": "Test Video", "duration": 60});
374        let video_id = storage
375            .store_video(&test_video_path, &metadata)
376            .await
377            .unwrap();
378
379        // Retrieve video
380        let retrieved_video = storage.retrieve_video(&video_id).await.unwrap();
381        assert_eq!(retrieved_video, b"test video content");
382
383        // Get video metadata
384        let retrieved_metadata = storage.get_video_metadata(&video_id).await.unwrap();
385        assert_eq!(retrieved_metadata["title"], "Test Video");
386        assert_eq!(retrieved_metadata["duration"], 60);
387    }
388
389    #[tokio::test]
390    async fn test_delete_video() {
391        let temp_dir = tempdir().unwrap();
392        let config = StorageConfig {
393            base_dir: temp_dir.path().to_path_buf(),
394            ..Default::default()
395        };
396
397        let storage = FileStorage::new(config).unwrap();
398
399        // Create a test video file
400        let test_video_path = temp_dir.path().join("test_video.mp4");
401        std::fs::write(&test_video_path, "test video content").unwrap();
402
403        // Store video
404        let metadata = json!({"title": "Test Video", "duration": 60});
405        let video_id = storage
406            .store_video(&test_video_path, &metadata)
407            .await
408            .unwrap();
409
410        // Delete video
411        let deleted = storage.delete_video(&video_id).await.unwrap();
412        assert!(deleted);
413
414        // Try to retrieve deleted video
415        let result = storage.retrieve_video(&video_id).await;
416        assert!(result.is_err());
417    }
418
419    #[tokio::test]
420    async fn test_store_retrieve_commentary() {
421        let temp_dir = tempdir().unwrap();
422        let config = StorageConfig {
423            base_dir: temp_dir.path().to_path_buf(),
424            ..Default::default()
425        };
426
427        let storage = FileStorage::new(config).unwrap();
428
429        // Store commentary
430        let commentary = json!({
431            "video_id": "test-video-id",
432            "content": "Test commentary",
433            "style": "professional",
434            "language": "en"
435        });
436        let commentary_id = storage.store_commentary(&commentary).await.unwrap();
437
438        // Retrieve commentary
439        let retrieved_commentary = storage.retrieve_commentary(&commentary_id).await.unwrap();
440        assert_eq!(retrieved_commentary["video_id"], "test-video-id");
441        assert_eq!(retrieved_commentary["content"], "Test commentary");
442    }
443
444    #[tokio::test]
445    async fn test_update_commentary() {
446        let temp_dir = tempdir().unwrap();
447        let config = StorageConfig {
448            base_dir: temp_dir.path().to_path_buf(),
449            ..Default::default()
450        };
451
452        let storage = FileStorage::new(config).unwrap();
453
454        // Store commentary
455        let commentary = json!({
456            "video_id": "test-video-id",
457            "content": "Test commentary",
458            "style": "professional",
459            "language": "en"
460        });
461        let commentary_id = storage.store_commentary(&commentary).await.unwrap();
462
463        // Update commentary
464        let updated_commentary = json!({
465            "video_id": "test-video-id",
466            "content": "Updated commentary",
467            "style": "professional",
468            "language": "en"
469        });
470        let updated = storage
471            .update_commentary(&commentary_id, &updated_commentary)
472            .await
473            .unwrap();
474        assert!(updated);
475
476        // Retrieve updated commentary
477        let retrieved_commentary = storage.retrieve_commentary(&commentary_id).await.unwrap();
478        assert_eq!(retrieved_commentary["content"], "Updated commentary");
479    }
480
481    #[tokio::test]
482    async fn test_delete_commentary() {
483        let temp_dir = tempdir().unwrap();
484        let config = StorageConfig {
485            base_dir: temp_dir.path().to_path_buf(),
486            ..Default::default()
487        };
488
489        let storage = FileStorage::new(config).unwrap();
490
491        // Store commentary
492        let commentary = json!({
493            "video_id": "test-video-id",
494            "content": "Test commentary",
495            "style": "professional",
496            "language": "en"
497        });
498        let commentary_id = storage.store_commentary(&commentary).await.unwrap();
499
500        // Delete commentary
501        let deleted = storage.delete_commentary(&commentary_id).await.unwrap();
502        assert!(deleted);
503
504        // Try to retrieve deleted commentary
505        let result = storage.retrieve_commentary(&commentary_id).await;
506        assert!(result.is_err());
507    }
508
509    #[tokio::test]
510    async fn test_list_videos() {
511        let temp_dir = tempdir().unwrap();
512        let config = StorageConfig {
513            base_dir: temp_dir.path().to_path_buf(),
514            ..Default::default()
515        };
516
517        let storage = FileStorage::new(config).unwrap();
518
519        // Create test video files
520        for i in 0..3 {
521            let test_video_path = temp_dir.path().join(format!("test_video_{}.mp4", i));
522            std::fs::write(&test_video_path, format!("test video content {}", i)).unwrap();
523
524            let metadata = json!({
525                "title": format!("Test Video {}", i),
526                "duration": 60
527            });
528            storage
529                .store_video(&test_video_path, &metadata)
530                .await
531                .unwrap();
532        }
533
534        // List videos
535        let videos = storage.list_videos(1, 10).await.unwrap();
536        assert_eq!(videos.len(), 3);
537
538        // Test pagination
539        let videos_page_1 = storage.list_videos(1, 2).await.unwrap();
540        assert_eq!(videos_page_1.len(), 2);
541
542        let videos_page_2 = storage.list_videos(2, 2).await.unwrap();
543        assert_eq!(videos_page_2.len(), 1);
544    }
545
546    #[tokio::test]
547    async fn test_list_commentaries() {
548        let temp_dir = tempdir().unwrap();
549        let config = StorageConfig {
550            base_dir: temp_dir.path().to_path_buf(),
551            ..Default::default()
552        };
553
554        let storage = FileStorage::new(config).unwrap();
555
556        // Store commentaries for different videos
557        for i in 0..3 {
558            let commentary = json!({
559                "video_id": "test-video-id",
560                "content": format!("Test commentary {}", i),
561                "style": "professional",
562                "language": "en"
563            });
564            storage.store_commentary(&commentary).await.unwrap();
565        }
566
567        // Store a commentary for a different video
568        let other_commentary = json!({
569            "video_id": "other-video-id",
570            "content": "Other video commentary",
571            "style": "professional",
572            "language": "en"
573        });
574        storage.store_commentary(&other_commentary).await.unwrap();
575
576        // List commentaries for test-video-id
577        let commentaries = storage.list_commentaries("test-video-id").await.unwrap();
578        assert_eq!(commentaries.len(), 3);
579
580        // List commentaries for other-video-id
581        let other_commentaries = storage.list_commentaries("other-video-id").await.unwrap();
582        assert_eq!(other_commentaries.len(), 1);
583    }
584
585    #[tokio::test]
586    async fn test_file_storage_with_compression() {
587        let temp_dir = tempdir().unwrap();
588        let config = StorageConfig {
589            base_dir: temp_dir.path().to_path_buf(),
590            enable_compression: true,
591            ..Default::default()
592        };
593
594        let storage = FileStorage::new(config).unwrap();
595
596        // Create a test video file
597        let test_video_path = temp_dir.path().join("test_video.mp4");
598        std::fs::write(&test_video_path, "test video content with compression").unwrap();
599
600        // Store video with compression
601        let metadata = json!({"title": "Test Video", "duration": 60});
602        let video_id = storage
603            .store_video(&test_video_path, &metadata)
604            .await
605            .unwrap();
606
607        // Retrieve video with decompression
608        let retrieved_video = storage.retrieve_video(&video_id).await.unwrap();
609        assert_eq!(retrieved_video, b"test video content with compression");
610    }
611}