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}