Skip to main content

jellytau_lib/repository/
hybrid.rs

1// Hybrid repository - parallel racing between cache and server
2//
3// @req: UR-002 - Access media when online or offline
4// @req: IR-013 - SQLite integration for local database
5// @req: DR-012 - Local database for media metadata cache
6// @req: DR-013 - Repository pattern for online/offline data access
7//
8// TRACES: UR-002, UR-052 | IR-013 | DR-012, DR-013, DR-080
9
10#[cfg(test)]
11use crate::utils::lock::MutexSafe;
12use std::sync::Arc;
13
14use async_trait::async_trait;
15use log::{debug, warn};
16use tokio::time::{timeout, Duration};
17
18use super::exclusions::ExcludeHidden;
19use super::{types::*, MediaRepository, OfflineRepository, OnlineRepository};
20
21/// The cache side of a cache-first query.
22///
23/// Either the cache answered inside the fast path, or it is still working and
24/// the query can be collected later. Keeping the slow case *addressable* rather
25/// than discarding it is what lets an offline query fall back to cached content
26/// after the server leg fails.
27///
28/// TRACES: UR-002 | DR-013
29enum CacheLeg<T> {
30    /// The cache answered within [`HybridRepository::CACHE_FAST_PATH`].
31    Ready(Result<T, RepoError>),
32    /// Still running. Awaiting the handle yields the answer eventually.
33    Slow(tokio::task::JoinHandle<Result<T, RepoError>>),
34}
35
36impl<T> CacheLeg<T> {
37    /// Split into the fast-path answer and the still-running query. Exactly one
38    /// side is `Some`.
39    #[allow(clippy::type_complexity)]
40    fn split(
41        self,
42    ) -> (
43        Option<Result<T, RepoError>>,
44        Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
45    ) {
46        match self {
47            CacheLeg::Ready(result) => (Some(result), None),
48            CacheLeg::Slow(handle) => (None, Some(handle)),
49        }
50    }
51}
52
53/// Hybrid repository combining online and offline data sources
54///
55/// Uses cache-first parallel racing strategy:
56/// - Runs SQLite cache and HTTP server queries in parallel
57/// - Cache has 100ms timeout for fast feedback
58/// - Returns cache result if it has meaningful content
59/// - Falls back to server result if cache is empty/stale
60///
61/// @req: UR-002 - Access media when online or offline
62/// @req: DR-012 - Local database for media metadata cache
63/// @req: DR-013 - Repository pattern for online/offline data access
64pub struct HybridRepository {
65    online: Arc<OnlineRepository>,
66    offline: Arc<OfflineRepository>,
67}
68
69impl HybridRepository {
70    pub fn new(online: OnlineRepository, offline: OfflineRepository) -> Self {
71        Self {
72            online: Arc::new(online),
73            offline: Arc::new(offline),
74        }
75    }
76
77    /// The signed-in user this repository acts for.
78    ///
79    /// TRACES: UR-069 | DR-120
80    pub fn user_id(&self) -> &str {
81        self.online.user_id()
82    }
83
84    /// Download raw bytes from a URL using the shared authenticated HTTP client.
85    /// Delegates to online repository for connection reuse and proper auth.
86    pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
87        self.online.download_bytes(url).await
88    }
89
90    /// Remove catalog entries the server no longer has. Cache-only, so it goes
91    /// straight to the offline repository. Callers must only invoke this after a
92    /// crawl in which every library succeeded — see
93    /// `OfflineRepository::prune_stale_catalog` for why a partial crawl must not
94    /// sweep.
95    ///
96    /// TRACES: UR-065 | DR-110
97    pub async fn prune_stale_catalog(
98        &self,
99        cutoff: &str,
100        item_types: &[String],
101    ) -> Result<usize, RepoError> {
102        self.offline.prune_stale_catalog(cutoff, item_types).await
103    }
104
105    /// Query the JRay plugin for actors on screen at time `t`. Online-only
106    /// (the plugin lives on the Jellyfin server); empty when JRay isn't present.
107    pub async fn get_jray_actors(
108        &self,
109        item_id: &str,
110        t: f64,
111    ) -> Result<Vec<super::JRayActor>, RepoError> {
112        self.online.get_jray_actors(item_id, t).await
113    }
114
115    /// Get video stream URL. This method is online-only since offline playback
116    /// uses local file paths.
117    ///
118    /// Takes no start position: the URL is an HLS playlist spanning the whole
119    /// item, and a position on it would 400 every segment — see
120    /// `OnlineRepository::get_video_stream_url`. Resume by seeking after load.
121    pub async fn get_video_stream_url(
122        &self,
123        item_id: &str,
124        media_source_id: Option<&str>,
125        audio_stream_index: Option<i32>,
126    ) -> Result<String, RepoError> {
127        self.online
128            .get_video_stream_url(item_id, media_source_id, audio_stream_index)
129            .await
130    }
131
132    /// Decide what stream to play and describe it fully — the DR-225 contract.
133    ///
134    /// Online-only for the same reason as `get_video_stream_url`: an offline
135    /// item is a file on disk, and the caller builds
136    /// [`StreamSelection::local_file`] for it rather than negotiating anything.
137    ///
138    /// TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228
139    pub async fn get_stream_selection(
140        &self,
141        item_id: &str,
142        media_source_id: Option<&str>,
143        audio_stream_index: Option<i32>,
144    ) -> Result<super::StreamSelection, RepoError> {
145        self.online
146            .get_stream_selection(item_id, media_source_id, audio_stream_index)
147            .await
148    }
149
150    /// Get an audio-only stream URL for a video item (background-audio handoff).
151    /// Online-only, like `get_video_stream_url`.
152    ///
153    /// TRACES: UR-040 | JA-032
154    pub async fn get_audio_only_stream_url_for_video(
155        &self,
156        item_id: &str,
157        media_source_id: Option<&str>,
158        start_time_seconds: Option<f64>,
159        audio_stream_index: Option<i32>,
160    ) -> Result<String, RepoError> {
161        self.online
162            .get_audio_only_stream_url_for_video(
163                item_id,
164                media_source_id,
165                start_time_seconds,
166                audio_stream_index,
167            )
168            .await
169    }
170
171    /// Every track of an album, asked of the **server** rather than the cache.
172    ///
173    /// Deliberately not `get_items`, which is cache-first: it answers from SQLite
174    /// the moment the cache has any content. That is right for browsing and wrong
175    /// for deciding what to download, because a partial or unlinked cache then
176    /// decides how much of the album gets queued while the user is told the whole
177    /// album is downloading. Downloading is the one operation that must know the
178    /// album's *complete* contents.
179    ///
180    /// Errors when the server cannot answer (offline); the caller falls back to
181    /// the local catalog and the rows are queued either way, resolving on
182    /// reconnect. Server results are written back to the cache, so browsing
183    /// benefits from the round trip too.
184    ///
185    /// TRACES: UR-018, UR-055 | DR-173
186    pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<MediaItem>, RepoError> {
187        let options = Some(GetItemsOptions {
188            include_item_types: Some(vec!["Audio".to_string()]),
189            sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
190            limit: Some(1000),
191            ..Default::default()
192        });
193
194        let result = self.online.get_items(album_id, options).await?;
195
196        if !result.items.is_empty() {
197            if let Err(e) = self.offline.save_to_cache(album_id, &result.items).await {
198                warn!("[HybridRepo] Failed to cache album tracks: {:?}", e);
199            }
200        }
201
202        Ok(result.items)
203    }
204
205    /// Immediate children of a container with the user's browsing exclusions
206    /// **not** applied.
207    ///
208    /// Exists for the exclusion picker in settings. Everything else in this
209    /// repository hides what the user has hidden, which would make the setting
210    /// one-way: a folder already excluded would vanish from the list of folders
211    /// to exclude and could never be un-hidden. Server-first so the picker sees
212    /// the real library, falling back to the cache when unreachable.
213    ///
214    /// TRACES: UR-076 | DR-209
215    pub async fn get_items_unfiltered(
216        &self,
217        parent_id: &str,
218        options: Option<GetItemsOptions>,
219    ) -> Result<SearchResult, RepoError> {
220        match self.online.get_items(parent_id, options.clone()).await {
221            Ok(result) => Ok(result),
222            Err(e) => self.offline.get_items(parent_id, options).await.or(Err(e)),
223        }
224    }
225
226    /// Search only the local SQLite cache (downloaded content).
227    ///
228    /// Fast (100ms timeout) — used to render instant results before the server
229    /// responds. Returns an empty result rather than erroring on timeout so the
230    /// caller can still fall through to the server.
231    pub async fn search_cache_only(
232        &self,
233        query: &str,
234        options: Option<SearchOptions>,
235    ) -> Result<SearchResult, RepoError> {
236        let offline = Arc::clone(&self.offline);
237        let query = query.to_string();
238        self.cache_with_timeout(async move { offline.search(&query, options).await })
239            .await
240            .map(ExcludeHidden::without_excluded)
241    }
242
243    /// Favourites held locally, without touching the server. Backs the instant
244    /// leg of the two-phase favourites read in the command layer.
245    ///
246    /// TRACES: UR-067 | DR-115
247    pub async fn get_favorites_cache_only(
248        &self,
249        scope: SearchScope,
250        options: Option<GetItemsOptions>,
251    ) -> Result<SearchResult, RepoError> {
252        let offline = Arc::clone(&self.offline);
253        self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
254            .await
255            .map(ExcludeHidden::without_excluded)
256    }
257
258    /// Favourites straight from the server, persisted to the cache on the way
259    /// through — which is also what mirrors their favourite flags into
260    /// `user_data` (DR-114), so the next offline read agrees with the server.
261    ///
262    /// TRACES: UR-067 | DR-115
263    pub async fn get_favorites_server_only(
264        &self,
265        scope: SearchScope,
266        options: Option<GetItemsOptions>,
267    ) -> Result<SearchResult, RepoError> {
268        let result = self.online.get_favorites(scope, options).await?;
269        if !result.items.is_empty() {
270            // Favourites span libraries, so there is no single parent to file
271            // them under; the parent id is only used for stub rows.
272            if let Err(e) = self.offline.save_to_cache("favorites", &result.items).await {
273                debug!("[HybridRepo] Failed to cache favourites: {:?}", e);
274            }
275        }
276        Ok(result.without_excluded())
277    }
278
279    /// Fetch a folder's items from the live server and persist them to the
280    /// offline cache synchronously (unlike `get_items`, which saves in a
281    /// fire-and-forget background task after a 100ms cache race).
282    ///
283    /// Used by the full-catalog pre-sync (`sync_full_catalog`) to deterministically
284    /// walk every library while online so the whole catalog is browsable — greyed
285    /// out — offline. Returns the items fetched so the caller can recurse into
286    /// containers. Server-only: errors if unreachable.
287    pub async fn cache_items_from_server(
288        &self,
289        parent_id: &str,
290        options: Option<GetItemsOptions>,
291    ) -> Result<Vec<MediaItem>, RepoError> {
292        let result = self.online.get_items(parent_id, options).await?;
293        if !result.items.is_empty() {
294            self.offline.save_to_cache(parent_id, &result.items).await?;
295        }
296        Ok(result.items)
297    }
298
299    /// Browse downloaded content only — the dedicated Downloads surface.
300    ///
301    /// Bypasses the cache/server merge entirely and reads the offline repository
302    /// directly, so an empty result is authoritative ("nothing downloaded here")
303    /// and never falls through to the server (DR-080). Available online too — a
304    /// user who is reachable still wants to browse what's on the device.
305    ///
306    /// TRACES: UR-055 | DR-082, DR-083
307    pub async fn get_downloaded_items(
308        &self,
309        parent_id: &str,
310        options: Option<GetItemsOptions>,
311    ) -> Result<SearchResult, RepoError> {
312        // Deliberately *not* filtered by the user's hidden folders: this surface
313        // manages what is on the device, and hiding a download would leave the
314        // user unable to delete a file they can still see the disk usage of.
315        // TRACES: UR-076 | DR-209
316        self.offline.get_downloaded_items(parent_id, options).await
317    }
318
319    /// Libraries that contain downloaded content (offline-only, authoritative).
320    ///
321    /// TRACES: UR-055 | DR-082
322    pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
323        self.offline.get_downloaded_libraries().await
324    }
325
326    /// On-disk usage of downloaded content, for the disk-usage display.
327    ///
328    /// TRACES: UR-056 | DR-085
329    pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
330        self.offline.get_download_disk_usage().await
331    }
332
333    /// Search only the live Jellyfin server (full library).
334    pub async fn search_server_only(
335        &self,
336        query: &str,
337        options: Option<SearchOptions>,
338    ) -> Result<SearchResult, RepoError> {
339        self.online
340            .search(query, options)
341            .await
342            .map(ExcludeHidden::without_excluded)
343    }
344
345    /// Merge cache and server search results into a single de-duplicated list.
346    ///
347    /// Ordering: local (cached/downloaded) items first, then server-only items
348    /// appended. On a duplicate `id`, the server's item wins (fresher, more
349    /// complete metadata) but keeps the local item's earlier position.
350    pub fn merge_search_results(cache: SearchResult, server: SearchResult) -> SearchResult {
351        use std::collections::HashMap;
352
353        // Index server items by id so we can (a) override duplicates with the
354        // server's metadata and (b) know which server items are brand new.
355        let mut server_by_id: HashMap<String, MediaItem> = HashMap::new();
356        let mut server_order: Vec<String> = Vec::with_capacity(server.items.len());
357        for item in server.items {
358            if !server_by_id.contains_key(&item.id) {
359                server_order.push(item.id.clone());
360            }
361            server_by_id.insert(item.id.clone(), item);
362        }
363
364        let mut items: Vec<MediaItem> = Vec::new();
365        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
366
367        // Local items first, in their original order. If the server also
368        // returned this item, take the server's copy (newer metadata).
369        for local in cache.items {
370            if !seen.insert(local.id.clone()) {
371                continue;
372            }
373            match server_by_id.remove(&local.id) {
374                Some(server_item) => items.push(server_item),
375                None => items.push(local),
376            }
377        }
378
379        // Then append server-only items, preserving the server's order.
380        for id in server_order {
381            if let Some(server_item) = server_by_id.remove(&id) {
382                if seen.insert(id) {
383                    items.push(server_item);
384                }
385            }
386        }
387
388        let total_record_count = items.len();
389        SearchResult {
390            items,
391            total_record_count,
392        }
393    }
394
395    /// Cache-first query: try cache, fall back to server on miss.
396    ///
397    /// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
398    /// 2. If cache has meaningful content → return immediately (fast path)
399    /// 3. If cache is empty/stale → query server (fresh data)
400    /// 4. If server fails → return cache even if empty (offline fallback)
401    ///
402    /// Both legs are passed through [`ExcludeHidden`] before the "does the cache
403    /// have content?" question is asked. This is the single place the cache and
404    /// server results of a cache-first query converge, so applying the user's
405    /// browsing exclusions here covers every query built on it at once — and
406    /// filtering *before* the content check is what makes a cache page holding
407    /// nothing but hidden items fall through to the server instead of being
408    /// served as an empty listing.
409    ///
410    /// @req: UR-002 - Access media when online or offline
411    /// @req: DR-013 - Repository pattern for online/offline data access
412    ///
413    /// TRACES: UR-002, UR-076 | DR-013, DR-209
414    async fn parallel_race<T, F2>(cache: CacheLeg<T>, server_future: F2) -> Result<T, RepoError>
415    where
416        T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
417        F2: std::future::Future<Output = Result<T, RepoError>> + Send,
418    {
419        Self::race_with_refresh(cache, server_future, || {}).await
420    }
421
422    /// [`Self::parallel_race`], plus a callback fired on the fast path so the
423    /// caller can refresh the cache in the background.
424    ///
425    /// A plain cache hit answers from data that may be arbitrarily old, which
426    /// is right for the *response* and wrong for what it leaves behind: per-user
427    /// state (watch positions, favourites) only reaches the local tables when a
428    /// server result is cached, so a surface that always hits cache never learns
429    /// what another device did. `get_items` had a bespoke version of this; this
430    /// is the same idea, reusable.
431    ///
432    /// The callback runs only on a cache hit — on a miss the server result is
433    /// already being fetched and cached by the normal path.
434    ///
435    /// TRACES: UR-002, UR-025, UR-076 | DR-155, DR-209
436    async fn race_with_refresh<T, F2, R>(
437        cache: CacheLeg<T>,
438        server_future: F2,
439        on_cache_hit: R,
440    ) -> Result<T, RepoError>
441    where
442        T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
443        F2: std::future::Future<Output = Result<T, RepoError>> + Send,
444        R: FnOnce(),
445    {
446        let (fast, slow) = cache.split();
447        let fast = fast.map(|r| r.map(ExcludeHidden::without_excluded));
448
449        if let Some(Ok(data)) = &fast {
450            if data.has_content() {
451                debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
452                on_cache_hit();
453                return Ok(data.clone());
454            }
455        }
456
457        debug!("[HybridRepo] Cache miss or slow, querying server");
458        match server_future.await {
459            Ok(data) => Ok(data.without_excluded()),
460            Err(e) => {
461                // The server cannot answer. If the cache is still working, it is
462                // now the only thing that can, so wait it out rather than
463                // reporting the server's failure over data we are about to hold.
464                // This is the offline path: a cache read slowed by a concurrent
465                // write used to surface as a network error.
466                if let Some(handle) = slow {
467                    debug!("[HybridRepo] Server failed; waiting for the slow cache query");
468                    return match handle.await {
469                        Ok(Ok(data)) => Ok(data.without_excluded()),
470                        Ok(Err(cache_err)) => {
471                            debug!("[HybridRepo] Slow cache query also failed: {cache_err}");
472                            Err(e)
473                        }
474                        Err(join) => {
475                            debug!("[HybridRepo] Slow cache query panicked: {join}");
476                            Err(e)
477                        }
478                    };
479                }
480                // Cache answered in time but had nothing: return that, so an
481                // empty-but-valid cached listing still beats a network error.
482                fast.unwrap_or(Err(e))
483            }
484        }
485    }
486
487    /// How long the cache gets to answer before a query falls through to the
488    /// server. Short on purpose: this bounds how long a *cache hit* may delay
489    /// the UI, not how long the query is allowed to take.
490    const CACHE_FAST_PATH: Duration = Duration::from_millis(100);
491
492    /// Start a cache query and give it [`Self::CACHE_FAST_PATH`] to answer.
493    ///
494    /// Missing the deadline does **not** cancel the query — it keeps running on
495    /// its own task and [`CacheLeg::settle`] can still collect it. That
496    /// distinction is the whole point. The database is one SQLite connection
497    /// behind one mutex, so a concurrent write (a sync drain, a bulk
498    /// `save_to_cache`) blocks reads for its duration and this deadline trips
499    /// routinely on slow storage. Treating that as "the cache is empty" while
500    /// throwing the answer away meant that offline — where the server leg also
501    /// fails — browsing surfaced a network error instead of the cached content
502    /// sitting right there on disk.
503    ///
504    /// TRACES: UR-002 | DR-013
505    async fn cache_leg<T>(
506        future: impl std::future::Future<Output = Result<T, RepoError>> + Send + 'static,
507    ) -> CacheLeg<T>
508    where
509        T: Send + 'static,
510    {
511        // `&mut handle` so the timeout borrows the join handle rather than
512        // consuming it: on expiry the task is still ours to collect.
513        let mut handle = tokio::spawn(future);
514        match timeout(Self::CACHE_FAST_PATH, &mut handle).await {
515            Ok(Ok(result)) => CacheLeg::Ready(result),
516            Ok(Err(join)) => CacheLeg::Ready(Err(RepoError::Database {
517                message: format!("Cache query failed: {join}"),
518            })),
519            Err(_) => {
520                debug!("[HybridRepo] Cache missed the fast path; leaving it running");
521                CacheLeg::Slow(handle)
522            }
523        }
524    }
525
526    /// Await a cache query that is still running, however long it takes.
527    async fn cache_with_timeout<T>(
528        &self,
529        future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
530    ) -> Result<T, RepoError> {
531        timeout(Self::CACHE_FAST_PATH, future)
532            .await
533            .unwrap_or_else(|_| {
534                Err(RepoError::Database {
535                    message: "Cache query timeout".to_string(),
536                })
537            })
538    }
539}
540
541#[async_trait]
542impl MediaRepository for HybridRepository {
543    async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
544        // Cache-first (100ms). On a cache hit, refresh the cache from the server
545        // in the background. On a miss, fetch from the server and persist so the
546        // list is available on the next (possibly offline) startup.
547        let cache_result = self.cache_with_timeout(self.offline.get_libraries()).await;
548
549        if let Ok(libs) = &cache_result {
550            if libs.has_content() {
551                debug!("[HybridRepo] Cache hit for libraries, returning immediately");
552                let online = Arc::clone(&self.online);
553                let offline = Arc::clone(&self.offline);
554                tokio::spawn(async move {
555                    if let Ok(server_libs) = online.get_libraries().await {
556                        if !server_libs.is_empty() {
557                            if let Err(e) = offline.save_libraries_to_cache(&server_libs).await {
558                                warn!(
559                                    "[HybridRepo] Background library cache update failed: {:?}",
560                                    e
561                                );
562                            }
563                        }
564                    }
565                });
566                return cache_result;
567            }
568        }
569
570        // Cache miss — fetch from server and persist for offline use.
571        match self.online.get_libraries().await {
572            Ok(server_libs) => {
573                if !server_libs.is_empty() {
574                    if let Err(e) = self.offline.save_libraries_to_cache(&server_libs).await {
575                        warn!(
576                            "[HybridRepo] Failed to cache {} libraries: {:?}",
577                            server_libs.len(),
578                            e
579                        );
580                    }
581                }
582                Ok(server_libs)
583            }
584            Err(e) => cache_result.or(Err(e)),
585        }
586    }
587
588    async fn get_items(
589        &self,
590        parent_id: &str,
591        options: Option<GetItemsOptions>,
592    ) -> Result<SearchResult, RepoError> {
593        let offline = Arc::clone(&self.offline);
594        let offline_for_save = Arc::clone(&self.offline);
595        let online = Arc::clone(&self.online);
596        let parent_id = parent_id.to_string();
597        let parent_id_clone = parent_id.clone();
598        let parent_id_for_save = parent_id.clone();
599        let opts_clone = options.clone();
600
601        // Start server request in background (non-blocking)
602        let server_handle =
603            tokio::spawn(async move { online.get_items(&parent_id_clone, options).await });
604
605        // Check cache first (fast, 100ms timeout).
606        //
607        // Exclusions are applied here rather than at each return below so the
608        // "has content" decisions further down are made about what the user will
609        // actually see. `get_items` is the one query that does not go through
610        // `parallel_race` — it interleaves the downloads-only gate and a
611        // background cache write — so it applies the filter itself.
612        // TRACES: UR-076 | DR-209
613        let cache_result = self
614            .cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
615            .await
616            .map(ExcludeHidden::without_excluded);
617
618        // Downloads-only gate: when the "Show all server media" toggle is off
619        // (offline), an empty offline result is authoritative — the user asked
620        // for downloaded media only and this library has none. Return it as-is
621        // rather than falling through to the server, which would re-pad the page
622        // with the full catalog and re-defeat the filter (DR-080). When the flag
623        // is on (the default, and always so while reachable) behaviour below is
624        // unchanged, including the background cache refresh on a hit.
625        if !crate::repository::offline::include_catalog_browse() {
626            if let Ok(data) = &cache_result {
627                debug!(
628                    "[HybridRepo] Downloads-only gate: returning offline result ({} items) as authoritative for parent {}",
629                    data.items.len(),
630                    &parent_id_for_save[..8.min(parent_id_for_save.len())]
631                );
632                // Abort the in-flight server request; we won't use it.
633                server_handle.abort();
634                return Ok(data.clone());
635            }
636        }
637
638        // Cache hit: return immediately, update cache in background
639        if let Ok(data) = &cache_result {
640            if data.has_content() {
641                debug!(
642                    "[HybridRepo] Cache hit for get_items, returning immediately for parent {}",
643                    &parent_id_for_save[..8.min(parent_id_for_save.len())]
644                );
645                // Background: save server result to cache when it arrives
646                tokio::spawn(async move {
647                    match server_handle.await {
648                        Ok(Ok(server_data)) if !server_data.items.is_empty() => {
649                            if let Err(e) = offline_for_save
650                                .save_to_cache(&parent_id_for_save, &server_data.items)
651                                .await
652                            {
653                                warn!("[HybridRepo] Background cache update failed: {:?}", e);
654                            } else {
655                                debug!(
656                                    "[HybridRepo] Background updated {} cached items for parent {}",
657                                    server_data.items.len(),
658                                    &parent_id_for_save[..8.min(parent_id_for_save.len())]
659                                );
660                            }
661                        }
662                        _ => {} // Server failed or returned empty — keep existing cache
663                    }
664                });
665                return Ok(data.clone());
666            }
667        }
668
669        // Cache miss — wait for server result
670        match server_handle.await {
671            Ok(Ok(server_data)) => {
672                if !server_data.items.is_empty() {
673                    let items_clone = server_data.items.clone();
674                    tokio::spawn(async move {
675                        if let Err(e) = offline_for_save
676                            .save_to_cache(&parent_id_for_save, &items_clone)
677                            .await
678                        {
679                            warn!(
680                                "[HybridRepo] Failed to save {} items to cache: {:?}",
681                                items_clone.len(),
682                                e
683                            );
684                        } else {
685                            debug!(
686                                "[HybridRepo] Saved {} items to cache for parent {}",
687                                items_clone.len(),
688                                &parent_id_for_save[..8.min(parent_id_for_save.len())]
689                            );
690                        }
691                    });
692                }
693                // The cache keeps the server's full page (above) — an exclusion
694                // is a view preference and can be undone, so hiding items from
695                // the *cache* would make un-hiding them require a re-crawl. Only
696                // what is handed back is filtered.
697                // TRACES: UR-076 | DR-209
698                Ok(server_data.without_excluded())
699            }
700            Ok(Err(e)) => cache_result.or(Err(e)),
701            Err(join_err) => cache_result.or(Err(RepoError::Network {
702                message: format!("Server task failed: {}", join_err),
703            })),
704        }
705    }
706
707    /// A single item, cache-first — and, on a cache hit, refreshed in the
708    /// background so the stored copy keeps up with the server.
709    ///
710    /// The background refresh is what carries per-user state home: caching an
711    /// item runs `mirror_user_data`, which is the only path by which a watch
712    /// position set on another device reaches the local `user_data` row the
713    /// resume check reads. Without it a cache hit returned this device's own
714    /// stale position forever and cross-device resume silently did nothing —
715    /// `get_items` already refreshes this way, so browsing a season worked
716    /// while opening the episode directly did not.
717    ///
718    /// The refreshed value lands for the *next* read rather than this one: the
719    /// point of the cache-first race is to answer immediately.
720    ///
721    /// TRACES: UR-025, UR-002 | DR-155 | UT-152
722    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
723        let offline = Arc::clone(&self.offline);
724        let online = Arc::clone(&self.online);
725        let item_id = item_id.to_string();
726        let item_id_clone = item_id.clone();
727
728        let cache_future = Self::cache_leg(async move { offline.get_item(&item_id).await }).await;
729
730        let online_for_refresh = Arc::clone(&self.online);
731        let offline_for_save = Arc::clone(&self.offline);
732        let refresh_id = item_id_clone.clone();
733        let on_cache_hit = move || {
734            tokio::spawn(async move {
735                match online_for_refresh.get_item(&refresh_id).await {
736                    Ok(fresh) => {
737                        // `save_to_cache` files the row under a parent; the item's
738                        // own parent keeps it where a later listing expects it.
739                        let parent = fresh
740                            .parent_id
741                            .clone()
742                            .unwrap_or_else(|| "item".to_string());
743                        if let Err(e) = offline_for_save.save_to_cache(&parent, &[fresh]).await {
744                            debug!("[HybridRepo] Background item refresh failed: {:?}", e);
745                        }
746                    }
747                    Err(e) => debug!("[HybridRepo] Background item refresh unavailable: {:?}", e),
748                }
749            });
750        };
751
752        let server_future = async move { online.get_item(&item_id_clone).await };
753
754        Self::race_with_refresh(cache_future, server_future, on_cache_hit).await
755    }
756
757    async fn get_latest_items(
758        &self,
759        parent_id: &str,
760        limit: Option<usize>,
761    ) -> Result<Vec<MediaItem>, RepoError> {
762        let offline = Arc::clone(&self.offline);
763        let online = Arc::clone(&self.online);
764        let parent_id = parent_id.to_string();
765        let parent_id_clone = parent_id.clone();
766        let limit_clone = limit;
767
768        let cache_future =
769            Self::cache_leg(async move { offline.get_latest_items(&parent_id, limit).await }).await;
770
771        let server_future =
772            async move { online.get_latest_items(&parent_id_clone, limit_clone).await };
773
774        Self::parallel_race(cache_future, server_future).await
775    }
776
777    async fn get_resume_items(
778        &self,
779        parent_id: Option<&str>,
780        limit: Option<usize>,
781    ) -> Result<Vec<MediaItem>, RepoError> {
782        let offline = Arc::clone(&self.offline);
783        let online = Arc::clone(&self.online);
784        let parent_id_str = parent_id.map(|s| s.to_string());
785        let parent_id_clone = parent_id_str.clone();
786        let limit_clone = limit;
787
788        let cache_future = Self::cache_leg(async move {
789            offline
790                .get_resume_items(parent_id_str.as_deref(), limit)
791                .await
792        })
793        .await;
794
795        let server_future = async move {
796            online
797                .get_resume_items(parent_id_clone.as_deref(), limit_clone)
798                .await
799        };
800
801        Self::parallel_race(cache_future, server_future).await
802    }
803
804    async fn get_next_up_episodes(
805        &self,
806        series_id: Option<&str>,
807        limit: Option<usize>,
808    ) -> Result<Vec<MediaItem>, RepoError> {
809        // Next up is dynamic, always fetch from server
810        self.online
811            .get_next_up_episodes(series_id, limit)
812            .await
813            .map(ExcludeHidden::without_excluded)
814    }
815
816    async fn get_recently_played_audio(
817        &self,
818        limit: Option<usize>,
819    ) -> Result<Vec<MediaItem>, RepoError> {
820        let offline = Arc::clone(&self.offline);
821        let online = Arc::clone(&self.online);
822        let limit_clone = limit;
823
824        let cache_future =
825            Self::cache_leg(async move { offline.get_recently_played_audio(limit).await }).await;
826
827        let server_future = async move { online.get_recently_played_audio(limit_clone).await };
828
829        Self::parallel_race(cache_future, server_future).await
830    }
831
832    async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
833        let offline = Arc::clone(&self.offline);
834        let online = Arc::clone(&self.online);
835        let limit_clone = limit;
836
837        let cache_future =
838            Self::cache_leg(async move { offline.get_resume_movies(limit).await }).await;
839
840        let server_future = async move { online.get_resume_movies(limit_clone).await };
841
842        Self::parallel_race(cache_future, server_future).await
843    }
844
845    async fn get_rediscover_albums(
846        &self,
847        parent_id: Option<&str>,
848        limit: Option<usize>,
849    ) -> Result<Vec<MediaItem>, RepoError> {
850        let offline = Arc::clone(&self.offline);
851        let online = Arc::clone(&self.online);
852        let parent_id_owned = parent_id.map(|s| s.to_string());
853        let parent_id_clone = parent_id_owned.clone();
854
855        let cache_future = Self::cache_leg(async move {
856            offline
857                .get_rediscover_albums(parent_id_owned.as_deref(), limit)
858                .await
859        })
860        .await;
861
862        let server_future = async move {
863            online
864                .get_rediscover_albums(parent_id_clone.as_deref(), limit)
865                .await
866        };
867
868        Self::parallel_race(cache_future, server_future).await
869    }
870
871    async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
872        // Cache-first (100ms). On a cache hit, refresh the cached genre catalog
873        // from the server in the background. On a miss, fetch from the server and
874        // persist so the full genre list is available offline. Mirrors
875        // get_libraries — NOT parallel_race, whose "any non-empty cache wins"
876        // rule would pin genres to whatever sparse set the local albums yield.
877        let parent_id_str = parent_id.map(|s| s.to_string());
878
879        let cache_offline = Arc::clone(&self.offline);
880        let cache_pid = parent_id_str.clone();
881        let cache_result = self
882            .cache_with_timeout(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
883            .await;
884
885        if let Ok(genres) = &cache_result {
886            if genres.has_content() {
887                debug!("[HybridRepo] Cache hit for genres, returning immediately");
888                let online = Arc::clone(&self.online);
889                let offline = Arc::clone(&self.offline);
890                let pid = parent_id_str.clone();
891                tokio::spawn(async move {
892                    if let Ok(server_genres) = online.get_genres(pid.as_deref()).await {
893                        if !server_genres.is_empty() {
894                            if let Err(e) = offline
895                                .save_genres_to_cache(pid.as_deref(), &server_genres)
896                                .await
897                            {
898                                warn!("[HybridRepo] Background genre cache update failed: {:?}", e);
899                            }
900                        }
901                    }
902                });
903                return cache_result;
904            }
905        }
906
907        // Cache miss — fetch from server and persist for offline use.
908        match self.online.get_genres(parent_id_str.as_deref()).await {
909            Ok(server_genres) => {
910                if !server_genres.is_empty() {
911                    if let Err(e) = self
912                        .offline
913                        .save_genres_to_cache(parent_id_str.as_deref(), &server_genres)
914                        .await
915                    {
916                        warn!(
917                            "[HybridRepo] Failed to cache {} genres: {:?}",
918                            server_genres.len(),
919                            e
920                        );
921                    }
922                }
923                Ok(server_genres)
924            }
925            Err(e) => cache_result.or(Err(e)),
926        }
927    }
928
929    async fn search(
930        &self,
931        query: &str,
932        options: Option<SearchOptions>,
933    ) -> Result<SearchResult, RepoError> {
934        let offline = Arc::clone(&self.offline);
935        let online = Arc::clone(&self.online);
936        let query = query.to_string();
937        let query_clone = query.clone();
938        let opts_clone = options.clone();
939
940        let cache_future =
941            Self::cache_leg(async move { offline.search(&query, opts_clone).await }).await;
942
943        let server_future = async move { online.search(&query_clone, options).await };
944
945        Self::parallel_race(cache_future, server_future).await
946    }
947
948    async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
949        // Playback info requires server communication for transcoding decisions
950        self.online.get_playback_info(item_id).await
951    }
952
953    async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
954        // Stream URLs require server communication - delegate to online repository
955        self.online.get_audio_stream_url(item_id).await
956    }
957
958    async fn get_audio_only_stream_url_for_video(
959        &self,
960        item_id: &str,
961        media_source_id: Option<&str>,
962        start_time_seconds: Option<f64>,
963        audio_stream_index: Option<i32>,
964    ) -> Result<String, RepoError> {
965        // Audio-only transcode of a video requires the server - delegate to online.
966        self.online
967            .build_audio_only_stream_url_for_video(
968                item_id,
969                media_source_id,
970                start_time_seconds,
971                audio_stream_index,
972            )
973            .await
974    }
975
976    async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
977        // Live TV requires server communication - delegate to online repository
978        self.online
979            .get_live_tv_channels()
980            .await
981            .map(ExcludeHidden::without_excluded)
982    }
983
984    async fn get_channels(&self) -> Result<SearchResult, RepoError> {
985        // Plugin channels require server communication - delegate to online repository
986        self.online
987            .get_channels()
988            .await
989            .map(ExcludeHidden::without_excluded)
990    }
991
992    async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
993        // Opening a live stream requires server communication - delegate to online
994        self.online.open_live_stream(item_id).await
995    }
996
997    async fn report_playback_start(
998        &self,
999        item_id: &str,
1000        position_ticks: i64,
1001    ) -> Result<(), RepoError> {
1002        // Playback reporting goes directly to server
1003        self.online
1004            .report_playback_start(item_id, position_ticks)
1005            .await
1006    }
1007
1008    async fn report_playback_progress(
1009        &self,
1010        item_id: &str,
1011        position_ticks: i64,
1012    ) -> Result<(), RepoError> {
1013        // Playback reporting goes directly to server
1014        self.online
1015            .report_playback_progress(item_id, position_ticks)
1016            .await
1017    }
1018
1019    async fn report_playback_stopped(
1020        &self,
1021        item_id: &str,
1022        position_ticks: i64,
1023    ) -> Result<(), RepoError> {
1024        // Playback reporting goes directly to server
1025        self.online
1026            .report_playback_stopped(item_id, position_ticks)
1027            .await
1028    }
1029
1030    fn get_image_url(
1031        &self,
1032        item_id: &str,
1033        image_type: ImageType,
1034        options: Option<ImageOptions>,
1035    ) -> String {
1036        // Always use online URL for images (thumbnail cache handles offline)
1037        self.online.get_image_url(item_id, image_type, options)
1038    }
1039
1040    fn get_subtitle_url(
1041        &self,
1042        item_id: &str,
1043        media_source_id: &str,
1044        stream_index: i32,
1045        format: &str,
1046    ) -> String {
1047        // Always use online URL for subtitles
1048        self.online
1049            .get_subtitle_url(item_id, media_source_id, stream_index, format)
1050    }
1051
1052    fn get_video_download_url(
1053        &self,
1054        item_id: &str,
1055        quality: &str,
1056        media_source_id: Option<&str>,
1057        source_audio_codec: Option<&str>,
1058    ) -> String {
1059        // Always use online URL for downloads
1060        self.online
1061            .get_video_download_url(item_id, quality, media_source_id, source_audio_codec)
1062    }
1063
1064    async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
1065        // Write operations go directly to server
1066        self.online.mark_favorite(item_id).await
1067    }
1068
1069    async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
1070        // Write operations go directly to server
1071        self.online.unmark_favorite(item_id).await
1072    }
1073
1074    async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
1075        // Write operations go directly to server
1076        self.online.clear_watch_history(item_id).await
1077    }
1078
1079    async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
1080        // Write operations go directly to server
1081        self.online.mark_played(item_id).await
1082    }
1083
1084    async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
1085        let offline = Arc::clone(&self.offline);
1086        let online = Arc::clone(&self.online);
1087        let person_id = person_id.to_string();
1088        let person_id_clone = person_id.clone();
1089
1090        let cache_future =
1091            Self::cache_leg(async move { offline.get_person(&person_id).await }).await;
1092
1093        let server_future = async move { online.get_person(&person_id_clone).await };
1094
1095        Self::parallel_race(cache_future, server_future).await
1096    }
1097
1098    async fn get_items_by_person(
1099        &self,
1100        person_id: &str,
1101        options: Option<GetItemsOptions>,
1102    ) -> Result<SearchResult, RepoError> {
1103        let offline = Arc::clone(&self.offline);
1104        let online = Arc::clone(&self.online);
1105        let person_id = person_id.to_string();
1106        let person_id_clone = person_id.clone();
1107        let opts_clone = options.clone();
1108
1109        let cache_future =
1110            Self::cache_leg(
1111                async move { offline.get_items_by_person(&person_id, opts_clone).await },
1112            )
1113            .await;
1114
1115        let server_future =
1116            async move { online.get_items_by_person(&person_id_clone, options).await };
1117
1118        Self::parallel_race(cache_future, server_future).await
1119    }
1120
1121    /// TRACES: UR-067 | DR-115
1122    async fn get_favorites(
1123        &self,
1124        scope: SearchScope,
1125        options: Option<GetItemsOptions>,
1126    ) -> Result<SearchResult, RepoError> {
1127        let cache_result = self.get_favorites_cache_only(scope, options.clone()).await;
1128
1129        // Downloads-only gate: with "Show all server media" off, an empty local
1130        // result means "nothing favourited is on this device" and is
1131        // authoritative. Falling through to the server here would re-pad the
1132        // page with the full favourited catalog and defeat the filter (DR-080).
1133        if !crate::repository::offline::include_catalog_browse() {
1134            if let Ok(data) = &cache_result {
1135                return Ok(data.clone());
1136            }
1137        }
1138
1139        if let Ok(data) = &cache_result {
1140            if data.has_content() {
1141                return Ok(data.clone());
1142            }
1143        }
1144
1145        // Cache miss — answer from the server, *saving through* on the way back.
1146        // Every other read path persists what it fetches; skipping it here would
1147        // mean the favourites page re-queries the server on every visit and the
1148        // offline mirror (DR-114) never learns about favourites marked
1149        // elsewhere, since this path is what fills it on a fresh install.
1150        match self.get_favorites_server_only(scope, options).await {
1151            Ok(data) => Ok(data),
1152            Err(e) => cache_result.or(Err(e)),
1153        }
1154    }
1155
1156    async fn get_similar_items(
1157        &self,
1158        item_id: &str,
1159        limit: Option<usize>,
1160    ) -> Result<SearchResult, RepoError> {
1161        let offline = Arc::clone(&self.offline);
1162        let online = Arc::clone(&self.online);
1163        let item_id = item_id.to_string();
1164        let item_id_clone = item_id.clone();
1165
1166        let cache_future =
1167            Self::cache_leg(async move { offline.get_similar_items(&item_id, limit).await }).await;
1168
1169        let server_future = async move { online.get_similar_items(&item_id_clone, limit).await };
1170
1171        Self::parallel_race(cache_future, server_future).await
1172    }
1173
1174    // ===== Playlist Methods =====
1175
1176    async fn create_playlist(
1177        &self,
1178        name: &str,
1179        item_ids: &[String],
1180    ) -> Result<PlaylistCreatedResult, RepoError> {
1181        // Write operation - delegate directly to server
1182        self.online.create_playlist(name, item_ids).await
1183    }
1184
1185    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
1186        // Write operation - delegate directly to server
1187        self.online.delete_playlist(playlist_id).await
1188    }
1189
1190    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
1191        // Write operation - delegate directly to server
1192        self.online.rename_playlist(playlist_id, name).await
1193    }
1194
1195    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
1196        let offline = Arc::clone(&self.offline);
1197        let offline_for_save = Arc::clone(&self.offline);
1198        let online = Arc::clone(&self.online);
1199        let playlist_id = playlist_id.to_string();
1200        let playlist_id_clone = playlist_id.clone();
1201        let playlist_id_for_save = playlist_id.clone();
1202
1203        // Start server request in background (non-blocking)
1204        let server_handle =
1205            tokio::spawn(async move { online.get_playlist_items(&playlist_id_clone).await });
1206
1207        // Check cache first (fast, 100ms timeout)
1208        let cache_result = self
1209            .cache_with_timeout(async move { offline.get_playlist_items(&playlist_id).await })
1210            .await;
1211
1212        // Cache hit: return immediately, update cache in background
1213        if let Ok(data) = &cache_result {
1214            if data.has_content() {
1215                debug!("[HybridRepo] Cache hit for playlist items, returning immediately");
1216                tokio::spawn(async move {
1217                    if let Ok(Ok(server_entries)) = server_handle.await {
1218                        if let Err(e) = offline_for_save
1219                            .save_playlist_items_to_cache(&playlist_id_for_save, &server_entries)
1220                            .await
1221                        {
1222                            warn!("[HybridRepo] Failed to update playlist cache: {:?}", e);
1223                        }
1224                    }
1225                });
1226                return cache_result;
1227            }
1228        }
1229
1230        // Cache miss — wait for server result
1231        match server_handle.await {
1232            Ok(Ok(entries)) => {
1233                let entries_clone = entries.clone();
1234                tokio::spawn(async move {
1235                    if let Err(e) = offline_for_save
1236                        .save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone)
1237                        .await
1238                    {
1239                        warn!(
1240                            "[HybridRepo] Failed to save playlist items to cache: {:?}",
1241                            e
1242                        );
1243                    }
1244                });
1245                Ok(entries)
1246            }
1247            Ok(Err(e)) => cache_result.or(Err(e)),
1248            Err(join_err) => cache_result.or(Err(RepoError::Network {
1249                message: format!("Server task failed: {}", join_err),
1250            })),
1251        }
1252    }
1253
1254    async fn add_to_playlist(
1255        &self,
1256        playlist_id: &str,
1257        item_ids: &[String],
1258    ) -> Result<(), RepoError> {
1259        // Write operation - delegate directly to server
1260        self.online.add_to_playlist(playlist_id, item_ids).await
1261    }
1262
1263    async fn remove_from_playlist(
1264        &self,
1265        playlist_id: &str,
1266        entry_ids: &[String],
1267    ) -> Result<(), RepoError> {
1268        // Write operation - delegate directly to server
1269        self.online
1270            .remove_from_playlist(playlist_id, entry_ids)
1271            .await
1272    }
1273
1274    async fn move_playlist_item(
1275        &self,
1276        playlist_id: &str,
1277        item_id: &str,
1278        new_index: u32,
1279    ) -> Result<(), RepoError> {
1280        // Write operation - delegate directly to server
1281        self.online
1282            .move_playlist_item(playlist_id, item_id, new_index)
1283            .await
1284    }
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289    // `GATE_TEST_LOCK` below serialises the tests that flip the process-global
1290    // `INCLUDE_CATALOG_BROWSE` flag, so its guard is deliberately held across
1291    // the `.await` of the repository call under test — that await *is* the
1292    // critical section. This is not the production deadlock hazard the lint
1293    // targets: the lock is test-only, uncontended outside these tests, and each
1294    // `#[tokio::test]` runs on its own single-threaded runtime, so a held guard
1295    // cannot block another task on the same worker. Restructuring around it
1296    // would reintroduce the flag race the lock exists to prevent.
1297    #![allow(clippy::await_holding_lock)]
1298
1299    use super::*;
1300    use std::sync::Mutex;
1301
1302    /// Offline, a cache read slowed past the fast path must still answer.
1303    ///
1304    /// The database is one SQLite connection behind one mutex, so a concurrent
1305    /// write blocks reads for its duration and the 100 ms fast path trips on
1306    /// slow storage. The deadline used to *cancel* the read and report it as a
1307    /// miss; with the server leg also failing (offline), the user got a network
1308    /// error over cached content that was sitting on disk.
1309    ///
1310    /// TRACES: UR-002 | DR-013
1311    #[tokio::test]
1312    async fn a_slow_cache_still_answers_when_the_server_is_gone() {
1313        let cache = HybridRepository::cache_leg(async {
1314            tokio::time::sleep(Duration::from_millis(250)).await;
1315            Ok(vec![MediaItem {
1316                id: "cached-item".to_string(),
1317                ..Default::default()
1318            }])
1319        })
1320        .await;
1321
1322        let server = async {
1323            Err(RepoError::Network {
1324                message: "offline".to_string(),
1325            })
1326        };
1327
1328        let got = HybridRepository::parallel_race(cache, server)
1329            .await
1330            .expect("a slow cache read must still be delivered when the server is gone");
1331        assert_eq!(got.len(), 1);
1332        assert_eq!(got[0].id, "cached-item");
1333    }
1334
1335    /// A cache that beats the deadline still short-circuits the server.
1336    ///
1337    /// TRACES: UR-002 | DR-013
1338    #[tokio::test]
1339    async fn a_fast_cache_hit_never_reaches_the_server() {
1340        let cache = HybridRepository::cache_leg(async {
1341            Ok(vec![MediaItem {
1342                id: "fast".to_string(),
1343                ..Default::default()
1344            }])
1345        })
1346        .await;
1347
1348        let server = async {
1349            panic!("the server leg must not run on a cache hit");
1350        };
1351
1352        let got = HybridRepository::parallel_race(cache, server)
1353            .await
1354            .unwrap();
1355        assert_eq!(got[0].id, "fast");
1356    }
1357
1358    /// When both sides fail, the server's error is what the caller sees.
1359    ///
1360    /// TRACES: UR-002 | DR-013
1361    #[tokio::test]
1362    async fn a_failing_slow_cache_reports_the_server_error() {
1363        let cache: CacheLeg<Vec<MediaItem>> = HybridRepository::cache_leg(async {
1364            tokio::time::sleep(Duration::from_millis(250)).await;
1365            Err(RepoError::Database {
1366                message: "disk gone".to_string(),
1367            })
1368        })
1369        .await;
1370
1371        let server = async {
1372            Err(RepoError::Network {
1373                message: "offline".to_string(),
1374            })
1375        };
1376
1377        let err = HybridRepository::parallel_race(cache, server)
1378            .await
1379            .unwrap_err();
1380        assert!(matches!(err, RepoError::Network { .. }), "got {err:?}");
1381    }
1382
1383    /// Mock offline repository that tracks queries and saves
1384    struct MockOfflineRepo {
1385        items: Arc<Mutex<Vec<MediaItem>>>,
1386        query_count: Arc<Mutex<usize>>,
1387        save_count: Arc<Mutex<usize>>,
1388    }
1389
1390    impl MockOfflineRepo {
1391        fn new() -> Self {
1392            Self {
1393                items: Arc::new(Mutex::new(Vec::new())),
1394                query_count: Arc::new(Mutex::new(0)),
1395                save_count: Arc::new(Mutex::new(0)),
1396            }
1397        }
1398
1399        fn get_query_count(&self) -> usize {
1400            *self.query_count.lock_safe()
1401        }
1402
1403        fn get_save_count(&self) -> usize {
1404            *self.save_count.lock_safe()
1405        }
1406
1407        async fn save_to_cache(
1408            &self,
1409            _parent_id: &str,
1410            items: &[MediaItem],
1411        ) -> Result<usize, RepoError> {
1412            *self.save_count.lock_safe() += 1;
1413            *self.items.lock_safe() = items.to_vec();
1414            Ok(items.len())
1415        }
1416    }
1417
1418    #[async_trait]
1419    impl MediaRepository for MockOfflineRepo {
1420        async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1421            unimplemented!()
1422        }
1423
1424        async fn get_items(
1425            &self,
1426            _parent_id: &str,
1427            _options: Option<GetItemsOptions>,
1428        ) -> Result<SearchResult, RepoError> {
1429            *self.query_count.lock_safe() += 1;
1430            let items = self.items.lock_safe().clone();
1431            let count = items.len();
1432            Ok(SearchResult {
1433                items,
1434                total_record_count: count,
1435            })
1436        }
1437
1438        async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
1439            unimplemented!()
1440        }
1441
1442        async fn get_latest_items(
1443            &self,
1444            _parent_id: &str,
1445            _limit: Option<usize>,
1446        ) -> Result<Vec<MediaItem>, RepoError> {
1447            unimplemented!()
1448        }
1449
1450        async fn get_resume_items(
1451            &self,
1452            _parent_id: Option<&str>,
1453            _limit: Option<usize>,
1454        ) -> Result<Vec<MediaItem>, RepoError> {
1455            unimplemented!()
1456        }
1457
1458        async fn get_next_up_episodes(
1459            &self,
1460            _series_id: Option<&str>,
1461            _limit: Option<usize>,
1462        ) -> Result<Vec<MediaItem>, RepoError> {
1463            unimplemented!()
1464        }
1465
1466        async fn get_recently_played_audio(
1467            &self,
1468            _limit: Option<usize>,
1469        ) -> Result<Vec<MediaItem>, RepoError> {
1470            unimplemented!()
1471        }
1472
1473        async fn get_resume_movies(
1474            &self,
1475            _limit: Option<usize>,
1476        ) -> Result<Vec<MediaItem>, RepoError> {
1477            unimplemented!()
1478        }
1479
1480        async fn get_rediscover_albums(
1481            &self,
1482            _parent_id: Option<&str>,
1483            _limit: Option<usize>,
1484        ) -> Result<Vec<MediaItem>, RepoError> {
1485            unimplemented!()
1486        }
1487
1488        async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1489            unimplemented!()
1490        }
1491
1492        async fn search(
1493            &self,
1494            _query: &str,
1495            _options: Option<SearchOptions>,
1496        ) -> Result<SearchResult, RepoError> {
1497            unimplemented!()
1498        }
1499
1500        async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
1501            unimplemented!()
1502        }
1503
1504        async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
1505            unimplemented!()
1506        }
1507
1508        async fn get_audio_only_stream_url_for_video(
1509            &self,
1510            _item_id: &str,
1511            _media_source_id: Option<&str>,
1512            _start_time_seconds: Option<f64>,
1513            _audio_stream_index: Option<i32>,
1514        ) -> Result<String, RepoError> {
1515            unimplemented!()
1516        }
1517
1518        async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
1519            unimplemented!()
1520        }
1521
1522        async fn get_channels(&self) -> Result<SearchResult, RepoError> {
1523            unimplemented!()
1524        }
1525
1526        async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
1527            unimplemented!()
1528        }
1529
1530        async fn report_playback_start(
1531            &self,
1532            _item_id: &str,
1533            _position_ticks: i64,
1534        ) -> Result<(), RepoError> {
1535            unimplemented!()
1536        }
1537
1538        async fn report_playback_progress(
1539            &self,
1540            _item_id: &str,
1541            _position_ticks: i64,
1542        ) -> Result<(), RepoError> {
1543            unimplemented!()
1544        }
1545
1546        async fn report_playback_stopped(
1547            &self,
1548            _item_id: &str,
1549            _position_ticks: i64,
1550        ) -> Result<(), RepoError> {
1551            unimplemented!()
1552        }
1553
1554        fn get_image_url(
1555            &self,
1556            _item_id: &str,
1557            _image_type: ImageType,
1558            _options: Option<ImageOptions>,
1559        ) -> String {
1560            unimplemented!()
1561        }
1562
1563        fn get_subtitle_url(
1564            &self,
1565            _item_id: &str,
1566            _media_source_id: &str,
1567            _stream_index: i32,
1568            _format: &str,
1569        ) -> String {
1570            unimplemented!()
1571        }
1572
1573        fn get_video_download_url(
1574            &self,
1575            _item_id: &str,
1576            _quality: &str,
1577            _media_source_id: Option<&str>,
1578            _source_audio_codec: Option<&str>,
1579        ) -> String {
1580            unimplemented!()
1581        }
1582
1583        async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1584            unimplemented!()
1585        }
1586
1587        async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1588            unimplemented!()
1589        }
1590
1591        async fn get_favorites(
1592            &self,
1593            _scope: SearchScope,
1594            _options: Option<GetItemsOptions>,
1595        ) -> Result<SearchResult, RepoError> {
1596            unimplemented!()
1597        }
1598
1599        async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
1600            unimplemented!()
1601        }
1602
1603        async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
1604            unimplemented!()
1605        }
1606
1607        async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
1608            unimplemented!()
1609        }
1610
1611        async fn get_items_by_person(
1612            &self,
1613            _person_id: &str,
1614            _options: Option<GetItemsOptions>,
1615        ) -> Result<SearchResult, RepoError> {
1616            unimplemented!()
1617        }
1618
1619        async fn get_similar_items(
1620            &self,
1621            _item_id: &str,
1622            _limit: Option<usize>,
1623        ) -> Result<SearchResult, RepoError> {
1624            unimplemented!()
1625        }
1626
1627        async fn create_playlist(
1628            &self,
1629            _name: &str,
1630            _item_ids: &[String],
1631        ) -> Result<PlaylistCreatedResult, RepoError> {
1632            unimplemented!()
1633        }
1634
1635        async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
1636            unimplemented!()
1637        }
1638
1639        async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
1640            unimplemented!()
1641        }
1642
1643        async fn get_playlist_items(
1644            &self,
1645            _playlist_id: &str,
1646        ) -> Result<Vec<PlaylistEntry>, RepoError> {
1647            unimplemented!()
1648        }
1649
1650        async fn add_to_playlist(
1651            &self,
1652            _playlist_id: &str,
1653            _item_ids: &[String],
1654        ) -> Result<(), RepoError> {
1655            unimplemented!()
1656        }
1657
1658        async fn remove_from_playlist(
1659            &self,
1660            _playlist_id: &str,
1661            _entry_ids: &[String],
1662        ) -> Result<(), RepoError> {
1663            unimplemented!()
1664        }
1665
1666        async fn move_playlist_item(
1667            &self,
1668            _playlist_id: &str,
1669            _item_id: &str,
1670            _new_index: u32,
1671        ) -> Result<(), RepoError> {
1672            unimplemented!()
1673        }
1674    }
1675
1676    /// Mock online repository that returns predefined items
1677    struct MockOnlineRepo {
1678        items: Vec<MediaItem>,
1679        query_count: Arc<Mutex<usize>>,
1680    }
1681
1682    impl MockOnlineRepo {
1683        fn new(items: Vec<MediaItem>) -> Self {
1684            Self {
1685                items,
1686                query_count: Arc::new(Mutex::new(0)),
1687            }
1688        }
1689
1690        fn get_query_count(&self) -> usize {
1691            *self.query_count.lock_safe()
1692        }
1693    }
1694
1695    #[async_trait]
1696    impl MediaRepository for MockOnlineRepo {
1697        async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1698            unimplemented!()
1699        }
1700
1701        async fn get_items(
1702            &self,
1703            _parent_id: &str,
1704            _options: Option<GetItemsOptions>,
1705        ) -> Result<SearchResult, RepoError> {
1706            *self.query_count.lock_safe() += 1;
1707            Ok(SearchResult {
1708                items: self.items.clone(),
1709                total_record_count: self.items.len(),
1710            })
1711        }
1712
1713        async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
1714            unimplemented!()
1715        }
1716
1717        async fn get_latest_items(
1718            &self,
1719            _parent_id: &str,
1720            _limit: Option<usize>,
1721        ) -> Result<Vec<MediaItem>, RepoError> {
1722            unimplemented!()
1723        }
1724
1725        async fn get_resume_items(
1726            &self,
1727            _parent_id: Option<&str>,
1728            _limit: Option<usize>,
1729        ) -> Result<Vec<MediaItem>, RepoError> {
1730            unimplemented!()
1731        }
1732
1733        async fn get_next_up_episodes(
1734            &self,
1735            _series_id: Option<&str>,
1736            _limit: Option<usize>,
1737        ) -> Result<Vec<MediaItem>, RepoError> {
1738            unimplemented!()
1739        }
1740
1741        async fn get_recently_played_audio(
1742            &self,
1743            _limit: Option<usize>,
1744        ) -> Result<Vec<MediaItem>, RepoError> {
1745            unimplemented!()
1746        }
1747
1748        async fn get_resume_movies(
1749            &self,
1750            _limit: Option<usize>,
1751        ) -> Result<Vec<MediaItem>, RepoError> {
1752            unimplemented!()
1753        }
1754
1755        async fn get_rediscover_albums(
1756            &self,
1757            _parent_id: Option<&str>,
1758            _limit: Option<usize>,
1759        ) -> Result<Vec<MediaItem>, RepoError> {
1760            unimplemented!()
1761        }
1762
1763        async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1764            unimplemented!()
1765        }
1766
1767        async fn search(
1768            &self,
1769            _query: &str,
1770            _options: Option<SearchOptions>,
1771        ) -> Result<SearchResult, RepoError> {
1772            unimplemented!()
1773        }
1774
1775        async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
1776            unimplemented!()
1777        }
1778
1779        async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
1780            unimplemented!()
1781        }
1782
1783        async fn get_audio_only_stream_url_for_video(
1784            &self,
1785            _item_id: &str,
1786            _media_source_id: Option<&str>,
1787            _start_time_seconds: Option<f64>,
1788            _audio_stream_index: Option<i32>,
1789        ) -> Result<String, RepoError> {
1790            unimplemented!()
1791        }
1792
1793        async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
1794            unimplemented!()
1795        }
1796
1797        async fn get_channels(&self) -> Result<SearchResult, RepoError> {
1798            unimplemented!()
1799        }
1800
1801        async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
1802            unimplemented!()
1803        }
1804
1805        async fn report_playback_start(
1806            &self,
1807            _item_id: &str,
1808            _position_ticks: i64,
1809        ) -> Result<(), RepoError> {
1810            unimplemented!()
1811        }
1812
1813        async fn report_playback_progress(
1814            &self,
1815            _item_id: &str,
1816            _position_ticks: i64,
1817        ) -> Result<(), RepoError> {
1818            unimplemented!()
1819        }
1820
1821        async fn report_playback_stopped(
1822            &self,
1823            _item_id: &str,
1824            _position_ticks: i64,
1825        ) -> Result<(), RepoError> {
1826            unimplemented!()
1827        }
1828
1829        fn get_image_url(
1830            &self,
1831            _item_id: &str,
1832            _image_type: ImageType,
1833            _options: Option<ImageOptions>,
1834        ) -> String {
1835            unimplemented!()
1836        }
1837
1838        fn get_subtitle_url(
1839            &self,
1840            _item_id: &str,
1841            _media_source_id: &str,
1842            _stream_index: i32,
1843            _format: &str,
1844        ) -> String {
1845            unimplemented!()
1846        }
1847
1848        fn get_video_download_url(
1849            &self,
1850            _item_id: &str,
1851            _quality: &str,
1852            _media_source_id: Option<&str>,
1853            _source_audio_codec: Option<&str>,
1854        ) -> String {
1855            unimplemented!()
1856        }
1857
1858        async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1859            unimplemented!()
1860        }
1861
1862        async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1863            unimplemented!()
1864        }
1865
1866        async fn get_favorites(
1867            &self,
1868            _scope: SearchScope,
1869            _options: Option<GetItemsOptions>,
1870        ) -> Result<SearchResult, RepoError> {
1871            unimplemented!()
1872        }
1873
1874        async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
1875            unimplemented!()
1876        }
1877
1878        async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
1879            unimplemented!()
1880        }
1881
1882        async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
1883            unimplemented!()
1884        }
1885
1886        async fn get_items_by_person(
1887            &self,
1888            _person_id: &str,
1889            _options: Option<GetItemsOptions>,
1890        ) -> Result<SearchResult, RepoError> {
1891            unimplemented!()
1892        }
1893
1894        async fn get_similar_items(
1895            &self,
1896            _item_id: &str,
1897            _limit: Option<usize>,
1898        ) -> Result<SearchResult, RepoError> {
1899            unimplemented!()
1900        }
1901
1902        async fn create_playlist(
1903            &self,
1904            _name: &str,
1905            _item_ids: &[String],
1906        ) -> Result<PlaylistCreatedResult, RepoError> {
1907            unimplemented!()
1908        }
1909
1910        async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
1911            unimplemented!()
1912        }
1913
1914        async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
1915            unimplemented!()
1916        }
1917
1918        async fn get_playlist_items(
1919            &self,
1920            _playlist_id: &str,
1921        ) -> Result<Vec<PlaylistEntry>, RepoError> {
1922            unimplemented!()
1923        }
1924
1925        async fn add_to_playlist(
1926            &self,
1927            _playlist_id: &str,
1928            _item_ids: &[String],
1929        ) -> Result<(), RepoError> {
1930            unimplemented!()
1931        }
1932
1933        async fn remove_from_playlist(
1934            &self,
1935            _playlist_id: &str,
1936            _entry_ids: &[String],
1937        ) -> Result<(), RepoError> {
1938            unimplemented!()
1939        }
1940
1941        async fn move_playlist_item(
1942            &self,
1943            _playlist_id: &str,
1944            _item_id: &str,
1945            _new_index: u32,
1946        ) -> Result<(), RepoError> {
1947            unimplemented!()
1948        }
1949    }
1950
1951    fn create_test_item(id: &str, name: &str) -> MediaItem {
1952        MediaItem {
1953            id: id.to_string(),
1954            name: name.to_string(),
1955            item_type: "Movie".to_string(),
1956            kind: crate::domain::MediaKind::Movie,
1957            is_folder: false,
1958            server_id: "test-server".to_string(),
1959            parent_id: Some("parent-123".to_string()),
1960            library_id: Some("library-456".to_string()),
1961            overview: Some("Test overview".to_string()),
1962            genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
1963            runtime_ticks: Some(7200000000),
1964            duration_ms: Some(720000),
1965            production_year: Some(2024),
1966            premiere_date: None,
1967            community_rating: Some(8.5),
1968            official_rating: Some("PG-13".to_string()),
1969            primary_image_tag: Some("image-tag-123".to_string()),
1970            image_id: Some("image-tag-123".to_string()),
1971            backdrop_image_tags: Some(vec!["backdrop-1".to_string()]),
1972            parent_backdrop_image_tags: None,
1973            album_id: None,
1974            album_name: None,
1975            album_artist: None,
1976            artists: None,
1977            artist_items: None,
1978            index_number: None,
1979            series_id: None,
1980            series_name: None,
1981            season_id: None,
1982            season_name: None,
1983            parent_index_number: None,
1984            user_data: None,
1985            media_streams: None,
1986            media_sources: None,
1987            people: None,
1988        }
1989    }
1990
1991    /// Helper to test the caching logic
1992    struct TestHybridRepo {
1993        offline: Arc<MockOfflineRepo>,
1994        online: Arc<MockOnlineRepo>,
1995    }
1996
1997    impl TestHybridRepo {
1998        fn new(server_items: Vec<MediaItem>) -> Self {
1999            let offline = Arc::new(MockOfflineRepo::new());
2000            let online = Arc::new(MockOnlineRepo::new(server_items));
2001            Self { offline, online }
2002        }
2003
2004        /// Test version of get_items that implements the cache logic
2005        async fn get_items(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
2006            let offline = Arc::clone(&self.offline);
2007            let offline_for_save = Arc::clone(&self.offline);
2008            let online = Arc::clone(&self.online);
2009            let parent_id = parent_id.to_string();
2010            let parent_id_clone = parent_id.clone();
2011            let parent_id_for_save = parent_id.clone();
2012
2013            // Check cache first
2014            let cache_future = async move { offline.get_items(&parent_id, None).await };
2015
2016            let server_future = async move { online.get_items(&parent_id_clone, None).await };
2017
2018            // Wait for both, prefer cache if available
2019            let (cache_result, server_result) = tokio::join!(cache_future, server_future);
2020
2021            // Check if cache had meaningful content
2022            let cache_had_content = cache_result
2023                .as_ref()
2024                .map(|data| data.has_content())
2025                .unwrap_or(false);
2026
2027            // Prefer cache if it has content (mimics hybrid.rs get_items logic)
2028            let result = if cache_had_content {
2029                cache_result?
2030            } else {
2031                // Use server result and save to cache for next time
2032                let server_data = server_result?;
2033
2034                if !server_data.items.is_empty() {
2035                    let items_clone = server_data.items.clone();
2036                    offline_for_save
2037                        .save_to_cache(&parent_id_for_save, &items_clone)
2038                        .await?;
2039                }
2040
2041                server_data
2042            };
2043
2044            Ok(result)
2045        }
2046
2047        /// Test version mirroring the real `HybridRepository::get_items`
2048        /// downloads-only gate: when `include_catalog_browse()` is false, the
2049        /// offline result is authoritative and the server is NOT queried, even
2050        /// when the cache is empty. Otherwise falls through to the normal
2051        /// cache-first logic in `get_items`.
2052        async fn get_items_gated(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
2053            if !crate::repository::offline::include_catalog_browse() {
2054                let items = self.offline.get_items(parent_id, None).await?;
2055                // Authoritative: return as-is, never touch the server.
2056                return Ok(items);
2057            }
2058            self.get_items(parent_id).await
2059        }
2060    }
2061
2062    /// Serialize tests that mutate the process-global INCLUDE_CATALOG_BROWSE
2063    /// flag, and always restore it to the default (true) afterwards.
2064    static GATE_TEST_LOCK: Mutex<()> = Mutex::new(());
2065
2066    /// UT-070: with the downloads-only gate off, an empty offline result is
2067    /// returned as-is and the server is NOT queried.
2068    ///
2069    /// @req-test: UR-052 - Offline "downloaded only" filtering
2070    /// @req-test: DR-080 - Empty offline result is authoritative when gate off
2071    #[tokio::test]
2072    async fn test_get_items_gate_off_empty_does_not_query_server() {
2073        let _guard = GATE_TEST_LOCK.lock_safe();
2074        crate::repository::offline::set_include_catalog_browse(false);
2075
2076        // Server has items, cache is empty. Gate off ⇒ the server must be ignored.
2077        let repo = TestHybridRepo::new(vec![
2078            create_test_item("s-1", "Server 1"),
2079            create_test_item("s-2", "Server 2"),
2080        ]);
2081
2082        let result = repo.get_items_gated("parent-123").await.unwrap();
2083
2084        assert_eq!(
2085            result.items.len(),
2086            0,
2087            "empty offline result is authoritative when the gate is off"
2088        );
2089        assert_eq!(
2090            repo.online.get_query_count(),
2091            0,
2092            "server must NOT be queried when the gate is off"
2093        );
2094
2095        crate::repository::offline::set_include_catalog_browse(true);
2096    }
2097
2098    /// Guard the online path: with the gate ON and an empty cache, get_items
2099    /// still falls through to the server (unchanged behaviour).
2100    ///
2101    /// @req-test: UR-052 - Offline "downloaded only" filtering
2102    /// @req-test: DR-080 - Gate on ⇒ empty cache still queries the server
2103    #[tokio::test]
2104    async fn test_get_items_gate_on_empty_falls_through_to_server() {
2105        let _guard = GATE_TEST_LOCK.lock_safe();
2106        crate::repository::offline::set_include_catalog_browse(true);
2107
2108        let repo = TestHybridRepo::new(vec![
2109            create_test_item("s-1", "Server 1"),
2110            create_test_item("s-2", "Server 2"),
2111        ]);
2112
2113        let result = repo.get_items_gated("parent-123").await.unwrap();
2114
2115        assert_eq!(result.items.len(), 2, "server result used on empty cache");
2116        assert_eq!(
2117            repo.online.get_query_count(),
2118            1,
2119            "server IS queried when the gate is on and the cache is empty"
2120        );
2121    }
2122
2123    /// Test cache miss saves server data to cache for next time
2124    ///
2125    /// @req-test: UR-002 - Access media when online or offline
2126    /// @req-test: DR-013 - Repository pattern for online/offline data access
2127    /// @req-test: DR-012 - Local database for media metadata cache
2128    #[tokio::test]
2129    async fn test_cache_miss_saves_to_cache() {
2130        // Setup: Server has 3 items, cache is empty
2131        let server_items = vec![
2132            create_test_item("item-1", "Movie 1"),
2133            create_test_item("item-2", "Movie 2"),
2134            create_test_item("item-3", "Movie 3"),
2135        ];
2136
2137        let repo = TestHybridRepo::new(server_items.clone());
2138
2139        // First request - cache miss
2140        let result = repo.get_items("parent-123").await.unwrap();
2141
2142        // Should return server items
2143        assert_eq!(result.items.len(), 3);
2144        assert_eq!(result.items[0].id, "item-1");
2145
2146        // Should have queried both cache and server
2147        assert_eq!(
2148            repo.offline.get_query_count(),
2149            1,
2150            "Cache should be queried once"
2151        );
2152        assert_eq!(
2153            repo.online.get_query_count(),
2154            1,
2155            "Server should be queried once"
2156        );
2157
2158        // Should have saved to cache
2159        assert_eq!(
2160            repo.offline.get_save_count(),
2161            1,
2162            "Should save to cache on miss"
2163        );
2164    }
2165
2166    /// Test cache hit prevents duplicate save to cache
2167    ///
2168    /// Verifies parallel racing strategy: both cache and server are queried,
2169    /// but when cache has content, it's used and no duplicate save occurs.
2170    ///
2171    /// @req-test: UR-002 - Access media when online or offline
2172    /// @req-test: DR-013 - Repository pattern for online/offline data access
2173    /// @req-test: DR-012 - Local database cache (avoid duplicate writes)
2174    #[tokio::test]
2175    async fn test_cache_hit_no_save() {
2176        // Setup: Server has 3 items, we'll pre-populate cache
2177        let server_items = vec![
2178            create_test_item("item-1", "Movie 1"),
2179            create_test_item("item-2", "Movie 2"),
2180            create_test_item("item-3", "Movie 3"),
2181        ];
2182
2183        let repo = TestHybridRepo::new(server_items.clone());
2184
2185        // Pre-populate cache
2186        repo.offline
2187            .save_to_cache("parent-123", &server_items)
2188            .await
2189            .unwrap();
2190        assert_eq!(repo.offline.get_save_count(), 1);
2191
2192        // Second request - cache hit
2193        let result = repo.get_items("parent-123").await.unwrap();
2194
2195        // Should return cached items
2196        assert_eq!(result.items.len(), 3);
2197        assert_eq!(result.items[0].id, "item-1");
2198
2199        // Should have queried cache and server (parallel race)
2200        assert_eq!(repo.offline.get_query_count(), 1, "Cache should be queried");
2201        assert_eq!(
2202            repo.online.get_query_count(),
2203            1,
2204            "Server is queried in parallel"
2205        );
2206
2207        // Should NOT have saved again (no duplicate save)
2208        assert_eq!(
2209            repo.offline.get_save_count(),
2210            1,
2211            "Should NOT save when using cache"
2212        );
2213    }
2214
2215    /// Test empty results are not saved to cache
2216    ///
2217    /// @req-test: DR-013 - Repository pattern (edge case handling)
2218    /// @req-test: DR-012 - Local database cache (avoid saving empty data)
2219    #[tokio::test]
2220    async fn test_empty_cache_returns_empty_result() {
2221        // Setup: Server has no items
2222        let repo = TestHybridRepo::new(vec![]);
2223
2224        // Request with empty server
2225        let result = repo.get_items("parent-123").await.unwrap();
2226
2227        // Should return empty result
2228        assert_eq!(result.items.len(), 0);
2229
2230        // Should NOT save empty results
2231        assert_eq!(
2232            repo.offline.get_save_count(),
2233            0,
2234            "Should not save empty results"
2235        );
2236    }
2237
2238    /// Test SearchResult::has_content helper method
2239    ///
2240    /// @req-test: DR-013 - Repository pattern (content detection helper)
2241    #[tokio::test]
2242    async fn test_has_content_check() {
2243        // Test that SearchResult::has_content works correctly
2244        let empty_result = SearchResult {
2245            items: vec![],
2246            total_record_count: 0,
2247        };
2248        assert!(
2249            !empty_result.has_content(),
2250            "Empty result should not have content"
2251        );
2252
2253        let result_with_items = SearchResult {
2254            items: vec![create_test_item("item-1", "Movie 1")],
2255            total_record_count: 1,
2256        };
2257        assert!(
2258            result_with_items.has_content(),
2259            "Result with items should have content"
2260        );
2261    }
2262
2263    #[test]
2264    fn test_merge_search_local_first_then_server_appended() {
2265        let cache = SearchResult {
2266            items: vec![
2267                create_test_item("a", "Cached A"),
2268                create_test_item("b", "Cached B"),
2269            ],
2270            total_record_count: 2,
2271        };
2272        let server = SearchResult {
2273            items: vec![
2274                create_test_item("c", "Server C"),
2275                create_test_item("d", "Server D"),
2276            ],
2277            total_record_count: 2,
2278        };
2279
2280        let merged = HybridRepository::merge_search_results(cache, server);
2281
2282        // Local items first (in order), then server-only items appended.
2283        let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
2284        assert_eq!(ids, vec!["a", "b", "c", "d"]);
2285        assert_eq!(merged.total_record_count, 4);
2286    }
2287
2288    #[test]
2289    fn test_merge_search_dedupes_with_server_winning() {
2290        // "b" appears in both. Server metadata should win, but the item keeps
2291        // its earlier (local) position and is not duplicated.
2292        let cache = SearchResult {
2293            items: vec![
2294                create_test_item("a", "Cached A"),
2295                create_test_item("b", "Cached B"),
2296            ],
2297            total_record_count: 2,
2298        };
2299        let server = SearchResult {
2300            items: vec![
2301                create_test_item("b", "Server B (fresher)"),
2302                create_test_item("c", "Server C"),
2303            ],
2304            total_record_count: 2,
2305        };
2306
2307        let merged = HybridRepository::merge_search_results(cache, server);
2308
2309        let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
2310        assert_eq!(
2311            ids,
2312            vec!["a", "b", "c"],
2313            "no duplicate, local position kept"
2314        );
2315
2316        let b = merged.items.iter().find(|i| i.id == "b").unwrap();
2317        assert_eq!(
2318            b.name, "Server B (fresher)",
2319            "server metadata wins on conflict"
2320        );
2321        assert_eq!(merged.total_record_count, 3);
2322    }
2323
2324    #[test]
2325    fn test_merge_search_handles_empty_sides() {
2326        let only_server = HybridRepository::merge_search_results(
2327            SearchResult {
2328                items: vec![],
2329                total_record_count: 0,
2330            },
2331            SearchResult {
2332                items: vec![create_test_item("x", "X")],
2333                total_record_count: 1,
2334            },
2335        );
2336        assert_eq!(only_server.items.len(), 1);
2337        assert_eq!(only_server.items[0].id, "x");
2338
2339        let only_cache = HybridRepository::merge_search_results(
2340            SearchResult {
2341                items: vec![create_test_item("y", "Y")],
2342                total_record_count: 1,
2343            },
2344            SearchResult {
2345                items: vec![],
2346                total_record_count: 0,
2347            },
2348        );
2349        assert_eq!(only_cache.items.len(), 1);
2350        assert_eq!(only_cache.items[0].id, "y");
2351    }
2352}