// Offline repository - queries SQLite database for cached data use std::sync::Arc; use async_trait::async_trait; use log::debug; use super::{MediaRepository, types::*}; use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService}; pub struct OfflineRepository { db_service: Arc, server_id: String, user_id: String, } impl OfflineRepository { pub fn new(db_service: Arc, server_id: String, user_id: String) -> Self { Self { db_service, server_id, user_id } } /// Helper to convert CachedItem from storage to MediaItem fn cached_item_to_media_item(item: CachedItem, user_data: Option) -> MediaItem { let artists_vec = item.artists .as_ref() .and_then(|s| serde_json::from_str::>(s).ok()) .unwrap_or_default(); MediaItem { id: item.id.clone(), name: item.name, item_type: item.item_type, server_id: item.server_id, parent_id: item.parent_id, library_id: item.library_id, overview: item.overview, genres: item.genres .as_ref() .and_then(|s| serde_json::from_str::>(s).ok()), runtime_ticks: item.runtime_ticks, production_year: item.production_year, community_rating: item.community_rating, official_rating: item.official_rating, primary_image_tag: item.primary_image_tag, backdrop_image_tags: item.backdrop_image_tags, parent_backdrop_image_tags: item.parent_backdrop_image_tags, album_id: item.album_id, album_name: item.album_name, album_artist: item.album_artist, artists: Some(artists_vec), artist_items: None, // Not stored in cache yet - TODO: add to database schema index_number: item.index_number, series_id: item.series_id, series_name: item.series_name, season_id: item.season_id, season_name: item.season_name, parent_index_number: item.parent_index_number, user_data, media_streams: None, // Not cached offline media_sources: None, // Not cached offline people: None, // Not cached offline - TODO: add to database schema } } /// Get user data for an item (playback position, favorite, etc.) async fn get_user_data(&self, item_id: &str) -> Option { let query = Query::with_params( "SELECT playback_position_ticks, is_played, is_favorite, play_count, last_played_at, playback_context_type, playback_context_id FROM user_data WHERE user_id = ? AND item_id = ?", vec![ QueryParam::String(self.user_id.clone()), QueryParam::String(item_id.to_string()), ], ); self.db_service.query_optional(query, |row| { Ok(UserData { playback_position_ticks: row.get(0).ok(), is_played: row.get::<_, Option>(1).ok().flatten().map(|v| v != 0), is_favorite: row.get::<_, Option>(2).ok().flatten().map(|v| v != 0), play_count: row.get(3).ok(), last_played_date: row.get(4).ok(), playback_context_type: row.get(5).ok(), playback_context_id: row.get(6).ok(), }) }).await.ok().flatten() } } // Helper struct matching storage.rs CachedItem structure #[derive(Debug)] struct CachedItem { id: String, name: String, item_type: String, server_id: String, parent_id: Option, library_id: Option, overview: Option, genres: Option, runtime_ticks: Option, production_year: Option, community_rating: Option, official_rating: Option, primary_image_tag: Option, backdrop_image_tags: Option>, parent_backdrop_image_tags: Option>, album_id: Option, album_name: Option, album_artist: Option, artists: Option, index_number: Option, series_id: Option, series_name: Option, season_id: Option, season_name: Option, parent_index_number: Option, } fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result { Ok(CachedItem { id: row.get(0)?, name: row.get(1)?, item_type: row.get(2)?, server_id: row.get(3)?, parent_id: row.get(4)?, library_id: row.get(5)?, overview: row.get(6)?, genres: row.get(7)?, runtime_ticks: row.get(8)?, production_year: row.get(9)?, community_rating: row.get(10)?, official_rating: row.get(11)?, primary_image_tag: row.get(12)?, backdrop_image_tags: None, // TODO: Add to DB schema parent_backdrop_image_tags: None, // TODO: Add to DB schema album_id: row.get(13)?, album_name: row.get(14)?, album_artist: row.get(15)?, artists: row.get(16)?, index_number: row.get(17)?, series_id: row.get(18)?, series_name: row.get(19)?, season_id: row.get(20)?, season_name: row.get(21)?, parent_index_number: row.get(22)?, }) } impl OfflineRepository { /// Save browsed items to cache for faster subsequent loading /// /// This persists metadata for items that were browsed (not necessarily downloaded). /// Items are marked with current timestamp for freshness tracking. pub async fn save_to_cache( &self, parent_id: &str, items: &[MediaItem], ) -> Result { if items.is_empty() { return Ok(0); } let now = chrono::Utc::now().to_rfc3339(); // Temporarily disable foreign key constraints to avoid CASCADE DELETE issues // when replacing stub parent items with their actual data self.db_service.execute(Query::new("PRAGMA foreign_keys = OFF")).await .map_err(|e| RepoError::Database { message: e })?; // Ensure we re-enable foreign keys even if an error occurs let result = self.save_to_cache_impl(parent_id, items, &now).await; // Re-enable foreign key constraints let _ = self.db_service.execute(Query::new("PRAGMA foreign_keys = ON")).await; result } async fn save_to_cache_impl( &self, parent_id: &str, items: &[MediaItem], now: &str, ) -> Result { // Collect all unique parent IDs referenced by items being saved let mut parent_ids = std::collections::HashSet::new(); parent_ids.insert(parent_id.to_string()); for item in items { if let Some(pid) = &item.parent_id { parent_ids.insert(pid.clone()); } } // Insert stub entries for all parent IDs to satisfy FK constraints #[cfg(test)] println!("Creating stub parents for: {:?}", parent_ids); for pid in parent_ids { let parent_query = Query::with_params( "INSERT OR IGNORE INTO items (id, server_id, name, item_type, synced_at) VALUES (?1, ?2, ?3, ?4, ?5)", vec![ QueryParam::String(pid.clone()), QueryParam::String(self.server_id.clone()), QueryParam::String("Parent".to_string()), QueryParam::String("Folder".to_string()), QueryParam::String(now.to_string()), ], ); let _stub_rows = self.db_service.execute(parent_query).await .map_err(|e| RepoError::Database { message: e })?; #[cfg(test)] println!(" Created stub parent {} (rows affected: {})", pid, _stub_rows); } let mut count = 0; for item in items { // Convert Option> to JSON strings for storage let genres_json = item.genres.as_ref() .map(|g| serde_json::to_string(g).unwrap_or_else(|_| "[]".to_string())); let artists_json = item.artists.as_ref() .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string())); let backdrop_tags_json = item.backdrop_image_tags.as_ref() .map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".to_string())); // Use INSERT OR REPLACE to upsert items let query = Query::with_params( "INSERT OR REPLACE INTO items ( id, server_id, library_id, parent_id, name, item_type, overview, genres, series_id, series_name, season_id, season_name, index_number, parent_index_number, album_id, album_name, album_artist, artists, production_year, runtime_ticks, primary_image_tag, backdrop_image_tags, community_rating, official_rating, synced_at ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25 )", vec![ QueryParam::String(item.id.clone()), QueryParam::String(self.server_id.clone()), // Library is NULL for cached items (may not be synced yet) QueryParam::Null, // library_id // Use the item's actual parent_id, not the function parameter match &item.parent_id { Some(pid) => QueryParam::String(pid.clone()), None => QueryParam::Null, }, QueryParam::String(item.name.clone()), QueryParam::String(item.item_type.clone()), match &item.overview { Some(o) => QueryParam::String(o.clone()), None => QueryParam::Null, }, match genres_json { Some(g) => QueryParam::String(g), None => QueryParam::Null, }, match &item.series_id { Some(s) => QueryParam::String(s.clone()), None => QueryParam::Null, }, match &item.series_name { Some(s) => QueryParam::String(s.clone()), None => QueryParam::Null, }, match &item.season_id { Some(s) => QueryParam::String(s.clone()), None => QueryParam::Null, }, match &item.season_name { Some(s) => QueryParam::String(s.clone()), None => QueryParam::Null, }, match item.index_number { Some(i) => QueryParam::Int(i), None => QueryParam::Null, }, match item.parent_index_number { Some(i) => QueryParam::Int(i), None => QueryParam::Null, }, match &item.album_id { Some(a) => QueryParam::String(a.clone()), None => QueryParam::Null, }, match &item.album_name { Some(a) => QueryParam::String(a.clone()), None => QueryParam::Null, }, match &item.album_artist { Some(a) => QueryParam::String(a.clone()), None => QueryParam::Null, }, match artists_json { Some(a) => QueryParam::String(a), None => QueryParam::Null, }, match item.production_year { Some(y) => QueryParam::Int(y), None => QueryParam::Null, }, match item.runtime_ticks { Some(r) => QueryParam::Int64(r), None => QueryParam::Null, }, match &item.primary_image_tag { Some(t) => QueryParam::String(t.clone()), None => QueryParam::Null, }, match backdrop_tags_json { Some(b) => QueryParam::String(b), None => QueryParam::Null, }, match item.community_rating { Some(r) => QueryParam::Float(r), None => QueryParam::Null, }, match &item.official_rating { Some(r) => QueryParam::String(r.clone()), None => QueryParam::Null, }, QueryParam::String(now.to_string()), ], ); let _rows_affected = self.db_service.execute(query).await .map_err(|e| RepoError::Database { message: format!("Failed to insert item {}: {}", item.id, e) })?; #[cfg(test)] println!(" [save_to_cache] Saved item {} (rows affected: {})", item.id, _rows_affected); count += 1; } Ok(count) } /// Cache playlist items from server into local database /// Called by HybridRepository after fetching from online pub async fn save_playlist_items_to_cache( &self, playlist_id: &str, entries: &[PlaylistEntry], ) -> Result<(), RepoError> { let playlist_id = playlist_id.to_string(); let user_id = self.user_id.clone(); let entries: Vec<(String, String, usize)> = entries .iter() .enumerate() .map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i)) .collect(); self.db_service .transaction(move |tx| { use crate::storage::db_service::{Query, QueryParam}; // Ensure playlist record exists tx.execute(Query::with_params( "INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)", vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)], ))?; // Clear existing entries and re-insert tx.execute(Query::with_params( "DELETE FROM playlist_items WHERE playlist_id = ?", vec![QueryParam::String(playlist_id.clone())], ))?; for (_, item_id, sort_order) in &entries { tx.execute(Query::with_params( "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)", vec![ QueryParam::String(playlist_id.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(*sort_order as i32), ], ))?; } Ok(()) }) .await .map_err(|e| RepoError::Database { message: format!("Failed to cache playlist items: {}", e), }) } } #[async_trait] impl MediaRepository for OfflineRepository { async fn get_libraries(&self) -> Result, RepoError> { // Only show libraries that have downloaded content // Check both direct children and nested children (e.g., albums inside library) let query = Query::with_params( "SELECT DISTINCT l.id, l.name, l.collection_type, l.image_tag FROM libraries l INNER JOIN items i ON i.library_id = l.id WHERE l.server_id = ? AND ( -- Direct playable items with downloads (i.item_type IN ('Audio', 'Movie', 'Episode') AND EXISTS (SELECT 1 FROM downloads d WHERE d.item_id = i.id AND d.status = 'completed')) OR -- Container items with downloaded children (i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') AND EXISTS ( SELECT 1 FROM items children INNER JOIN downloads d ON children.id = d.item_id WHERE children.parent_id = i.id AND d.status = 'completed' )) ) ORDER BY l.sort_order ASC, l.name ASC", vec![QueryParam::String(self.server_id.clone())], ); self.db_service.query_many(query, |row| { Ok(Library { id: row.get(0)?, name: row.get(1)?, collection_type: row.get::<_, Option>(2)?.unwrap_or_else(|| "unknown".to_string()), image_tag: row.get(3)?, }) }).await.map_err(|e| RepoError::Database { message: e }) } async fn get_items(&self, parent_id: &str, options: Option) -> Result { debug!("[OfflineRepo] get_items called for parent_id: {}", &parent_id[..8.min(parent_id.len())]); let opts = options.unwrap_or_default(); let limit = opts.limit.unwrap_or(10000); // Match frontend limit for full library loading let start_index = opts.start_index.unwrap_or(0); // Build type filter for optional filtering let type_filter = if let Some(include_item_types) = &opts.include_item_types { if !include_item_types.is_empty() { let types = include_item_types .iter() .map(|t| format!("'{}'", t)) .collect::>() .join(","); format!(" AND i.item_type IN ({})", types) } else { String::new() } } else { String::new() }; // Use CTE to find items that are either: // 1. Playable items (Audio, Movie, Episode) with completed downloads (offline mode) // 2. Container items (MusicAlbum, Series, Season) with at least one downloaded child (offline mode) // 3. Cached items with recent synced_at timestamp (online mode - for fast browsing) let sql = format!( "WITH available_items AS ( -- Playable items with completed downloads SELECT DISTINCT i.id FROM items i INNER JOIN downloads d ON i.id = d.item_id WHERE d.status = 'completed' AND i.item_type IN ('Audio', 'Movie', 'Episode') UNION -- Containers with downloaded children SELECT DISTINCT i.id FROM items i INNER JOIN items children ON children.parent_id = i.id INNER JOIN downloads d ON children.id = d.item_id WHERE d.status = 'completed' AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') UNION -- Cached items for fast browsing (when online) SELECT DISTINCT i.id FROM items i WHERE i.synced_at IS NOT NULL ) SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.parent_index_number FROM items i INNER JOIN available_items ai ON i.id = ai.id WHERE i.server_id = ? AND i.parent_id = ?{} ORDER BY i.sort_name ASC, i.name ASC LIMIT {} OFFSET {}", type_filter, limit, start_index ); let query = Query::with_params( sql, vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(parent_id.to_string()), ], ); let cached_items: Vec = self.db_service.query_many(query, row_to_cached_item) .await .map_err(|e| RepoError::Database { message: e })?; debug!("[OfflineRepo] Found {} cached items for parent {}", cached_items.len(), &parent_id[..8.min(parent_id.len())]); // Fetch user data for each item let mut items = Vec::new(); for cached in cached_items { let user_data = self.get_user_data(&cached.id).await; items.push(Self::cached_item_to_media_item(cached, user_data)); } let total_record_count = items.len(); debug!("[OfflineRepo] Returning {} items for parent {}", total_record_count, &parent_id[..8.min(parent_id.len())]); Ok(SearchResult { items, total_record_count, }) } async fn get_item(&self, item_id: &str) -> Result { // Check if item is available offline (either downloaded itself or has downloaded children) let query = Query::with_params( "WITH downloaded_items AS ( -- Playable items with completed downloads SELECT DISTINCT i.id FROM items i INNER JOIN downloads d ON i.id = d.item_id WHERE d.status = 'completed' AND i.item_type IN ('Audio', 'Movie', 'Episode') UNION -- Containers with downloaded children SELECT DISTINCT i.id FROM items i INNER JOIN items children ON children.parent_id = i.id INNER JOIN downloads d ON children.id = d.item_id WHERE d.status = 'completed' AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') ) SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.parent_index_number FROM items i INNER JOIN downloaded_items di ON i.id = di.id WHERE i.id = ?", vec![QueryParam::String(item_id.to_string())], ); let cached = self.db_service.query_optional(query, row_to_cached_item) .await .map_err(|e| RepoError::Database { message: e })? .ok_or_else(|| RepoError::NotFound { message: format!("Item {} not found in offline cache or not downloaded", item_id), })?; let user_data = self.get_user_data(item_id).await; Ok(Self::cached_item_to_media_item(cached, user_data)) } async fn get_latest_items(&self, parent_id: &str, limit: Option) -> Result, RepoError> { let limit_val = limit.unwrap_or(16); let query = Query::with_params( format!( "WITH downloaded_items AS ( -- Playable items with completed downloads SELECT DISTINCT i.id FROM items i INNER JOIN downloads d ON i.id = d.item_id WHERE d.status = 'completed' AND i.item_type IN ('Audio', 'Movie', 'Episode') UNION -- Containers with downloaded children SELECT DISTINCT i.id FROM items i INNER JOIN items children ON children.parent_id = i.id INNER JOIN downloads d ON children.id = d.item_id WHERE d.status = 'completed' AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') ) SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.parent_index_number FROM items i INNER JOIN downloaded_items di ON i.id = di.id WHERE i.server_id = ? AND i.library_id = ? ORDER BY i.synced_at DESC LIMIT {}", limit_val ), vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(parent_id.to_string()), ], ); let cached_items: Vec = self.db_service.query_many(query, row_to_cached_item) .await .map_err(|e| RepoError::Database { message: e })?; let mut items = Vec::new(); for cached in cached_items { let user_data = self.get_user_data(&cached.id).await; items.push(Self::cached_item_to_media_item(cached, user_data)); } Ok(items) } async fn get_resume_items(&self, parent_id: Option<&str>, limit: Option) -> Result, RepoError> { let limit_val = limit.unwrap_or(12); // Resume items are video-only (Movie, Episode) - audio is handled by get_recently_played_audio let (sql, params) = if let Some(pid) = parent_id { ( format!( "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.parent_index_number FROM items i JOIN user_data ud ON i.id = ud.item_id INNER JOIN downloads d ON i.id = d.item_id WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ? AND ud.playback_position_ticks > 0 AND ud.is_played = 0 AND d.status = 'completed' AND i.item_type IN ('Movie', 'Episode') ORDER BY ud.last_played_at DESC LIMIT {}", limit_val ), vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(self.user_id.clone()), QueryParam::String(pid.to_string()), ] ) } else { ( format!( "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.parent_index_number FROM items i JOIN user_data ud ON i.id = ud.item_id INNER JOIN downloads d ON i.id = d.item_id WHERE i.server_id = ? AND ud.user_id = ? AND ud.playback_position_ticks > 0 AND ud.is_played = 0 AND d.status = 'completed' AND i.item_type IN ('Movie', 'Episode') ORDER BY ud.last_played_at DESC LIMIT {}", limit_val ), vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(self.user_id.clone()), ] ) }; let query = Query::with_params(sql, params); let cached_items: Vec = self.db_service.query_many(query, row_to_cached_item) .await .map_err(|e| RepoError::Database { message: e })?; let mut items = Vec::new(); for cached in cached_items { let user_data = self.get_user_data(&cached.id).await; items.push(Self::cached_item_to_media_item(cached, user_data)); } Ok(items) } async fn get_next_up_episodes(&self, _series_id: Option<&str>, _limit: Option) -> Result, RepoError> { // Next up is complex - would need to track watched episodes and find the next unwatched // For now, return empty for offline mode Ok(Vec::new()) } async fn get_recently_played_audio(&self, limit: Option) -> Result, RepoError> { let limit_val = limit.unwrap_or(12); // Use CTE to intelligently group by playback context and filter by downloads // Shows containers (albums) when context_type='container', individual tracks when context_type='single' // Falls back to album grouping for legacy data (NULL context) // Only shows items that are downloaded or have downloaded children let query = Query::with_params( format!( "WITH downloaded_items AS ( -- Playable items with completed downloads (Audio tracks) SELECT DISTINCT i.id FROM items i INNER JOIN downloads d ON i.id = d.item_id WHERE d.status = 'completed' AND i.item_type = 'Audio' UNION -- Containers with downloaded children (Albums) SELECT DISTINCT i.id FROM items i INNER JOIN items children ON children.parent_id = i.id INNER JOIN downloads d ON children.id = d.item_id WHERE d.status = 'completed' AND i.item_type = 'MusicAlbum' ), ranked_plays AS ( SELECT CASE WHEN ud.playback_context_type = 'container' THEN ud.playback_context_id WHEN ud.playback_context_type = 'single' THEN ud.item_id ELSE COALESCE(i.album_id, ud.item_id) END AS display_id, MAX(ud.last_played_at) AS most_recent_play FROM user_data ud JOIN items i ON ud.item_id = i.id WHERE ud.user_id = ? AND i.server_id = ? AND i.item_type = 'Audio' AND ud.last_played_at IS NOT NULL GROUP BY display_id ORDER BY most_recent_play DESC LIMIT {} ) SELECT DISTINCT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.parent_index_number FROM ranked_plays rp JOIN items i ON rp.display_id = i.id INNER JOIN downloaded_items di ON i.id = di.id ORDER BY rp.most_recent_play DESC", limit_val ), vec![ QueryParam::String(self.user_id.clone()), QueryParam::String(self.server_id.clone()), ], ); let cached_items: Vec = self.db_service.query_many(query, row_to_cached_item) .await .map_err(|e| RepoError::Database { message: e })?; let mut items = Vec::new(); for cached in cached_items { let user_data = self.get_user_data(&cached.id).await; items.push(Self::cached_item_to_media_item(cached, user_data)); } Ok(items) } async fn get_rediscover_albums( &self, _parent_id: Option<&str>, _limit: Option, ) -> Result, RepoError> { // "Rediscover" is a discovery feature over the full server library. // Offline only holds downloaded items, so there is nothing meaningful // to surface here; the hybrid repo serves this from the server instead. Ok(Vec::new()) } async fn get_resume_movies(&self, limit: Option) -> Result, RepoError> { let limit_val = limit.unwrap_or(12); // Resume movies are playable items, so simple JOIN with downloads let query = Query::with_params( format!( "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.parent_index_number FROM items i JOIN user_data ud ON i.id = ud.item_id INNER JOIN downloads d ON i.id = d.item_id WHERE i.server_id = ? AND ud.user_id = ? AND i.item_type = 'Movie' AND ud.playback_position_ticks > 0 AND ud.is_played = 0 AND d.status = 'completed' ORDER BY ud.last_played_at DESC LIMIT {}", limit_val ), vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(self.user_id.clone()), ], ); let cached_items: Vec = self.db_service.query_many(query, row_to_cached_item) .await .map_err(|e| RepoError::Database { message: e })?; let mut items = Vec::new(); for cached in cached_items { let user_data = self.get_user_data(&cached.id).await; items.push(Self::cached_item_to_media_item(cached, user_data)); } Ok(items) } async fn get_genres(&self, parent_id: Option<&str>) -> Result, RepoError> { // Extract unique genres from cached items let mut genre_set = std::collections::HashSet::new(); let (sql, params) = if let Some(pid) = parent_id { ( "SELECT DISTINCT genres FROM items WHERE server_id = ? AND library_id = ? AND genres IS NOT NULL", vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(pid.to_string()), ] ) } else { ( "SELECT DISTINCT genres FROM items WHERE server_id = ? AND genres IS NOT NULL", vec![QueryParam::String(self.server_id.clone())] ) }; let query = Query::with_params(sql, params); let genres_rows: Vec = self.db_service.query_many(query, |row| row.get(0)) .await .map_err(|e| RepoError::Database { message: e })?; for genres_json in genres_rows { if let Ok(genres_vec) = serde_json::from_str::>(&genres_json) { for genre in genres_vec { genre_set.insert(genre); } } } let genres = genre_set .into_iter() .map(|name| Genre { id: name.clone(), name }) .collect(); Ok(genres) } async fn search(&self, query: &str, options: Option) -> Result { let opts = options.unwrap_or_default(); let limit = opts.limit.unwrap_or(20); // Escape FTS special characters and add prefix matching let fts_query = format!("{}*", query.replace('"', "\"\"")); // Build type filter let type_filter = if let Some(include_item_types) = &opts.include_item_types { if !include_item_types.is_empty() { let types = include_item_types .iter() .map(|t| format!("'{}'", t)) .collect::>() .join(","); format!(" AND i.item_type IN ({})", types) } else { String::new() } } else { String::new() }; // Use CTE to filter by downloads, then search within downloaded content let sql = format!( "WITH downloaded_items AS ( -- Playable items with completed downloads SELECT DISTINCT i.id FROM items i INNER JOIN downloads d ON i.id = d.item_id WHERE d.status = 'completed' AND i.item_type IN ('Audio', 'Movie', 'Episode') UNION -- Containers with downloaded children SELECT DISTINCT i.id FROM items i INNER JOIN items children ON children.parent_id = i.id INNER JOIN downloads d ON children.id = d.item_id WHERE d.status = 'completed' AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') ) SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.parent_index_number FROM items i JOIN items_fts fts ON fts.rowid = i.rowid INNER JOIN downloaded_items di ON i.id = di.id WHERE i.server_id = ? AND items_fts MATCH ?{} ORDER BY rank LIMIT {}", type_filter, limit ); let db_query = Query::with_params( sql, vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(fts_query), ], ); let cached_items: Vec = self.db_service.query_many(db_query, row_to_cached_item) .await .map_err(|e| RepoError::Database { message: e })?; let mut items = Vec::new(); for cached in cached_items { let user_data = self.get_user_data(&cached.id).await; items.push(Self::cached_item_to_media_item(cached, user_data)); } let total_record_count = items.len(); Ok(SearchResult { items, total_record_count, }) } async fn get_playback_info(&self, _item_id: &str) -> Result { // Playback info requires server communication for transcoding decisions Err(RepoError::Offline) } async fn get_audio_stream_url(&self, _item_id: &str) -> Result { // Cannot get stream URLs while offline - offline tracks use local paths Err(RepoError::Offline) } async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> { // Cannot report to server while offline Err(RepoError::Offline) } async fn report_playback_progress(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> { // Cannot report to server while offline Err(RepoError::Offline) } async fn report_playback_stopped(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> { // Cannot report to server while offline Err(RepoError::Offline) } fn get_image_url(&self, item_id: &str, image_type: ImageType, options: Option) -> String { // Return a placeholder path for offline image retrieval // The actual image should be in thumbnail cache let type_str = match image_type { ImageType::Primary => "Primary", ImageType::Backdrop => "Backdrop", ImageType::Logo => "Logo", ImageType::Thumb => "Thumb", ImageType::Banner => "Banner", }; if let Some(opts) = options { if let Some(tag) = opts.tag { return format!("offline://{}/{}/{}", item_id, type_str, tag); } } format!("offline://{}/{}", item_id, type_str) } fn get_subtitle_url( &self, _item_id: &str, _media_source_id: &str, _stream_index: i32, _format: &str, ) -> String { // Subtitles not available offline String::new() } fn get_video_download_url( &self, _item_id: &str, _quality: &str, _media_source_id: Option<&str>, ) -> String { // Cannot download while offline String::new() } async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> { // Cannot update server while offline Err(RepoError::Offline) } async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> { // Cannot update server while offline Err(RepoError::Offline) } async fn get_person(&self, person_id: &str) -> Result { let query = Query::with_params( "SELECT id, name, overview, primary_image_tag FROM people WHERE id = ?", vec![QueryParam::String(person_id.to_string())], ); let person_data = self.db_service.query_optional(query, |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?, )) }) .await .map_err(|e| RepoError::Database { message: e })? .ok_or_else(|| RepoError::NotFound { message: format!("Person {} not found in cache", person_id), })?; Ok(MediaItem { id: person_data.0, name: person_data.1, item_type: "Person".to_string(), server_id: self.server_id.clone(), parent_id: None, library_id: None, overview: person_data.2, genres: None, runtime_ticks: None, production_year: None, community_rating: None, official_rating: None, primary_image_tag: person_data.3, 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, 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, }) } async fn get_items_by_person(&self, person_id: &str, options: Option) -> Result { let opts = options.unwrap_or_default(); let limit = opts.limit.unwrap_or(10000); // Match frontend limit // Filter by downloads using CTE let query = Query::with_params( format!( "WITH downloaded_items AS ( -- Playable items with completed downloads SELECT DISTINCT i.id FROM items i INNER JOIN downloads d ON i.id = d.item_id WHERE d.status = 'completed' AND i.item_type IN ('Audio', 'Movie', 'Episode') UNION -- Containers with downloaded children SELECT DISTINCT i.id FROM items i INNER JOIN items children ON children.parent_id = i.id INNER JOIN downloads d ON children.id = d.item_id WHERE d.status = 'completed' AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') ) SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, i.parent_index_number FROM items i JOIN item_people ip ON i.id = ip.item_id INNER JOIN downloaded_items di ON i.id = di.id WHERE i.server_id = ? AND ip.person_id = ? ORDER BY i.production_year DESC, i.sort_name ASC LIMIT {}", limit ), vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(person_id.to_string()), ], ); let cached_items: Vec = self.db_service.query_many(query, row_to_cached_item) .await .map_err(|e| RepoError::Database { message: e })?; let mut items = Vec::new(); for cached in cached_items { let user_data = self.get_user_data(&cached.id).await; items.push(Self::cached_item_to_media_item(cached, user_data)); } let total_record_count = items.len(); Ok(SearchResult { items, total_record_count, }) } async fn get_similar_items(&self, _item_id: &str, _limit: Option) -> Result { // Similar items require server-side computation and are not available offline Err(RepoError::Offline) } // ===== Playlist Methods ===== async fn create_playlist( &self, name: &str, item_ids: &[String], ) -> Result { let playlist_id = uuid::Uuid::new_v4().to_string(); let user_id = self.user_id.clone(); let name = name.to_string(); let item_ids = item_ids.to_vec(); let pid = playlist_id.clone(); self.db_service .transaction(move |tx| { use crate::storage::db_service::{Query, QueryParam}; tx.execute(Query::with_params( "INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)", vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)], ))?; for (i, item_id) in item_ids.iter().enumerate() { tx.execute(Query::with_params( "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)", vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)], ))?; } Ok(()) }) .await .map_err(|e| RepoError::Database { message: format!("Failed to create playlist: {}", e), })?; Ok(PlaylistCreatedResult { id: playlist_id }) } async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> { let query = Query::with_params( "DELETE FROM playlists WHERE id = ?", vec![QueryParam::String(playlist_id.to_string())], ); self.db_service.execute(query).await.map_err(|e| RepoError::Database { message: format!("Failed to delete playlist: {}", e), })?; Ok(()) } async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> { let query = Query::with_params( "UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", vec![ QueryParam::String(name.to_string()), QueryParam::String(playlist_id.to_string()), ], ); self.db_service.execute(query).await.map_err(|e| RepoError::Database { message: format!("Failed to rename playlist: {}", e), })?; Ok(()) } async fn get_playlist_items( &self, playlist_id: &str, ) -> Result, RepoError> { let query = Query::with_params( "SELECT pi.id, \ i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \ i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \ i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \ i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \ i.parent_index_number \ FROM playlist_items pi \ JOIN items i ON pi.item_id = i.id \ WHERE pi.playlist_id = ? \ ORDER BY pi.sort_order ASC", vec![QueryParam::String(playlist_id.to_string())], ); let items = self.db_service .query_many(query, |row| { let entry_id: i64 = row.get(0)?; // Columns offset by 1 because first column is pi.id let cached = CachedItem { id: row.get(1)?, name: row.get(2)?, item_type: row.get(3)?, server_id: row.get(4)?, parent_id: row.get(5)?, library_id: row.get(6)?, overview: row.get(7)?, genres: row.get(8)?, runtime_ticks: row.get(9)?, production_year: row.get(10)?, community_rating: row.get(11)?, official_rating: row.get(12)?, primary_image_tag: row.get(13)?, backdrop_image_tags: None, parent_backdrop_image_tags: None, album_id: row.get(14)?, album_name: row.get(15)?, album_artist: row.get(16)?, artists: row.get(17)?, index_number: row.get(18)?, series_id: row.get(19)?, series_name: row.get(20)?, season_id: row.get(21)?, season_name: row.get(22)?, parent_index_number: row.get(23)?, }; Ok((entry_id.to_string(), cached)) }) .await .map_err(|e| RepoError::Database { message: format!("Failed to get playlist items: {}", e), })?; Ok(items .into_iter() .map(|(entry_id, cached)| PlaylistEntry { playlist_item_id: entry_id, item: Self::cached_item_to_media_item(cached, None), }) .collect()) } async fn add_to_playlist( &self, playlist_id: &str, item_ids: &[String], ) -> Result<(), RepoError> { // Get current max sort_order let max_query = Query::with_params( "SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?", vec![QueryParam::String(playlist_id.to_string())], ); let max_order: i32 = self.db_service .query_one(max_query, |row| row.get(0)) .await .unwrap_or(-1); let playlist_id = playlist_id.to_string(); let item_ids = item_ids.to_vec(); self.db_service .transaction(move |tx| { use crate::storage::db_service::{Query, QueryParam}; for (i, item_id) in item_ids.iter().enumerate() { tx.execute(Query::with_params( "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)", vec![ QueryParam::String(playlist_id.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(max_order + 1 + i as i32), ], ))?; } Ok(()) }) .await .map_err(|e| RepoError::Database { message: format!("Failed to add items to playlist: {}", e), })?; Ok(()) } async fn remove_from_playlist( &self, playlist_id: &str, entry_ids: &[String], ) -> Result<(), RepoError> { let playlist_id = playlist_id.to_string(); let entry_ids = entry_ids.to_vec(); self.db_service .transaction(move |tx| { use crate::storage::db_service::{Query, QueryParam}; for entry_id in &entry_ids { tx.execute(Query::with_params( "DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?", vec![QueryParam::String(playlist_id.clone()), QueryParam::String(entry_id.clone())], ))?; } Ok(()) }) .await .map_err(|e| RepoError::Database { message: format!("Failed to remove items from playlist: {}", e), })?; Ok(()) } async fn move_playlist_item( &self, playlist_id: &str, item_id: &str, new_index: u32, ) -> Result<(), RepoError> { let playlist_id = playlist_id.to_string(); let item_id = item_id.to_string(); self.db_service .transaction(move |tx| { use crate::storage::db_service::{Query, QueryParam}; // Get all items ordered by sort_order let items: Vec<(i64, String)> = tx.query_many( Query::with_params( "SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order", vec![QueryParam::String(playlist_id)], ), |row| Ok((row.get(0)?, row.get(1)?)), )?; // Find the item to move let old_idx = items.iter().position(|(_, iid)| iid == &item_id); if let Some(old_pos) = old_idx { let mut ids = items; let entry = ids.remove(old_pos); let insert_at = (new_index as usize).min(ids.len()); ids.insert(insert_at, entry); // Renumber all sort_orders for (i, (entry_id, _)) in ids.iter().enumerate() { tx.execute(Query::with_params( "UPDATE playlist_items SET sort_order = ? WHERE id = ?", vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)], ))?; } } Ok(()) }) .await .map_err(|e| RepoError::Database { message: format!("Failed to move playlist item: {}", e), })?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::storage::db_service::RusqliteService; use rusqlite::Connection; use std::sync::{Arc, Mutex}; /// Helper to create a test database with the necessary schema fn create_test_db() -> Arc { let conn = Connection::open_in_memory().unwrap(); // Enable foreign key constraints (they're disabled by default in SQLite) conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); // Create minimal schema for testing conn.execute_batch(r#" CREATE TABLE servers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, url TEXT NOT NULL UNIQUE ); CREATE TABLE items ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, library_id TEXT, parent_id TEXT REFERENCES items(id) ON DELETE CASCADE, name TEXT NOT NULL, item_type TEXT NOT NULL, overview TEXT, genres TEXT, runtime_ticks INTEGER, production_year INTEGER, community_rating REAL, official_rating TEXT, primary_image_tag TEXT, backdrop_image_tags TEXT, album_id TEXT, album_name TEXT, album_artist TEXT, artists TEXT, index_number INTEGER, series_id TEXT, series_name TEXT, season_id TEXT, season_name TEXT, parent_index_number INTEGER, synced_at TEXT, sort_name TEXT ); CREATE TABLE user_data ( user_id TEXT NOT NULL, item_id TEXT NOT NULL, playback_position_ticks INTEGER, is_played INTEGER, is_favorite INTEGER, play_count INTEGER, last_played_at TEXT, playback_context_type TEXT, playback_context_id TEXT, PRIMARY KEY (user_id, item_id) ); CREATE TABLE playlists ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, is_local INTEGER DEFAULT 0, jellyfin_id TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT ); CREATE TABLE playlist_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, sort_order INTEGER NOT NULL, added_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(playlist_id, item_id) ); CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order); "#).unwrap(); // Insert a test server conn.execute( "INSERT INTO servers (id, name, url) VALUES ('test-server', 'Test Server', 'http://test')", [], ).unwrap(); Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn)))) } /// Helper to create a test MediaItem fn create_test_item(id: &str, name: &str, parent_id: Option<&str>) -> MediaItem { MediaItem { id: id.to_string(), name: name.to_string(), item_type: "Audio".to_string(), server_id: "test-server".to_string(), parent_id: parent_id.map(|s| s.to_string()), library_id: None, overview: None, genres: None, runtime_ticks: None, production_year: None, community_rating: None, official_rating: 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, 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, } } #[tokio::test] async fn test_save_to_cache_with_missing_parent_fk() { let db_service = create_test_db(); // Verify FK constraints are actually enabled let fk_enabled: i32 = db_service .query_one(Query::new("PRAGMA foreign_keys"), |row| row.get(0)) .await .unwrap(); println!("Foreign keys enabled: {}", fk_enabled); assert_eq!(fk_enabled, 1, "Foreign keys should be enabled"); let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); // Create items where CHILDREN come BEFORE their PARENTS in the list // This simulates the real-world scenario where Jellyfin's API // returns items in an arbitrary order (e.g., alphabetically) let items = vec![ // Tracks from Album 1 (album-1 doesn't exist yet!) create_test_item("track-1", "Track 1", Some("album-1")), create_test_item("track-2", "Track 2", Some("album-1")), // Tracks from Album 2 (album-2 doesn't exist yet!) create_test_item("track-3", "Track 3", Some("album-2")), create_test_item("track-4", "Track 4", Some("album-2")), // Albums come later in the list create_test_item("album-1", "Album One", Some("library-123")), create_test_item("album-2", "Album Two", Some("library-123")), ]; println!("Attempting to save {} items...", items.len()); for (i, item) in items.iter().enumerate() { println!(" Item {}: {} (parent: {:?})", i, item.id, item.parent_id); } // This should fail with the current implementation because: // 1. It only creates a stub for "library-123" (the parent_id parameter) // 2. When it tries to insert track-1 with parent_id = "album-1", // album-1 doesn't exist yet, causing FK constraint failure let result = repo.save_to_cache("library-123", &items).await; // After the fix, this should succeed and preserve parent relationships match &result { Ok(count) => { println!("✓ Saved {} items", count); assert_eq!(*count, 6); // Debug: Check all items in the database let all_items: Vec<(String, Option)> = db_service .query_many( Query::new("SELECT id, parent_id FROM items ORDER BY id"), |row| Ok((row.get(0)?, row.get(1)?)), ) .await .unwrap(); println!("\nAll items in database:"); for (id, parent) in &all_items { println!(" {} -> parent: {:?}", id, parent); } // Verify the ACTUAL parent_ids in the database are preserved correctly let track1_parent: Option = db_service .query_optional( Query::with_params( "SELECT parent_id FROM items WHERE id = ?", vec![QueryParam::String("track-1".to_string())], ), |row| row.get(0), ) .await .unwrap() .flatten(); println!("\ntrack-1 parent_id in DB: {:?}", track1_parent); println!("track-1 expected parent_id: Some(\"album-1\")"); // Verify parent relationships are preserved assert_eq!( track1_parent, Some("album-1".to_string()), "track-1 should have parent_id='album-1'" ); // Verify album-1 has the correct parent too let album1_parent: Option = db_service .query_optional( Query::with_params( "SELECT parent_id FROM items WHERE id = ?", vec![QueryParam::String("album-1".to_string())], ), |row| row.get(0), ) .await .unwrap() .flatten(); assert_eq!( album1_parent, Some("library-123".to_string()), "album-1 should have parent_id='library-123'" ); } Err(e) => panic!("Unexpected error: {:?}", e), } } #[tokio::test] async fn test_save_to_cache_simple_case() { let db_service = create_test_db(); let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); // Simple case: all items have the same parent_id as the parameter let items = vec![ create_test_item("item-1", "Item 1", Some("parent-123")), create_test_item("item-2", "Item 2", Some("parent-123")), create_test_item("item-3", "Item 3", Some("parent-123")), ]; let result = repo.save_to_cache("parent-123", &items).await; assert!(result.is_ok(), "Simple case should work: {:?}", result); assert_eq!(result.unwrap(), 3); } // ===== Playlist Tests ===== /// Helper to seed items into the DB for playlist tests async fn seed_items(repo: &OfflineRepository, ids: &[&str]) { let items: Vec = ids.iter().map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1"))).collect(); repo.save_to_cache("library-1", &items).await.unwrap(); } #[tokio::test] async fn test_playlist_create_empty() { let db_service = create_test_db(); let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); let result = repo.create_playlist("My Playlist", &[]).await; assert!(result.is_ok()); let created = result.unwrap(); assert!(!created.id.is_empty(), "Should return a non-empty playlist ID"); // Verify playlist exists in DB let name: String = db_service .query_one( Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]), |row| row.get(0), ) .await .unwrap(); assert_eq!(name, "My Playlist"); } #[tokio::test] async fn test_playlist_create_with_items() { let db_service = create_test_db(); let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); seed_items(&repo, &["t1", "t2", "t3"]).await; let created = repo.create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap(); let items = repo.get_playlist_items(&created.id).await.unwrap(); assert_eq!(items.len(), 3); assert_eq!(items[0].item.id, "t1"); assert_eq!(items[1].item.id, "t2"); assert_eq!(items[2].item.id, "t3"); } #[tokio::test] async fn test_playlist_delete() { let db_service = create_test_db(); let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); seed_items(&repo, &["t1"]).await; let created = repo.create_playlist("To Delete", &["t1".into()]).await.unwrap(); // Delete it repo.delete_playlist(&created.id).await.unwrap(); // Verify playlist is gone let count: i32 = db_service .query_one( Query::with_params("SELECT COUNT(*) FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]), |row| row.get(0), ) .await .unwrap(); assert_eq!(count, 0); // Verify cascade deleted playlist_items let item_count: i32 = db_service .query_one( Query::with_params("SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?", vec![QueryParam::String(created.id)]), |row| row.get(0), ) .await .unwrap(); assert_eq!(item_count, 0); } #[tokio::test] async fn test_playlist_rename() { let db_service = create_test_db(); let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); let created = repo.create_playlist("Original Name", &[]).await.unwrap(); repo.rename_playlist(&created.id, "New Name").await.unwrap(); let name: String = db_service .query_one( Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id)]), |row| row.get(0), ) .await .unwrap(); assert_eq!(name, "New Name"); } #[tokio::test] async fn test_playlist_get_items_preserves_order() { let db_service = create_test_db(); let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); seed_items(&repo, &["a", "b", "c"]).await; let created = repo.create_playlist("Ordered", &["c".into(), "a".into(), "b".into()]).await.unwrap(); let items = repo.get_playlist_items(&created.id).await.unwrap(); assert_eq!(items.len(), 3); // Order should match insertion order: c, a, b assert_eq!(items[0].item.id, "c"); assert_eq!(items[1].item.id, "a"); assert_eq!(items[2].item.id, "b"); // Each entry should have a unique playlist_item_id assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id); assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id); } #[tokio::test] async fn test_playlist_get_items_empty_playlist() { let db_service = create_test_db(); let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); let created = repo.create_playlist("Empty", &[]).await.unwrap(); let items = repo.get_playlist_items(&created.id).await.unwrap(); assert!(items.is_empty()); } #[tokio::test] async fn test_playlist_add_items() { let db_service = create_test_db(); let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); seed_items(&repo, &["t1", "t2", "t3"]).await; let created = repo.create_playlist("Addable", &["t1".into()]).await.unwrap(); // Add two more tracks repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()]).await.unwrap(); let items = repo.get_playlist_items(&created.id).await.unwrap(); assert_eq!(items.len(), 3); assert_eq!(items[0].item.id, "t1"); assert_eq!(items[1].item.id, "t2"); assert_eq!(items[2].item.id, "t3"); } #[tokio::test] async fn test_playlist_add_duplicate_items_ignored() { let db_service = create_test_db(); let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); seed_items(&repo, &["t1"]).await; let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap(); // Try to add the same item again repo.add_to_playlist(&created.id, &["t1".into()]).await.unwrap(); let items = repo.get_playlist_items(&created.id).await.unwrap(); assert_eq!(items.len(), 1, "Duplicate should be ignored (UNIQUE constraint)"); } #[tokio::test] async fn test_playlist_remove_items() { let db_service = create_test_db(); let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); seed_items(&repo, &["t1", "t2", "t3"]).await; let created = repo.create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap(); let items = repo.get_playlist_items(&created.id).await.unwrap(); assert_eq!(items.len(), 3); // Remove the middle track by its entry ID let entry_id_to_remove = items[1].playlist_item_id.clone(); repo.remove_from_playlist(&created.id, &[entry_id_to_remove]).await.unwrap(); let items_after = repo.get_playlist_items(&created.id).await.unwrap(); assert_eq!(items_after.len(), 2); assert_eq!(items_after[0].item.id, "t1"); assert_eq!(items_after[1].item.id, "t3"); } #[tokio::test] async fn test_playlist_move_item_forward() { let db_service = create_test_db(); let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); seed_items(&repo, &["a", "b", "c", "d"]).await; let created = repo.create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap(); // Move 'a' (index 0) to index 2: expect b, c, a, d repo.move_playlist_item(&created.id, "a", 2).await.unwrap(); let items = repo.get_playlist_items(&created.id).await.unwrap(); let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect(); assert_eq!(ids, vec!["b", "c", "a", "d"]); } #[tokio::test] async fn test_playlist_move_item_backward() { let db_service = create_test_db(); let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); seed_items(&repo, &["a", "b", "c", "d"]).await; let created = repo.create_playlist("Reorder2", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap(); // Move 'd' (index 3) to index 0: expect d, a, b, c repo.move_playlist_item(&created.id, "d", 0).await.unwrap(); let items = repo.get_playlist_items(&created.id).await.unwrap(); let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect(); assert_eq!(ids, vec!["d", "a", "b", "c"]); } #[tokio::test] async fn test_playlist_move_item_to_end() { let db_service = create_test_db(); let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); seed_items(&repo, &["a", "b", "c"]).await; let created = repo.create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()]).await.unwrap(); // Move 'a' to index 99 (beyond end, should clamp): expect b, c, a repo.move_playlist_item(&created.id, "a", 99).await.unwrap(); let items = repo.get_playlist_items(&created.id).await.unwrap(); let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect(); assert_eq!(ids, vec!["b", "c", "a"]); } #[tokio::test] async fn test_playlist_move_nonexistent_item_is_noop() { let db_service = create_test_db(); let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string()); seed_items(&repo, &["a", "b"]).await; let created = repo.create_playlist("NoOp", &["a".into(), "b".into()]).await.unwrap(); // Move a nonexistent item - should not error, just no-op repo.move_playlist_item(&created.id, "nonexistent", 0).await.unwrap(); let items = repo.get_playlist_items(&created.id).await.unwrap(); let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect(); assert_eq!(ids, vec!["a", "b"]); } }