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