use std::collections::HashMap; use pinakes_core::{model::*, storage::StorageBackend}; mod common; use common::{make_test_media, setup}; #[tokio::test] async fn test_media_crud() { let storage = setup().await; let now = chrono::Utc::now(); let id = MediaId::new(); let item = MediaItem { id, path: "/tmp/test.txt".into(), file_name: "test.txt".to_string(), media_type: pinakes_core::media_type::MediaType::Builtin( pinakes_core::media_type::BuiltinMediaType::PlainText, ), content_hash: ContentHash::new("abc123".to_string()), file_size: 100, title: Some("Test Title".to_string()), artist: None, album: None, genre: None, year: Some(2024), duration_secs: None, description: Some("A test file".to_string()), thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, date_taken: None, latitude: None, longitude: None, camera_make: None, camera_model: None, rating: None, perceptual_hash: None, storage_mode: StorageMode::External, original_filename: None, uploaded_at: None, storage_key: None, created_at: now, updated_at: now, deleted_at: None, links_extracted_at: None, }; // Insert storage.insert_media(&item).await.unwrap(); // Get let fetched = storage.get_media(id).await.unwrap(); assert_eq!(fetched.id, id); assert_eq!(fetched.title.as_deref(), Some("Test Title")); assert_eq!(fetched.file_size, 100); // Get by hash let by_hash = storage .get_media_by_hash(&ContentHash::new("abc123".into())) .await .unwrap(); assert!(by_hash.is_some()); assert_eq!(by_hash.unwrap().id, id); // Update let mut updated = fetched; updated.title = Some("Updated Title".to_string()); storage.update_media(&updated).await.unwrap(); let re_fetched = storage.get_media(id).await.unwrap(); assert_eq!(re_fetched.title.as_deref(), Some("Updated Title")); // List let list = storage.list_media(&Pagination::default()).await.unwrap(); assert_eq!(list.len(), 1); // Delete storage.delete_media(id).await.unwrap(); let result = storage.get_media(id).await; assert!(result.is_err()); } #[tokio::test] async fn test_tags() { let storage = setup().await; // Create tags let parent = storage.create_tag("Music", None).await.unwrap(); let child = storage.create_tag("Rock", Some(parent.id)).await.unwrap(); assert_eq!(parent.name, "Music"); assert_eq!(child.parent_id, Some(parent.id)); // List tags let tags = storage.list_tags().await.unwrap(); assert_eq!(tags.len(), 2); // Get descendants let descendants = storage.get_tag_descendants(parent.id).await.unwrap(); assert!(descendants.iter().any(|t| t.name == "Rock")); // Tag media let now = chrono::Utc::now(); let id = MediaId::new(); let item = MediaItem { id, path: "/tmp/song.mp3".into(), file_name: "song.mp3".to_string(), media_type: pinakes_core::media_type::MediaType::Builtin( pinakes_core::media_type::BuiltinMediaType::Mp3, ), content_hash: ContentHash::new("hash1".to_string()), file_size: 5000, title: Some("Test Song".to_string()), artist: Some("Test Artist".to_string()), album: None, genre: None, year: None, duration_secs: Some(180.0), description: None, thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, date_taken: None, latitude: None, longitude: None, camera_make: None, camera_model: None, rating: None, perceptual_hash: None, storage_mode: StorageMode::External, original_filename: None, uploaded_at: None, storage_key: None, created_at: now, updated_at: now, deleted_at: None, links_extracted_at: None, }; storage.insert_media(&item).await.unwrap(); storage.tag_media(id, parent.id).await.unwrap(); let media_tags = storage.get_media_tags(id).await.unwrap(); assert_eq!(media_tags.len(), 1); assert_eq!(media_tags[0].name, "Music"); // Untag storage.untag_media(id, parent.id).await.unwrap(); let media_tags = storage.get_media_tags(id).await.unwrap(); assert_eq!(media_tags.len(), 0); // Delete tag storage.delete_tag(child.id).await.unwrap(); let tags = storage.list_tags().await.unwrap(); assert_eq!(tags.len(), 1); } #[tokio::test] async fn test_collections() { let storage = setup().await; let col = storage .create_collection( "Favorites", CollectionKind::Manual, Some("My faves"), None, ) .await .unwrap(); assert_eq!(col.name, "Favorites"); assert_eq!(col.kind, CollectionKind::Manual); let now = chrono::Utc::now(); let id = MediaId::new(); let item = MediaItem { id, path: "/tmp/doc.pdf".into(), file_name: "doc.pdf".to_string(), media_type: pinakes_core::media_type::MediaType::Builtin( pinakes_core::media_type::BuiltinMediaType::Pdf, ), content_hash: ContentHash::new("pdfhash".to_string()), file_size: 10000, title: None, artist: None, album: None, genre: None, year: None, duration_secs: None, description: None, thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, date_taken: None, latitude: None, longitude: None, camera_make: None, camera_model: None, rating: None, perceptual_hash: None, storage_mode: StorageMode::External, original_filename: None, uploaded_at: None, storage_key: None, created_at: now, updated_at: now, deleted_at: None, links_extracted_at: None, }; storage.insert_media(&item).await.unwrap(); storage.add_to_collection(col.id, id, 0).await.unwrap(); let members = storage.get_collection_members(col.id).await.unwrap(); assert_eq!(members.len(), 1); assert_eq!(members[0].id, id); storage.remove_from_collection(col.id, id).await.unwrap(); let members = storage.get_collection_members(col.id).await.unwrap(); assert_eq!(members.len(), 0); // List collections let cols = storage.list_collections().await.unwrap(); assert_eq!(cols.len(), 1); storage.delete_collection(col.id).await.unwrap(); let cols = storage.list_collections().await.unwrap(); assert_eq!(cols.len(), 0); } #[tokio::test] async fn test_custom_fields() { let storage = setup().await; let now = chrono::Utc::now(); let id = MediaId::new(); let item = MediaItem { id, path: "/tmp/test.md".into(), file_name: "test.md".to_string(), media_type: pinakes_core::media_type::MediaType::Builtin( pinakes_core::media_type::BuiltinMediaType::Markdown, ), content_hash: ContentHash::new("mdhash".to_string()), file_size: 500, title: None, artist: None, album: None, genre: None, year: None, duration_secs: None, description: None, thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, date_taken: None, latitude: None, longitude: None, camera_make: None, camera_model: None, rating: None, perceptual_hash: None, storage_mode: StorageMode::External, original_filename: None, uploaded_at: None, storage_key: None, created_at: now, updated_at: now, deleted_at: None, links_extracted_at: None, }; storage.insert_media(&item).await.unwrap(); // Set custom field let field = CustomField { field_type: CustomFieldType::Text, value: "important".to_string(), }; storage .set_custom_field(id, "priority", &field) .await .unwrap(); // Get custom fields let fields = storage.get_custom_fields(id).await.unwrap(); assert_eq!(fields.len(), 1); assert_eq!(fields["priority"].value, "important"); // Verify custom fields are loaded with get_media let media = storage.get_media(id).await.unwrap(); assert_eq!(media.custom_fields.len(), 1); assert_eq!(media.custom_fields["priority"].value, "important"); // Delete custom field storage.delete_custom_field(id, "priority").await.unwrap(); let fields = storage.get_custom_fields(id).await.unwrap(); assert_eq!(fields.len(), 0); } #[tokio::test] async fn test_search() { let storage = setup().await; let now = chrono::Utc::now(); // Insert a few items for (i, (name, title, artist)) in [ ("song1.mp3", "Bohemian Rhapsody", "Queen"), ("song2.mp3", "Stairway to Heaven", "Led Zeppelin"), ("doc.pdf", "Rust Programming", ""), ] .iter() .enumerate() { let item = MediaItem { id: MediaId::new(), path: format!("/tmp/{name}").into(), file_name: name.to_string(), media_type: pinakes_core::media_type::MediaType::from_path( std::path::Path::new(name), ) .unwrap(), content_hash: ContentHash::new(format!("hash{i}")), file_size: 1000 * (i as u64 + 1), title: Some(title.to_string()), artist: if artist.is_empty() { None } else { Some(artist.to_string()) }, album: None, genre: None, year: None, duration_secs: None, description: None, thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, date_taken: None, latitude: None, longitude: None, camera_make: None, camera_model: None, rating: None, perceptual_hash: None, storage_mode: StorageMode::External, original_filename: None, uploaded_at: None, storage_key: None, created_at: now, updated_at: now, deleted_at: None, links_extracted_at: None, }; storage.insert_media(&item).await.unwrap(); } // Full-text search let request = pinakes_core::search::SearchRequest { query: pinakes_core::search::parse_search_query("Bohemian").unwrap(), sort: pinakes_core::search::SortOrder::Relevance, pagination: Pagination::new(0, 50, None), }; let results = storage.search(&request).await.unwrap(); assert_eq!(results.total_count, 1); assert_eq!(results.items[0].title.as_deref(), Some("Bohemian Rhapsody")); // Type filter let request = pinakes_core::search::SearchRequest { query: pinakes_core::search::parse_search_query("type:pdf").unwrap(), sort: pinakes_core::search::SortOrder::Relevance, pagination: Pagination::new(0, 50, None), }; let results = storage.search(&request).await.unwrap(); assert_eq!(results.total_count, 1); assert_eq!(results.items[0].file_name, "doc.pdf"); } #[tokio::test] async fn test_audit_log() { let storage = setup().await; let entry = AuditEntry { id: uuid::Uuid::now_v7(), media_id: None, action: AuditAction::Scanned, details: Some("test scan".to_string()), timestamp: chrono::Utc::now(), }; storage.record_audit(&entry).await.unwrap(); let entries = storage .list_audit_entries(None, &Pagination::new(0, 10, None)) .await .unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].action, AuditAction::Scanned); } #[tokio::test] async fn test_import_with_dedup() { let storage = setup().await as pinakes_core::storage::DynStorageBackend; // Create a temp file let dir = tempfile::tempdir().unwrap(); let file_path = dir.path().join("test.txt"); std::fs::write(&file_path, "hello world").unwrap(); // First import let result1 = pinakes_core::import::import_file(&storage, &file_path, None) .await .unwrap(); assert!(!result1.was_duplicate); // Second import of same file let result2 = pinakes_core::import::import_file(&storage, &file_path, None) .await .unwrap(); assert!(result2.was_duplicate); assert_eq!(result1.media_id, result2.media_id); } #[tokio::test] async fn test_root_dirs() { let storage = setup().await; storage.add_root_dir("/tmp/music".into()).await.unwrap(); storage.add_root_dir("/tmp/docs".into()).await.unwrap(); let dirs = storage.list_root_dirs().await.unwrap(); assert_eq!(dirs.len(), 2); storage .remove_root_dir(std::path::Path::new("/tmp/music")) .await .unwrap(); let dirs = storage.list_root_dirs().await.unwrap(); assert_eq!(dirs.len(), 1); assert_eq!(dirs[0], std::path::PathBuf::from("/tmp/docs")); } #[tokio::test] async fn test_library_statistics_empty() { let storage = setup().await; let stats = storage.library_statistics().await.unwrap(); assert_eq!(stats.total_media, 0); assert_eq!(stats.total_size_bytes, 0); assert_eq!(stats.avg_file_size_bytes, 0); assert!(stats.media_by_type.is_empty()); assert!(stats.storage_by_type.is_empty()); assert!(stats.top_tags.is_empty()); assert!(stats.top_collections.is_empty()); assert!(stats.newest_item.is_none()); assert!(stats.oldest_item.is_none()); assert_eq!(stats.total_tags, 0); assert_eq!(stats.total_collections, 0); assert_eq!(stats.total_duplicates, 0); } #[tokio::test] async fn test_library_statistics_with_data() { let storage = setup().await; let now = chrono::Utc::now(); let item = MediaItem { id: MediaId::new(), path: "/tmp/stats_test.mp3".into(), file_name: "stats_test.mp3".to_string(), media_type: pinakes_core::media_type::MediaType::Builtin( pinakes_core::media_type::BuiltinMediaType::Mp3, ), content_hash: ContentHash::new("stats_hash".to_string()), file_size: 5000, title: Some("Stats Song".to_string()), artist: None, album: None, genre: None, year: None, duration_secs: Some(120.0), description: None, thumbnail_path: None, custom_fields: HashMap::new(), file_mtime: None, date_taken: None, latitude: None, longitude: None, camera_make: None, camera_model: None, rating: None, perceptual_hash: None, storage_mode: StorageMode::External, original_filename: None, uploaded_at: None, storage_key: None, created_at: now, updated_at: now, deleted_at: None, links_extracted_at: None, }; storage.insert_media(&item).await.unwrap(); let stats = storage.library_statistics().await.unwrap(); assert_eq!(stats.total_media, 1); assert_eq!(stats.total_size_bytes, 5000); assert_eq!(stats.avg_file_size_bytes, 5000); assert!(!stats.media_by_type.is_empty()); assert!(stats.newest_item.is_some()); assert!(stats.oldest_item.is_some()); } // Media Server Features #[tokio::test] async fn test_ratings_crud() { let storage = setup().await; let item = make_test_media("rating1"); storage.insert_media(&item).await.unwrap(); let user_id = pinakes_core::users::UserId::new(); // Rate media let rating = storage .rate_media(user_id, item.id, 4, Some("Great video")) .await .unwrap(); assert_eq!(rating.stars, 4); assert_eq!(rating.review_text.as_deref(), Some("Great video")); // Get user's rating let fetched = storage.get_user_rating(user_id, item.id).await.unwrap(); assert!(fetched.is_some()); assert_eq!(fetched.unwrap().stars, 4); // Get media ratings let ratings = storage.get_media_ratings(item.id).await.unwrap(); assert_eq!(ratings.len(), 1); // Delete rating storage.delete_rating(rating.id).await.unwrap(); let empty = storage.get_media_ratings(item.id).await.unwrap(); assert!(empty.is_empty()); } #[tokio::test] async fn test_comments_crud() { let storage = setup().await; let item = make_test_media("comment1"); storage.insert_media(&item).await.unwrap(); let user_id = pinakes_core::users::UserId::new(); // Add comment let comment = storage .add_comment(user_id, item.id, "Nice video!", None) .await .unwrap(); assert_eq!(comment.text, "Nice video!"); assert!(comment.parent_comment_id.is_none()); // Add reply let reply = storage .add_comment(user_id, item.id, "Thanks!", Some(comment.id)) .await .unwrap(); assert_eq!(reply.parent_comment_id, Some(comment.id)); // List comments let comments = storage.get_media_comments(item.id).await.unwrap(); assert_eq!(comments.len(), 2); // Delete comment storage.delete_comment(reply.id).await.unwrap(); let remaining = storage.get_media_comments(item.id).await.unwrap(); assert_eq!(remaining.len(), 1); } #[tokio::test] async fn test_favorites_toggle() { let storage = setup().await; let item = make_test_media("fav1"); storage.insert_media(&item).await.unwrap(); let user_id = pinakes_core::users::UserId::new(); // Not a favorite initially assert!(!storage.is_favorite(user_id, item.id).await.unwrap()); // Add favorite storage.add_favorite(user_id, item.id).await.unwrap(); assert!(storage.is_favorite(user_id, item.id).await.unwrap()); // List favorites let favs = storage .get_user_favorites(user_id, &Pagination::default()) .await .unwrap(); assert_eq!(favs.len(), 1); // Remove favorite storage.remove_favorite(user_id, item.id).await.unwrap(); assert!(!storage.is_favorite(user_id, item.id).await.unwrap()); } #[tokio::test] async fn test_share_links() { let storage = setup().await; let item = make_test_media("share1"); storage.insert_media(&item).await.unwrap(); let user_id = pinakes_core::users::UserId::new(); let token = "test_share_token_abc123"; // Create share link let link = storage .create_share_link(item.id, user_id, token, None, None) .await .unwrap(); assert_eq!(link.token, token); assert_eq!(link.view_count, 0); // Get share link let fetched = storage.get_share_link(token).await.unwrap(); assert_eq!(fetched.media_id, item.id); // Increment views storage.increment_share_views(token).await.unwrap(); let updated = storage.get_share_link(token).await.unwrap(); assert_eq!(updated.view_count, 1); // Delete share link storage.delete_share_link(link.id).await.unwrap(); let result = storage.get_share_link(token).await; assert!(result.is_err()); } #[tokio::test] async fn test_playlists_crud() { let storage = setup().await; let item1 = make_test_media("pl1"); let item2 = make_test_media("pl2"); storage.insert_media(&item1).await.unwrap(); storage.insert_media(&item2).await.unwrap(); let owner = pinakes_core::users::UserId::new(); // Create playlist let playlist = storage .create_playlist( owner, "My Playlist", Some("A test playlist"), true, false, None, ) .await .unwrap(); assert_eq!(playlist.name, "My Playlist"); assert!(playlist.is_public); // Get playlist let fetched = storage.get_playlist(playlist.id).await.unwrap(); assert_eq!(fetched.name, "My Playlist"); // Add items storage .add_to_playlist(playlist.id, item1.id, 0) .await .unwrap(); storage .add_to_playlist(playlist.id, item2.id, 1) .await .unwrap(); // Get playlist items let items = storage.get_playlist_items(playlist.id).await.unwrap(); assert_eq!(items.len(), 2); // Reorder storage .reorder_playlist(playlist.id, item2.id, 0) .await .unwrap(); // Remove item storage .remove_from_playlist(playlist.id, item1.id) .await .unwrap(); let items = storage.get_playlist_items(playlist.id).await.unwrap(); assert_eq!(items.len(), 1); // Update playlist let updated = storage .update_playlist(playlist.id, Some("Renamed"), None, Some(false)) .await .unwrap(); assert_eq!(updated.name, "Renamed"); assert!(!updated.is_public); // List playlists let playlists = storage.list_playlists(None).await.unwrap(); assert!(!playlists.is_empty()); // Delete playlist storage.delete_playlist(playlist.id).await.unwrap(); let result = storage.get_playlist(playlist.id).await; assert!(result.is_err()); } #[tokio::test] async fn test_analytics_usage_events() { let storage = setup().await; let item = make_test_media("analytics1"); storage.insert_media(&item).await.unwrap(); let user_id = pinakes_core::users::UserId::new(); // Record events let event = pinakes_core::analytics::UsageEvent { id: uuid::Uuid::now_v7(), media_id: Some(item.id), user_id: Some(user_id), event_type: pinakes_core::analytics::UsageEventType::View, timestamp: chrono::Utc::now(), duration_secs: Some(60.0), context_json: None, }; storage.record_usage_event(&event).await.unwrap(); // Get usage events let events = storage .get_usage_events(Some(item.id), None, 10) .await .unwrap(); assert_eq!(events.len(), 1); assert_eq!( events[0].event_type, pinakes_core::analytics::UsageEventType::View ); // Most viewed let most_viewed = storage.get_most_viewed(10).await.unwrap(); assert_eq!(most_viewed.len(), 1); assert_eq!(most_viewed[0].1, 1); // Recently viewed let recent = storage.get_recently_viewed(user_id, 10).await.unwrap(); assert_eq!(recent.len(), 1); } #[tokio::test] async fn test_watch_progress() { let storage = setup().await; let item = make_test_media("progress1"); storage.insert_media(&item).await.unwrap(); let user_id = pinakes_core::users::UserId::new(); // No progress initially let progress = storage.get_watch_progress(user_id, item.id).await.unwrap(); assert!(progress.is_none()); // Update progress storage .update_watch_progress(user_id, item.id, 45.5) .await .unwrap(); let progress = storage.get_watch_progress(user_id, item.id).await.unwrap(); assert_eq!(progress, Some(45.5)); // Update again (should upsert) storage .update_watch_progress(user_id, item.id, 90.0) .await .unwrap(); let progress = storage.get_watch_progress(user_id, item.id).await.unwrap(); assert_eq!(progress, Some(90.0)); } #[tokio::test] async fn test_cleanup_old_events() { let storage = setup().await; let old_event = pinakes_core::analytics::UsageEvent { id: uuid::Uuid::now_v7(), media_id: None, user_id: None, event_type: pinakes_core::analytics::UsageEventType::Search, timestamp: chrono::Utc::now() - chrono::Duration::days(100), duration_secs: None, context_json: None, }; storage.record_usage_event(&old_event).await.unwrap(); let cutoff = chrono::Utc::now() - chrono::Duration::days(90); let cleaned = storage.cleanup_old_events(cutoff).await.unwrap(); assert_eq!(cleaned, 1); } #[tokio::test] async fn test_subtitles_crud() { let storage = setup().await; let item = make_test_media("sub1"); storage.insert_media(&item).await.unwrap(); let subtitle = pinakes_core::subtitles::Subtitle { id: uuid::Uuid::now_v7(), media_id: item.id, language: Some("en".to_string()), format: pinakes_core::subtitles::SubtitleFormat::Srt, file_path: Some("/tmp/test.srt".into()), is_embedded: false, track_index: None, offset_ms: 0, created_at: chrono::Utc::now(), }; storage.add_subtitle(&subtitle).await.unwrap(); // Get subtitles let subs = storage.get_media_subtitles(item.id).await.unwrap(); assert_eq!(subs.len(), 1); assert_eq!(subs[0].language.as_deref(), Some("en")); assert_eq!(subs[0].format, pinakes_core::subtitles::SubtitleFormat::Srt); // Update offset storage .update_subtitle_offset(subtitle.id, 500) .await .unwrap(); let updated = storage.get_media_subtitles(item.id).await.unwrap(); assert_eq!(updated[0].offset_ms, 500); // Delete subtitle storage.delete_subtitle(subtitle.id).await.unwrap(); let empty = storage.get_media_subtitles(item.id).await.unwrap(); assert!(empty.is_empty()); } #[tokio::test] async fn test_external_metadata() { let storage = setup().await; let item = make_test_media("enrich1"); storage.insert_media(&item).await.unwrap(); let meta = pinakes_core::enrichment::ExternalMetadata { id: uuid::Uuid::now_v7(), media_id: item.id, source: pinakes_core::enrichment::EnrichmentSourceType::MusicBrainz, external_id: Some("mb-123".to_string()), metadata_json: r#"{"title":"Test"}"#.to_string(), confidence: 0.85, last_updated: chrono::Utc::now(), }; storage.store_external_metadata(&meta).await.unwrap(); // Get external metadata let metas = storage.get_external_metadata(item.id).await.unwrap(); assert_eq!(metas.len(), 1); assert_eq!( metas[0].source, pinakes_core::enrichment::EnrichmentSourceType::MusicBrainz ); assert_eq!(metas[0].external_id.as_deref(), Some("mb-123")); assert!((metas[0].confidence - 0.85).abs() < 0.01); // Delete storage.delete_external_metadata(meta.id).await.unwrap(); let empty = storage.get_external_metadata(item.id).await.unwrap(); assert!(empty.is_empty()); } #[tokio::test] async fn test_transcode_sessions() { let storage = setup().await; let item = make_test_media("transcode1"); storage.insert_media(&item).await.unwrap(); let session = pinakes_core::transcode::TranscodeSession { id: uuid::Uuid::now_v7(), media_id: item.id, user_id: None, profile: "720p".to_string(), cache_path: "/tmp/transcode/test.mp4".into(), status: pinakes_core::transcode::TranscodeStatus::Pending, progress: 0.0, created_at: chrono::Utc::now(), expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(24)), duration_secs: None, child_cancel: None, }; storage.create_transcode_session(&session).await.unwrap(); // Get session let fetched = storage.get_transcode_session(session.id).await.unwrap(); assert_eq!(fetched.profile, "720p"); assert_eq!(fetched.status.as_str(), "pending"); // Update status storage .update_transcode_status( session.id, pinakes_core::transcode::TranscodeStatus::Transcoding, 0.5, ) .await .unwrap(); let updated = storage.get_transcode_session(session.id).await.unwrap(); assert_eq!(updated.status.as_str(), "transcoding"); assert!((updated.progress - 0.5).abs() < 0.01); // List sessions let sessions = storage.list_transcode_sessions(None).await.unwrap(); assert_eq!(sessions.len(), 1); // List by media ID let sessions = storage .list_transcode_sessions(Some(item.id)) .await .unwrap(); assert_eq!(sessions.len(), 1); // Cleanup expired let far_future = chrono::Utc::now() + chrono::Duration::days(365); let cleaned = storage .cleanup_expired_transcodes(far_future) .await .unwrap(); assert_eq!(cleaned, 1); }