First working POC
This commit is contained in:
@@ -0,0 +1,822 @@
|
||||
// Hybrid repository - parallel racing between cache and server
|
||||
//
|
||||
// @req: UR-002 - Access media when online or offline
|
||||
// @req: IR-013 - SQLite integration for local database
|
||||
// @req: DR-012 - Local database for media metadata cache
|
||||
// @req: DR-013 - Repository pattern for online/offline data access
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use log::{debug, warn};
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use super::{MediaRepository, OnlineRepository, OfflineRepository, types::*};
|
||||
|
||||
/// Hybrid repository combining online and offline data sources
|
||||
///
|
||||
/// Uses cache-first parallel racing strategy:
|
||||
/// - Runs SQLite cache and HTTP server queries in parallel
|
||||
/// - Cache has 100ms timeout for fast feedback
|
||||
/// - Returns cache result if it has meaningful content
|
||||
/// - Falls back to server result if cache is empty/stale
|
||||
///
|
||||
/// @req: UR-002 - Access media when online or offline
|
||||
/// @req: DR-012 - Local database for media metadata cache
|
||||
/// @req: DR-013 - Repository pattern for online/offline data access
|
||||
pub struct HybridRepository {
|
||||
online: Arc<OnlineRepository>,
|
||||
offline: Arc<OfflineRepository>,
|
||||
}
|
||||
|
||||
impl HybridRepository {
|
||||
pub fn new(online: OnlineRepository, offline: OfflineRepository) -> Self {
|
||||
Self {
|
||||
online: Arc::new(online),
|
||||
offline: Arc::new(offline),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get video stream URL with optional seeking support.
|
||||
/// This method is online-only since offline playback uses local file paths.
|
||||
pub async fn get_video_stream_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: Option<&str>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await
|
||||
}
|
||||
|
||||
/// Race cache vs server, return first valid result
|
||||
/// Prefer cache if it has meaningful content, otherwise use server
|
||||
///
|
||||
/// Core algorithm of the cache-first parallel racing strategy.
|
||||
/// Runs both cache and server queries concurrently, then:
|
||||
/// 1. If cache has meaningful content → return cache (fast path)
|
||||
/// 2. If cache is empty/stale → return server (fresh data)
|
||||
/// 3. If server fails → return cache even if empty (offline fallback)
|
||||
///
|
||||
/// @req: UR-002 - Access media when online or offline
|
||||
/// @req: DR-013 - Repository pattern for online/offline data access
|
||||
async fn parallel_race<T, F1, F2>(
|
||||
&self,
|
||||
cache_future: F1,
|
||||
server_future: F2,
|
||||
) -> Result<T, RepoError>
|
||||
where
|
||||
T: MeaningfulContent + Clone + Send + 'static,
|
||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
{
|
||||
// Wait for both to complete (cache has 100ms timeout)
|
||||
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
|
||||
|
||||
// Prefer cache if it has meaningful content
|
||||
if let Ok(data) = &cache_result {
|
||||
if data.has_content() {
|
||||
debug!("[HybridRepo] Using cache result (has content)");
|
||||
return Ok(data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to server result
|
||||
match server_result {
|
||||
Ok(data) => {
|
||||
debug!("[HybridRepo] Using server result");
|
||||
// TODO: Spawn background cache update
|
||||
Ok(data)
|
||||
}
|
||||
Err(e) => {
|
||||
// Server failed, try to return cache even if empty
|
||||
cache_result.or(Err(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple timeout wrapper for cache queries (100ms timeout)
|
||||
///
|
||||
/// @req: DR-013 - Repository pattern (cache-first with timeout)
|
||||
async fn cache_with_timeout<T>(
|
||||
&self,
|
||||
future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
) -> Result<T, RepoError> {
|
||||
timeout(Duration::from_millis(100), future)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(RepoError::Database {
|
||||
message: "Cache query timeout".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MediaRepository for HybridRepository {
|
||||
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
|
||||
// Libraries change infrequently, try cache first with fast timeout
|
||||
let cache_future = self.cache_with_timeout(self.offline.get_libraries());
|
||||
let server_future = self.online.get_libraries();
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_items(&self, parent_id: &str, options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let offline_for_save = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let parent_id = parent_id.to_string();
|
||||
let parent_id_clone = parent_id.clone();
|
||||
let parent_id_for_save = parent_id.clone();
|
||||
let opts_clone = options.clone();
|
||||
|
||||
// Check cache first to see if we have data
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_items(&parent_id, opts_clone).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_items(&parent_id_clone, options).await
|
||||
};
|
||||
|
||||
// Wait for both, prefer cache if available
|
||||
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
|
||||
|
||||
// Check if cache had meaningful content
|
||||
let cache_had_content = cache_result.as_ref()
|
||||
.map(|data| data.has_content())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Prefer cache if it has content
|
||||
let result = if cache_had_content {
|
||||
debug!("[HybridRepo] Using cached data for parent {}", &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
||||
cache_result?
|
||||
} else {
|
||||
// Use server result and save to cache for next time
|
||||
let server_data = server_result?;
|
||||
|
||||
if !server_data.items.is_empty() {
|
||||
let items_clone = server_data.items.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &items_clone).await {
|
||||
warn!("[HybridRepo] Failed to save {} items to cache: {:?}", items_clone.len(), e);
|
||||
} else {
|
||||
debug!("[HybridRepo] Saved {} items to cache for parent {}", items_clone.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
server_data
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let item_id = item_id.to_string();
|
||||
let item_id_clone = item_id.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_item(&item_id).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_item(&item_id_clone).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_latest_items(&self, parent_id: &str, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let parent_id = parent_id.to_string();
|
||||
let parent_id_clone = parent_id.clone();
|
||||
let limit_clone = limit;
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_latest_items(&parent_id, limit).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_latest_items(&parent_id_clone, limit_clone).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_resume_items(&self, parent_id: Option<&str>, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let parent_id_str = parent_id.map(|s| s.to_string());
|
||||
let parent_id_clone = parent_id_str.clone();
|
||||
let limit_clone = limit;
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_resume_items(parent_id_str.as_deref(), limit).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_resume_items(parent_id_clone.as_deref(), limit_clone).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_next_up_episodes(&self, series_id: Option<&str>, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Next up is dynamic, always fetch from server
|
||||
self.online.get_next_up_episodes(series_id, limit).await
|
||||
}
|
||||
|
||||
async fn get_recently_played_audio(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let limit_clone = limit;
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_recently_played_audio(limit).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_recently_played_audio(limit_clone).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let limit_clone = limit;
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_resume_movies(limit).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_resume_movies(limit_clone).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let parent_id_str = parent_id.map(|s| s.to_string());
|
||||
let parent_id_clone = parent_id_str.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_genres(parent_id_str.as_deref()).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_genres(parent_id_clone.as_deref()).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str, options: Option<SearchOptions>) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let query = query.to_string();
|
||||
let query_clone = query.clone();
|
||||
let opts_clone = options.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.search(&query, opts_clone).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.search(&query_clone, options).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
||||
// Playback info requires server communication for transcoding decisions
|
||||
self.online.get_playback_info(item_id).await
|
||||
}
|
||||
|
||||
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
|
||||
// Stream URLs require server communication - delegate to online repository
|
||||
self.online.get_audio_stream_url(item_id).await
|
||||
}
|
||||
|
||||
async fn report_playback_start(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> {
|
||||
// Playback reporting goes directly to server
|
||||
self.online.report_playback_start(item_id, position_ticks).await
|
||||
}
|
||||
|
||||
async fn report_playback_progress(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> {
|
||||
// Playback reporting goes directly to server
|
||||
self.online.report_playback_progress(item_id, position_ticks).await
|
||||
}
|
||||
|
||||
async fn report_playback_stopped(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> {
|
||||
// Playback reporting goes directly to server
|
||||
self.online.report_playback_stopped(item_id, position_ticks).await
|
||||
}
|
||||
|
||||
fn get_image_url(&self, item_id: &str, image_type: ImageType, options: Option<ImageOptions>) -> String {
|
||||
// Always use online URL for images (thumbnail cache handles offline)
|
||||
self.online.get_image_url(item_id, image_type, options)
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
// Write operations go directly to server
|
||||
self.online.mark_favorite(item_id).await
|
||||
}
|
||||
|
||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
// Write operations go directly to server
|
||||
self.online.unmark_favorite(item_id).await
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let person_id = person_id.to_string();
|
||||
let person_id_clone = person_id.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_person(&person_id).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_person(&person_id_clone).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_items_by_person(&self, person_id: &str, options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let person_id = person_id.to_string();
|
||||
let person_id_clone = person_id.clone();
|
||||
let opts_clone = options.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_items_by_person(&person_id, opts_clone).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_items_by_person(&person_id_clone, options).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_similar_items(&self, item_id: &str, limit: Option<usize>) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let item_id = item_id.to_string();
|
||||
let item_id_clone = item_id.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_similar_items(&item_id, limit).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_similar_items(&item_id_clone, limit).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Mock offline repository that tracks queries and saves
|
||||
struct MockOfflineRepo {
|
||||
items: Arc<Mutex<Vec<MediaItem>>>,
|
||||
query_count: Arc<Mutex<usize>>,
|
||||
save_count: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
impl MockOfflineRepo {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
items: Arc::new(Mutex::new(Vec::new())),
|
||||
query_count: Arc::new(Mutex::new(0)),
|
||||
save_count: Arc::new(Mutex::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_query_count(&self) -> usize {
|
||||
*self.query_count.lock().unwrap()
|
||||
}
|
||||
|
||||
fn get_save_count(&self) -> usize {
|
||||
*self.save_count.lock().unwrap()
|
||||
}
|
||||
|
||||
async fn save_to_cache(&self, _parent_id: &str, items: &[MediaItem]) -> Result<usize, RepoError> {
|
||||
*self.save_count.lock().unwrap() += 1;
|
||||
*self.items.lock().unwrap() = items.to_vec();
|
||||
Ok(items.len())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MediaRepository for MockOfflineRepo {
|
||||
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
*self.query_count.lock().unwrap() += 1;
|
||||
let items = self.items.lock().unwrap().clone();
|
||||
let count = items.len();
|
||||
Ok(SearchResult {
|
||||
items,
|
||||
total_record_count: count,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_latest_items(&self, _parent_id: &str, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_resume_items(&self, _parent_id: Option<&str>, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_next_up_episodes(&self, _series_id: Option<&str>, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_recently_played_audio(&self, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_resume_movies(&self, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn search(&self, _query: &str, _options: Option<SearchOptions>) -> Result<SearchResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn report_playback_progress(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn report_playback_stopped(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn get_image_url(&self, _item_id: &str, _image_type: ImageType, _options: Option<ImageOptions>) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_items_by_person(&self, _person_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock online repository that returns predefined items
|
||||
struct MockOnlineRepo {
|
||||
items: Vec<MediaItem>,
|
||||
query_count: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
impl MockOnlineRepo {
|
||||
fn new(items: Vec<MediaItem>) -> Self {
|
||||
Self {
|
||||
items,
|
||||
query_count: Arc::new(Mutex::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_query_count(&self) -> usize {
|
||||
*self.query_count.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MediaRepository for MockOnlineRepo {
|
||||
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
*self.query_count.lock().unwrap() += 1;
|
||||
Ok(SearchResult {
|
||||
items: self.items.clone(),
|
||||
total_record_count: self.items.len(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_latest_items(&self, _parent_id: &str, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_resume_items(&self, _parent_id: Option<&str>, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_next_up_episodes(&self, _series_id: Option<&str>, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_recently_played_audio(&self, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_resume_movies(&self, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn search(&self, _query: &str, _options: Option<SearchOptions>) -> Result<SearchResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn report_playback_progress(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn report_playback_stopped(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn get_image_url(&self, _item_id: &str, _image_type: ImageType, _options: Option<ImageOptions>) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_items_by_person(&self, _person_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_item(id: &str, name: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
item_type: "Movie".to_string(),
|
||||
server_id: "test-server".to_string(),
|
||||
parent_id: Some("parent-123".to_string()),
|
||||
library_id: Some("library-456".to_string()),
|
||||
overview: Some("Test overview".to_string()),
|
||||
genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
|
||||
runtime_ticks: Some(7200000000),
|
||||
production_year: Some(2024),
|
||||
community_rating: Some(8.5),
|
||||
official_rating: Some("PG-13".to_string()),
|
||||
primary_image_tag: Some("image-tag-123".to_string()),
|
||||
backdrop_image_tags: Some(vec!["backdrop-1".to_string()]),
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
album_name: None,
|
||||
album_artist: None,
|
||||
artists: None,
|
||||
artist_items: None,
|
||||
index_number: None,
|
||||
series_id: None,
|
||||
series_name: None,
|
||||
season_id: None,
|
||||
season_name: None,
|
||||
parent_index_number: None,
|
||||
user_data: None,
|
||||
media_streams: None,
|
||||
media_sources: None,
|
||||
people: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to test the caching logic
|
||||
struct TestHybridRepo {
|
||||
offline: Arc<MockOfflineRepo>,
|
||||
online: Arc<MockOnlineRepo>,
|
||||
}
|
||||
|
||||
impl TestHybridRepo {
|
||||
fn new(server_items: Vec<MediaItem>) -> Self {
|
||||
let offline = Arc::new(MockOfflineRepo::new());
|
||||
let online = Arc::new(MockOnlineRepo::new(server_items));
|
||||
Self { offline, online }
|
||||
}
|
||||
|
||||
/// Test version of get_items that implements the cache logic
|
||||
async fn get_items(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let offline_for_save = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let parent_id = parent_id.to_string();
|
||||
let parent_id_clone = parent_id.clone();
|
||||
let parent_id_for_save = parent_id.clone();
|
||||
|
||||
// Check cache first
|
||||
let cache_future = async move {
|
||||
offline.get_items(&parent_id, None).await
|
||||
};
|
||||
|
||||
let server_future = async move {
|
||||
online.get_items(&parent_id_clone, None).await
|
||||
};
|
||||
|
||||
// Wait for both, prefer cache if available
|
||||
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
|
||||
|
||||
// Check if cache had meaningful content
|
||||
let cache_had_content = cache_result.as_ref()
|
||||
.map(|data| data.has_content())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Prefer cache if it has content (mimics hybrid.rs get_items logic)
|
||||
let result = if cache_had_content {
|
||||
cache_result?
|
||||
} else {
|
||||
// Use server result and save to cache for next time
|
||||
let server_data = server_result?;
|
||||
|
||||
if !server_data.items.is_empty() {
|
||||
let items_clone = server_data.items.clone();
|
||||
offline_for_save.save_to_cache(&parent_id_for_save, &items_clone).await?;
|
||||
}
|
||||
|
||||
server_data
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Test cache miss saves server data to cache for next time
|
||||
///
|
||||
/// @req-test: UR-002 - Access media when online or offline
|
||||
/// @req-test: DR-013 - Repository pattern for online/offline data access
|
||||
/// @req-test: DR-012 - Local database for media metadata cache
|
||||
#[tokio::test]
|
||||
async fn test_cache_miss_saves_to_cache() {
|
||||
// Setup: Server has 3 items, cache is empty
|
||||
let server_items = vec![
|
||||
create_test_item("item-1", "Movie 1"),
|
||||
create_test_item("item-2", "Movie 2"),
|
||||
create_test_item("item-3", "Movie 3"),
|
||||
];
|
||||
|
||||
let repo = TestHybridRepo::new(server_items.clone());
|
||||
|
||||
// First request - cache miss
|
||||
let result = repo.get_items("parent-123").await.unwrap();
|
||||
|
||||
// Should return server items
|
||||
assert_eq!(result.items.len(), 3);
|
||||
assert_eq!(result.items[0].id, "item-1");
|
||||
|
||||
// Should have queried both cache and server
|
||||
assert_eq!(repo.offline.get_query_count(), 1, "Cache should be queried once");
|
||||
assert_eq!(repo.online.get_query_count(), 1, "Server should be queried once");
|
||||
|
||||
// Should have saved to cache
|
||||
assert_eq!(repo.offline.get_save_count(), 1, "Should save to cache on miss");
|
||||
}
|
||||
|
||||
/// Test cache hit prevents duplicate save to cache
|
||||
///
|
||||
/// Verifies parallel racing strategy: both cache and server are queried,
|
||||
/// but when cache has content, it's used and no duplicate save occurs.
|
||||
///
|
||||
/// @req-test: UR-002 - Access media when online or offline
|
||||
/// @req-test: DR-013 - Repository pattern for online/offline data access
|
||||
/// @req-test: DR-012 - Local database cache (avoid duplicate writes)
|
||||
#[tokio::test]
|
||||
async fn test_cache_hit_no_save() {
|
||||
// Setup: Server has 3 items, we'll pre-populate cache
|
||||
let server_items = vec![
|
||||
create_test_item("item-1", "Movie 1"),
|
||||
create_test_item("item-2", "Movie 2"),
|
||||
create_test_item("item-3", "Movie 3"),
|
||||
];
|
||||
|
||||
let repo = TestHybridRepo::new(server_items.clone());
|
||||
|
||||
// Pre-populate cache
|
||||
repo.offline.save_to_cache("parent-123", &server_items).await.unwrap();
|
||||
assert_eq!(repo.offline.get_save_count(), 1);
|
||||
|
||||
// Second request - cache hit
|
||||
let result = repo.get_items("parent-123").await.unwrap();
|
||||
|
||||
// Should return cached items
|
||||
assert_eq!(result.items.len(), 3);
|
||||
assert_eq!(result.items[0].id, "item-1");
|
||||
|
||||
// Should have queried cache and server (parallel race)
|
||||
assert_eq!(repo.offline.get_query_count(), 1, "Cache should be queried");
|
||||
assert_eq!(repo.online.get_query_count(), 1, "Server is queried in parallel");
|
||||
|
||||
// Should NOT have saved again (no duplicate save)
|
||||
assert_eq!(repo.offline.get_save_count(), 1, "Should NOT save when using cache");
|
||||
}
|
||||
|
||||
/// Test empty results are not saved to cache
|
||||
///
|
||||
/// @req-test: DR-013 - Repository pattern (edge case handling)
|
||||
/// @req-test: DR-012 - Local database cache (avoid saving empty data)
|
||||
#[tokio::test]
|
||||
async fn test_empty_cache_returns_empty_result() {
|
||||
// Setup: Server has no items
|
||||
let repo = TestHybridRepo::new(vec![]);
|
||||
|
||||
// Request with empty server
|
||||
let result = repo.get_items("parent-123").await.unwrap();
|
||||
|
||||
// Should return empty result
|
||||
assert_eq!(result.items.len(), 0);
|
||||
|
||||
// Should NOT save empty results
|
||||
assert_eq!(repo.offline.get_save_count(), 0, "Should not save empty results");
|
||||
}
|
||||
|
||||
/// Test SearchResult::has_content helper method
|
||||
///
|
||||
/// @req-test: DR-013 - Repository pattern (content detection helper)
|
||||
#[tokio::test]
|
||||
async fn test_has_content_check() {
|
||||
// Test that SearchResult::has_content works correctly
|
||||
let empty_result = SearchResult {
|
||||
items: vec![],
|
||||
total_record_count: 0,
|
||||
};
|
||||
assert!(!empty_result.has_content(), "Empty result should not have content");
|
||||
|
||||
let result_with_items = SearchResult {
|
||||
items: vec![create_test_item("item-1", "Movie 1")],
|
||||
total_record_count: 1,
|
||||
};
|
||||
assert!(result_with_items.has_content(), "Result with items should have content");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
pub mod types;
|
||||
pub mod online;
|
||||
pub mod offline;
|
||||
pub mod hybrid;
|
||||
|
||||
pub use types::*;
|
||||
pub use online::OnlineRepository;
|
||||
pub use offline::OfflineRepository;
|
||||
pub use hybrid::HybridRepository;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Repository trait for media access (online, offline, or hybrid)
|
||||
///
|
||||
/// @req: UR-002 - Access media when online or offline
|
||||
/// @req: UR-007 - Navigate media in library
|
||||
/// @req: UR-008 - Search media across libraries
|
||||
/// @req: IR-010 - Jellyfin API client for library browsing
|
||||
/// @req: DR-012 - Local database for media metadata cache
|
||||
/// @req: DR-013 - Repository pattern for online/offline data access
|
||||
#[async_trait]
|
||||
pub trait MediaRepository: Send + Sync {
|
||||
/// Get all libraries
|
||||
///
|
||||
/// @req: UR-007 - Navigate media in library
|
||||
/// @req: JA-003 - Get user library views
|
||||
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError>;
|
||||
|
||||
/// Get items in a library or parent
|
||||
///
|
||||
/// @req: UR-007 - Navigate media in library
|
||||
/// @req: JA-004 - Get library items (paginated)
|
||||
async fn get_items(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError>;
|
||||
|
||||
/// Get a single item by ID
|
||||
///
|
||||
/// @req: UR-007 - Navigate media in library
|
||||
/// @req: JA-005 - Get item details and metadata
|
||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError>;
|
||||
|
||||
/// Get latest items in a library
|
||||
///
|
||||
/// @req: UR-024 - View recently added content on server
|
||||
/// @req: JA-016 - Get recently added items
|
||||
async fn get_latest_items(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
/// Get resume items (continue watching/listening)
|
||||
///
|
||||
/// @req: UR-019 - Resume playback from where you left off
|
||||
/// @req: UR-023 - View "Next Up" / Continue Watching on home screen
|
||||
/// @req: JA-015 - Get "Continue Watching" items
|
||||
async fn get_resume_items(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
/// Get next up episodes
|
||||
///
|
||||
/// @req: UR-023 - View "Next Up" / Continue Watching; auto-play next episode
|
||||
/// @req: JA-014 - Get "Next Up" items
|
||||
async fn get_next_up_episodes(
|
||||
&self,
|
||||
series_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
/// Get recently played audio
|
||||
async fn get_recently_played_audio(
|
||||
&self,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
/// Get resume movies
|
||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
/// Get genres
|
||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError>;
|
||||
|
||||
/// Search for items
|
||||
///
|
||||
/// @req: UR-008 - Search media across libraries
|
||||
/// @req: JA-006 - Search across libraries
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
options: Option<SearchOptions>,
|
||||
) -> Result<SearchResult, RepoError>;
|
||||
|
||||
/// Get playback info for streaming
|
||||
///
|
||||
/// @req: UR-003 - Play videos
|
||||
/// @req: UR-004 - Play audio uninterrupted
|
||||
/// @req: JA-007 - Get playback info and stream URL
|
||||
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError>;
|
||||
|
||||
/// Get audio stream URL for a track
|
||||
///
|
||||
/// @req: UR-004 - Play audio uninterrupted
|
||||
/// @req: JA-007 - Get playback info and stream URL
|
||||
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
|
||||
|
||||
/// Report playback start
|
||||
///
|
||||
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
|
||||
/// @req: JA-010 - Report playback start
|
||||
async fn report_playback_start(
|
||||
&self,
|
||||
item_id: &str,
|
||||
position_ticks: i64,
|
||||
) -> Result<(), RepoError>;
|
||||
|
||||
/// Report playback progress
|
||||
///
|
||||
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
|
||||
/// @req: JA-011 - Report playback progress (periodic)
|
||||
async fn report_playback_progress(
|
||||
&self,
|
||||
item_id: &str,
|
||||
position_ticks: i64,
|
||||
) -> Result<(), RepoError>;
|
||||
|
||||
/// Report playback stopped
|
||||
///
|
||||
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
|
||||
/// @req: JA-012 - Report playback stopped
|
||||
async fn report_playback_stopped(
|
||||
&self,
|
||||
item_id: &str,
|
||||
position_ticks: i64,
|
||||
) -> Result<(), RepoError>;
|
||||
|
||||
/// Get image URL (synchronous - just constructs URL)
|
||||
fn get_image_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
image_type: ImageType,
|
||||
options: Option<ImageOptions>,
|
||||
) -> String;
|
||||
|
||||
/// Mark item as favorite
|
||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Unmark item as favorite
|
||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Get person details
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
|
||||
|
||||
/// Get items by person (filmography)
|
||||
async fn get_items_by_person(
|
||||
&self,
|
||||
person_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError>;
|
||||
|
||||
/// Get similar/related items for a movie or show
|
||||
///
|
||||
/// @req: UR-009 - Discover similar content based on current item
|
||||
async fn get_similar_items(
|
||||
&self,
|
||||
item_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<SearchResult, RepoError>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,566 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Error types for repository operations
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum RepoError {
|
||||
Network { message: String },
|
||||
Authentication { message: String },
|
||||
NotFound { message: String },
|
||||
Server { message: String },
|
||||
Database { message: String },
|
||||
Offline,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RepoError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RepoError::Network { message } => write!(f, "Network error: {}", message),
|
||||
RepoError::Authentication { message } => write!(f, "Authentication error: {}", message),
|
||||
RepoError::NotFound { message } => write!(f, "Not found: {}", message),
|
||||
RepoError::Server { message } => write!(f, "Server error: {}", message),
|
||||
RepoError::Database { message } => write!(f, "Database error: {}", message),
|
||||
RepoError::Offline => write!(f, "Offline - no server connection"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RepoError {}
|
||||
|
||||
/// Library (media collection)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Library {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub collection_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_tag: Option<String>,
|
||||
}
|
||||
|
||||
/// User-specific data for an item (playback state, favorites, etc.)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserData {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub playback_position_ticks: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_played: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_favorite: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub play_count: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_played_date: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub playback_context_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub playback_context_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Artist item with ID and name (for clickable artist links)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ArtistItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Person (cast/crew member) - for movies, series, and episodes
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Person {
|
||||
/// Deserializes from API's "Id" field (PascalCase), serializes as "id" (camelCase to frontend)
|
||||
#[serde(alias = "Id")]
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
/// Deserializes from API's "Name" field (PascalCase), serializes as "name" (camelCase to frontend)
|
||||
#[serde(alias = "Name")]
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// Person type from Jellyfin API (Actor, Director, Writer, etc.)
|
||||
/// Deserializes from API's "Type" field (PascalCase), serializes as "type" (camelCase to frontend)
|
||||
#[serde(rename = "type")]
|
||||
#[serde(alias = "Type")]
|
||||
#[serde(default)]
|
||||
pub person_type: String,
|
||||
/// Deserializes from API's "Role" field (PascalCase), serializes as "role" (camelCase to frontend)
|
||||
#[serde(alias = "Role")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<String>,
|
||||
/// Deserializes from API's "PrimaryImageTag" field (PascalCase), serializes as "primaryImageTag" (camelCase to frontend)
|
||||
#[serde(alias = "PrimaryImageTag")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_image_tag: Option<String>,
|
||||
}
|
||||
|
||||
/// Media item
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: String,
|
||||
pub server_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub library_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overview: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub genres: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub production_year: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub community_rating: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub official_rating: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub runtime_ticks: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_image_tag: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backdrop_image_tags: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_backdrop_image_tags: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub album_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub album_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub album_artist: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub artists: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub artist_items: Option<Vec<ArtistItem>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub index_number: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_index_number: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub series_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub series_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub season_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub season_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user_data: Option<UserData>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media_streams: Option<Vec<MediaStream>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media_sources: Option<Vec<MediaSource>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub people: Option<Vec<Person>>,
|
||||
}
|
||||
|
||||
/// Media stream information (audio, video, subtitle tracks)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaStream {
|
||||
#[serde(rename = "type")]
|
||||
pub stream_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub codec: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_title: Option<String>,
|
||||
pub index: i32,
|
||||
pub is_default: bool,
|
||||
pub is_forced: bool,
|
||||
}
|
||||
|
||||
/// Media source information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaSource {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub container: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bitrate: Option<i32>,
|
||||
pub supports_direct_play: bool,
|
||||
pub supports_direct_stream: bool,
|
||||
pub supports_transcoding: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub direct_stream_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Search result with pagination
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchResult {
|
||||
pub items: Vec<MediaItem>,
|
||||
pub total_record_count: usize,
|
||||
}
|
||||
|
||||
/// Options for querying items
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetItemsOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub start_index: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sort_by: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sort_order: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_item_types: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recursive: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fields: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub genres: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Options for search queries
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_item_types: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub search_term: Option<String>,
|
||||
}
|
||||
|
||||
/// Playback information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaybackInfo {
|
||||
pub media_source_id: String,
|
||||
pub play_session_id: String,
|
||||
pub stream_url: String,
|
||||
pub direct_play: bool,
|
||||
pub needs_transcoding: bool,
|
||||
}
|
||||
|
||||
/// Genre
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Genre {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Image type
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ImageType {
|
||||
Primary,
|
||||
Backdrop,
|
||||
Banner,
|
||||
Thumb,
|
||||
Logo,
|
||||
}
|
||||
|
||||
impl ImageType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ImageType::Primary => "Primary",
|
||||
ImageType::Backdrop => "Backdrop",
|
||||
ImageType::Banner => "Banner",
|
||||
ImageType::Thumb => "Thumb",
|
||||
ImageType::Logo => "Logo",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Image options
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImageOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_width: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_height: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<String>,
|
||||
}
|
||||
|
||||
/// Trait for checking if data has meaningful content
|
||||
pub trait MeaningfulContent {
|
||||
fn has_content(&self) -> bool;
|
||||
}
|
||||
|
||||
impl MeaningfulContent for Vec<Library> {
|
||||
fn has_content(&self) -> bool {
|
||||
!self.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl MeaningfulContent for Vec<MediaItem> {
|
||||
fn has_content(&self) -> bool {
|
||||
!self.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl MeaningfulContent for SearchResult {
|
||||
fn has_content(&self) -> bool {
|
||||
!self.items.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl MeaningfulContent for MediaItem {
|
||||
fn has_content(&self) -> bool {
|
||||
true // A single item always has content if it exists
|
||||
}
|
||||
}
|
||||
|
||||
impl MeaningfulContent for Vec<Genre> {
|
||||
fn has_content(&self) -> bool {
|
||||
!self.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl MeaningfulContent for PlaybackInfo {
|
||||
fn has_content(&self) -> bool {
|
||||
!self.stream_url.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_artist_item_deserialize_pascal_case() {
|
||||
// Test that ArtistItem correctly deserializes PascalCase JSON from Jellyfin API
|
||||
let json = r#"{"Id": "artist123", "Name": "Bob Dylan"}"#;
|
||||
let result: Result<ArtistItem, _> = serde_json::from_str(json);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let artist = result.unwrap();
|
||||
assert_eq!(artist.id, "artist123");
|
||||
assert_eq!(artist.name, "Bob Dylan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_artist_item_deserialize_array() {
|
||||
// Test deserializing array of ArtistItems (common in API responses)
|
||||
let json = r#"[
|
||||
{"Id": "artist1", "Name": "Bob Dylan"},
|
||||
{"Id": "artist2", "Name": "Johnny Cash"}
|
||||
]"#;
|
||||
let result: Result<Vec<ArtistItem>, _> = serde_json::from_str(json);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let artists = result.unwrap();
|
||||
assert_eq!(artists.len(), 2);
|
||||
assert_eq!(artists[0].id, "artist1");
|
||||
assert_eq!(artists[0].name, "Bob Dylan");
|
||||
assert_eq!(artists[1].id, "artist2");
|
||||
assert_eq!(artists[1].name, "Johnny Cash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_artist_item_serialize() {
|
||||
// Test that ArtistItem serializes to PascalCase for consistency
|
||||
let artist = ArtistItem {
|
||||
id: "test-id".to_string(),
|
||||
name: "Test Artist".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&artist).expect("Failed to serialize");
|
||||
assert!(json.contains(r#""Id":"test-id""#));
|
||||
assert!(json.contains(r#""Name":"Test Artist""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_media_item_with_primary_image_tag() {
|
||||
// Test that MediaItem correctly handles primary_image_tag
|
||||
let json = r#"{
|
||||
"id": "item123",
|
||||
"name": "Test Item",
|
||||
"type": "MusicAlbum",
|
||||
"serverId": "server1",
|
||||
"primaryImageTag": "tag123"
|
||||
}"#;
|
||||
|
||||
let result: Result<MediaItem, _> = serde_json::from_str(json);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let item = result.unwrap();
|
||||
assert_eq!(item.id, "item123");
|
||||
assert_eq!(item.name, "Test Item");
|
||||
assert_eq!(item.primary_image_tag, Some("tag123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_media_item_with_artists() {
|
||||
// Test MediaItem with artists array
|
||||
let json = r#"{
|
||||
"id": "track1",
|
||||
"name": "Test Track",
|
||||
"type": "Audio",
|
||||
"serverId": "server1",
|
||||
"artists": ["Artist 1", "Artist 2"]
|
||||
}"#;
|
||||
|
||||
let result: Result<MediaItem, _> = serde_json::from_str(json);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let item = result.unwrap();
|
||||
let artists = item.artists.expect("Expected artists");
|
||||
assert_eq!(artists.len(), 2);
|
||||
assert_eq!(artists[0], "Artist 1");
|
||||
assert_eq!(artists[1], "Artist 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_result_meaningful_content() {
|
||||
// Test MeaningfulContent trait for SearchResult
|
||||
let empty_result = SearchResult {
|
||||
items: vec![],
|
||||
total_record_count: 0,
|
||||
};
|
||||
assert!(!empty_result.has_content());
|
||||
|
||||
let non_empty_result = SearchResult {
|
||||
items: vec![MediaItem {
|
||||
id: "1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
item_type: "Audio".to_string(),
|
||||
server_id: "server1".to_string(),
|
||||
parent_id: None,
|
||||
library_id: None,
|
||||
overview: None,
|
||||
genres: None,
|
||||
production_year: None,
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
runtime_ticks: None,
|
||||
primary_image_tag: None,
|
||||
backdrop_image_tags: None,
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
album_name: None,
|
||||
album_artist: None,
|
||||
artists: None,
|
||||
artist_items: None,
|
||||
index_number: None,
|
||||
parent_index_number: None,
|
||||
series_id: None,
|
||||
series_name: None,
|
||||
season_id: None,
|
||||
season_name: None,
|
||||
user_data: None,
|
||||
media_streams: None,
|
||||
media_sources: None,
|
||||
people: None,
|
||||
}],
|
||||
total_record_count: 1,
|
||||
};
|
||||
assert!(non_empty_result.has_content());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_person_deserialize_complete() {
|
||||
// Test that Person deserializes correctly with all fields (PascalCase from Jellyfin API)
|
||||
let json = r#"{
|
||||
"Id": "person123",
|
||||
"Name": "Tom Hanks",
|
||||
"Type": "Actor",
|
||||
"Role": "Lead Actor",
|
||||
"PrimaryImageTag": "tag456"
|
||||
}"#;
|
||||
|
||||
let result: Result<Person, _> = serde_json::from_str(json);
|
||||
assert!(result.is_ok(), "Failed to deserialize: {:?}", result.err());
|
||||
|
||||
let person = result.unwrap();
|
||||
assert_eq!(person.id, "person123");
|
||||
assert_eq!(person.name, "Tom Hanks");
|
||||
assert_eq!(person.person_type, "Actor");
|
||||
assert_eq!(person.role, Some("Lead Actor".to_string()));
|
||||
assert_eq!(person.primary_image_tag, Some("tag456".to_string()));
|
||||
|
||||
// Verify serialization uses camelCase for frontend
|
||||
let serialized = serde_json::to_string(&person).expect("Failed to serialize");
|
||||
assert!(serialized.contains(r#""type":"Actor""#), "Serialized form should use 'type' not 'Type'");
|
||||
assert!(serialized.contains(r#""id":"person123""#));
|
||||
assert!(serialized.contains(r#""primaryImageTag":"tag456""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_person_deserialize_minimal() {
|
||||
// Test that Person deserializes with missing optional fields (uses defaults)
|
||||
let json = r#"{
|
||||
"Id": "person456",
|
||||
"Name": "Meryl Streep",
|
||||
"Type": "Actress"
|
||||
}"#;
|
||||
|
||||
let result: Result<Person, _> = serde_json::from_str(json);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let person = result.unwrap();
|
||||
assert_eq!(person.id, "person456");
|
||||
assert_eq!(person.name, "Meryl Streep");
|
||||
assert_eq!(person.person_type, "Actress");
|
||||
assert_eq!(person.role, None);
|
||||
assert_eq!(person.primary_image_tag, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_person_array_deserialize() {
|
||||
// Test deserializing array of Person objects (common in Jellyfin API)
|
||||
let json = r#"[
|
||||
{"Id": "actor1", "Name": "Actor One", "Type": "Actor"},
|
||||
{"Id": "director1", "Name": "Director One", "Type": "Director", "Role": "Director"}
|
||||
]"#;
|
||||
|
||||
let result: Result<Vec<Person>, _> = serde_json::from_str(json);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let people = result.unwrap();
|
||||
assert_eq!(people.len(), 2);
|
||||
assert_eq!(people[0].name, "Actor One");
|
||||
assert_eq!(people[1].person_type, "Director");
|
||||
assert_eq!(people[1].role, Some("Director".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_media_item_with_people() {
|
||||
// Test that MediaItem correctly deserializes with people array (from API in PascalCase)
|
||||
let json = r#"{
|
||||
"id": "movie1",
|
||||
"name": "Test Movie",
|
||||
"type": "Movie",
|
||||
"serverId": "server1",
|
||||
"people": [
|
||||
{"Id": "actor1", "Name": "John Doe", "Type": "Actor"},
|
||||
{"Id": "director1", "Name": "Jane Smith", "Type": "Director"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let result: Result<MediaItem, _> = serde_json::from_str(json);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let item = result.unwrap();
|
||||
let people = item.people.expect("Expected people array");
|
||||
assert_eq!(people.len(), 2);
|
||||
assert_eq!(people[0].name, "John Doe");
|
||||
assert_eq!(people[0].person_type, "Actor");
|
||||
assert_eq!(people[1].person_type, "Director");
|
||||
|
||||
// Verify that when serialized to frontend, it uses camelCase
|
||||
let serialized = serde_json::to_string(&item).expect("Failed to serialize");
|
||||
let re_parsed: serde_json::Value = serde_json::from_str(&serialized).expect("Failed to parse serialized");
|
||||
let people_array = re_parsed["people"].as_array().expect("people should be array");
|
||||
assert!(people_array[0].get("type").is_some(), "Serialized person should have 'type' field");
|
||||
assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user