Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If4372ea33b93306486170353f9edf4a76a6a6964
929 lines
26 KiB
Rust
929 lines
26 KiB
Rust
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);
|
|
}
|