diff --git a/docs/architecture/06-downloads-and-offline.md b/docs/architecture/06-downloads-and-offline.md index 311d86f50..d3f8b2bbb 100644 --- a/docs/architecture/06-downloads-and-offline.md +++ b/docs/architecture/06-downloads-and-offline.md @@ -186,6 +186,62 @@ Per-item disk usage comes from `repository_get_download_disk_usage` Downloaded browse cards, detail pages, the device total and the remove confirmation (DR-085). +## What a Video Download Fetches + +**TRACES**: UR-071, UR-004 | DR-171, DR-293 + +An `original`-quality download is the server's untouched file (`Static=true`) +unless its audio cannot be decoded by **the renderer that will play it** — +`renderer_can_decode_audio`, DR-234's per-platform answer. Only then is the +server asked to re-encode the audio on the way down (`allowVideoStreamCopy` +keeps the picture byte-for-byte). + +The distinction matters because a transcode is generated as it is sent: no +`Content-Length`, `Range` ignored. It measured ~1 MB/s and restarted from byte +zero on every network blip, against a direct copy that moved a 910 MB episode in +94 s with no retries. On Android the renderer is ExoPlayer with the FFmpeg +extension ([05-platform-backends.md](05-platform-backends.md)), which decodes +AC-3/E-AC-3/DTS/TrueHD, so Android downloads are always the direct copy. On +Linux the webview still renders video and the transcode still applies. + +The policy used to judge against the *webview's* codec list on every platform +(DR-171), because a download outlives the native-video setting that was active +when it arrived. That reasoning is why Android's webview video path was removed +rather than merely defaulted off: a file downloaded as the original must never +meet a renderer that cannot decode it. + +## Offline Means No Network + +**TRACES**: UR-002, UR-071 | DR-294 + +Three defects made "offline" depend on the network; the invariants that replace +them: + +- **A download plays without the server.** Playing a downloaded item asked the + server for its `PlaybackInfo` only to read the media-source id; offline that + retried for seven seconds, failed, and the file was never opened. + `OfflineRepository::local_playback_info` answers for any completed download of + the current user — local path, direct play, item id as media source (a download + names no source, so the server served its default, which carries the item's id) + — and `HybridRepository::get_playback_info` consults it **first**. +- **A slow cache read is waited for, never discarded.** The cache is one SQLite + connection behind one mutex, so any write in progress (the catalog sync that + starts at every launch, a download finishing) pushes a read past the 100 ms fast + path. `get_items`, the library list, genres and playlist items used to discard + such a read, wait for the server, and — offline — return its error over data on + disk; "More info" on a downloaded show failed that way. They now start the read + with `cache_try` (which keeps it running) and `settle` on it when the server + fails. Cache-only reads (search, favourites) have no server to fall back from, + so they simply await the cache. +- **Server-only sections degrade, they do not fail a page.** Next Up went only to + the server, and the TV landing page loads it in one `Promise.all`, so offline it + blanked the whole page. It now falls back to the cache when the server cannot + answer. + +What still needs the server, deliberately: streaming anything not downloaded, +live TV and channels, reporting playback, and edits (favourites, playlists, +played state). + ## Download Commands **Location**: `src-tauri/src/commands/download/` — `mod.rs` (the commands below), `pinning.rs`, `smart_cache.rs` diff --git a/src-tauri/src/repository/hybrid.rs b/src-tauri/src/repository/hybrid.rs index a5eb76e97..dc671a1ee 100644 --- a/src-tauri/src/repository/hybrid.rs +++ b/src-tauri/src/repository/hybrid.rs @@ -235,7 +235,10 @@ impl HybridRepository { ) -> Result { let offline = Arc::clone(&self.offline); let query = query.to_string(); - self.cache_with_timeout(async move { offline.search(&query, options).await }) + // Cache-only: there is no server to fall back to, so a busy database + // delays the answer rather than failing it (DR-294). + offline + .search(&query, options) .await .map(ExcludeHidden::without_excluded) } @@ -250,7 +253,9 @@ impl HybridRepository { options: Option, ) -> Result { let offline = Arc::clone(&self.offline); - self.cache_with_timeout(async move { offline.get_favorites(scope, options).await }) + // Cache-only, as `search_cache_only` above (DR-294). + offline + .get_favorites(scope, options) .await .map(ExcludeHidden::without_excluded) } @@ -394,7 +399,7 @@ impl HybridRepository { /// Cache-first query: try cache, fall back to server on miss. /// - /// 1. Check cache (100ms timeout applied by caller via cache_with_timeout) + /// 1. Check cache (100ms fast path, via `cache_leg`; a slow read keeps running) /// 2. If cache has meaningful content → return immediately (fast path) /// 3. If cache is empty/stale → query server (fresh data) /// 4. If server fails → return cache even if empty (offline fallback) @@ -523,18 +528,64 @@ impl HybridRepository { } } - /// Await a cache query that is still running, however long it takes. - async fn cache_with_timeout( - &self, - future: impl std::future::Future> + Send, - ) -> Result { - timeout(Self::CACHE_FAST_PATH, future) - .await - .unwrap_or_else(|_| { - Err(RepoError::Database { - message: "Cache query timeout".to_string(), - }) + /// Start a cache read with [`Self::CACHE_FAST_PATH`] to answer: its result + /// if it made it, and otherwise the read itself, still running. + /// + /// The cache-then-server queries used to *discard* a read that missed the + /// deadline. The database is one SQLite connection behind one mutex, so any + /// write in progress — the catalog sync that starts at every launch, a + /// download finishing — pushes a read past 100 ms routinely; offline the + /// server then failed too, and the page reported a network error over data + /// sitting on disk. Keeping the read lets [`Self::settle`] wait for it. + /// + /// TRACES: UR-002 | DR-013, DR-294 + async fn cache_try( + future: impl std::future::Future> + Send + 'static, + ) -> ( + Result, + Option>>, + ) + where + T: Send + 'static, + { + let (fast, slow) = Self::cache_leg(future).await.split(); + let fast = fast.unwrap_or_else(|| { + Err(RepoError::Database { + message: "Cache query still running".to_string(), }) + }); + (fast, slow) + } + + /// The server could not answer: the cache's answer if it has one — + /// waiting for a read still in flight — else the server's error. + /// + /// TRACES: UR-002 | DR-294 | UT-263 + async fn settle( + fast: Result, + slow: Option>>, + server_err: RepoError, + ) -> Result { + if let Some(handle) = slow { + debug!("[HybridRepo] Server failed; waiting for the slow cache read"); + return match handle.await { + Ok(Ok(data)) => Ok(data), + _ => Err(server_err), + }; + } + fast.or(Err(server_err)) + } + + /// [`Self::settle`] for `get_items`, whose slow read has not yet had + /// exclusions applied. + async fn cache_or( + fast: Result, + slow: Option>>, + server_err: RepoError, + ) -> Result { + Self::settle(fast, slow, server_err) + .await + .map(ExcludeHidden::without_excluded) } } @@ -544,7 +595,9 @@ impl MediaRepository for HybridRepository { // Cache-first (100ms). On a cache hit, refresh the cache from the server // in the background. On a miss, fetch from the server and persist so the // list is available on the next (possibly offline) startup. - let cache_result = self.cache_with_timeout(self.offline.get_libraries()).await; + let offline_read = Arc::clone(&self.offline); + let (cache_result, slow_cache) = + Self::cache_try(async move { offline_read.get_libraries().await }).await; if let Ok(libs) = &cache_result { if libs.has_content() { @@ -581,7 +634,8 @@ impl MediaRepository for HybridRepository { } Ok(server_libs) } - Err(e) => cache_result.or(Err(e)), + // TRACES: UR-002 | DR-294 | UT-263 + Err(e) => Self::settle(cache_result, slow_cache, e).await, } } @@ -610,10 +664,16 @@ impl MediaRepository for HybridRepository { // `parallel_race` — it interleaves the downloads-only gate and a // background cache write — so it applies the filter itself. // TRACES: UR-076 | DR-209 - let cache_result = self - .cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await }) - .await - .map(ExcludeHidden::without_excluded); + // + // A read that misses the fast path is kept running, not discarded: if + // the server then fails, the cache is the only thing that can answer, + // and it is waited for (see the end of this function). Discarding it + // is what made a downloaded show fail offline ("Failed to load item") + // whenever a write held the database past 100 ms — the catalog sync + // that starts at every launch does, routinely. TRACES: UR-002 | DR-294 + let (cache_result, slow_cache) = + Self::cache_try(async move { offline.get_items(&parent_id, opts_clone).await }).await; + let cache_result = cache_result.map(ExcludeHidden::without_excluded); // Downloads-only gate: when the "Show all server media" toggle is off // (offline), an empty offline result is authoritative — the user asked @@ -697,10 +757,13 @@ impl MediaRepository for HybridRepository { // TRACES: UR-076 | DR-209 Ok(server_data.without_excluded()) } - Ok(Err(e)) => cache_result.or(Err(e)), - Err(join_err) => cache_result.or(Err(RepoError::Network { - message: format!("Server task failed: {}", join_err), - })), + Ok(Err(e)) => Self::cache_or(cache_result, slow_cache, e).await, + Err(join_err) => { + let e = RepoError::Network { + message: format!("Server task failed: {}", join_err), + }; + Self::cache_or(cache_result, slow_cache, e).await + } } } @@ -806,11 +869,22 @@ impl MediaRepository for HybridRepository { series_id: Option<&str>, limit: Option, ) -> Result, RepoError> { - // Next up is dynamic, always fetch from server - self.online - .get_next_up_episodes(series_id, limit) - .await - .map(ExcludeHidden::without_excluded) + // Next Up is dynamic, so the server's answer is preferred — but when the + // server cannot answer, the cache's stands in. It used to be server-only, + // and the TV landing page loads Next Up in one `Promise.all` with its + // other rows, so offline that single failure blanked the whole page with + // Continue Watching and Latest sitting in the cache (DR-294). + // TRACES: UR-002 | DR-294 | UT-261 + match self.online.get_next_up_episodes(series_id, limit).await { + Ok(items) => Ok(items.without_excluded()), + Err(e) => { + debug!("[HybridRepo] Next Up from server failed ({e}); using the cache"); + self.offline + .get_next_up_episodes(series_id, limit) + .await + .map(ExcludeHidden::without_excluded) + } + } } async fn get_recently_played_audio( @@ -878,9 +952,9 @@ impl MediaRepository for HybridRepository { let cache_offline = Arc::clone(&self.offline); let cache_pid = parent_id_str.clone(); - let cache_result = self - .cache_with_timeout(async move { cache_offline.get_genres(cache_pid.as_deref()).await }) - .await; + let (cache_result, slow_cache) = + Self::cache_try(async move { cache_offline.get_genres(cache_pid.as_deref()).await }) + .await; if let Ok(genres) = &cache_result { if genres.has_content() { @@ -922,7 +996,7 @@ impl MediaRepository for HybridRepository { } Ok(server_genres) } - Err(e) => cache_result.or(Err(e)), + Err(e) => Self::settle(cache_result, slow_cache, e).await, } } @@ -946,7 +1020,15 @@ impl MediaRepository for HybridRepository { } async fn get_playback_info(&self, item_id: &str) -> Result { - // Playback info requires server communication for transcoding decisions + // A downloaded item is played from disk and needs nothing the server + // negotiates, so its answer comes from the download row — first, and + // whether or not the server is reachable. Asking the server first is + // what made a download unplayable offline (DR-294). Anything not held + // locally is a streaming question, and only the server can answer it. + // TRACES: UR-002, UR-071 | DR-294 | UT-260 + if let Some(local) = self.offline.local_playback_info(item_id).await? { + return Ok(local); + } self.online.get_playback_info(item_id).await } @@ -1205,9 +1287,8 @@ impl MediaRepository for HybridRepository { tokio::spawn(async move { online.get_playlist_items(&playlist_id_clone).await }); // Check cache first (fast, 100ms timeout) - let cache_result = self - .cache_with_timeout(async move { offline.get_playlist_items(&playlist_id).await }) - .await; + let (cache_result, slow_cache) = + Self::cache_try(async move { offline.get_playlist_items(&playlist_id).await }).await; // Cache hit: return immediately, update cache in background if let Ok(data) = &cache_result { @@ -1244,10 +1325,13 @@ impl MediaRepository for HybridRepository { }); Ok(entries) } - Ok(Err(e)) => cache_result.or(Err(e)), - Err(join_err) => cache_result.or(Err(RepoError::Network { - message: format!("Server task failed: {}", join_err), - })), + Ok(Err(e)) => Self::settle(cache_result, slow_cache, e).await, + Err(join_err) => { + let e = RepoError::Network { + message: format!("Server task failed: {}", join_err), + }; + Self::settle(cache_result, slow_cache, e).await + } } } diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index 83a6ad1fd..d78e56fb9 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -115,6 +115,54 @@ pub struct OfflineRepository { } impl OfflineRepository { + /// Playback info for an item this user has downloaded, built from the + /// download row — no server involved. `None` when there is no completed + /// local file, which leaves the question to the server. + /// + /// This exists because playing a download asked the server first. The + /// player needs only the media-source id (subtitle URLs are keyed by it), + /// and fetching that from `/PlaybackInfo` meant a downloaded film would not + /// play offline: the call retried for seven seconds and failed, and the + /// file on disk was never opened. + /// + /// The media-source id is the item id. A download never names a source — + /// the URL carries no `mediaSourceId` — so the server serves its default, + /// and Jellyfin gives an item's default source the item's own id. + /// + /// TRACES: UR-002, UR-071 | DR-294 | UT-260 + pub async fn local_playback_info( + &self, + item_id: &str, + ) -> Result, RepoError> { + let rows = self + .db_service + .query_many( + Query::with_params( + "SELECT file_path FROM downloads \ + WHERE item_id = ? AND user_id = ? AND status = 'completed' \ + AND file_path IS NOT NULL \ + LIMIT 1", + vec![ + QueryParam::String(item_id.to_string()), + QueryParam::String(self.user_id.clone()), + ], + ), + |row| row.get::<_, String>(0), + ) + .await + .map_err(|e| RepoError::Database { message: e })?; + + Ok(rows.into_iter().next().map(|file_path| PlaybackInfo { + media_source_id: item_id.to_string(), + // No server session: nothing was negotiated, and a local file has no + // transcode job for a session id to name. + play_session_id: String::new(), + stream_url: file_path, + direct_play: true, + needs_transcoding: false, + })) + } + pub fn new(db_service: Arc, server_id: String, user_id: String) -> Self { Self { db_service, @@ -2760,6 +2808,12 @@ mod tests { } fn create_test_db() -> Arc { + Arc::new(RusqliteService::new(create_test_conn())) + } + + /// The raw connection behind [`create_test_db`], for tests that need to + /// hold its lock — standing in for a concurrent write. + fn create_test_conn() -> Arc> { let conn = Connection::open_in_memory().unwrap(); // Enable foreign key constraints (they're disabled by default in SQLite) @@ -2868,6 +2922,8 @@ mod tests { CREATE TABLE downloads ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, + user_id TEXT, + file_path TEXT, status TEXT NOT NULL, file_size INTEGER ); @@ -2934,10 +2990,267 @@ mod tests { [], ).unwrap(); - Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn)))) + Arc::new(Mutex::new(conn)) } /// Helper to create a test MediaItem + /// The reported bug: offline, a downloaded episode would not play. The + /// player found the file on disk, then asked the *server* for the item's + /// playback info — only to read its media-source id — and with no network + /// that call retried for seven seconds and failed, so playback never began. + /// A download that needs the internet to play is not a download. + /// + /// Driven through the real `HybridRepository` against a server that refuses + /// connections, because the defect was the hybrid layer sending this call + /// only to the server. + /// + /// TRACES: UR-002, UR-071 | DR-294 | UT-260 + #[tokio::test] + async fn test_a_downloaded_item_gets_playback_info_without_the_server() { + let db_service = create_test_db(); + for sql in [ + "INSERT INTO downloads (item_id, user_id, file_path, status) \ + VALUES ('ep-6', 'test-user', '/data/videos/S01E06.mp4', 'completed')", + // Still downloading: there is no local file to play yet. + "INSERT INTO downloads (item_id, user_id, file_path, status) \ + VALUES ('ep-7', 'test-user', '/data/videos/S01E07.mp4', 'downloading')", + // Someone else's download is not this user's local copy. + "INSERT INTO downloads (item_id, user_id, file_path, status) \ + VALUES ('ep-8', 'other-user', '/data/videos/S01E08.mp4', 'completed')", + ] { + db_service.execute(Query::new(sql)).await.unwrap(); + } + let offline = OfflineRepository::new( + db_service.clone(), + "test-server".to_string(), + "test-user".to_string(), + ); + let local = OfflineRepository::new( + db_service, + "test-server".to_string(), + "test-user".to_string(), + ); + // Port 9 on loopback: nothing listens, so every request is refused. + let online = crate::repository::OnlineRepository::new( + Arc::new( + crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(), + ), + "http://127.0.0.1:9".to_string(), + "test-user".to_string(), + "test-token".to_string(), + ); + let hybrid = crate::repository::HybridRepository::new(online, offline); + + let started = std::time::Instant::now(); + let info = hybrid + .get_playback_info("ep-6") + .await + .expect("a downloaded item must get playback info with no server"); + assert!( + started.elapsed() < std::time::Duration::from_secs(1), + "answered locally, not after the network gave up: {:?}", + started.elapsed() + ); + assert_eq!(info.stream_url, "/data/videos/S01E06.mp4"); + assert!(info.direct_play && !info.needs_transcoding); + // Jellyfin's default media source shares the item's id, and a download + // is always of the default source (no mediaSourceId is requested). + assert_eq!(info.media_source_id, "ep-6"); + + // Not playable locally → still the server's question to answer. + for not_local in ["ep-7", "ep-8", "never-downloaded"] { + assert!( + local + .local_playback_info(not_local) + .await + .unwrap() + .is_none(), + "{not_local} has no completed local file for this user" + ); + } + } + + /// Next Up went only to the server, so offline it failed — and the TV + /// landing page loads it together with Continue Watching and Latest in one + /// `Promise.all`, so that one failure blanked the whole page ("Failed to + /// load TV sections: Offline") with its other rows sitting in the cache. + /// With the server unreachable, the answer is the cache's. + /// + /// TRACES: UR-002 | DR-294 | UT-261 + #[tokio::test] + async fn test_next_up_answers_from_the_cache_when_the_server_is_unreachable() { + let offline = OfflineRepository::new( + create_test_db(), + "test-server".to_string(), + "test-user".to_string(), + ); + let online = crate::repository::OnlineRepository::new( + Arc::new( + crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(), + ), + "http://127.0.0.1:9".to_string(), + "test-user".to_string(), + "test-token".to_string(), + ); + let hybrid = crate::repository::HybridRepository::new(online, offline); + + let next_up = hybrid.get_next_up_episodes(None, Some(12)).await; + assert!( + next_up.is_ok(), + "an unreachable server must not fail Next Up offline: {next_up:?}" + ); + } + + /// The reported bug: offline, "More info" on a downloaded show failed with + /// "Failed to load item". Its seasons were in the cache the whole time. + /// + /// `get_items` gave the cache 100 ms and *discarded* a slower answer, then + /// waited on the server — which offline fails — and returned the server's + /// error. The cache is one SQLite connection behind one mutex, so any write + /// in progress (the catalog sync that starts at every launch, a download + /// finishing) pushes a read past 100 ms routinely. Every other cached query + /// already keeps a slow read alive and waits for it when the server fails; + /// `get_items`, which the series page calls for the show and each season, + /// did not. + /// + /// TRACES: UR-002 | DR-294 | UT-263 + #[tokio::test] + async fn test_get_items_waits_for_a_slow_cache_when_the_server_is_unreachable() { + let _guard = lock_catalog_browse(); + set_include_catalog_browse(true); + let conn = create_test_conn(); + let db_service = Arc::new(RusqliteService::new(Arc::clone(&conn))); + for sql in [ + "INSERT INTO items (id, server_id, name, item_type, synced_at) \ + VALUES ('series-1', 'test-server', 'Show', 'Series', '2026-01-01')", + "INSERT INTO items (id, server_id, parent_id, name, item_type, synced_at) \ + VALUES ('season-1', 'test-server', 'series-1', 'Season 1', 'Season', '2026-01-01')", + ] { + db_service.execute(Query::new(sql)).await.unwrap(); + } + let offline = OfflineRepository::new( + db_service, + "test-server".to_string(), + "test-user".to_string(), + ); + let online = crate::repository::OnlineRepository::new( + Arc::new( + crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(), + ), + "http://127.0.0.1:9".to_string(), + "test-user".to_string(), + "test-token".to_string(), + ); + let hybrid = crate::repository::HybridRepository::new(online, offline); + + // Sanity: with nothing holding the database, the season is found. A + // failure here is the fixture, not the bug. + let quick = hybrid + .get_items("series-1", None) + .await + .expect("unlocked read"); + assert_eq!(quick.items.len(), 1, "fixture: the season is cached"); + + // Now a write holds the connection for 300 ms — longer than the fast path. + let held = Arc::clone(&conn); + let writer = std::thread::spawn(move || { + let _lock = held.lock().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(300)); + }); + std::thread::sleep(std::time::Duration::from_millis(20)); + + let slow = hybrid.get_items("series-1", None).await; + writer.join().unwrap(); + let slow = slow.expect("a slow cache must still answer when the server cannot"); + assert_eq!(slow.items.len(), 1); + assert_eq!(slow.items[0].id, "season-1"); + } + + /// A hybrid repository whose server refuses every connection, over `conn`. + fn hybrid_without_server(conn: &Arc>) -> crate::repository::HybridRepository { + let offline = OfflineRepository::new( + Arc::new(RusqliteService::new(Arc::clone(conn))), + "test-server".to_string(), + "test-user".to_string(), + ); + let online = crate::repository::OnlineRepository::new( + Arc::new( + crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(), + ), + "http://127.0.0.1:9".to_string(), + "test-user".to_string(), + "test-token".to_string(), + ); + crate::repository::HybridRepository::new(online, offline) + } + + /// Hold the database for `ms`, as a concurrent write does. + fn hold_database(conn: &Arc>, ms: u64) -> std::thread::JoinHandle<()> { + let held = Arc::clone(conn); + let writer = std::thread::spawn(move || { + let _lock = held.lock().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(ms)); + }); + std::thread::sleep(std::time::Duration::from_millis(20)); + writer + } + + /// The library list is the first thing an offline launch shows, and it had + /// the same defect as `get_items`: a cache read slowed past 100 ms by a write + /// was discarded, the server then failed, and the list came back as a + /// network error with the libraries on disk. + /// + /// TRACES: UR-002 | DR-294 | UT-263 + #[tokio::test] + async fn test_libraries_wait_for_a_slow_cache_when_the_server_is_unreachable() { + let conn = create_test_conn(); + conn.lock() + .unwrap() + .execute( + "INSERT INTO libraries (id, server_id, name, collection_type) \ + VALUES ('lib-tv', 'test-server', 'Shows', 'tvshows')", + [], + ) + .unwrap(); + let hybrid = hybrid_without_server(&conn); + + let writer = hold_database(&conn, 300); + let libs = hybrid.get_libraries().await; + writer.join().unwrap(); + + let libs = libs.expect("a slow cache must still answer when the server cannot"); + assert_eq!(libs.len(), 1); + assert_eq!(libs[0].id, "lib-tv"); + } + + /// Search and favourites read only the cache — there is no server to fall + /// back from — so a 100 ms deadline on them just fails them whenever the + /// database is busy, online or not. + /// + /// TRACES: UR-002 | DR-294 | UT-263 + #[tokio::test] + async fn test_cache_only_reads_wait_out_a_busy_database() { + let conn = create_test_conn(); + let hybrid = hybrid_without_server(&conn); + // Fixture sanity: with the database free both answer. + hybrid + .search_cache_only("anything", None) + .await + .expect("unlocked search"); + + let writer = hold_database(&conn, 300); + let search = hybrid.search_cache_only("anything", None).await; + writer.join().unwrap(); + search.expect("a busy database delays a cache-only search, it must not fail it"); + + let writer = hold_database(&conn, 300); + let favourites = hybrid + .get_favorites_cache_only(crate::repository::SearchScope::All, None) + .await; + writer.join().unwrap(); + favourites.expect("a busy database delays cache-only favourites, it must not fail them"); + } + fn create_test_item(id: &str, name: &str, parent_id: Option<&str>) -> MediaItem { MediaItem { id: id.to_string(),