// Offline repository - queries SQLite database for cached data // // TRACES: UR-002, UR-052 | DR-012, DR-013, DR-078 use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use async_trait::async_trait; use log::debug; use super::{types::*, MediaRepository}; use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService}; /// Whether offline library queries may include catalog items that are merely /// *browsed/synced* but not downloaded (the greyed-out "browse the whole server" /// view). Defaults to `true` so online browsing (which reads this same cache as /// a fast path) still sees the full catalog. /// /// While offline, the frontend drives this from the "Show all server media" /// toggle: OFF means library pages show only downloaded/local media, ON reveals /// the full greyed-out catalog. See `set_include_catalog_browse` and the /// `showServerCatalog` UI flag. Fixes the bug where offline library pages showed /// every server item regardless of the toggle. /// /// TRACES: UR-052 | DR-078 static INCLUDE_CATALOG_BROWSE: AtomicBool = AtomicBool::new(true); /// Set whether offline `get_items` includes non-downloaded (synced-only) catalog /// items. Called from the frontend: `true` when online or when the offline /// "Show all server media" toggle is on; `false` when offline with the toggle /// off (show downloaded/local media only). pub fn set_include_catalog_browse(include: bool) { INCLUDE_CATALOG_BROWSE.store(include, Ordering::Relaxed); } /// Whether offline `get_items` currently includes the synced-but-not-downloaded /// catalog (the greyed-out browse view). Mirrors `set_include_catalog_browse`. /// /// Exposed so the hybrid repo can tell "cache is cold, ask the server" from /// "user asked for downloads only and there are none here": when this is false, /// an empty offline `get_items` is authoritative and must not fall through to /// the server. See hybrid.rs `get_items`. /// /// TRACES: UR-052 | DR-078, DR-080 pub fn include_catalog_browse() -> bool { INCLUDE_CATALOG_BROWSE.load(Ordering::Relaxed) } /// Build a safe FTS5 prefix query from raw user input. /// /// Every whitespace-separated token is emitted as a *quoted phrase*, so /// punctuation the user types (apostrophes in `Bob's Burgers`, hyphens in /// `Spider-Man`, the `/` in `AC/DC`) is treated as data rather than FTS5 /// operator syntax — unquoted, those characters make `MATCH` raise a syntax /// error and the whole search fails. The final token carries the `*` prefix /// operator so results appear while the user is still typing; earlier tokens are /// implicitly ANDed, which preserves the pre-existing matching semantics. /// /// Returns `None` when the input holds nothing searchable (empty, or pure /// punctuation), so callers skip the query rather than handing FTS5 a string it /// will reject — `search("")` is a real call site, used to list all playlists. /// /// TRACES: UR-065 | DR-108 | UT-111 fn build_fts_prefix_query(query: &str) -> Option { let tokens: Vec = query .split_whitespace() .filter(|token| token.chars().any(char::is_alphanumeric)) .map(|token| format!("\"{}\"", token.replace('"', "\"\""))) .collect(); let last = tokens.len().checked_sub(1)?; Some( tokens .iter() .enumerate() .map(|(i, token)| { if i == last { format!("{}*", token) } else { token.clone() } }) .collect::>() .join(" "), ) } 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(); let kind = crate::domain::kind_from_jellyfin(&item.item_type, item.is_folder); MediaItem { id: item.id.clone(), name: item.name, item_type: item.item_type, kind, is_folder: item.is_folder, 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, duration_ms: item.runtime_ticks.map(crate::domain::ticks_to_ms), production_year: item.production_year, premiere_date: item.premiere_date, community_rating: item.community_rating, official_rating: item.official_rating, primary_image_tag: item.primary_image_tag.clone(), image_id: 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| { let playback_position_ticks: Option = row.get(0).ok(); Ok(UserData { playback_position_ticks, playback_position_ms: playback_position_ticks.map(crate::domain::ticks_to_ms), 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, is_folder: bool, server_id: String, parent_id: Option, library_id: Option, overview: Option, genres: Option, runtime_ticks: Option, production_year: Option, premiere_date: 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)?, // Appended as the final columns in every SELECT that maps through this fn. is_folder: row.get::<_, Option>(23)?.unwrap_or(0) != 0, premiere_date: row.get(24)?, }) } impl OfflineRepository { /// Remove catalog entries the server no longer has (mark-and-sweep). /// /// `save_to_cache` stamps every row it writes with a fresh `synced_at`, so /// after a complete crawl anything still on the server carries a timestamp /// newer than `cutoff` (taken before the crawl began) and anything deleted /// server-side kept its older one. Sweeping by timestamp avoids binding the /// crawl's entire id set, which would blow past SQLite's variable limit on a /// large library. /// /// Three exclusions, each load-bearing: /// /// * **Only `item_types` the crawl actually requested.** The crawl asks for /// `CATALOG_ITEM_TYPES`; rows of any other type (artists, playlists, the /// `Folder` parent stubs `save_to_cache` inserts) are never refreshed by /// it, so sweeping by age alone would delete every one of them. /// * **Anything a completed download depends on** — the downloaded item /// itself, and any container with a downloaded child. The user has those /// bytes on disk; dropping the row would orphan the file. /// * Callers must only invoke this after a crawl in which *every* library /// succeeded. `sync_full_catalog` is best-effort per library, and /// `items.parent_id` is `ON DELETE CASCADE`, so sweeping after a partial /// crawl could cascade an entire series away because one request timed out. /// /// Returns the number of rows removed. /// /// TRACES: UR-065 | DR-110 | UT-113 pub async fn prune_stale_catalog( &self, cutoff: &str, item_types: &[String], ) -> Result { if item_types.is_empty() { return Ok(0); } let placeholders = vec!["?"; item_types.len()].join(","); let sql = format!( "DELETE FROM items WHERE server_id = ? AND synced_at IS NOT NULL AND synced_at < ? AND item_type IN ({}) AND id NOT IN ( -- Playable items with completed downloads SELECT i.id FROM items i INNER JOIN downloads d ON i.id = d.item_id WHERE d.status = 'completed' UNION -- Containers with downloaded children SELECT i.id FROM items i INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id) INNER JOIN downloads d ON children.id = d.item_id WHERE d.status = 'completed' )", placeholders ); let mut params = vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(cutoff.to_string()), ]; params.extend(item_types.iter().cloned().map(QueryParam::String)); let removed = self .db_service .execute(Query::with_params(sql, params)) .await .map_err(|e| RepoError::Database { message: e })?; Ok(removed) } /// Full-text search over cached people (cast and crew). /// /// People are stored in their own table rather than in `items`, so search /// has to look them up separately and adapt them to `MediaItem`. Availability /// gating deliberately does not apply: a person is metadata, never a /// download, so there is nothing to be offline about. /// /// TRACES: UR-065, UR-060 | DR-111 | UT-114 async fn search_people( &self, fts_query: &str, limit: usize, ) -> Result, RepoError> { let sql = format!( "SELECT p.id, p.name, p.overview, p.primary_image_tag, p.premiere_date FROM people p JOIN people_fts fts ON fts.rowid = p.rowid WHERE p.server_id = ? AND people_fts MATCH ? ORDER BY rank LIMIT {}", limit ); let rows = self .db_service .query_many( Query::with_params( sql, vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(fts_query.to_string()), ], ), |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?, row.get::<_, Option>(4)?, )) }, ) .await .map_err(|e| RepoError::Database { message: e })?; Ok(rows .into_iter() .map( |(id, name, overview, primary_image_tag, premiere_date)| MediaItem { id, name, item_type: "Person".to_string(), kind: crate::domain::MediaKind::Person, is_folder: false, server_id: self.server_id.clone(), overview, primary_image_tag, premiere_date, ..Default::default() }, ) .collect()) } /// 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())); // A real UPSERT, not INSERT OR REPLACE. REPLACE deletes the existing // row and inserts a new one, which (a) fires no AFTER DELETE trigger // unless `recursive_triggers` is on — it is not, so `items_ad` never // ran and the old `items_fts` row was orphaned — and (b) assigns a // *fresh rowid*, because `items.id` is a TEXT PRIMARY KEY, so // `items_ai` then appended a second index entry. The result was one // duplicate FTS index per catalog pass. ON CONFLICT keeps the rowid // that `items_fts.content_rowid` refers to and fires `items_au`, // which correctly replaces the entry. // // It also preserves columns absent from this statement (etag, // sort_name, tagline, …) instead of resetting them to defaults the // way REPLACE did. // // TRACES: UR-065 | DR-110 | UT-112 let query = Query::with_params( "INSERT INTO items ( id, server_id, library_id, parent_id, name, item_type, is_folder, overview, genres, series_id, series_name, season_id, season_name, index_number, parent_index_number, album_id, album_name, album_artist, artists, production_year, premiere_date, 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, ?26, ?27 ) ON CONFLICT(id) DO UPDATE SET server_id = excluded.server_id, -- This call site never supplies library_id (it is always -- bound NULL), so keep whatever another path recorded rather -- than clearing it the way REPLACE did. library_id = COALESCE(excluded.library_id, items.library_id), parent_id = excluded.parent_id, name = excluded.name, item_type = excluded.item_type, is_folder = excluded.is_folder, overview = excluded.overview, genres = excluded.genres, series_id = excluded.series_id, series_name = excluded.series_name, season_id = excluded.season_id, season_name = excluded.season_name, index_number = excluded.index_number, parent_index_number = excluded.parent_index_number, album_id = excluded.album_id, album_name = excluded.album_name, album_artist = excluded.album_artist, artists = excluded.artists, production_year = excluded.production_year, premiere_date = excluded.premiere_date, runtime_ticks = excluded.runtime_ticks, primary_image_tag = excluded.primary_image_tag, backdrop_image_tags = excluded.backdrop_image_tags, community_rating = excluded.community_rating, official_rating = excluded.official_rating, synced_at = excluded.synced_at", 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()), QueryParam::Int(if item.is_folder { 1 } else { 0 }), 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.premiere_date { Some(d) => QueryParam::String(d.clone()), 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 ); self.mirror_user_data(item, now).await?; count += 1; } Ok(count) } /// Mirror the server's per-user state for an item into the local /// `user_data` table, so favourites marked — and positions watched — on any /// other client are visible here, including offline, where the local table /// is the only source. /// /// The `WHERE user_data.pending_sync = 0` on the conflict clause is the /// conflict rule: a change made while the server was unreachable is still /// waiting to be pushed, and must not be clobbered by the stale value the /// server is still reporting. For a position that means it is never pulled /// *backwards* by a server that has not yet heard where we got to. /// /// Each field is mirrored only when the server actually reported it — /// `COALESCE(excluded.x, user_data.x)` keeps the stored value for anything /// absent, and a row with neither field is skipped outright rather than /// written as zeroes, which would fabricate an "unfavourited, unwatched" /// record from an endpoint that simply omits `UserData`. /// /// The position half is what makes cross-device resume work: the resume /// check reads this table alone, so before it was mirrored an item watched /// elsewhere resumed from whatever *this* device last saw, or not at all. /// /// TRACES: UR-025, UR-069 | DR-114, DR-155 | UT-102, UT-152 async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> { let user_data = item.user_data.as_ref(); let is_favorite = user_data.and_then(|ud| ud.is_favorite); let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks); // Nothing the server actually told us about — do not invent a row. if is_favorite.is_none() && position_ticks.is_none() { return Ok(()); } let query = Query::with_params( "INSERT INTO user_data (user_id, item_id, is_favorite, playback_position_ticks, synced_at, pending_sync) VALUES (?1, ?2, ?3, ?4, ?5, 0) ON CONFLICT(user_id, item_id) DO UPDATE SET is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite), playback_position_ticks = COALESCE( excluded.playback_position_ticks, user_data.playback_position_ticks), synced_at = excluded.synced_at WHERE user_data.pending_sync = 0", vec![ QueryParam::String(self.user_id.clone()), QueryParam::String(item.id.clone()), is_favorite .map(|f| QueryParam::Int(if f { 1 } else { 0 })) .unwrap_or(QueryParam::Null), position_ticks .map(QueryParam::Int64) .unwrap_or(QueryParam::Null), QueryParam::String(now.to_string()), ], ); // A missing item row (FK) is not fatal here — the mirror is best-effort // metadata, and failing the whole cache write over it would break // browsing. if let Err(e) = self.db_service.execute(query).await { debug!( "[OfflineRepo] user_data mirror skipped for {}: {}", item.id, e ); } Ok(()) } /// Cache the library (view) list from the server into the local database. /// Called by HybridRepository after a successful online fetch so the list is /// available offline. Without this, the `libraries` table stays empty and /// offline startup shows no libraries at all. pub async fn save_libraries_to_cache(&self, libraries: &[Library]) -> Result { if libraries.is_empty() { return Ok(0); } let mut count = 0; for (idx, lib) in libraries.iter().enumerate() { let query = Query::with_params( "INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)", vec![ QueryParam::String(lib.id.clone()), QueryParam::String(self.server_id.clone()), QueryParam::String(lib.name.clone()), QueryParam::String(lib.collection_type.clone()), lib.image_tag.clone().map(QueryParam::String).unwrap_or(QueryParam::Null), QueryParam::Int(idx as i32), ], ); self.db_service .execute(query) .await .map_err(|e| RepoError::Database { message: e })?; count += 1; } Ok(count) } /// Cache the full server genre catalog for a library, so offline (and the /// hybrid cache-first race) can return the complete list instead of only the /// genres derivable from locally-cached albums. Replaces the scope's rows /// wholesale so genres removed on the server don't linger. pub async fn save_genres_to_cache( &self, parent_id: Option<&str>, genres: &[Genre], ) -> Result { if genres.is_empty() { return Ok(0); } // library_id is part of the primary key; NULL keys don't de-dupe in // SQLite, so store the "no library" scope as an empty string. let library_id = parent_id.unwrap_or("").to_string(); let server_id = self.server_id.clone(); let genres: Vec<(String, String, Option)> = genres .iter() .map(|g| (g.id.clone(), g.name.clone(), g.album_count)) .collect(); let saved = genres.len(); self.db_service .transaction(move |tx| { use crate::storage::db_service::{Query, QueryParam}; // Clear the scope's existing genres, then re-insert the fresh set. tx.execute(Query::with_params( "DELETE FROM genres WHERE server_id = ? AND library_id = ?", vec![ QueryParam::String(server_id.clone()), QueryParam::String(library_id.clone()), ], ))?; for (id, name, album_count) in &genres { tx.execute(Query::with_params( "INSERT OR REPLACE INTO genres (id, server_id, library_id, name, album_count, synced_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)", vec![ QueryParam::String(id.clone()), QueryParam::String(server_id.clone()), QueryParam::String(library_id.clone()), QueryParam::String(name.clone()), album_count.map(|c| QueryParam::Int(c as i32)).unwrap_or(QueryParam::Null), ], ))?; } Ok(()) }) .await .map_err(|e| RepoError::Database { message: e })?; Ok(saved) } /// SQL fragment: the set of item ids that are "on the device" — playable /// items with a completed download, plus containers (album/series/season) /// that have at least one downloaded child. This is the `get_items` CTE with /// the synced-but-not-downloaded catalog branch deliberately excluded, so it /// is authoritative regardless of the process-wide catalog-browse flag. /// /// Whether cached item `i` belongs to library `l`, decided by media kind. /// /// The cache leaves `library_id`/`parent_id` NULL on every item /// ([[offline-libraries-never-cached]]), so there is no link to follow: a /// library's `collection_type` and an item's `item_type` are the only things /// that can associate them. This is Jellyfin taxonomy and therefore lives in /// Rust, never in the frontend. /// /// It is a named constant because it is needed in two places that must agree /// — which library *appears* in the Downloaded list, and which items appear /// *inside* it. They disagreed: the listing query used this mapping while the /// browse query only checked that the requested library existed, so opening /// any library showed every downloaded top-level item on the server. /// /// A library of some other (or unknown) type keeps everything, since there is /// no mapping to narrow it by and hiding its contents would be worse. /// /// TRACES: UR-055 | DR-082, DR-167 const LIBRARY_HOLDS_ITEM: &'static str = "( (l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio')) OR (l.collection_type = 'movies' AND i.item_type = 'Movie') OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode')) OR l.collection_type IS NULL OR l.collection_type NOT IN ('music', 'movies', 'tvshows') )"; /// TRACES: UR-055 | DR-082, DR-083 const DOWNLOADED_ITEMS_CTE: &'static str = " WITH downloaded_items AS ( 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 SELECT DISTINCT i.id FROM items i INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_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') )"; /// Downloaded-only browse: items under `parent_id` that are on the device. /// /// Unlike [`MediaRepository::get_items`], this never includes the /// synced-but-not-downloaded catalog and never consults the process-wide /// `INCLUDE_CATALOG_BROWSE` flag — it is the dedicated Downloads surface. /// An empty result is authoritative ("nothing downloaded here"), so the /// hybrid repo must call this directly rather than racing the server. /// /// TRACES: UR-055 | DR-082, DR-083 pub async fn get_downloaded_items( &self, parent_id: &str, options: Option, ) -> Result { let opts = options.unwrap_or_default(); let limit = opts.limit.unwrap_or(10000); let start_index = opts.start_index.unwrap_or(0); 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.replace('\'', "''"))) .collect::>() .join(","); format!(" AND i.item_type IN ({})", types) } else { String::new() } } else { String::new() }; // When the parent is a LIBRARY, cached items carry no link back to it // (library_id/parent_id are NULL), so the `libraries` EXISTS clause below // matches every downloaded item on the server — both containers // (MusicAlbum/Series/…) AND their leaves (Audio/Episode). Listing the // leaves alongside the containers is the "I see individual songs, not // albums" bug: a library landing page must show only *top-level* items. // So at the library level we exclude any leaf whose own container // (album/season/series/parent) is itself present in `downloaded_items` — // that container represents it in the grid. Items with no downloaded // container (e.g. a downloaded Movie, or a stray track whose album isn't // cached) still surface. This mirrors the online music library, which // routes to a dedicated albums view. See [[offline-libraries-never-cached]]. let sql = format!( "{cte} 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, i.is_folder, i.premiere_date FROM items i INNER JOIN downloaded_items di ON i.id = di.id WHERE i.server_id = ? AND ( i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ? OR ( EXISTS ( SELECT 1 FROM libraries l WHERE l.id = ? AND l.server_id = i.server_id AND {membership} ) -- Top-level only: hide leaves whose container is downloaded. AND NOT EXISTS ( SELECT 1 FROM downloaded_items parent WHERE parent.id = i.album_id OR parent.id = i.season_id OR parent.id = i.series_id OR parent.id = i.parent_id ) ) ){type_filter} ORDER BY i.sort_name ASC, i.name ASC LIMIT {limit} OFFSET {start_index}", cte = Self::DOWNLOADED_ITEMS_CTE, membership = Self::LIBRARY_HOLDS_ITEM, ); let query = Query::with_params( sql, vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(parent_id.to_string()), QueryParam::String(parent_id.to_string()), QueryParam::String(parent_id.to_string()), QueryParam::String(parent_id.to_string()), 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)); } let total_record_count = items.len(); Ok(SearchResult { items, total_record_count, }) } /// Libraries that contain at least one downloaded item. Libraries with /// nothing on the device are omitted, so the Downloaded surface only lists /// libraries the user actually has offline content in. /// /// TRACES: UR-055 | DR-082 pub async fn get_downloaded_libraries(&self) -> Result, RepoError> { // A downloaded item links back to its library only indirectly (the // cache leaves library_id NULL — see [[offline-libraries-never-cached]]). // We match a library by collection_type ↔ item_type instead: any // completed download of a given media kind qualifies that library. let query = Query::with_params( format!( "{cte} SELECT l.id, l.name, l.collection_type, l.image_tag FROM libraries l WHERE l.server_id = ? AND EXISTS ( SELECT 1 FROM items i INNER JOIN downloaded_items di ON i.id = di.id WHERE i.server_id = l.server_id AND {membership} ) ORDER BY l.sort_order ASC, l.name ASC", cte = Self::DOWNLOADED_ITEMS_CTE, membership = Self::LIBRARY_HOLDS_ITEM, ), vec![QueryParam::String(self.server_id.clone())], ); self.db_service .query_many(query, |row| { Ok(Library::new( row.get(0)?, row.get(1)?, row.get::<_, Option>(2)? .unwrap_or_else(|| "unknown".to_string()), row.get(3)?, )) }) .await .map_err(|e| RepoError::Database { message: e }) } /// On-disk bytes for downloaded content, for the disk-usage display. /// /// Returns one entry per *container or leaf* that appears in the Downloaded /// browse: a leaf's own `file_size`, a container's summed downloaded /// descendants — plus the device total and item (leaf) count. This is pure /// aggregation over `downloads.file_size`, not new tracking. /// /// TRACES: UR-056 | DR-085 pub async fn get_download_disk_usage(&self) -> Result { // Per-leaf sizes (completed playable downloads only). let leaf_query = Query::with_params( "SELECT d.item_id, COALESCE(d.file_size, 0) FROM downloads d INNER JOIN items i ON i.id = d.item_id WHERE d.status = 'completed' AND i.server_id = ? AND i.item_type IN ('Audio', 'Movie', 'Episode')", vec![QueryParam::String(self.server_id.clone())], ); let leaves: Vec<(String, i64)> = self .db_service .query_many(leaf_query, |row| Ok((row.get(0)?, row.get(1)?))) .await .map_err(|e| RepoError::Database { message: e })?; // Container subtotals: sum each container's downloaded descendants. let container_query = Query::with_params( "SELECT c.id, COALESCE(SUM(d.file_size), 0) FROM items c INNER JOIN items children ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id) INNER JOIN downloads d ON children.id = d.item_id WHERE d.status = 'completed' AND c.server_id = ? AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') GROUP BY c.id", vec![QueryParam::String(self.server_id.clone())], ); let containers: Vec<(String, i64)> = self .db_service .query_many(container_query, |row| Ok((row.get(0)?, row.get(1)?))) .await .map_err(|e| RepoError::Database { message: e })?; // Partiality per container: a container is "partial" when it has cached // descendants that are NOT downloaded. We compare downloaded-descendant // count against total-cached-descendant count (the offline cache holds // the synced full catalog, so this is meaningful). // // Perf: restrict `c` to containers that actually have a completed // download *first* (the CTE), so the OR-based self-join runs over that // handful of rows instead of the entire synced catalog. Without this the // join is an unindexable O(items²) scan and the Downloaded page hangs on // a large library ("Loading your downloads…" forever). let partial_query = Query::with_params( "WITH downloaded_containers AS ( SELECT DISTINCT c.id FROM items c INNER JOIN items children ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id) INNER JOIN downloads d ON children.id = d.item_id WHERE d.status = 'completed' AND c.server_id = ? AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') ) SELECT c.id, COUNT(children.id) AS total_children, SUM(CASE WHEN d.status = 'completed' THEN 1 ELSE 0 END) AS downloaded_children FROM items c INNER JOIN downloaded_containers dc ON dc.id = c.id INNER JOIN items children ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id) LEFT JOIN downloads d ON children.id = d.item_id AND d.status = 'completed' WHERE children.item_type IN ('Audio', 'Movie', 'Episode', 'Season') GROUP BY c.id", vec![QueryParam::String(self.server_id.clone())], ); let partial_rows: Vec<(String, i64, i64)> = self .db_service .query_many(partial_query, |row| { Ok(( row.get(0)?, row.get(1)?, row.get::<_, Option>(2)?.unwrap_or(0), )) }) .await .map_err(|e| RepoError::Database { message: e })?; let mut partial_containers = std::collections::HashMap::new(); for (id, total, downloaded) in partial_rows { // Only record containers that actually have a download (they appear // in the browse); mark partial when some cached child is missing. if downloaded > 0 && downloaded < total { partial_containers.insert(id, true); } } let item_count = leaves.len() as u32; let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum(); let mut sizes = std::collections::HashMap::new(); for (id, bytes) in leaves.into_iter().chain(containers) { // A container id can never collide with a leaf id, so a plain insert // is fine; use entry to be defensive against duplicate rows. *sizes.entry(id).or_insert(0) += bytes; } Ok(DownloadDiskUsage { sizes, partial_containers, device_total_bytes, item_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> { // Return every cached library for this server. We deliberately do NOT // gate on `items.library_id` here: that column is not populated in the // cache (the Jellyfin client doesn't parse it), so the old // `INNER JOIN items i ON i.library_id = l.id` matched nothing and left // offline startup with zero libraries. Navigating into a library still // filters to downloaded content via get_items, so listing all cached // libraries is correct — it's the "local first" list the UI browses. let query = Query::with_params( "SELECT l.id, l.name, l.collection_type, l.image_tag FROM libraries l WHERE l.server_id = ? 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::new( row.get(0)?, row.get(1)?, row.get::<_, Option>(2)? .unwrap_or_else(|| "unknown".to_string()), 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); // SortBy=Random is the only sort the landing pages rely on offline (the // hero "surprise" pool); everything else keeps the stable name order. let order_by = match opts.sort_by.as_deref() { Some("Random") => "RANDOM()", _ => "i.sort_name ASC, i.name ASC", }; // Bind the type filter rather than interpolating it: `include_item_types` // is settable straight from the frontend (GenericMediaListPage passes it), // so a quote in a type must be data, not syntax. Same shape as `search` // and `get_favorites`. // // TRACES: UR-065 | DR-212 | UT-206 let type_values: &[String] = opts .include_item_types .as_deref() .filter(|types| !types.is_empty()) .unwrap_or(&[]); let type_filter = if type_values.is_empty() { String::new() } else { let placeholders = vec!["?"; type_values.len()].join(","); format!(" AND i.item_type IN ({})", placeholders) }; // Favourites narrowing for a normal library listing. Bound rather than // interpolated, and appended after the parent-matching group so its // parameter is simply the last one in the vec below. // TRACES: UR-067 | DR-116 | UT-104 let favorites_filter = if opts.favorites_only == Some(true) { " AND EXISTS ( SELECT 1 FROM user_data ud WHERE ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1 )" } else { "" }; // 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 (fast online browsing, or the // offline "Show all server media" catalog view) — only when the catalog-browse // flag is set. When offline with the toggle off, this branch is omitted so the // page shows downloaded/local media only. See `set_include_catalog_browse`. let catalog_branch = if include_catalog_browse() { "UNION -- Cached items for fast browsing (online) or the offline catalog view SELECT DISTINCT i.id FROM items i WHERE i.synced_at IS NOT NULL" } else { "" }; 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 OR children.album_id = i.id OR children.season_id = i.id OR children.series_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') {catalog_branch} ) 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, i.is_folder, i.premiere_date FROM items i INNER JOIN available_items ai ON i.id = ai.id WHERE i.server_id = ? AND ( i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ? -- When the requested parent is a LIBRARY, there is no per-item -- link back to it (library_id/parent_id are NULL in the cache), -- so match every item on the server and let the type filter -- (e.g. MusicAlbum / Movie / Series) narrow it. This is what -- makes library landing pages show albums/movies/shows offline. OR EXISTS ( SELECT 1 FROM libraries l WHERE l.id = ? AND l.server_id = i.server_id ) ){}{} ORDER BY {} LIMIT {} OFFSET {}", type_filter, favorites_filter, order_by, limit, start_index ); // The requested id is compared against every hierarchy-linkage column // because `parent_id` is not populated for cached items — music tracks // link to their album via `album_id`, episodes to their season/series // via `season_id`/`series_id`, and a library parent matches via the // `libraries` EXISTS clause. See [[offline-libraries-never-cached]]. let mut params = vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(parent_id.to_string()), // i.parent_id = ? QueryParam::String(parent_id.to_string()), // i.album_id = ? QueryParam::String(parent_id.to_string()), // i.season_id = ? QueryParam::String(parent_id.to_string()), // i.series_id = ? QueryParam::String(parent_id.to_string()), // libraries.id = ? ]; // Positional order matters: the type placeholders sit in `{type_filter}`, // which the statement interpolates immediately after the parent-matching // group and before `{favorites_filter}`, so they bind here — after the // six ids above, before the favourites user id. params.extend(type_values.iter().cloned().map(QueryParam::String)); if !favorites_filter.is_empty() { params.push(QueryParam::String(self.user_id.clone())); // ud.user_id = ? } 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 })?; 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 OR children.album_id = i.id OR children.season_id = i.id OR children.series_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, i.is_folder, i.premiere_date 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 OR children.album_id = i.id OR children.season_id = i.id OR children.series_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, i.is_folder, i.premiere_date FROM items i INNER JOIN downloaded_items di ON i.id = di.id WHERE i.server_id = ? AND i.library_id = ? -- Collapse leaves into the container that was added: a new -- 14-track album should read as one album, not 14 songs. Only -- drops a leaf when its own container is present in the same -- result, so a standalone track or movie still appears. AND NOT EXISTS ( SELECT 1 FROM downloaded_items parent WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_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, i.is_folder, i.premiere_date 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, i.is_folder, i.premiere_date 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 OR children.album_id = i.id OR children.season_id = i.id OR children.series_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, i.is_folder, i.premiere_date 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, i.is_folder, i.premiere_date 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> { // Read the cached server genre catalog (populated by the hybrid repo via // save_genres_to_cache). This is the FULL genre list for the library, not // just the genres derivable from locally-cached albums — so offline keeps // the same variety the server has. library_id NULL is stored as ''. let library_id = parent_id.unwrap_or("").to_string(); let query = Query::with_params( "SELECT id, name, album_count FROM genres WHERE server_id = ? AND library_id = ?", vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(library_id), ], ); let genres: Vec = self .db_service .query_many(query, |row| { Ok(Genre { id: row.get(0)?, name: row.get(1)?, album_count: row.get::<_, Option>(2)?.map(|c| c as u32), }) }) .await .map_err(|e| RepoError::Database { message: e })?; Ok(genres) } async fn search( &self, query: &str, options: Option, ) -> Result { let opts = options.unwrap_or_default(); let limit = opts.limit.unwrap_or(20); // Nothing searchable (empty query, or pure punctuation): return an empty // result rather than letting FTS5 reject the string and fail the search. let Some(fts_query) = build_fts_prefix_query(query) else { return Ok(SearchResult { items: Vec::new(), total_record_count: 0, }); }; // Bind the type filter rather than interpolating it: `include_item_types` // is settable straight from the frontend (GenericMediaListPage passes it), // so a quote in a type must be data, not syntax. let type_values: &[String] = opts .include_item_types .as_deref() .filter(|types| !types.is_empty()) .unwrap_or(&[]); let type_filter = if type_values.is_empty() { String::new() } else { let placeholders = vec!["?"; type_values.len()].join(","); format!(" AND i.item_type IN ({})", placeholders) }; // Availability CTE — deliberately identical to the one `get_items` uses, // including the `include_catalog_browse()` gate, so search and browse can // never disagree about what is visible. Before DR-108 this leg was // downloads-only, which meant a user with no downloads got nothing from // the local index and every keystroke fell through to the server. let catalog_branch = if include_catalog_browse() { "UNION -- Synced catalog: fast online search, or the offline -- 'Show all server media' view. See set_include_catalog_browse. SELECT DISTINCT i.id FROM items i WHERE i.synced_at IS NOT NULL" } else { "" }; 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 OR children.album_id = i.id OR children.season_id = i.id OR children.series_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, i.is_folder, i.premiere_date FROM items i JOIN items_fts fts ON fts.rowid = i.rowid INNER JOIN available_items ai ON i.id = ai.id WHERE i.server_id = ? AND items_fts MATCH ?{} ORDER BY rank LIMIT {}", catalog_branch, type_filter, limit ); let mut params = vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(fts_query.clone()), ]; params.extend(type_values.iter().cloned().map(QueryParam::String)); let db_query = Query::with_params(sql, params); 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)); } // People are not rows in `items` — they live in their own table — so // they need a second lookup. Only when the scope admits them: an // explicit type filter (Music/Movies/TV) never includes People, whereas // `SearchScope::All` expands to *no* filter (DR-063), which is exactly // the case where the People group should be populated. if type_values.is_empty() { items.extend(self.search_people(&fts_query, limit).await?); } 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 get_audio_only_stream_url_for_video( &self, _item_id: &str, _media_source_id: Option<&str>, _start_time_seconds: Option, _audio_stream_index: Option, ) -> Result { // Audio-only transcode requires the server; offline downloads play locally. Err(RepoError::Offline) } async fn get_live_tv_channels(&self) -> Result, RepoError> { // Live TV is inherently online-only. Err(RepoError::Offline) } async fn get_channels(&self) -> Result { // Plugin channels are inherently online-only. Err(RepoError::Offline) } async fn open_live_stream(&self, _item_id: &str) -> Result { // Live streams cannot be opened offline. 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>, _source_audio_codec: 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) } /// Favourites held locally — the ones mirrored from the server by /// `save_to_cache` plus anything favourited on this device. /// /// Gated by the same `available_items` rules as browsing, so with "Show all /// server media" off this returns favourites that are actually on the /// device rather than the whole favourited catalog (DR-080). /// /// TRACES: UR-067 | DR-115 | UT-101 async fn get_favorites( &self, scope: SearchScope, options: Option, ) -> Result { let opts = options.unwrap_or_default(); let limit = opts.limit.unwrap_or(10000); let start_index = opts.start_index.unwrap_or(0); // Scope → item types is expanded in Rust (DR-063); `All` yields no // filter at all rather than a union. let type_filter = match scope.item_types() { Some(types) if !types.is_empty() => { let placeholders = vec!["?"; types.len()].join(","); format!(" AND i.item_type IN ({})", placeholders) } _ => String::new(), }; let catalog_branch = if include_catalog_browse() { "UNION SELECT DISTINCT i.id FROM items i WHERE i.synced_at IS NOT NULL" } else { "" }; let sql = format!( "WITH available_items AS ( 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 SELECT DISTINCT i.id FROM items i INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_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') {catalog_branch} ) 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, i.is_folder, i.premiere_date FROM items i INNER JOIN available_items ai ON i.id = ai.id INNER JOIN user_data ud ON ud.item_id = i.id WHERE i.server_id = ? AND ud.user_id = ? AND ud.is_favorite = 1{} ORDER BY i.sort_name ASC, i.name ASC LIMIT {} OFFSET {}", type_filter, limit, start_index ); let mut params = vec![ QueryParam::String(self.server_id.clone()), QueryParam::String(self.user_id.clone()), ]; if let Some(types) = scope.item_types() { params.extend(types.into_iter().map(QueryParam::String)); } let cached_items: Vec = self .db_service .query_many(Query::with_params(sql, params), 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(); debug!("[OfflineRepo] Returning {} favourites", total_record_count); Ok(SearchResult { items, total_record_count, }) } async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> { // Erasing history has to reach the server to be meaningful — clearing // it only locally would be silently undone by the next sync. Err(RepoError::Offline) } async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> { // Offline the local flag is written by `storage_mark_played` and the // server half is queued in `sync_queue`; this path has no server. 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(), kind: crate::domain::MediaKind::Person, is_folder: false, server_id: self.server_id.clone(), parent_id: None, library_id: None, overview: person_data.2, genres: None, runtime_ticks: None, duration_ms: None, production_year: None, premiere_date: None, community_rating: None, official_rating: None, primary_image_tag: person_data.3.clone(), image_id: 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 OR children.album_id = i.id OR children.season_id = i.id OR children.series_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, i.is_folder, i.premiere_date 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, i.is_folder, i.premiere_date \ 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)?, is_folder: row.get::<_, Option>(24)?.unwrap_or(0) != 0, premiere_date: row.get(25)?, }; 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 { // `CATALOG_BROWSE_LOCK` below serialises the tests that flip the // process-global `INCLUDE_CATALOG_BROWSE` flag, so its guard is deliberately // held across the `.await` of the query under test — that await *is* the // critical section. This is not the production deadlock hazard the lint // targets: the lock is test-only, uncontended outside these tests, and each // `#[tokio::test]` runs on its own single-threaded runtime, so a held guard // cannot block another task on the same worker. Restructuring around it // would reintroduce the flag race the lock exists to prevent. #![allow(clippy::await_holding_lock)] 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 /// `INCLUDE_CATALOG_BROWSE` is a process-global `AtomicBool`, so every test /// that flips it must hold this lock — cargo runs tests in parallel threads /// and would otherwise let them race, producing intermittent failures that /// look like query bugs. Poisoning is recovered rather than propagated: a /// panic in one such test should not cascade into unrelated ones. static CATALOG_BROWSE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); fn lock_catalog_browse() -> std::sync::MutexGuard<'static, ()> { use crate::utils::lock::MutexSafe; CATALOG_BROWSE_LOCK.lock_safe() } /// TRACES: UR-065 | DR-108 | UT-111 #[test] fn test_build_fts_prefix_query() { // Plain input: quoted phrase plus the prefix operator. assert_eq!(build_fts_prefix_query("Arr").as_deref(), Some("\"Arr\"*")); // Multiple tokens stay implicitly ANDed; only the last is a prefix, so // results still narrow while the user is typing. assert_eq!( build_fts_prefix_query("parks rec").as_deref(), Some("\"parks\" \"rec\"*") ); // Punctuation is data, not FTS5 syntax. Unquoted, each of these makes // MATCH raise a syntax error and the whole search fail. for query in ["Bob's Burgers", "Spider-Man", "AC/DC", "Wall-E", "9-1-1"] { let built = build_fts_prefix_query(query).expect("should build"); assert!( built.starts_with('"') && built.ends_with("*"), "{query:?} produced {built:?}" ); } // An embedded double quote is escaped by doubling, not left to close // the phrase early. assert_eq!( build_fts_prefix_query("say \"hi\"").as_deref(), Some("\"say\" \"\"\"hi\"\"\"*") ); // Nothing searchable => None, so callers skip the query instead of // handing FTS5 a string it rejects. `search("")` is a real call site. assert_eq!(build_fts_prefix_query(""), None); assert_eq!(build_fts_prefix_query(" "), None); assert_eq!(build_fts_prefix_query("-"), None); } /// An empty query must yield an empty result, not a database error — the /// "list all playlists" call site passes one. /// /// TRACES: UR-065 | DR-108 | UT-111 #[tokio::test] async fn test_search_empty_query_returns_empty_not_error() { let db_service = create_test_db(); let repo = OfflineRepository::new( db_service, "test-server".to_string(), "test-user".to_string(), ); let result = repo.search("", None).await; assert!( result.is_ok(), "empty query must not error: {:?}", result.err() ); assert!(result.unwrap().items.is_empty()); } 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, is_folder INTEGER DEFAULT 0, overview TEXT, genres TEXT, runtime_ticks INTEGER, production_year INTEGER, premiere_date TEXT, 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 ); -- Mirrors the real FTS5 index and its triggers (schema.rs migration -- 001) so search can be exercised in tests at all. CREATE VIRTUAL TABLE items_fts USING fts5( name, overview, album_name, album_artist, artists, series_name, content='items', content_rowid='rowid' ); CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name) VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name); END; CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name) VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name); END; CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name) VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name); INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name) VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name); END; 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, synced_at TEXT, pending_sync INTEGER DEFAULT 0, 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); CREATE TABLE downloads ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, status TEXT NOT NULL, file_size INTEGER ); CREATE TABLE libraries ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL, name TEXT NOT NULL, collection_type TEXT, image_tag TEXT, sort_order INTEGER DEFAULT 0, synced_at TEXT ); -- Mirrors migration 009 + the migration 022 FTS index. CREATE TABLE people ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL, name TEXT NOT NULL, overview TEXT, primary_image_tag TEXT, premiere_date TEXT, end_date TEXT, synced_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE VIRTUAL TABLE people_fts USING fts5( name, overview, content='people', content_rowid='rowid' ); CREATE TRIGGER people_ai AFTER INSERT ON people BEGIN INSERT INTO people_fts(rowid, name, overview) VALUES (new.rowid, new.name, new.overview); END; CREATE TRIGGER people_ad AFTER DELETE ON people BEGIN INSERT INTO people_fts(people_fts, rowid, name, overview) VALUES('delete', old.rowid, old.name, old.overview); END; CREATE TRIGGER people_au AFTER UPDATE ON people BEGIN INSERT INTO people_fts(people_fts, rowid, name, overview) VALUES('delete', old.rowid, old.name, old.overview); INSERT INTO people_fts(rowid, name, overview) VALUES (new.rowid, new.name, new.overview); END; CREATE TABLE genres ( id TEXT NOT NULL, server_id TEXT NOT NULL, library_id TEXT, name TEXT NOT NULL, album_count INTEGER, synced_at TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (server_id, library_id, name) ); "#, ) .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(), kind: crate::domain::MediaKind::Track, is_folder: false, 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, duration_ms: None, production_year: None, premiere_date: None, community_rating: None, official_rating: None, primary_image_tag: None, image_id: 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); } /// Regression: a MusicAlbum whose tracks link via `album_id` (and have a /// NULL `parent_id`, which is how the Jellyfin cache actually stores them) /// must be recognized as available offline when a track is downloaded. /// /// Before the fix, `get_item(album_id)` only matched children by /// `children.parent_id = i.id`, so a fully-downloaded album returned /// NotFound offline and playback fell through to the (unreachable) server. #[tokio::test] async fn test_get_item_album_available_via_album_id_link() { use crate::storage::db_service::DatabaseService; let db_service = create_test_db(); for sql in [ // Album container (no children by parent_id). "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \ VALUES ('album-1', 'test-server', 'Hadestown', 'MusicAlbum', NULL, NULL)", // Track linked to the album ONLY via album_id, parent_id NULL. "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \ VALUES ('track-1', 'test-server', 'Wait For Me', 'Audio', 'album-1', NULL)", "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')", ] { db_service.execute(Query::new(sql)).await.unwrap(); } let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); // The downloaded track itself resolves offline. assert!( repo.get_item("track-1").await.is_ok(), "downloaded track should be available offline" ); // The album must also resolve offline because it has a downloaded child // linked by album_id (not parent_id). let album = repo.get_item("album-1").await; assert!( album.is_ok(), "album with an album_id-linked downloaded track should be available offline, got {:?}", album.err() ); assert_eq!(album.unwrap().id, "album-1"); // Browsing into the album (get_items) must return its tracks even though // they link by album_id and have a NULL parent_id. This is the call // play_album_track makes to build the queue. let tracks = repo.get_items("album-1", None).await.unwrap(); assert_eq!( tracks.items.len(), 1, "get_items(album_id) should return the track" ); assert_eq!(tracks.items[0].id, "track-1"); } /// Regression: offline library pages must honor the "Show all server media" /// toggle. With `include_catalog_browse` off, `get_items` returns only /// downloaded media — not the whole synced catalog. With it on, the full /// (synced-but-not-downloaded) catalog is revealed. Fixes the bug where /// offline library pages showed every server item regardless of the toggle. /// /// This is also the backend half of the end-to-end offline-listing scenario /// IT-016: toggle off ⇒ downloaded media only; toggle on ⇒ the cached server /// catalog is additionally revealed (greyed-out in the UI, distinguished by /// the absence of a `downloads` row — see `MediaCard.isServerOnly`). /// /// TRACES: UR-052 | DR-078 | UT-067, IT-016 #[tokio::test] async fn test_get_items_toggle_gates_synced_catalog() { use crate::storage::db_service::DatabaseService; let _guard = lock_catalog_browse(); let db_service = create_test_db(); for sql in [ // Two movies in a library, both merely synced (browsed) — no download. "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \ VALUES ('movie-dl', 'test-server', 'Downloaded', 'Movie', 'lib-1', '2026-01-01')", "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \ VALUES ('movie-cat', 'test-server', 'CatalogOnly', 'Movie', 'lib-1', '2026-01-01')", // Only the first movie is actually downloaded. "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')", // A library row so the library-parent EXISTS clause matches. "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')", ] { db_service.execute(Query::new(sql)).await.unwrap(); } let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); let opts = Some(GetItemsOptions { include_item_types: Some(vec!["Movie".to_string()]), ..Default::default() }); // Toggle OFF: only the downloaded movie is returned. set_include_catalog_browse(false); let local_only = repo.get_items("lib-1", opts.clone()).await.unwrap(); let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect(); assert_eq!( ids, vec!["movie-dl"], "toggle off should show downloaded media only" ); // Toggle ON: both the downloaded and the catalog-only movie are returned. set_include_catalog_browse(true); let full_catalog = repo.get_items("lib-1", opts).await.unwrap(); let mut ids: Vec<&str> = full_catalog.items.iter().map(|i| i.id.as_str()).collect(); ids.sort(); assert_eq!( ids, vec!["movie-cat", "movie-dl"], "toggle on should reveal the full catalog" ); // Restore default for other tests sharing this process-global flag. set_include_catalog_browse(true); } /// Searching an actor's name must reach them from the local index, and only /// under a scope that admits People — `SearchScope::All`, which expands to /// no type filter (DR-063). Before DR-111, `people` had no FTS index at all, /// so the People group UR-060 requires could only be filled by the server. /// /// TRACES: UR-065, UR-060 | DR-111 | UT-114 #[tokio::test] async fn test_search_includes_cached_people() { use crate::storage::db_service::DatabaseService; let _guard = lock_catalog_browse(); let db_service = create_test_db(); for sql in [ "INSERT INTO people (id, server_id, name, overview) \ VALUES ('p1', 'test-server', 'Tilda Swinton', 'Actor')", // A movie that also matches, to prove people are added to — not // substituted for — item results. "INSERT INTO items (id, server_id, name, item_type, synced_at) \ VALUES ('m1', 'test-server', 'Tilda the Movie', 'Movie', '2026-01-01')", ] { db_service.execute(Query::new(sql)).await.unwrap(); } let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); set_include_catalog_browse(true); // Unscoped search (SearchScope::All => no type filter) reaches people. let all = repo.search("Tilda", None).await.unwrap(); let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect(); ids.sort(); assert_eq!(ids, vec!["m1", "p1"], "unscoped search must include people"); let person = all.items.iter().find(|i| i.id == "p1").unwrap(); assert_eq!(person.item_type, "Person"); assert_eq!(person.kind, crate::domain::MediaKind::Person); // A scoped search names item types, and People are never among them. let scoped = repo .search( "Tilda", Some(SearchOptions { include_item_types: Some(vec!["Movie".to_string()]), ..Default::default() }), ) .await .unwrap(); let ids: Vec<&str> = scoped.items.iter().map(|i| i.id.as_str()).collect(); assert_eq!(ids, vec!["m1"], "a scoped search must not leak people in"); set_include_catalog_browse(true); } /// The post-crawl sweep must remove what the server dropped, and nothing /// else. Before DR-110 there was no `DELETE FROM items` anywhere in the /// codebase, so the local catalog was append-only and media removed /// server-side stayed searchable forever. /// /// TRACES: UR-065 | DR-110 | UT-113 #[tokio::test] async fn test_prune_stale_catalog() { use crate::storage::db_service::DatabaseService; let db_service = create_test_db(); // "old" predates the crawl; "new" is what this crawl just wrote. let old = "2026-01-01T00:00:00+00:00"; let new = "2026-06-01T00:00:00+00:00"; let cutoff = "2026-03-01T00:00:00+00:00"; for sql in [ // A second server, so the sweep can be shown to stay scoped to one. "INSERT INTO servers (id, name, url) \ VALUES ('other-server', 'Other', 'http://other')" .to_string(), // Refreshed by the crawl => still on the server => keep. format!( "INSERT INTO items (id, server_id, name, item_type, synced_at) \ VALUES ('keep-fresh', 'test-server', 'Fresh', 'Movie', '{new}')" ), // Not refreshed => gone from the server => sweep. format!( "INSERT INTO items (id, server_id, name, item_type, synced_at) \ VALUES ('gone', 'test-server', 'Vanished', 'Movie', '{old}')" ), // Not refreshed, but downloaded => the file is on disk => keep. format!( "INSERT INTO items (id, server_id, name, item_type, synced_at) \ VALUES ('keep-dl', 'test-server', 'Downloaded', 'Movie', '{old}')" ), "INSERT INTO downloads (item_id, status) VALUES ('keep-dl', 'completed')".to_string(), // Not refreshed, but a container whose child is downloaded => keep both. format!( "INSERT INTO items (id, server_id, name, item_type, synced_at) \ VALUES ('keep-album', 'test-server', 'Album', 'MusicAlbum', '{old}')" ), format!( "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \ VALUES ('keep-track', 'test-server', 'Track', 'Audio', 'keep-album', '{old}')" ), "INSERT INTO downloads (item_id, status) VALUES ('keep-track', 'completed')" .to_string(), // A type the crawl never requests => it is never refreshed, so age // says nothing about it => keep. format!( "INSERT INTO items (id, server_id, name, item_type, synced_at) \ VALUES ('keep-artist', 'test-server', 'Artist', 'MusicArtist', '{old}')" ), // Another server's row must be untouched. format!( "INSERT INTO items (id, server_id, name, item_type, synced_at) \ VALUES ('keep-other', 'other-server', 'Elsewhere', 'Movie', '{old}')" ), ] { db_service.execute(Query::new(&sql)).await.unwrap(); } let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); let crawled_types = vec![ "Movie".to_string(), "MusicAlbum".to_string(), "Audio".to_string(), ]; let removed = repo .prune_stale_catalog(cutoff, &crawled_types) .await .unwrap(); assert_eq!(removed, 1, "only the vanished movie should be swept"); let mut surviving: Vec = db_service .query_many(Query::new("SELECT id FROM items"), |row| row.get(0)) .await .unwrap(); surviving.sort(); assert_eq!( surviving, vec![ "keep-album", "keep-artist", "keep-dl", "keep-fresh", "keep-other", "keep-track", ] ); // An empty type list is a no-op, not a "delete everything". assert_eq!(repo.prune_stale_catalog(cutoff, &[]).await.unwrap(), 0); } /// Re-caching an item must not append a second FTS entry for it. /// /// `INSERT OR REPLACE` fires no `AFTER DELETE` trigger unless /// `recursive_triggers` is on (it is not — storage/mod.rs sets only /// `foreign_keys` and `journal_mode`), so `items_ad` never ran and the old /// index row was orphaned; and because `items.id` is a `TEXT PRIMARY KEY`, /// the replacement row also took a *fresh rowid* and inserted a second /// entry. Every catalog pass therefore appended a duplicate index. Results /// stayed correct (the rowid join hides orphans) but `MATCH` degraded /// permanently — and once the DR-110 deletion sweep starts freeing rowids, /// a reused rowid would collide with a stale orphan and produce a genuine /// false positive. /// /// TRACES: UR-065 | DR-110 | UT-112 #[tokio::test] async fn test_repeated_cache_does_not_duplicate_fts_entries() { use crate::storage::db_service::DatabaseService; let db_service = create_test_db(); let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); let items = vec![create_test_item("track-1", "Wait For Me", None)]; // Three catalog passes over unchanged content, as happens on every app // start. for _ in 0..3 { repo.save_to_cache("parent-1", &items).await.unwrap(); } // (`save_to_cache` also inserts a stub row for the parent, so scope this // to the item itself.) let item_rows: i64 = db_service .query_one( Query::new("SELECT COUNT(*) FROM items WHERE id = 'track-1'"), |row| row.get(0), ) .await .unwrap(); assert_eq!(item_rows, 1, "three passes must leave one item row"); let fts_hits: i64 = db_service .query_one( Query::with_params( "SELECT COUNT(*) FROM items_fts WHERE items_fts MATCH ?", vec![QueryParam::String("\"Wait\"*".to_string())], ), |row| row.get(0), ) .await .unwrap(); assert_eq!( fts_hits, 1, "the FTS index must hold one entry per item, not one per sync pass" ); } /// Search must read the *synced catalog*, not only downloaded media — /// mirroring `get_items` and gated on the same `include_catalog_browse` /// flag. Before DR-108 the cache leg wrapped its FTS query in a /// `downloaded_items` CTE requiring `d.status = 'completed'`, so a user with /// no downloads got an empty instant result on every keystroke and every /// query fell through to a full `Recursive=true` server request. /// /// TRACES: UR-065 | DR-108 | UT-109 #[tokio::test] async fn test_search_toggle_gates_synced_catalog() { use crate::storage::db_service::DatabaseService; let _guard = lock_catalog_browse(); let db_service = create_test_db(); for sql in [ // Two movies, both merely synced (indexed by the catalog crawl). "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \ VALUES ('movie-dl', 'test-server', 'Arrival', 'Movie', 'lib-1', '2026-01-01')", "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \ VALUES ('movie-cat', 'test-server', 'Arrakis', 'Movie', 'lib-1', '2026-01-01')", // Only the first is actually downloaded. "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')", ] { db_service.execute(Query::new(sql)).await.unwrap(); } let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); // Toggle ON (the online case, and the offline "Show all server media" // case): the whole synced catalog is searchable. set_include_catalog_browse(true); let full = repo.search("Arr", None).await.unwrap(); let mut ids: Vec<&str> = full.items.iter().map(|i| i.id.as_str()).collect(); ids.sort(); assert_eq!( ids, vec!["movie-cat", "movie-dl"], "with catalog browse on, search must cover synced-but-not-downloaded items" ); // Toggle OFF (offline, downloads-only): unchanged from today. set_include_catalog_browse(false); let local_only = repo.search("Arr", None).await.unwrap(); let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect(); assert_eq!( ids, vec!["movie-dl"], "with catalog browse off, search stays downloads-only" ); // Restore default for other tests sharing this process-global flag. set_include_catalog_browse(true); } /// The scope/type filter must be *bound*, not interpolated into the SQL /// string. `SearchOptions.include_item_types` is settable from the frontend /// (GenericMediaListPage passes it directly), so a value containing a quote /// must not be able to alter the query. /// /// TRACES: UR-065 | DR-108 | UT-110 #[tokio::test] async fn test_search_type_filter_is_parameterised() { use crate::storage::db_service::DatabaseService; let _guard = lock_catalog_browse(); let db_service = create_test_db(); db_service .execute(Query::new( "INSERT INTO items (id, server_id, name, item_type, synced_at) \ VALUES ('m1', 'test-server', 'Arrival', 'Movie', '2026-01-01')", )) .await .unwrap(); let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); set_include_catalog_browse(true); // A quote-bearing type must be treated as data: no SQL error, no match. let opts = SearchOptions { include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]), ..Default::default() }; let result = repo.search("Arr", Some(opts)).await; assert!( result.is_ok(), "a quote in an item type must not break the query: {:?}", result.err() ); assert!( result.unwrap().items.is_empty(), "an injected type filter must not widen the result set" ); // The legitimate filter still works. let opts = SearchOptions { include_item_types: Some(vec!["Movie".to_string()]), ..Default::default() }; assert_eq!(repo.search("Arr", Some(opts)).await.unwrap().items.len(), 1); } /// Regression: TV episodes link to their season/series via `season_id` / /// `series_id` (NOT `parent_id`, which is NULL in the cache). A downloaded /// episode must make both its Season and Series available offline, and /// browsing either container must return the episode. #[tokio::test] async fn test_get_item_tv_available_via_season_series_link() { use crate::storage::db_service::DatabaseService; let db_service = create_test_db(); for sql in [ "INSERT INTO items (id, server_id, name, item_type, parent_id) \ VALUES ('series-1', 'test-server', 'Gilmore Girls', 'Series', NULL)", "INSERT INTO items (id, server_id, name, item_type, series_id, parent_id) \ VALUES ('season-1', 'test-server', 'Season 1', 'Season', 'series-1', NULL)", // Episode links to both season and series; parent_id NULL. "INSERT INTO items (id, server_id, name, item_type, season_id, series_id, parent_id) \ VALUES ('ep-1', 'test-server', 'Pilot', 'Episode', 'season-1', 'series-1', NULL)", "INSERT INTO downloads (item_id, status) VALUES ('ep-1', 'completed')", ] { db_service.execute(Query::new(sql)).await.unwrap(); } let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); assert!( repo.get_item("ep-1").await.is_ok(), "downloaded episode available offline" ); assert!( repo.get_item("season-1").await.is_ok(), "season with a season_id-linked downloaded episode should be available offline" ); assert!( repo.get_item("series-1").await.is_ok(), "series with a series_id-linked downloaded episode should be available offline" ); // Browsing the season returns the episode. let season_items = repo.get_items("season-1", None).await.unwrap(); assert!( season_items.items.iter().any(|i| i.id == "ep-1"), "get_items(season_id) should return the episode" ); // Browsing the series returns the episode (via series_id link). let series_items = repo.get_items("series-1", None).await.unwrap(); assert!( series_items.items.iter().any(|i| i.id == "ep-1"), "get_items(series_id) should surface the downloaded episode" ); } /// Regression: offline startup must list cached libraries. Previously /// `get_libraries` joined on `items.library_id` (always NULL in the cache), /// so it returned nothing offline and the app showed no libraries at all. /// The list must round-trip through `save_libraries_to_cache`. #[tokio::test] async fn test_libraries_cache_roundtrip_available_offline() { let db_service = create_test_db(); let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); // Empty cache → no libraries (this is the state that fell through to the // server and hung offline). assert!(repo.get_libraries().await.unwrap().is_empty()); // Simulate the online path persisting the server's library list. let server_libs = vec![ Library::new("music".into(), "Music".into(), "music".into(), None), Library::new( "movies".into(), "Movies".into(), "movies".into(), Some("tag".into()), ), ]; let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap(); assert_eq!(saved, 2); // Now offline get_libraries returns them without touching the server. let offline_libs = repo.get_libraries().await.unwrap(); let names: Vec<&str> = offline_libs.iter().map(|l| l.name.as_str()).collect(); assert_eq!( names, vec!["Music", "Movies"], "cached libraries available offline in sort order" ); // Re-saving is idempotent (INSERT OR REPLACE), not duplicating rows. repo.save_libraries_to_cache(&server_libs).await.unwrap(); assert_eq!(repo.get_libraries().await.unwrap().len(), 2); } /// Regression: the music/TV/movie landing pages lost genre variety because /// offline `get_genres` derived genres from cached albums only — so the /// hybrid cache-first race pinned the list to whatever sparse set the local /// albums yielded instead of the server's full catalog. The full genre list /// must round-trip through `save_genres_to_cache` and come back scoped by /// library. #[tokio::test] async fn test_genres_cache_roundtrip_scoped_by_library() { let db_service = create_test_db(); let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); // Empty cache → no genres. assert!(repo.get_genres(Some("music-lib")).await.unwrap().is_empty()); // Simulate the online path persisting the server's full genre catalog. let server_genres = vec![ Genre { id: "g1".into(), name: "Rock".into(), album_count: Some(42), }, Genre { id: "g2".into(), name: "Jazz".into(), album_count: Some(17), }, Genre { id: "g3".into(), name: "Ambient".into(), album_count: None, }, ]; let saved = repo .save_genres_to_cache(Some("music-lib"), &server_genres) .await .unwrap(); assert_eq!(saved, 3); // Offline get_genres returns the full set for that library, counts intact. let mut offline_genres = repo.get_genres(Some("music-lib")).await.unwrap(); offline_genres.sort_by(|a, b| a.name.cmp(&b.name)); let names: Vec<&str> = offline_genres.iter().map(|g| g.name.as_str()).collect(); assert_eq!(names, vec!["Ambient", "Jazz", "Rock"]); let rock = offline_genres.iter().find(|g| g.name == "Rock").unwrap(); assert_eq!(rock.album_count, Some(42)); // Genres are scoped: a different library sees nothing. assert!(repo.get_genres(Some("other-lib")).await.unwrap().is_empty()); // Re-saving replaces the scope's rows (server removed "Jazz"). let updated = vec![Genre { id: "g1".into(), name: "Rock".into(), album_count: Some(50), }]; repo.save_genres_to_cache(Some("music-lib"), &updated) .await .unwrap(); let after = repo.get_genres(Some("music-lib")).await.unwrap(); assert_eq!(after.len(), 1, "stale genres removed on refresh"); assert_eq!(after[0].album_count, Some(50), "counts updated on refresh"); } // ===== 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(); } /// Insert a fully-formed item row of a given type (bypasses save_to_cache's /// stub-parent machinery so containers/leaves can be linked precisely). async fn insert_item( db: &Arc, id: &str, item_type: &str, album_id: Option<&str>, series_id: Option<&str>, season_id: Option<&str>, ) { db.execute(Query::with_params( "INSERT INTO items (id, server_id, name, item_type, album_id, series_id, season_id, synced_at) VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, ?6, '2024-01-01')", vec![ QueryParam::String(id.to_string()), QueryParam::String(format!("Name {id}")), QueryParam::String(item_type.to_string()), album_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null), series_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null), season_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null), ], )) .await .unwrap(); } /// Like `insert_item`, but sets `library_id` — which `get_latest_items` /// filters on, so rows without it are invisible to that query. async fn insert_library_item( db: &Arc, id: &str, item_type: &str, library_id: &str, album_id: Option<&str>, ) { db.execute(Query::with_params( "INSERT INTO items (id, server_id, library_id, name, item_type, album_id, synced_at) VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, '2024-01-01')", vec![ QueryParam::String(id.to_string()), QueryParam::String(library_id.to_string()), QueryParam::String(format!("Name {id}")), QueryParam::String(item_type.to_string()), album_id .map(|s| QueryParam::String(s.to_string())) .unwrap_or(QueryParam::Null), ], )) .await .unwrap(); } async fn seed_completed_download(db: &Arc, item_id: &str, file_size: i64) { db.execute(Query::with_params( "INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)", vec![ QueryParam::String(item_id.to_string()), QueryParam::Int64(file_size), ], )) .await .unwrap(); } async fn seed_library(db: &Arc, id: &str, collection_type: &str) { db.execute(Query::with_params( "INSERT INTO libraries (id, server_id, name, collection_type, sort_order) VALUES (?1, 'test-server', ?2, ?3, 0)", vec![ QueryParam::String(id.to_string()), QueryParam::String(format!("Lib {id}")), QueryParam::String(collection_type.to_string()), ], )) .await .unwrap(); } fn make_repo(db: &Arc) -> OfflineRepository { OfflineRepository::new( db.clone(), "test-server".to_string(), "test-user".to_string(), ) } /// A newly-synced album appears once in "recently added", not once per track. /// /// The downloaded-items CTE deliberately matches both the leaves and their /// container, which is right for browsing but wrong here: it made a 3-track /// album occupy 4 slots in the row. Tracks whose album is itself in the /// result are now collapsed into it. #[tokio::test] async fn test_get_latest_items_collapses_tracks_into_their_album() { let db = create_test_db(); insert_library_item(&db, "album-1", "MusicAlbum", "lib-1", None).await; for track in ["track-1", "track-2", "track-3"] { insert_library_item(&db, track, "Audio", "lib-1", Some("album-1")).await; seed_completed_download(&db, track, 1000).await; } // A movie has no container, so it must still show up on its own. insert_library_item(&db, "movie-1", "Movie", "lib-1", None).await; seed_completed_download(&db, "movie-1", 2000).await; let repo = make_repo(&db); let latest = repo.get_latest_items("lib-1", Some(16)).await.unwrap(); let ids: Vec<&str> = latest.iter().map(|i| i.id.as_str()).collect(); assert!( !ids.iter().any(|id| id.starts_with("track-")), "individual tracks must collapse into their album, got: {ids:?}" ); assert!(ids.contains(&"album-1"), "the album itself is listed"); assert!(ids.contains(&"movie-1"), "containerless items still listed"); } /// UT: downloaded-only browse returns a downloaded leaf AND its container, /// filtered to the requested album parent. A non-downloaded sibling is omitted. /// /// TRACES: UR-055 | DR-082, DR-083 | UT-072 #[tokio::test] async fn test_get_downloaded_items_returns_leaf_and_container() { let db = create_test_db(); insert_item(&db, "album-1", "MusicAlbum", None, None, None).await; insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await; insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await; // track-1 downloaded; track-2 is NOT downloaded. seed_completed_download(&db, "track-1", 1000).await; let repo = make_repo(&db); // Browsing the album shows only the downloaded track. let in_album = repo.get_downloaded_items("album-1", None).await.unwrap(); let ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect(); assert_eq!(ids, vec!["track-1"], "only the downloaded track is listed"); } /// Regression: browsing a downloaded *library* (top level) lists containers, /// not their leaves — a music library shows the album, not the individual /// downloaded songs. The leaf is still reachable by drilling into the album. /// /// TRACES: UR-055 | DR-082, DR-083 | UT-076 #[tokio::test] async fn test_get_downloaded_items_library_lists_albums_not_tracks() { let db = create_test_db(); seed_library(&db, "music-lib", "music").await; insert_item(&db, "album-1", "MusicAlbum", None, None, None).await; // Tracks link to the album via album_id (parent_id NULL in the cache). insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await; insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await; seed_completed_download(&db, "track-1", 1000).await; seed_completed_download(&db, "track-2", 1000).await; let repo = make_repo(&db); // Library level: only the album shows, not the two tracks. let at_library = repo.get_downloaded_items("music-lib", None).await.unwrap(); let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect(); assert_eq!( ids, vec!["album-1"], "library browse lists the album container, not its tracks" ); // Drilling into the album still returns the downloaded tracks. let in_album = repo.get_downloaded_items("album-1", None).await.unwrap(); let mut track_ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect(); track_ids.sort(); assert_eq!(track_ids, vec!["track-1", "track-2"]); } /// Regression: each downloaded library shows **only its own media**. /// /// Cached items carry no link back to their library (`library_id`/`parent_id` /// are NULL — [[offline-libraries-never-cached]]), and the library branch of /// the query only asserted that the requested library *exists*, never that /// the item belongs to it. So opening any downloaded library listed every /// downloaded top-level item on the server: films in the music library, /// albums under TV. The library's `collection_type` decides which item types /// belong to it, the same mapping `get_downloaded_libraries` already uses. /// /// TRACES: UR-055 | DR-167 | UT-162 #[tokio::test] async fn test_get_downloaded_items_library_does_not_mix_media_types() { let db = create_test_db(); seed_library(&db, "music-lib", "music").await; seed_library(&db, "movie-lib", "movies").await; seed_library(&db, "tv-lib", "tvshows").await; insert_item(&db, "album-1", "MusicAlbum", None, None, None).await; insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await; insert_item(&db, "movie-1", "Movie", None, None, None).await; insert_item(&db, "series-1", "Series", None, None, None).await; insert_item(&db, "episode-1", "Episode", None, Some("series-1"), None).await; seed_completed_download(&db, "track-1", 1000).await; seed_completed_download(&db, "movie-1", 2000).await; seed_completed_download(&db, "episode-1", 3000).await; let repo = make_repo(&db); let music: Vec = repo .get_downloaded_items("music-lib", None) .await .unwrap() .items .iter() .map(|i| i.id.clone()) .collect(); assert_eq!( music, vec!["album-1"], "the music library must not list films or series; got {:?}", music ); let movies: Vec = repo .get_downloaded_items("movie-lib", None) .await .unwrap() .items .iter() .map(|i| i.id.clone()) .collect(); assert_eq!( movies, vec!["movie-1"], "the movie library must not list albums or series; got {:?}", movies ); let tv: Vec = repo .get_downloaded_items("tv-lib", None) .await .unwrap() .items .iter() .map(|i| i.id.clone()) .collect(); assert_eq!( tv, vec!["series-1"], "the TV library must not list albums or films; got {:?}", tv ); } /// Regression: a downloaded TV library lists the Series, not its Seasons or /// Episodes — the same "individual songs" bug seen for music, for TV. The /// season and episode are still reachable by drilling into the series. /// /// TRACES: UR-055 | DR-082, DR-083 | UT-077 #[tokio::test] async fn test_get_downloaded_items_library_lists_series_not_episodes() { let db = create_test_db(); seed_library(&db, "tv-lib", "tvshows").await; insert_item(&db, "series-1", "Series", None, None, None).await; // Season links to its series; episode links to both season and series. insert_item(&db, "season-1", "Season", None, Some("series-1"), None).await; insert_item( &db, "ep-1", "Episode", None, Some("series-1"), Some("season-1"), ) .await; seed_completed_download(&db, "ep-1", 4000).await; let repo = make_repo(&db); // Library level: only the series shows. let at_library = repo.get_downloaded_items("tv-lib", None).await.unwrap(); let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect(); assert_eq!( ids, vec!["series-1"], "TV library browse lists the series, not seasons/episodes" ); // Drilling into the series returns its season; into the season, the episode. let in_series = repo.get_downloaded_items("series-1", None).await.unwrap(); assert!( in_series.items.iter().any(|i| i.id == "season-1"), "series drill returns the season" ); let in_season = repo.get_downloaded_items("season-1", None).await.unwrap(); assert!( in_season.items.iter().any(|i| i.id == "ep-1"), "season drill returns the episode" ); } /// A downloaded leaf with no cached container (e.g. a Movie, or a track whose /// album isn't in the cache) still surfaces at the library level. /// /// TRACES: UR-055 | DR-082, DR-083 | UT-078 #[tokio::test] async fn test_get_downloaded_items_library_keeps_orphan_leaves() { let db = create_test_db(); seed_library(&db, "movie-lib", "movies").await; insert_item(&db, "movie-1", "Movie", None, None, None).await; seed_completed_download(&db, "movie-1", 5000).await; let repo = make_repo(&db); let at_library = repo.get_downloaded_items("movie-lib", None).await.unwrap(); let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect(); assert_eq!( ids, vec!["movie-1"], "a downloaded movie with no container shows" ); } /// UT: an empty downloaded-only browse is authoritative — no rows, no error, /// regardless of the catalog-browse flag (which the DR-080 fallthrough uses). /// /// TRACES: UR-055 | DR-082 | UT-073 #[tokio::test] async fn test_get_downloaded_items_empty_is_authoritative() { let db = create_test_db(); insert_item(&db, "album-1", "MusicAlbum", None, None, None).await; insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await; // Nothing downloaded, and the catalog-browse flag is ON (online default). set_include_catalog_browse(true); let repo = make_repo(&db); let result = repo.get_downloaded_items("album-1", None).await.unwrap(); assert!( result.items.is_empty(), "empty downloaded browse returns no items even with catalog-browse on" ); } /// UT: only libraries with downloaded content are listed; an empty one is omitted. /// /// TRACES: UR-055 | DR-082 | UT-074 #[tokio::test] async fn test_get_downloaded_libraries_omits_empty() { let db = create_test_db(); seed_library(&db, "music-lib", "music").await; seed_library(&db, "movie-lib", "movies").await; insert_item(&db, "track-1", "Audio", None, None, None).await; seed_completed_download(&db, "track-1", 500).await; let repo = make_repo(&db); let libs = repo.get_downloaded_libraries().await.unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l.id.as_str()).collect(); assert_eq!( ids, vec!["music-lib"], "movie library with no downloads omitted" ); } /// UT: disk usage reports a leaf's own size, a container's summed descendants, /// and reconciles the device total with the sum of leaves. /// /// TRACES: UR-056 | DR-085 | UT-075 #[tokio::test] async fn test_download_disk_usage_aggregates_containers() { let db = create_test_db(); insert_item(&db, "album-1", "MusicAlbum", None, None, None).await; insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await; insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await; seed_completed_download(&db, "track-1", 1000).await; seed_completed_download(&db, "track-2", 2000).await; let repo = make_repo(&db); let usage = repo.get_download_disk_usage().await.unwrap(); assert_eq!(usage.item_count, 2, "two leaf downloads"); assert_eq!( usage.device_total_bytes, 3000, "device total is the leaf sum" ); assert_eq!(usage.sizes.get("track-1"), Some(&1000)); assert_eq!( usage.sizes.get("album-1"), Some(&3000), "container = sum of children" ); // Both children downloaded ⇒ album is NOT partial. assert_eq!( usage.partial_containers.get("album-1"), None, "fully downloaded album is not partial" ); // Device total reconciles with the sum of the listed leaves. let leaf_sum: i64 = ["track-1", "track-2"] .iter() .map(|id| usage.sizes[*id]) .sum(); assert_eq!(leaf_sum, usage.device_total_bytes); } /// UT: a container with a downloaded child AND a non-downloaded cached child /// is flagged partial. TRACES: UR-055 | DR-083 | UT-051 #[tokio::test] async fn test_download_disk_usage_flags_partial_container() { let db = create_test_db(); insert_item(&db, "album-1", "MusicAlbum", None, None, None).await; insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await; insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await; // Only track-1 downloaded; track-2 is cached but not downloaded. seed_completed_download(&db, "track-1", 1000).await; let repo = make_repo(&db); let usage = repo.get_download_disk_usage().await.unwrap(); assert_eq!( usage.partial_containers.get("album-1"), Some(&true), "album with a missing child is partial" ); } #[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"]); } /// Seed two movies and one album, favouriting a subset, for the favourites /// query tests below. async fn seed_favorites(db_service: &Arc) { use crate::storage::db_service::DatabaseService; for sql in [ "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \ VALUES ('movie-fav', 'test-server', 'Favourite Movie', 'Movie', 'lib-1', '2026-01-01', 'Favourite Movie')", "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \ VALUES ('movie-plain', 'test-server', 'Ordinary Movie', 'Movie', 'lib-1', '2026-01-01', 'Ordinary Movie')", "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \ VALUES ('album-fav', 'test-server', 'Favourite Album', 'MusicAlbum', 'lib-2', '2026-01-01', 'Favourite Album')", "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')", "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-fav', 1)", "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-fav', 1)", // Explicitly not a favourite — must never show up. "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-plain', 0)", // Another user's favourite must not leak into this user's list. "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('other-user', 'movie-plain', 1)", ] { db_service.execute(Query::new(sql)).await.unwrap(); } } /// UT-101 — the offline favourites query returns only favourites, scoped, /// and only for the current user. /// /// TRACES: UR-067 | DR-115 | UT-101 #[tokio::test] async fn test_get_favorites_returns_only_scoped_favorites() { let _guard = lock_catalog_browse(); set_include_catalog_browse(true); let db_service = create_test_db(); seed_favorites(&db_service).await; let repo = OfflineRepository::new( db_service, "test-server".to_string(), "test-user".to_string(), ); let all = repo.get_favorites(SearchScope::All, None).await.unwrap(); let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect(); ids.sort(); assert_eq!( ids, vec!["album-fav", "movie-fav"], "All scope should return every favourite and nothing else" ); let movies = repo.get_favorites(SearchScope::Movies, None).await.unwrap(); let ids: Vec<&str> = movies.items.iter().map(|i| i.id.as_str()).collect(); assert_eq!(ids, vec!["movie-fav"]); let music = repo.get_favorites(SearchScope::Music, None).await.unwrap(); let ids: Vec<&str> = music.items.iter().map(|i| i.id.as_str()).collect(); assert_eq!(ids, vec!["album-fav"]); } /// With "Show all server media" off, favourites narrow to what is actually /// on the device — the same gate browsing obeys (DR-080). /// /// TRACES: UR-067 | DR-115 | UT-101 #[tokio::test] async fn test_get_favorites_respects_catalog_browse_gate() { use crate::storage::db_service::DatabaseService; let _guard = lock_catalog_browse(); let db_service = create_test_db(); seed_favorites(&db_service).await; // Only the album is downloaded... via a child track, the container case. db_service .execute(Query::new( "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \ VALUES ('track-1', 'test-server', 'Track', 'Audio', 'album-fav', '2026-01-01')", )) .await .unwrap(); db_service .execute(Query::new( "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')", )) .await .unwrap(); let repo = OfflineRepository::new( db_service, "test-server".to_string(), "test-user".to_string(), ); set_include_catalog_browse(false); let offline_only = repo.get_favorites(SearchScope::All, None).await.unwrap(); let ids: Vec<&str> = offline_only.items.iter().map(|i| i.id.as_str()).collect(); assert_eq!( ids, vec!["album-fav"], "with the gate off, only favourites on the device are listed" ); set_include_catalog_browse(true); let with_catalog = repo.get_favorites(SearchScope::All, None).await.unwrap(); assert_eq!(with_catalog.items.len(), 2); } /// UT-104 — the per-library favourites toggle narrows a normal listing. /// /// TRACES: UR-067 | DR-116 | UT-104 #[tokio::test] async fn test_get_items_favorites_only_filters_listing() { let _guard = lock_catalog_browse(); set_include_catalog_browse(true); let db_service = create_test_db(); seed_favorites(&db_service).await; let repo = OfflineRepository::new( db_service, "test-server".to_string(), "test-user".to_string(), ); let unfiltered = repo .get_items( "lib-1", Some(GetItemsOptions { include_item_types: Some(vec!["Movie".to_string()]), ..Default::default() }), ) .await .unwrap(); assert_eq!(unfiltered.items.len(), 2, "both movies without the filter"); let favourites = repo .get_items( "lib-1", Some(GetItemsOptions { include_item_types: Some(vec!["Movie".to_string()]), favorites_only: Some(true), ..Default::default() }), ) .await .unwrap(); let ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect(); assert_eq!(ids, vec!["movie-fav"]); } /// UT-206 — `include_item_types` reaches the listing query as bound /// parameters, so a type name can only ever be compared as data. /// /// Interpolated, the type below closed the `IN (` list and commented out the /// rest of the line, leaving `... AND i.item_type IN ('Movie') OR 1=1`, which /// is true for every row — the listing then returned the whole cache /// regardless of parent or type. Bound, it is just a type name that matches /// nothing. /// /// TRACES: UR-065 | DR-212 | UT-206 #[tokio::test] async fn test_get_items_type_filter_is_bound_not_interpolated() { let _guard = lock_catalog_browse(); set_include_catalog_browse(true); let db_service = create_test_db(); seed_favorites(&db_service).await; let repo = OfflineRepository::new( db_service, "test-server".to_string(), "test-user".to_string(), ); let injected = repo .get_items( "lib-1", Some(GetItemsOptions { include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]), ..Default::default() }), ) .await .expect("a hostile type name must be data, not a broken query"); assert!( injected.items.is_empty(), "no cached item has that type, so nothing may come back; got {:?}", injected .items .iter() .map(|i| i.id.as_str()) .collect::>() ); // A quote on its own is likewise just a character in a type name. let quoted = repo .get_items( "lib-1", Some(GetItemsOptions { include_item_types: Some(vec!["Mo'vie".to_string()]), ..Default::default() }), ) .await .expect("an embedded quote must not break the query"); assert!(quoted.items.is_empty()); } /// UT-206 — binding the type filter must not disturb the positions of the /// parameters around it: the parent ids bind before it and the favourites /// user id after it. A misordered vec would silently compare `user_id` /// against `item_type`, so this asserts the filters still compose. /// /// TRACES: UR-065, UR-067 | DR-212 | UT-206 #[tokio::test] async fn test_get_items_binds_multiple_types_in_parameter_order() { let _guard = lock_catalog_browse(); set_include_catalog_browse(true); let db_service = create_test_db(); seed_favorites(&db_service).await; let repo = OfflineRepository::new( db_service, "test-server".to_string(), "test-user".to_string(), ); let both = repo .get_items( "lib-1", Some(GetItemsOptions { include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]), ..Default::default() }), ) .await .unwrap(); let mut ids: Vec<&str> = both.items.iter().map(|i| i.id.as_str()).collect(); ids.sort(); assert_eq!(ids, vec!["album-fav", "movie-fav", "movie-plain"]); // Two type placeholders *and* the favourites parameter after them. let favourites = repo .get_items( "lib-1", Some(GetItemsOptions { include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]), favorites_only: Some(true), ..Default::default() }), ) .await .unwrap(); let mut ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect(); ids.sort(); assert_eq!(ids, vec!["album-fav", "movie-fav"]); } /// UT-102 — caching a server result mirrors its favourite state locally, /// but never over a row still waiting to be pushed. /// /// The second half is the one that matters: favourite something with the /// server unreachable, and the next successful browse would otherwise /// overwrite it with the server's stale `false` before the drain ever ran. /// /// TRACES: UR-069 | DR-114 | UT-102 #[tokio::test] async fn test_save_to_cache_mirrors_favorites_without_clobbering_pending() { use crate::storage::db_service::DatabaseService; let db_service = create_test_db(); let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); let favourite_flag = |id: &'static str| { let db = db_service.clone(); async move { db.query_optional( Query::with_params( "SELECT is_favorite, pending_sync FROM user_data \ WHERE user_id = ? AND item_id = ?", vec![ QueryParam::String("test-user".to_string()), QueryParam::String(id.to_string()), ], ), |row| Ok((row.get::<_, Option>(0)?, row.get::<_, Option>(1)?)), ) .await .unwrap() } }; // A server item the user favourited elsewhere. let mut favourited = create_test_item("fav-1", "Favourited Elsewhere", None); favourited.user_data = Some(UserData { is_favorite: Some(true), ..Default::default() }); // A server item with no user data at all — must not fabricate a row. let untouched = create_test_item("plain-1", "No User Data", None); repo.save_to_cache("parent-1", &[favourited.clone(), untouched]) .await .unwrap(); assert_eq!( favourite_flag("fav-1").await, Some((Some(1), Some(0))), "server favourite should be mirrored as synced" ); assert_eq!( favourite_flag("plain-1").await, None, "an item without UserData should not get an invented user_data row" ); // The user un-favourites it while offline: local write, pending_sync = 1. db_service .execute(Query::with_params( "UPDATE user_data SET is_favorite = 0, pending_sync = 1 \ WHERE user_id = ? AND item_id = ?", vec![ QueryParam::String("test-user".to_string()), QueryParam::String("fav-1".to_string()), ], )) .await .unwrap(); // The server still reports it as a favourite; caching must not win. repo.save_to_cache("parent-1", &[favourited]).await.unwrap(); assert_eq!( favourite_flag("fav-1").await, Some((Some(0), Some(1))), "an unsynced local toggle must survive a cache write" ); } /// UT-152 — the server's watch position is mirrored locally, so an item /// watched on another device resumes here. /// /// The resume check reads only the local `user_data` row, and the mirror /// previously carried `is_favorite` alone — so a position set on any other /// client never reached this device and cross-device resume silently did /// nothing. The `pending_sync` guard is the same conflict rule favourites /// use: a local position still waiting to be pushed must not be pulled /// backwards by the stale value the server is still reporting. /// /// TRACES: UR-025, UR-069 | DR-155 | UT-152 #[tokio::test] async fn test_save_to_cache_mirrors_playback_position_without_clobbering_pending() { use crate::storage::db_service::DatabaseService; let db_service = create_test_db(); let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); let position = |id: &'static str| { let db = db_service.clone(); async move { db.query_optional( Query::with_params( "SELECT playback_position_ticks, pending_sync FROM user_data \ WHERE user_id = ? AND item_id = ?", vec![ QueryParam::String("test-user".to_string()), QueryParam::String(id.to_string()), ], ), |row| Ok((row.get::<_, Option>(0)?, row.get::<_, Option>(1)?)), ) .await .unwrap() } }; // Watched 20 minutes into this episode on another device. let mut watched = create_test_item("ep-1", "Watched Elsewhere", None); watched.user_data = Some(UserData { playback_position_ticks: Some(12_000_000_000), ..Default::default() }); // No user data at all — must not fabricate a position of 0. let untouched = create_test_item("ep-2", "No User Data", None); repo.save_to_cache("parent-1", &[watched.clone(), untouched]) .await .unwrap(); assert_eq!( position("ep-1").await, Some((Some(12_000_000_000), Some(0))), "the server's position should be mirrored as synced" ); assert_eq!( position("ep-2").await, None, "an item without UserData should not get an invented position" ); // Watched further here while the server was unreachable: pending_sync = 1. db_service .execute(Query::with_params( "UPDATE user_data SET playback_position_ticks = ?, pending_sync = 1 \ WHERE user_id = ? AND item_id = ?", vec![ QueryParam::Int64(30_000_000_000), QueryParam::String("test-user".to_string()), QueryParam::String("ep-1".to_string()), ], )) .await .unwrap(); // The server still reports the older position; caching must not win. repo.save_to_cache("parent-1", &[watched]).await.unwrap(); assert_eq!( position("ep-1").await, Some((Some(30_000_000_000), Some(1))), "an unsynced local position must not be pulled backwards" ); } /// UT-152 — a server item carrying *only* a position (no favourite flag) /// still gets mirrored. /// /// The mirror used to return early whenever `is_favorite` was absent, which /// is exactly the shape of an ordinary watched episode: Jellyfin reports /// `PlaybackPositionTicks` with no favourite state. That early return is why /// the position never landed. /// /// TRACES: UR-025 | DR-155 | UT-152 #[tokio::test] async fn test_position_is_mirrored_even_when_no_favourite_flag_is_present() { use crate::storage::db_service::DatabaseService; let db_service = create_test_db(); let repo = OfflineRepository::new( db_service.clone(), "test-server".to_string(), "test-user".to_string(), ); let mut watched = create_test_item("ep-3", "Position Only", None); watched.user_data = Some(UserData { is_favorite: None, playback_position_ticks: Some(9_000_000_000), ..Default::default() }); repo.save_to_cache("parent-1", &[watched]).await.unwrap(); let stored = db_service .query_optional( Query::with_params( "SELECT playback_position_ticks FROM user_data \ WHERE user_id = ? AND item_id = ?", vec![ QueryParam::String("test-user".to_string()), QueryParam::String("ep-3".to_string()), ], ), |row| row.get::<_, Option>(0), ) .await .unwrap(); assert_eq!( stored, Some(Some(9_000_000_000)), "a position with no favourite flag must still be mirrored" ); } }