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