Skip to main content

#
jellytau_lib/repository/
offline.rs

    1// Offline repository - queries SQLite database for cached data
    2//
    3// TRACES: UR-002, UR-052 | DR-012, DR-013, DR-078
    4use std::sync::atomic::{AtomicBool, Ordering};
    5use std::sync::Arc;
    6
    7use async_trait::async_trait;
    8use log::debug;
    9
    10use super::{types::*, MediaRepository};
    11use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
    12
    13/// Whether offline library queries may include catalog items that are merely
    14/// *browsed/synced* but not downloaded (the greyed-out "browse the whole server"
    15/// view). Defaults to `true` so online browsing (which reads this same cache as
    16/// a fast path) still sees the full catalog.
    17///
    18/// While offline, the frontend drives this from the "Show all server media"
    19/// toggle: OFF means library pages show only downloaded/local media, ON reveals
    20/// the full greyed-out catalog. See `set_include_catalog_browse` and the
    21/// `showServerCatalog` UI flag. Fixes the bug where offline library pages showed
    22/// every server item regardless of the toggle.
    23///
    24/// TRACES: UR-052 | DR-078
    25static INCLUDE_CATALOG_BROWSE: AtomicBool = AtomicBool::new(true);
    26
    27/// Set whether offline `get_items` includes non-downloaded (synced-only) catalog
    28/// items. Called from the frontend: `true` when online or when the offline
    29/// "Show all server media" toggle is on; `false` when offline with the toggle
    30/// off (show downloaded/local media only).
    31pub fn set_include_catalog_browse(include: bool) {
    32    INCLUDE_CATALOG_BROWSE.store(include, Ordering::Relaxed);
    33}
    34
    35/// Whether offline `get_items` currently includes the synced-but-not-downloaded
    36/// catalog (the greyed-out browse view). Mirrors `set_include_catalog_browse`.
    37///
    38/// Exposed so the hybrid repo can tell "cache is cold, ask the server" from
    39/// "user asked for downloads only and there are none here": when this is false,
    40/// an empty offline `get_items` is authoritative and must not fall through to
    41/// the server. See hybrid.rs `get_items`.
    42///
    43/// TRACES: UR-052 | DR-078, DR-080
    44pub fn include_catalog_browse() -> bool {
    45    INCLUDE_CATALOG_BROWSE.load(Ordering::Relaxed)
    46}
    47
    48/// Build a safe FTS5 prefix query from raw user input.
    49///
    50/// Every whitespace-separated token is emitted as a *quoted phrase*, so
    51/// punctuation the user types (apostrophes in `Bob's Burgers`, hyphens in
    52/// `Spider-Man`, the `/` in `AC/DC`) is treated as data rather than FTS5
    53/// operator syntax — unquoted, those characters make `MATCH` raise a syntax
    54/// error and the whole search fails. The final token carries the `*` prefix
    55/// operator so results appear while the user is still typing; earlier tokens are
    56/// implicitly ANDed, which preserves the pre-existing matching semantics.
    57///
    58/// Returns `None` when the input holds nothing searchable (empty, or pure
    59/// punctuation), so callers skip the query rather than handing FTS5 a string it
    60/// will reject — `search("")` is a real call site, used to list all playlists.
    61///
    62/// TRACES: UR-065 | DR-108 | UT-111
    63fn build_fts_prefix_query(query: &str) -> Option<String> {
    64    let tokens: Vec<String> = query
    65        .split_whitespace()
    66        .filter(|token| token.chars().any(char::is_alphanumeric))
    67        .map(|token| format!("\"{}\"", token.replace('"', "\"\"")))
    68        .collect();
    69
    70    let last = tokens.len().checked_sub(1)?;
    71    Some(
    72        tokens
    73            .iter()
    74            .enumerate()
    75            .map(|(i, token)| {
    76                if i == last {
    77                    format!("{}*", token)
    78                } else {
    79                    token.clone()
    80                }
    81            })
    82            .collect::<Vec<_>>()
    83            .join(" "),
    84    )
    85}
    86
    87/// The Jellyfin taxonomy half of "does cached item `i` belong to library `l`":
    88/// the library's `collection_type` against the item's `item_type`.
    89///
    90/// A macro rather than a `const` because both callers need it *inside* a larger
    91/// SQL string literal, and `concat!` cannot take a const. One definition, so the
    92/// two sites cannot drift — they did once already, and opening any downloaded
    93/// library then listed every downloaded item on the server (DR-167).
    94///
    95/// Deliberately has **no fall-open arm**. Adding "…or the type is unknown"
    96/// makes the clause true for every row, which is precisely the defect it exists
    97/// to prevent; callers that want that behaviour must say so themselves and
    98/// justify it, as `LIBRARY_HOLDS_ITEM` does.
    99///
    100/// TRACES: UR-007, UR-055 | DR-167, DR-277
    101macro_rules! library_type_matches_item {
    102    () => {
    103        "(
    104               (l.collection_type = 'music'   AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
    105            OR (l.collection_type = 'movies'  AND i.item_type = 'Movie')
    106            OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
    107           )"
    108    };
    109}
    110
    111pub struct OfflineRepository {
    112    db_service: Arc<RusqliteService>,
    113    server_id: String,
    114    user_id: String,
    115}
    116
    117impl OfflineRepository {
    118    pub fn new(db_service: Arc<RusqliteService>, server_id: String, user_id: String) -> Self {
    119        Self {
    120            db_service,
    121            server_id,
    122            user_id,
    123        }
    124    }
    125
    126    /// Helper to convert CachedItem from storage to MediaItem
    127    fn cached_item_to_media_item(item: CachedItem, user_data: Option<UserData>) -> MediaItem {
    128        let artists_vec = item
    129            .artists
    130            .as_ref()
    131            .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
    132            .unwrap_or_default();
    133
    134        let kind = crate::domain::kind_from_jellyfin(&item.item_type, item.is_folder);
    135
    136        MediaItem {
    137            id: item.id.clone(),
    138            name: item.name,
    139            item_type: item.item_type,
    140            kind,
    141            is_folder: item.is_folder,
    142            server_id: item.server_id,
    143            parent_id: item.parent_id,
    144            library_id: item.library_id,
    145            overview: item.overview,
    146            genres: item
    147                .genres
    148                .as_ref()
    149                .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
    150            runtime_ticks: item.runtime_ticks,
    151            duration_ms: item.runtime_ticks.map(crate::domain::ticks_to_ms),
    152            production_year: item.production_year,
    153            premiere_date: item.premiere_date,
    154            community_rating: item.community_rating,
    155            official_rating: item.official_rating,
    156            primary_image_tag: item.primary_image_tag.clone(),
    157            image_id: item.primary_image_tag,
    158            backdrop_image_tags: item.backdrop_image_tags,
    159            parent_backdrop_image_tags: item.parent_backdrop_image_tags,
    160            album_id: item.album_id,
    161            album_name: item.album_name,
    162            album_artist: item.album_artist,
    163            artists: Some(artists_vec),
    164            artist_items: None, // Not stored in cache yet - TODO: add to database schema
    165            index_number: item.index_number,
    166            series_id: item.series_id,
    167            series_name: item.series_name,
    168            season_id: item.season_id,
    169            season_name: item.season_name,
    170            parent_index_number: item.parent_index_number,
    171            user_data,
    172            media_streams: None, // Not cached offline
    173            media_sources: None, // Not cached offline
    174            people: None,        // Not cached offline - TODO: add to database schema
    175        }
    176    }
    177
    178    /// Get user data for an item (playback position, favorite, etc.)
    179    async fn get_user_data(&self, item_id: &str) -> Option<UserData> {
    180        let query = Query::with_params(
    181            "SELECT playback_position_ticks, is_played, is_favorite, play_count, last_played_at, playback_context_type, playback_context_id
    182             FROM user_data WHERE user_id = ? AND item_id = ?",
    183            vec![
    184                QueryParam::String(self.user_id.clone()),
    185                QueryParam::String(item_id.to_string()),
    186            ],
    187        );
    188
    189        self.db_service
    190            .query_optional(query, |row| {
    191                let playback_position_ticks: Option<i64> = row.get(0).ok();
    192                Ok(UserData {
    193                    playback_position_ticks,
    194                    playback_position_ms: playback_position_ticks.map(crate::domain::ticks_to_ms),
    195                    is_played: row.get::<_, Option<i32>>(1).ok().flatten().map(|v| v != 0),
    196                    is_favorite: row.get::<_, Option<i32>>(2).ok().flatten().map(|v| v != 0),
    197                    play_count: row.get(3).ok(),
    198                    last_played_date: row.get(4).ok(),
    199                    playback_context_type: row.get(5).ok(),
    200                    playback_context_id: row.get(6).ok(),
    201                })
    202            })
    203            .await
    204            .ok()
    205            .flatten()
    206    }
    207}
    208
    209// Helper struct matching storage.rs CachedItem structure
    210#[derive(Debug)]
    211struct CachedItem {
    212    id: String,
    213    name: String,
    214    item_type: String,
    215    is_folder: bool,
    216    server_id: String,
    217    parent_id: Option<String>,
    218    library_id: Option<String>,
    219    overview: Option<String>,
    220    genres: Option<String>,
    221    runtime_ticks: Option<i64>,
    222    production_year: Option<i32>,
    223    premiere_date: Option<String>,
    224    community_rating: Option<f64>,
    225    official_rating: Option<String>,
    226    primary_image_tag: Option<String>,
    227    backdrop_image_tags: Option<Vec<String>>,
    228    parent_backdrop_image_tags: Option<Vec<String>>,
    229    album_id: Option<String>,
    230    album_name: Option<String>,
    231    album_artist: Option<String>,
    232    artists: Option<String>,
    233    index_number: Option<i32>,
    234    series_id: Option<String>,
    235    series_name: Option<String>,
    236    season_id: Option<String>,
    237    season_name: Option<String>,
    238    parent_index_number: Option<i32>,
    239}
    240
    241fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
    242    Ok(CachedItem {
    243        id: row.get(0)?,
    244        name: row.get(1)?,
    245        item_type: row.get(2)?,
    246        server_id: row.get(3)?,
    247        parent_id: row.get(4)?,
    248        library_id: row.get(5)?,
    249        overview: row.get(6)?,
    250        genres: row.get(7)?,
    251        runtime_ticks: row.get(8)?,
    252        production_year: row.get(9)?,
    253        community_rating: row.get(10)?,
    254        official_rating: row.get(11)?,
    255        primary_image_tag: row.get(12)?,
    256        backdrop_image_tags: None,        // TODO: Add to DB schema
    257        parent_backdrop_image_tags: None, // TODO: Add to DB schema
    258        album_id: row.get(13)?,
    259        album_name: row.get(14)?,
    260        album_artist: row.get(15)?,
    261        artists: row.get(16)?,
    262        index_number: row.get(17)?,
    263        series_id: row.get(18)?,
    264        series_name: row.get(19)?,
    265        season_id: row.get(20)?,
    266        season_name: row.get(21)?,
    267        parent_index_number: row.get(22)?,
    268        // Appended as the final columns in every SELECT that maps through this fn.
    269        is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0,
    270        premiere_date: row.get(24)?,
    271    })
    272}
    273
    274impl OfflineRepository {
    275    /// Remove catalog entries the server no longer has (mark-and-sweep).
    276    ///
    277    /// `save_to_cache` stamps every row it writes with a fresh `synced_at`, so
    278    /// after a complete crawl anything still on the server carries a timestamp
    279    /// newer than `cutoff` (taken before the crawl began) and anything deleted
    280    /// server-side kept its older one. Sweeping by timestamp avoids binding the
    281    /// crawl's entire id set, which would blow past SQLite's variable limit on a
    282    /// large library.
    283    ///
    284    /// Three exclusions, each load-bearing:
    285    ///
    286    /// * **Only `item_types` the crawl actually requested.** The crawl asks for
    287    ///   `CATALOG_ITEM_TYPES`; rows of any other type (artists, playlists, the
    288    ///   `Folder` parent stubs `save_to_cache` inserts) are never refreshed by
    289    ///   it, so sweeping by age alone would delete every one of them.
    290    /// * **Anything a completed download depends on** — the downloaded item
    291    ///   itself, and any container with a downloaded child. The user has those
    292    ///   bytes on disk; dropping the row would orphan the file.
    293    /// * Callers must only invoke this after a crawl in which *every* library
    294    ///   succeeded. `sync_full_catalog` is best-effort per library, and
    295    ///   `items.parent_id` is `ON DELETE CASCADE`, so sweeping after a partial
    296    ///   crawl could cascade an entire series away because one request timed out.
    297    ///
    298    /// Returns the number of rows removed.
    299    ///
    300    /// TRACES: UR-065 | DR-110 | UT-113
    301    pub async fn prune_stale_catalog(
    302        &self,
    303        cutoff: &str,
    304        item_types: &[String],
    305    ) -> Result<usize, RepoError> {
    306        if item_types.is_empty() {
    307            return Ok(0);
    308        }
    309        let placeholders = vec!["?"; item_types.len()].join(",");
    310        let sql = format!(
    311            "DELETE FROM items
    312              WHERE server_id = ?
    313                AND synced_at IS NOT NULL
    314                AND synced_at < ?
    315                AND item_type IN ({})
    316                AND id NOT IN (
    317                    -- Playable items with completed downloads
    318                    SELECT i.id
    319                    FROM items i
    320                    INNER JOIN downloads d ON i.id = d.item_id
    321                    WHERE d.status = 'completed'
    322
    323                    UNION
    324
    325                    -- Containers with downloaded children
    326                    SELECT i.id
    327                    FROM items i
    328                    INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
    329                    INNER JOIN downloads d ON children.id = d.item_id
    330                    WHERE d.status = 'completed'
    331                )",
    332            placeholders
    333        );
    334
    335        let mut params = vec![
    336            QueryParam::String(self.server_id.clone()),
    337            QueryParam::String(cutoff.to_string()),
    338        ];
    339        params.extend(item_types.iter().cloned().map(QueryParam::String));
    340
    341        let removed = self
    342            .db_service
    343            .execute(Query::with_params(sql, params))
    344            .await
    345            .map_err(|e| RepoError::Database { message: e })?;
    346
    347        Ok(removed)
    348    }
    349
    350    /// Full-text search over cached people (cast and crew).
    351    ///
    352    /// People are stored in their own table rather than in `items`, so search
    353    /// has to look them up separately and adapt them to `MediaItem`. Availability
    354    /// gating deliberately does not apply: a person is metadata, never a
    355    /// download, so there is nothing to be offline about.
    356    ///
    357    /// TRACES: UR-065, UR-060 | DR-111 | UT-114
    358    async fn search_people(
    359        &self,
    360        fts_query: &str,
    361        limit: usize,
    362    ) -> Result<Vec<MediaItem>, RepoError> {
    363        let sql = format!(
    364            "SELECT p.id, p.name, p.overview, p.primary_image_tag, p.premiere_date
    365               FROM people p
    366               JOIN people_fts fts ON fts.rowid = p.rowid
    367              WHERE p.server_id = ? AND people_fts MATCH ?
    368              ORDER BY rank
    369              LIMIT {}",
    370            limit
    371        );
    372
    373        let rows = self
    374            .db_service
    375            .query_many(
    376                Query::with_params(
    377                    sql,
    378                    vec![
    379                        QueryParam::String(self.server_id.clone()),
    380                        QueryParam::String(fts_query.to_string()),
    381                    ],
    382                ),
    383                |row| {
    384                    Ok((
    385                        row.get::<_, String>(0)?,
    386                        row.get::<_, String>(1)?,
    387                        row.get::<_, Option<String>>(2)?,
    388                        row.get::<_, Option<String>>(3)?,
    389                        row.get::<_, Option<String>>(4)?,
    390                    ))
    391                },
    392            )
    393            .await
    394            .map_err(|e| RepoError::Database { message: e })?;
    395
    396        Ok(rows
    397            .into_iter()
    398            .map(
    399                |(id, name, overview, primary_image_tag, premiere_date)| MediaItem {
    400                    id,
    401                    name,
    402                    item_type: "Person".to_string(),
    403                    kind: crate::domain::MediaKind::Person,
    404                    is_folder: false,
    405                    server_id: self.server_id.clone(),
    406                    overview,
    407                    primary_image_tag,
    408                    premiere_date,
    409                    ..Default::default()
    410                },
    411            )
    412            .collect())
    413    }
    414
    415    /// Save browsed items to cache for faster subsequent loading
    416    ///
    417    /// This persists metadata for items that were browsed (not necessarily downloaded).
    418    /// Items are marked with current timestamp for freshness tracking.
    419    pub async fn save_to_cache(
    420        &self,
    421        parent_id: &str,
    422        items: &[MediaItem],
    423    ) -> Result<usize, RepoError> {
    424        if items.is_empty() {
    425            return Ok(0);
    426        }
    427
    428        let now = chrono::Utc::now().to_rfc3339();
    429
    430        // Temporarily disable foreign key constraints to avoid CASCADE DELETE issues
    431        // when replacing stub parent items with their actual data
    432        self.db_service
    433            .execute(Query::new("PRAGMA foreign_keys = OFF"))
    434            .await
    435            .map_err(|e| RepoError::Database { message: e })?;
    436
    437        // Ensure we re-enable foreign keys even if an error occurs
    438        let result = self.save_to_cache_impl(parent_id, items, &now).await;
    439
    440        // Re-enable foreign key constraints
    441        let _ = self
    442            .db_service
    443            .execute(Query::new("PRAGMA foreign_keys = ON"))
    444            .await;
    445
    446        result
    447    }
    448
    449    async fn save_to_cache_impl(
    450        &self,
    451        parent_id: &str,
    452        items: &[MediaItem],
    453        now: &str,
    454    ) -> Result<usize, RepoError> {
    455        // Collect all unique parent IDs referenced by items being saved
    456        let mut parent_ids = std::collections::HashSet::new();
    457        parent_ids.insert(parent_id.to_string());
    458
    459        for item in items {
    460            if let Some(pid) = &item.parent_id {
    461                parent_ids.insert(pid.clone());
    462            }
    463        }
    464
    465        // Insert stub entries for all parent IDs to satisfy FK constraints
    466        #[cfg(test)]
    467        println!("Creating stub parents for: {:?}", parent_ids);
    468
    469        for pid in parent_ids {
    470            let parent_query = Query::with_params(
    471                "INSERT OR IGNORE INTO items (id, server_id, name, item_type, synced_at)
    472                 VALUES (?1, ?2, ?3, ?4, ?5)",
    473                vec![
    474                    QueryParam::String(pid.clone()),
    475                    QueryParam::String(self.server_id.clone()),
    476                    QueryParam::String("Parent".to_string()),
    477                    QueryParam::String("Folder".to_string()),
    478                    QueryParam::String(now.to_string()),
    479                ],
    480            );
    481
    482            let _stub_rows = self
    483                .db_service
    484                .execute(parent_query)
    485                .await
    486                .map_err(|e| RepoError::Database { message: e })?;
    487            #[cfg(test)]
    488            println!(
    489                "  Created stub parent {} (rows affected: {})",
    490                pid, _stub_rows
    491            );
    492        }
    493
    494        let mut count = 0;
    495
    496        for item in items {
    497            // Convert Option<Vec<String>> to JSON strings for storage
    498            let genres_json = item
    499                .genres
    500                .as_ref()
    501                .map(|g| serde_json::to_string(g).unwrap_or_else(|_| "[]".to_string()));
    502            let artists_json = item
    503                .artists
    504                .as_ref()
    505                .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
    506            let backdrop_tags_json = item
    507                .backdrop_image_tags
    508                .as_ref()
    509                .map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".to_string()));
    510
    511            // A real UPSERT, not INSERT OR REPLACE. REPLACE deletes the existing
    512            // row and inserts a new one, which (a) fires no AFTER DELETE trigger
    513            // unless `recursive_triggers` is on — it is not, so `items_ad` never
    514            // ran and the old `items_fts` row was orphaned — and (b) assigns a
    515            // *fresh rowid*, because `items.id` is a TEXT PRIMARY KEY, so
    516            // `items_ai` then appended a second index entry. The result was one
    517            // duplicate FTS index per catalog pass. ON CONFLICT keeps the rowid
    518            // that `items_fts.content_rowid` refers to and fires `items_au`,
    519            // which correctly replaces the entry.
    520            //
    521            // It also preserves columns absent from this statement (etag,
    522            // sort_name, tagline, …) instead of resetting them to defaults the
    523            // way REPLACE did.
    524            //
    525            // TRACES: UR-065 | DR-110 | UT-112
    526            let query = Query::with_params(
    527                "INSERT INTO items (
    528                    id, server_id, library_id, parent_id,
    529                    name, item_type, is_folder, overview,
    530                    genres, series_id, series_name,
    531                    season_id, season_name, index_number, parent_index_number,
    532                    album_id, album_name, album_artist, artists,
    533                    production_year, premiere_date, runtime_ticks,
    534                    primary_image_tag, backdrop_image_tags,
    535                    community_rating, official_rating,
    536                    synced_at
    537                ) VALUES (
    538                    ?1, ?2, ?3, ?4,
    539                    ?5, ?6, ?7, ?8,
    540                    ?9, ?10, ?11,
    541                    ?12, ?13, ?14, ?15,
    542                    ?16, ?17, ?18, ?19,
    543                    ?20, ?21, ?22,
    544                    ?23, ?24,
    545                    ?25, ?26,
    546                    ?27
    547                )
    548                ON CONFLICT(id) DO UPDATE SET
    549                    server_id = excluded.server_id,
    550                    -- This call site never supplies library_id (it is always
    551                    -- bound NULL), so keep whatever another path recorded rather
    552                    -- than clearing it the way REPLACE did.
    553                    library_id = COALESCE(excluded.library_id, items.library_id),
    554                    parent_id = excluded.parent_id,
    555                    name = excluded.name,
    556                    item_type = excluded.item_type,
    557                    is_folder = excluded.is_folder,
    558                    overview = excluded.overview,
    559                    genres = excluded.genres,
    560                    series_id = excluded.series_id,
    561                    series_name = excluded.series_name,
    562                    season_id = excluded.season_id,
    563                    season_name = excluded.season_name,
    564                    index_number = excluded.index_number,
    565                    parent_index_number = excluded.parent_index_number,
    566                    album_id = excluded.album_id,
    567                    album_name = excluded.album_name,
    568                    album_artist = excluded.album_artist,
    569                    artists = excluded.artists,
    570                    production_year = excluded.production_year,
    571                    premiere_date = excluded.premiere_date,
    572                    runtime_ticks = excluded.runtime_ticks,
    573                    primary_image_tag = excluded.primary_image_tag,
    574                    backdrop_image_tags = excluded.backdrop_image_tags,
    575                    community_rating = excluded.community_rating,
    576                    official_rating = excluded.official_rating,
    577                    synced_at = excluded.synced_at",
    578                vec![
    579                    QueryParam::String(item.id.clone()),
    580                    QueryParam::String(self.server_id.clone()),
    581                    // Library is NULL for cached items (may not be synced yet)
    582                    QueryParam::Null, // library_id
    583                    // Use the item's actual parent_id, not the function parameter
    584                    match &item.parent_id {
    585                        Some(pid) => QueryParam::String(pid.clone()),
    586                        None => QueryParam::Null,
    587                    },
    588                    QueryParam::String(item.name.clone()),
    589                    QueryParam::String(item.item_type.clone()),
    590                    QueryParam::Int(if item.is_folder { 1 } else { 0 }),
    591                    match &item.overview {
    592                        Some(o) => QueryParam::String(o.clone()),
    593                        None => QueryParam::Null,
    594                    },
    595                    match genres_json {
    596                        Some(g) => QueryParam::String(g),
    597                        None => QueryParam::Null,
    598                    },
    599                    match &item.series_id {
    600                        Some(s) => QueryParam::String(s.clone()),
    601                        None => QueryParam::Null,
    602                    },
    603                    match &item.series_name {
    604                        Some(s) => QueryParam::String(s.clone()),
    605                        None => QueryParam::Null,
    606                    },
    607                    match &item.season_id {
    608                        Some(s) => QueryParam::String(s.clone()),
    609                        None => QueryParam::Null,
    610                    },
    611                    match &item.season_name {
    612                        Some(s) => QueryParam::String(s.clone()),
    613                        None => QueryParam::Null,
    614                    },
    615                    match item.index_number {
    616                        Some(i) => QueryParam::Int(i),
    617                        None => QueryParam::Null,
    618                    },
    619                    match item.parent_index_number {
    620                        Some(i) => QueryParam::Int(i),
    621                        None => QueryParam::Null,
    622                    },
    623                    match &item.album_id {
    624                        Some(a) => QueryParam::String(a.clone()),
    625                        None => QueryParam::Null,
    626                    },
    627                    match &item.album_name {
    628                        Some(a) => QueryParam::String(a.clone()),
    629                        None => QueryParam::Null,
    630                    },
    631                    match &item.album_artist {
    632                        Some(a) => QueryParam::String(a.clone()),
    633                        None => QueryParam::Null,
    634                    },
    635                    match artists_json {
    636                        Some(a) => QueryParam::String(a),
    637                        None => QueryParam::Null,
    638                    },
    639                    match item.production_year {
    640                        Some(y) => QueryParam::Int(y),
    641                        None => QueryParam::Null,
    642                    },
    643                    match &item.premiere_date {
    644                        Some(d) => QueryParam::String(d.clone()),
    645                        None => QueryParam::Null,
    646                    },
    647                    match item.runtime_ticks {
    648                        Some(r) => QueryParam::Int64(r),
    649                        None => QueryParam::Null,
    650                    },
    651                    match &item.primary_image_tag {
    652                        Some(t) => QueryParam::String(t.clone()),
    653                        None => QueryParam::Null,
    654                    },
    655                    match backdrop_tags_json {
    656                        Some(b) => QueryParam::String(b),
    657                        None => QueryParam::Null,
    658                    },
    659                    match item.community_rating {
    660                        Some(r) => QueryParam::Float(r),
    661                        None => QueryParam::Null,
    662                    },
    663                    match &item.official_rating {
    664                        Some(r) => QueryParam::String(r.clone()),
    665                        None => QueryParam::Null,
    666                    },
    667                    QueryParam::String(now.to_string()),
    668                ],
    669            );
    670
    671            let _rows_affected =
    672                self.db_service
    673                    .execute(query)
    674                    .await
    675                    .map_err(|e| RepoError::Database {
    676                        message: format!("Failed to insert item {}: {}", item.id, e),
    677                    })?;
    678            #[cfg(test)]
    679            println!(
    680                "  [save_to_cache] Saved item {} (rows affected: {})",
    681                item.id, _rows_affected
    682            );
    683
    684            self.mirror_user_data(item, now).await?;
    685            count += 1;
    686        }
    687
    688        Ok(count)
    689    }
    690
    691    /// Mirror the server's per-user state for an item into the local
    692    /// `user_data` table, so favourites marked — and positions watched — on any
    693    /// other client are visible here, including offline, where the local table
    694    /// is the only source.
    695    ///
    696    /// The `WHERE user_data.pending_sync = 0` on the conflict clause is the
    697    /// conflict rule: a change made while the server was unreachable is still
    698    /// waiting to be pushed, and must not be clobbered by the stale value the
    699    /// server is still reporting. For a position that means it is never pulled
    700    /// *backwards* by a server that has not yet heard where we got to.
    701    ///
    702    /// Each field is mirrored only when the server actually reported it —
    703    /// `COALESCE(excluded.x, user_data.x)` keeps the stored value for anything
    704    /// absent, and a row with neither field is skipped outright rather than
    705    /// written as zeroes, which would fabricate an "unfavourited, unwatched"
    706    /// record from an endpoint that simply omits `UserData`.
    707    ///
    708    /// The position half is what makes cross-device resume work: the resume
    709    /// check reads this table alone, so before it was mirrored an item watched
    710    /// elsewhere resumed from whatever *this* device last saw, or not at all.
    711    /// The played flag rides along for the same reason: nothing else writes it
    712    /// but an explicit local toggle, so a cached episode list read every
    713    /// episode back as unwatched — the list the season view ticks and the one
    714    /// `pick_current_episode` reads to decide what is up next (DR-264).
    715    ///
    716    /// TRACES: UR-025, UR-062, UR-069 | DR-114, DR-155, DR-264 | UT-102, UT-152, UT-240
    717    async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
    718        let user_data = item.user_data.as_ref();
    719        let is_favorite = user_data.and_then(|ud| ud.is_favorite);
    720        let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
    721        let is_played = user_data.and_then(|ud| ud.is_played);
    722
    723        // Nothing the server actually told us about — do not invent a row.
    724        if is_favorite.is_none() && position_ticks.is_none() && is_played.is_none() {
    725            return Ok(());
    726        }
    727
    728        let query = Query::with_params(
    729            "INSERT INTO user_data
    730                (user_id, item_id, is_favorite, playback_position_ticks, is_played,
    731                 synced_at, pending_sync)
    732             VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)
    733             ON CONFLICT(user_id, item_id) DO UPDATE SET
    734                is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
    735                playback_position_ticks = COALESCE(
    736                    excluded.playback_position_ticks, user_data.playback_position_ticks),
    737                is_played = COALESCE(excluded.is_played, user_data.is_played),
    738                synced_at = excluded.synced_at
    739             WHERE user_data.pending_sync = 0",
    740            vec![
    741                QueryParam::String(self.user_id.clone()),
    742                QueryParam::String(item.id.clone()),
    743                is_favorite
    744                    .map(|f| QueryParam::Int(if f { 1 } else { 0 }))
    745                    .unwrap_or(QueryParam::Null),
    746                position_ticks
    747                    .map(QueryParam::Int64)
    748                    .unwrap_or(QueryParam::Null),
    749                is_played
    750                    .map(|p| QueryParam::Int(if p { 1 } else { 0 }))
    751                    .unwrap_or(QueryParam::Null),
    752                QueryParam::String(now.to_string()),
    753            ],
    754        );
    755
    756        // A missing item row (FK) is not fatal here — the mirror is best-effort
    757        // metadata, and failing the whole cache write over it would break
    758        // browsing.
    759        if let Err(e) = self.db_service.execute(query).await {
    760            debug!(
    761                "[OfflineRepo] user_data mirror skipped for {}: {}",
    762                item.id, e
    763            );
    764        }
    765
    766        Ok(())
    767    }
    768
    769    /// Cache the library (view) list from the server into the local database.
    770    /// Called by HybridRepository after a successful online fetch so the list is
    771    /// available offline. Without this, the `libraries` table stays empty and
    772    /// offline startup shows no libraries at all.
    773    pub async fn save_libraries_to_cache(&self, libraries: &[Library]) -> Result<usize, RepoError> {
    774        if libraries.is_empty() {
    775            return Ok(0);
    776        }
    777
    778        let mut count = 0;
    779        for (idx, lib) in libraries.iter().enumerate() {
    780            let query = Query::with_params(
    781                "INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
    782                 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
    783                vec![
    784                    QueryParam::String(lib.id.clone()),
    785                    QueryParam::String(self.server_id.clone()),
    786                    QueryParam::String(lib.name.clone()),
    787                    QueryParam::String(lib.collection_type.clone()),
    788                    lib.image_tag.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
    789                    QueryParam::Int(idx as i32),
    790                ],
    791            );
    792            self.db_service
    793                .execute(query)
    794                .await
    795                .map_err(|e| RepoError::Database { message: e })?;
    796            count += 1;
    797        }
    798        Ok(count)
    799    }
    800
    801    /// Cache the full server genre catalog for a library, so offline (and the
    802    /// hybrid cache-first race) can return the complete list instead of only the
    803    /// genres derivable from locally-cached albums. Replaces the scope's rows
    804    /// wholesale so genres removed on the server don't linger.
    805    pub async fn save_genres_to_cache(
    806        &self,
    807        parent_id: Option<&str>,
    808        genres: &[Genre],
    809    ) -> Result<usize, RepoError> {
    810        if genres.is_empty() {
    811            return Ok(0);
    812        }
    813
    814        // library_id is part of the primary key; NULL keys don't de-dupe in
    815        // SQLite, so store the "no library" scope as an empty string.
    816        let library_id = parent_id.unwrap_or("").to_string();
    817        let server_id = self.server_id.clone();
    818        let genres: Vec<(String, String, Option<u32>)> = genres
    819            .iter()
    820            .map(|g| (g.id.clone(), g.name.clone(), g.album_count))
    821            .collect();
    822        let saved = genres.len();
    823
    824        self.db_service
    825            .transaction(move |tx| {
    826                use crate::storage::db_service::{Query, QueryParam};
    827
    828                // Clear the scope's existing genres, then re-insert the fresh set.
    829                tx.execute(Query::with_params(
    830                    "DELETE FROM genres WHERE server_id = ? AND library_id = ?",
    831                    vec![
    832                        QueryParam::String(server_id.clone()),
    833                        QueryParam::String(library_id.clone()),
    834                    ],
    835                ))?;
    836
    837                for (id, name, album_count) in &genres {
    838                    tx.execute(Query::with_params(
    839                        "INSERT OR REPLACE INTO genres (id, server_id, library_id, name, album_count, synced_at)
    840                         VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
    841                        vec![
    842                            QueryParam::String(id.clone()),
    843                            QueryParam::String(server_id.clone()),
    844                            QueryParam::String(library_id.clone()),
    845                            QueryParam::String(name.clone()),
    846                            album_count.map(|c| QueryParam::Int(c as i32)).unwrap_or(QueryParam::Null),
    847                        ],
    848                    ))?;
    849                }
    850
    851                Ok(())
    852            })
    853            .await
    854            .map_err(|e| RepoError::Database { message: e })?;
    855
    856        Ok(saved)
    857    }
    858
    859    /// SQL fragment: the set of item ids that are "on the device" — playable
    860    /// items with a completed download, plus containers (album/series/season)
    861    /// that have at least one downloaded child. This is the `get_items` CTE with
    862    /// the synced-but-not-downloaded catalog branch deliberately excluded, so it
    863    /// is authoritative regardless of the process-wide catalog-browse flag.
    864    ///
    865    /// Whether cached item `i` belongs to library `l`, decided by media kind.
    866    ///
    867    /// The cache leaves `library_id`/`parent_id` NULL on every item
    868    /// ([[offline-libraries-never-cached]]), so there is no link to follow: a
    869    /// library's `collection_type` and an item's `item_type` are the only things
    870    /// that can associate them. This is Jellyfin taxonomy and therefore lives in
    871    /// Rust, never in the frontend.
    872    ///
    873    /// It is a named constant because it is needed in two places that must agree
    874    /// — which library *appears* in the Downloaded list, and which items appear
    875    /// *inside* it. They disagreed: the listing query used this mapping while the
    876    /// browse query only checked that the requested library existed, so opening
    877    /// any library showed every downloaded top-level item on the server.
    878    ///
    879    /// A library of some other (or unknown) type keeps everything, since there is
    880    /// no mapping to narrow it by and hiding its contents would be worse.
    881    ///
    882    /// TRACES: UR-055 | DR-082, DR-167
    883    const LIBRARY_HOLDS_ITEM: &'static str = concat!(
    884        "(",
    885        library_type_matches_item!(),
    886        "  OR l.collection_type IS NULL
    887           OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
    888        )"
    889    );
    890
    891    /// TRACES: UR-055 | DR-082, DR-083
    892    const DOWNLOADED_ITEMS_CTE: &'static str = "
    893        WITH downloaded_items AS (
    894            SELECT DISTINCT i.id
    895            FROM items i
    896            INNER JOIN downloads d ON i.id = d.item_id
    897            WHERE d.status = 'completed'
    898              AND i.item_type IN ('Audio', 'Movie', 'Episode')
    899
    900            UNION
    901
    902            SELECT DISTINCT i.id
    903            FROM items i
    904            INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
    905            INNER JOIN downloads d ON children.id = d.item_id
    906            WHERE d.status = 'completed'
    907              AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
    908        )";
    909
    910    /// Downloaded-only browse: items under `parent_id` that are on the device.
    911    ///
    912    /// Unlike [`MediaRepository::get_items`], this never includes the
    913    /// synced-but-not-downloaded catalog and never consults the process-wide
    914    /// `INCLUDE_CATALOG_BROWSE` flag — it is the dedicated Downloads surface.
    915    /// An empty result is authoritative ("nothing downloaded here"), so the
    916    /// hybrid repo must call this directly rather than racing the server.
    917    ///
    918    /// TRACES: UR-055 | DR-082, DR-083
    919    pub async fn get_downloaded_items(
    920        &self,
    921        parent_id: &str,
    922        options: Option<GetItemsOptions>,
    923    ) -> Result<SearchResult, RepoError> {
    924        let opts = options.unwrap_or_default();
    925        let limit = opts.limit.unwrap_or(10000);
    926        let start_index = opts.start_index.unwrap_or(0);
    927
    928        let type_filter = if let Some(include_item_types) = &opts.include_item_types {
    929            if !include_item_types.is_empty() {
    930                let types = include_item_types
    931                    .iter()
    932                    .map(|t| format!("'{}'", t.replace('\'', "''")))
    933                    .collect::<Vec<_>>()
    934                    .join(",");
    935                format!(" AND i.item_type IN ({})", types)
    936            } else {
    937                String::new()
    938            }
    939        } else {
    940            String::new()
    941        };
    942
    943        // When the parent is a LIBRARY, cached items carry no link back to it
    944        // (library_id/parent_id are NULL), so the `libraries` EXISTS clause below
    945        // matches every downloaded item on the server — both containers
    946        // (MusicAlbum/Series/…) AND their leaves (Audio/Episode). Listing the
    947        // leaves alongside the containers is the "I see individual songs, not
    948        // albums" bug: a library landing page must show only *top-level* items.
    949        // So at the library level we exclude any leaf whose own container
    950        // (album/season/series/parent) is itself present in `downloaded_items` —
    951        // that container represents it in the grid. Items with no downloaded
    952        // container (e.g. a downloaded Movie, or a stray track whose album isn't
    953        // cached) still surface. This mirrors the online music library, which
    954        // routes to a dedicated albums view. See [[offline-libraries-never-cached]].
    955        let sql = format!(
    956            "{cte}
    957            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
    958                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
    959                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
    960                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
    961                    i.parent_index_number, i.is_folder, i.premiere_date
    962            FROM items i
    963             INNER JOIN downloaded_items di ON i.id = di.id
    964             WHERE i.server_id = ?
    965               AND (
    966                   i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
    967                   OR (
    968                       EXISTS (
    969                           SELECT 1 FROM libraries l
    970                           WHERE l.id = ? AND l.server_id = i.server_id
    971                             AND {membership}
    972                       )
    973                       -- Top-level only: hide leaves whose container is downloaded.
    974                       AND NOT EXISTS (
    975                           SELECT 1 FROM downloaded_items parent
    976                           WHERE parent.id = i.album_id
    977                              OR parent.id = i.season_id
    978                              OR parent.id = i.series_id
    979                              OR parent.id = i.parent_id
    980                       )
    981                   )
    982               ){type_filter}
    983             ORDER BY i.sort_name ASC, i.name ASC
    984             LIMIT {limit} OFFSET {start_index}",
    985            cte = Self::DOWNLOADED_ITEMS_CTE,
    986            membership = Self::LIBRARY_HOLDS_ITEM,
    987        );
    988
    989        let query = Query::with_params(
    990            sql,
    991            vec![
    992                QueryParam::String(self.server_id.clone()),
    993                QueryParam::String(parent_id.to_string()),
    994                QueryParam::String(parent_id.to_string()),
    995                QueryParam::String(parent_id.to_string()),
    996                QueryParam::String(parent_id.to_string()),
    997                QueryParam::String(parent_id.to_string()),
    998            ],
    999        );
    1000
    1001        let cached_items: Vec<CachedItem> = self
    1002            .db_service
    1003            .query_many(query, row_to_cached_item)
    1004            .await
    1005            .map_err(|e| RepoError::Database { message: e })?;
    1006
    1007        let mut items = Vec::new();
    1008        for cached in cached_items {
    1009            let user_data = self.get_user_data(&cached.id).await;
    1010            items.push(Self::cached_item_to_media_item(cached, user_data));
    1011        }
    1012
    1013        let total_record_count = items.len();
    1014        Ok(SearchResult {
    1015            items,
    1016            total_record_count,
    1017        })
    1018    }
    1019
    1020    /// Libraries that contain at least one downloaded item. Libraries with
    1021    /// nothing on the device are omitted, so the Downloaded surface only lists
    1022    /// libraries the user actually has offline content in.
    1023    ///
    1024    /// TRACES: UR-055 | DR-082
    1025    pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
    1026        // A downloaded item links back to its library only indirectly (the
    1027        // cache leaves library_id NULL — see [[offline-libraries-never-cached]]).
    1028        // We match a library by collection_type ↔ item_type instead: any
    1029        // completed download of a given media kind qualifies that library.
    1030        let query = Query::with_params(
    1031            format!(
    1032                "{cte}
    1033                SELECT l.id, l.name, l.collection_type, l.image_tag
    1034                FROM libraries l
    1035                WHERE l.server_id = ?
    1036                  AND EXISTS (
    1037                      SELECT 1 FROM items i
    1038                      INNER JOIN downloaded_items di ON i.id = di.id
    1039                      WHERE i.server_id = l.server_id
    1040                        AND {membership}
    1041                  )
    1042                ORDER BY l.sort_order ASC, l.name ASC",
    1043                cte = Self::DOWNLOADED_ITEMS_CTE,
    1044                membership = Self::LIBRARY_HOLDS_ITEM,
    1045            ),
    1046            vec![QueryParam::String(self.server_id.clone())],
    1047        );
    1048
    1049        self.db_service
    1050            .query_many(query, |row| {
    1051                Ok(Library::new(
    1052                    row.get(0)?,
    1053                    row.get(1)?,
    1054                    row.get::<_, Option<String>>(2)?
    1055                        .unwrap_or_else(|| "unknown".to_string()),
    1056                    row.get(3)?,
    1057                ))
    1058            })
    1059            .await
    1060            .map_err(|e| RepoError::Database { message: e })
    1061    }
    1062
    1063    /// On-disk bytes for downloaded content, for the disk-usage display.
    1064    ///
    1065    /// Returns one entry per *container or leaf* that appears in the Downloaded
    1066    /// browse: a leaf's own `file_size`, a container's summed downloaded
    1067    /// descendants — plus the device total and item (leaf) count. This is pure
    1068    /// aggregation over `downloads.file_size`, not new tracking.
    1069    ///
    1070    /// TRACES: UR-056 | DR-085
    1071    pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
    1072        // Per-leaf sizes (completed playable downloads only).
    1073        let leaf_query = Query::with_params(
    1074            "SELECT d.item_id, COALESCE(d.file_size, 0)
    1075             FROM downloads d
    1076             INNER JOIN items i ON i.id = d.item_id
    1077             WHERE d.status = 'completed'
    1078               AND i.server_id = ?
    1079               AND i.item_type IN ('Audio', 'Movie', 'Episode')",
    1080            vec![QueryParam::String(self.server_id.clone())],
    1081        );
    1082        let leaves: Vec<(String, i64)> = self
    1083            .db_service
    1084            .query_many(leaf_query, |row| Ok((row.get(0)?, row.get(1)?)))
    1085            .await
    1086            .map_err(|e| RepoError::Database { message: e })?;
    1087
    1088        // Container subtotals: sum each container's downloaded descendants.
    1089        let container_query = Query::with_params(
    1090            "SELECT c.id, COALESCE(SUM(d.file_size), 0)
    1091             FROM items c
    1092             INNER JOIN items children
    1093                ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
    1094             INNER JOIN downloads d ON children.id = d.item_id
    1095             WHERE d.status = 'completed'
    1096               AND c.server_id = ?
    1097               AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
    1098             GROUP BY c.id",
    1099            vec![QueryParam::String(self.server_id.clone())],
    1100        );
    1101        let containers: Vec<(String, i64)> = self
    1102            .db_service
    1103            .query_many(container_query, |row| Ok((row.get(0)?, row.get(1)?)))
    1104            .await
    1105            .map_err(|e| RepoError::Database { message: e })?;
    1106
    1107        // Partiality per container: a container is "partial" when it has cached
    1108        // descendants that are NOT downloaded. We compare downloaded-descendant
    1109        // count against total-cached-descendant count (the offline cache holds
    1110        // the synced full catalog, so this is meaningful).
    1111        //
    1112        // Perf: restrict `c` to containers that actually have a completed
    1113        // download *first* (the CTE), so the OR-based self-join runs over that
    1114        // handful of rows instead of the entire synced catalog. Without this the
    1115        // join is an unindexable O(items²) scan and the Downloaded page hangs on
    1116        // a large library ("Loading your downloads…" forever).
    1117        let partial_query = Query::with_params(
    1118            "WITH downloaded_containers AS (
    1119                 SELECT DISTINCT c.id
    1120                 FROM items c
    1121                 INNER JOIN items children
    1122                    ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
    1123                 INNER JOIN downloads d ON children.id = d.item_id
    1124                 WHERE d.status = 'completed'
    1125                   AND c.server_id = ?
    1126                   AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
    1127             )
    1128             SELECT c.id,
    1129                    COUNT(children.id) AS total_children,
    1130                    SUM(CASE WHEN d.status = 'completed' THEN 1 ELSE 0 END) AS downloaded_children
    1131             FROM items c
    1132             INNER JOIN downloaded_containers dc ON dc.id = c.id
    1133             INNER JOIN items children
    1134                ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
    1135             LEFT JOIN downloads d ON children.id = d.item_id AND d.status = 'completed'
    1136             WHERE children.item_type IN ('Audio', 'Movie', 'Episode', 'Season')
    1137             GROUP BY c.id",
    1138            vec![QueryParam::String(self.server_id.clone())],
    1139        );
    1140        let partial_rows: Vec<(String, i64, i64)> = self
    1141            .db_service
    1142            .query_many(partial_query, |row| {
    1143                Ok((
    1144                    row.get(0)?,
    1145                    row.get(1)?,
    1146                    row.get::<_, Option<i64>>(2)?.unwrap_or(0),
    1147                ))
    1148            })
    1149            .await
    1150            .map_err(|e| RepoError::Database { message: e })?;
    1151
    1152        let mut partial_containers = std::collections::HashMap::new();
    1153        for (id, total, downloaded) in partial_rows {
    1154            // Only record containers that actually have a download (they appear
    1155            // in the browse); mark partial when some cached child is missing.
    1156            if downloaded > 0 && downloaded < total {
    1157                partial_containers.insert(id, true);
    1158            }
    1159        }
    1160
    1161        let item_count = leaves.len() as u32;
    1162        let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum();
    1163
    1164        let mut sizes = std::collections::HashMap::new();
    1165        for (id, bytes) in leaves.into_iter().chain(containers) {
    1166            // A container id can never collide with a leaf id, so a plain insert
    1167            // is fine; use entry to be defensive against duplicate rows.
    1168            *sizes.entry(id).or_insert(0) += bytes;
    1169        }
    1170
    1171        Ok(DownloadDiskUsage {
    1172            sizes,
    1173            partial_containers,
    1174            device_total_bytes,
    1175            item_count,
    1176        })
    1177    }
    1178
    1179    /// Cache playlist items from server into local database
    1180    /// Called by HybridRepository after fetching from online
    1181    pub async fn save_playlist_items_to_cache(
    1182        &self,
    1183        playlist_id: &str,
    1184        entries: &[PlaylistEntry],
    1185    ) -> Result<(), RepoError> {
    1186        let playlist_id = playlist_id.to_string();
    1187        let user_id = self.user_id.clone();
    1188        let entries: Vec<(String, String, usize)> = entries
    1189            .iter()
    1190            .enumerate()
    1191            .map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i))
    1192            .collect();
    1193
    1194        self.db_service
    1195            .transaction(move |tx| {
    1196                use crate::storage::db_service::{Query, QueryParam};
    1197
    1198                // Ensure playlist record exists
    1199                tx.execute(Query::with_params(
    1200                    "INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)",
    1201                    vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)],
    1202                ))?;
    1203
    1204                // Clear existing entries and re-insert
    1205                tx.execute(Query::with_params(
    1206                    "DELETE FROM playlist_items WHERE playlist_id = ?",
    1207                    vec![QueryParam::String(playlist_id.clone())],
    1208                ))?;
    1209
    1210                for (_, item_id, sort_order) in &entries {
    1211                    tx.execute(Query::with_params(
    1212                        "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
    1213                        vec![
    1214                            QueryParam::String(playlist_id.clone()),
    1215                            QueryParam::String(item_id.clone()),
    1216                            QueryParam::Int(*sort_order as i32),
    1217                        ],
    1218                    ))?;
    1219                }
    1220
    1221                Ok(())
    1222            })
    1223            .await
    1224            .map_err(|e| RepoError::Database {
    1225                message: format!("Failed to cache playlist items: {}", e),
    1226            })
    1227    }
    1228}
    1229
    1230#[async_trait]
    1231impl MediaRepository for OfflineRepository {
    1232    async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
    1233        // Return every cached library for this server. We deliberately do NOT
    1234        // gate on `items.library_id` here: that column is not populated in the
    1235        // cache (the Jellyfin client doesn't parse it), so the old
    1236        // `INNER JOIN items i ON i.library_id = l.id` matched nothing and left
    1237        // offline startup with zero libraries. Navigating into a library still
    1238        // filters to downloaded content via get_items, so listing all cached
    1239        // libraries is correct — it's the "local first" list the UI browses.
    1240        let query = Query::with_params(
    1241            "SELECT l.id, l.name, l.collection_type, l.image_tag
    1242             FROM libraries l
    1243             WHERE l.server_id = ?
    1244             ORDER BY l.sort_order ASC, l.name ASC",
    1245            vec![QueryParam::String(self.server_id.clone())],
    1246        );
    1247
    1248        self.db_service
    1249            .query_many(query, |row| {
    1250                Ok(Library::new(
    1251                    row.get(0)?,
    1252                    row.get(1)?,
    1253                    row.get::<_, Option<String>>(2)?
    1254                        .unwrap_or_else(|| "unknown".to_string()),
    1255                    row.get(3)?,
    1256                ))
    1257            })
    1258            .await
    1259            .map_err(|e| RepoError::Database { message: e })
    1260    }
    1261
    1262    async fn get_items(
    1263        &self,
    1264        parent_id: &str,
    1265        options: Option<GetItemsOptions>,
    1266    ) -> Result<SearchResult, RepoError> {
    1267        debug!(
    1268            "[OfflineRepo] get_items called for parent_id: {}",
    1269            &parent_id[..8.min(parent_id.len())]
    1270        );
    1271        let opts = options.unwrap_or_default();
    1272        let limit = opts.limit.unwrap_or(10000); // Match frontend limit for full library loading
    1273        let start_index = opts.start_index.unwrap_or(0);
    1274
    1275        // SortBy=Random is the only sort the landing pages rely on offline (the
    1276        // hero "surprise" pool); PremiereDate is what a channel folder's
    1277        // children are listed by (DR-257), so the cached leg of the race agrees
    1278        // with the server's order instead of flashing a name-sorted list first.
    1279        // Everything else keeps the stable name order.
    1280        //
    1281        // Rows with no premiere date sort last rather than leading the list.
    1282        let default_sort = default_listing_sort(opts.parent_kind);
    1283        let sort_field = opts
    1284            .sort_by
    1285            .as_deref()
    1286            .or(default_sort.map(|(field, _)| field));
    1287        let descending = opts
    1288            .sort_order
    1289            .as_deref()
    1290            .or(default_sort.map(|(_, order)| order))
    1291            == Some("Descending");
    1292        let order_by = match sort_field {
    1293            Some("Random") => "RANDOM()".to_string(),
    1294            Some("PremiereDate") => format!(
    1295                "i.premiere_date IS NULL, i.premiere_date {}, i.sort_name ASC",
    1296                if descending { "DESC" } else { "ASC" }
    1297            ),
    1298            _ => "i.sort_name ASC, i.name ASC".to_string(),
    1299        };
    1300
    1301        // Bind the type filter rather than interpolating it: `include_item_types`
    1302        // is settable straight from the frontend (GenericMediaListPage passes it),
    1303        // so a quote in a type must be data, not syntax. Same shape as `search`
    1304        // and `get_favorites`.
    1305        //
    1306        // TRACES: UR-065 | DR-212 | UT-206
    1307        let type_values: &[String] = opts
    1308            .include_item_types
    1309            .as_deref()
    1310            .filter(|types| !types.is_empty())
    1311            .unwrap_or(&[]);
    1312        let type_filter = if type_values.is_empty() {
    1313            String::new()
    1314        } else {
    1315            let placeholders = vec!["?"; type_values.len()].join(",");
    1316            format!(" AND i.item_type IN ({})", placeholders)
    1317        };
    1318
    1319        // Favourites narrowing for a normal library listing. Bound rather than
    1320        // interpolated, and appended after the parent-matching group so its
    1321        // parameter is simply the last one in the vec below.
    1322        // TRACES: UR-067 | DR-116 | UT-104
    1323        let favorites_filter = if opts.favorites_only == Some(true) {
    1324            " AND EXISTS (
    1325                   SELECT 1 FROM user_data ud
    1326                   WHERE ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1
    1327               )"
    1328        } else {
    1329            ""
    1330        };
    1331
    1332        // Use CTE to find items that are either:
    1333        // 1. Playable items (Audio, Movie, Episode) with completed downloads (offline mode)
    1334        // 2. Container items (MusicAlbum, Series, Season) with at least one downloaded child (offline mode)
    1335        // 3. Cached items with recent synced_at timestamp (fast online browsing, or the
    1336        //    offline "Show all server media" catalog view) — only when the catalog-browse
    1337        //    flag is set. When offline with the toggle off, this branch is omitted so the
    1338        //    page shows downloaded/local media only. See `set_include_catalog_browse`.
    1339        let catalog_branch = if include_catalog_browse() {
    1340            "UNION
    1341
    1342                -- Cached items for fast browsing (online) or the offline catalog view
    1343                SELECT DISTINCT i.id
    1344                FROM items i
    1345                WHERE i.synced_at IS NOT NULL"
    1346        } else {
    1347            ""
    1348        };
    1349        let sql = format!(
    1350            "WITH available_items AS (
    1351                -- Playable items with completed downloads
    1352                SELECT DISTINCT i.id
    1353                FROM items i
    1354                INNER JOIN downloads d ON i.id = d.item_id
    1355                WHERE d.status = 'completed'
    1356                  AND i.item_type IN ('Audio', 'Movie', 'Episode')
    1357
    1358                UNION
    1359
    1360                -- Containers with downloaded children
    1361                SELECT DISTINCT i.id
    1362                FROM items i
    1363                INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
    1364                INNER JOIN downloads d ON children.id = d.item_id
    1365                WHERE d.status = 'completed'
    1366                  AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
    1367
    1368                {catalog_branch}
    1369            )
    1370            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
    1371                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
    1372                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
    1373                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
    1374                    i.parent_index_number, i.is_folder, i.premiere_date
    1375            FROM items i
    1376             INNER JOIN available_items ai ON i.id = ai.id
    1377             WHERE i.server_id = ?
    1378               AND (
    1379                   i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
    1380                   -- When the requested parent is a LIBRARY, there is no per-item
    1381                   -- link back to it (library_id/parent_id are NULL in the cache),
    1382                   -- so match every item on the server and let the type filter
    1383                   -- (e.g. MusicAlbum / Movie / Series) narrow it. This is what
    1384                   -- makes library landing pages show albums/movies/shows offline.
    1385                   --
    1386                   -- The type correlation is NOT optional. Without it this
    1387                   -- EXISTS never mentions the item, so it is true for every
    1388                   -- cached row as soon as the requested parent is any library.
    1389                   -- Music/Movies/TV got away with that because their landing
    1390                   -- pages pass `include_item_types`, which narrowed the result;
    1391                   -- the generic library page passes none, so a Books or Photos
    1392                   -- library served the entire cached server (DR-277).
    1393                   --
    1394                   -- `library_id` wins wherever it survived the cache write:
    1395                   -- it is the server's own answer, and it is the only thing
    1396                   -- that can scope a library whose type has no mapping (Books,
    1397                   -- Photos, Collections) or none at all (a mixed library, where
    1398                   -- Jellyfin sends CollectionType null). The taxonomy is the
    1399                   -- fallback for rows that predate it being stored.
    1400                   --
    1401                   -- A library with neither a stored link nor a mapped type now
    1402                   -- matches nothing here and falls through to the server, which
    1403                   -- does know what is in it. Showing nothing briefly beats
    1404                   -- showing somebody else's films with confidence.
    1405                   OR EXISTS (
    1406                       SELECT 1 FROM libraries l
    1407                       WHERE l.id = ? AND l.server_id = i.server_id
    1408                         AND (
    1409                              i.library_id = l.id
    1410                           OR (i.library_id IS NULL AND {})
    1411                         )
    1412                   )
    1413               ){}{}
    1414             ORDER BY {}
    1415             LIMIT {} OFFSET {}",
    1416            library_type_matches_item!(),
    1417            type_filter,
    1418            favorites_filter,
    1419            order_by,
    1420            limit,
    1421            start_index
    1422        );
    1423
    1424        // The requested id is compared against every hierarchy-linkage column
    1425        // because `parent_id` is not populated for cached items — music tracks
    1426        // link to their album via `album_id`, episodes to their season/series
    1427        // via `season_id`/`series_id`, and a library parent matches via the
    1428        // `libraries` EXISTS clause. See [[offline-libraries-never-cached]].
    1429        let mut params = vec![
    1430            QueryParam::String(self.server_id.clone()),
    1431            QueryParam::String(parent_id.to_string()), // i.parent_id = ?
    1432            QueryParam::String(parent_id.to_string()), // i.album_id = ?
    1433            QueryParam::String(parent_id.to_string()), // i.season_id = ?
    1434            QueryParam::String(parent_id.to_string()), // i.series_id = ?
    1435            QueryParam::String(parent_id.to_string()), // libraries.id = ?
    1436        ];
    1437        // Positional order matters: the type placeholders sit in `{type_filter}`,
    1438        // which the statement interpolates immediately after the parent-matching
    1439        // group and before `{favorites_filter}`, so they bind here — after the
    1440        // six ids above, before the favourites user id.
    1441        params.extend(type_values.iter().cloned().map(QueryParam::String));
    1442        if !favorites_filter.is_empty() {
    1443            params.push(QueryParam::String(self.user_id.clone())); // ud.user_id = ?
    1444        }
    1445        let query = Query::with_params(sql, params);
    1446
    1447        let cached_items: Vec<CachedItem> = self
    1448            .db_service
    1449            .query_many(query, row_to_cached_item)
    1450            .await
    1451            .map_err(|e| RepoError::Database { message: e })?;
    1452
    1453        debug!(
    1454            "[OfflineRepo] Found {} cached items for parent {}",
    1455            cached_items.len(),
    1456            &parent_id[..8.min(parent_id.len())]
    1457        );
    1458
    1459        // Fetch user data for each item
    1460        let mut items = Vec::new();
    1461        for cached in cached_items {
    1462            let user_data = self.get_user_data(&cached.id).await;
    1463            items.push(Self::cached_item_to_media_item(cached, user_data));
    1464        }
    1465
    1466        let total_record_count = items.len();
    1467
    1468        debug!(
    1469            "[OfflineRepo] Returning {} items for parent {}",
    1470            total_record_count,
    1471            &parent_id[..8.min(parent_id.len())]
    1472        );
    1473
    1474        Ok(SearchResult {
    1475            items,
    1476            total_record_count,
    1477        })
    1478    }
    1479
    1480    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
    1481        // Check if item is available offline (either downloaded itself or has downloaded children)
    1482        let query = Query::with_params(
    1483            "WITH downloaded_items AS (
    1484                -- Playable items with completed downloads
    1485                SELECT DISTINCT i.id
    1486                FROM items i
    1487                INNER JOIN downloads d ON i.id = d.item_id
    1488                WHERE d.status = 'completed'
    1489                  AND i.item_type IN ('Audio', 'Movie', 'Episode')
    1490
    1491                UNION
    1492
    1493                -- Containers with downloaded children
    1494                SELECT DISTINCT i.id
    1495                FROM items i
    1496                INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
    1497                INNER JOIN downloads d ON children.id = d.item_id
    1498                WHERE d.status = 'completed'
    1499                  AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
    1500            )
    1501            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
    1502                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
    1503                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
    1504                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
    1505                    i.parent_index_number, i.is_folder, i.premiere_date
    1506            FROM items i
    1507             INNER JOIN downloaded_items di ON i.id = di.id
    1508             WHERE i.id = ?",
    1509            vec![QueryParam::String(item_id.to_string())],
    1510        );
    1511
    1512        let cached = self
    1513            .db_service
    1514            .query_optional(query, row_to_cached_item)
    1515            .await
    1516            .map_err(|e| RepoError::Database { message: e })?
    1517            .ok_or_else(|| RepoError::NotFound {
    1518                message: format!(
    1519                    "Item {} not found in offline cache or not downloaded",
    1520                    item_id
    1521                ),
    1522            })?;
    1523
    1524        let user_data = self.get_user_data(item_id).await;
    1525        Ok(Self::cached_item_to_media_item(cached, user_data))
    1526    }
    1527
    1528    async fn get_latest_items(
    1529        &self,
    1530        parent_id: &str,
    1531        limit: Option<usize>,
    1532    ) -> Result<Vec<MediaItem>, RepoError> {
    1533        let limit_val = limit.unwrap_or(16);
    1534
    1535        let query = Query::with_params(
    1536            format!(
    1537                "WITH downloaded_items AS (
    1538                    -- Playable items with completed downloads
    1539                    SELECT DISTINCT i.id
    1540                    FROM items i
    1541                    INNER JOIN downloads d ON i.id = d.item_id
    1542                    WHERE d.status = 'completed'
    1543                      AND i.item_type IN ('Audio', 'Movie', 'Episode')
    1544
    1545                    UNION
    1546
    1547                    -- Containers with downloaded children
    1548                    SELECT DISTINCT i.id
    1549                    FROM items i
    1550                    INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
    1551                    INNER JOIN downloads d ON children.id = d.item_id
    1552                    WHERE d.status = 'completed'
    1553                      AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
    1554                )
    1555                SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
    1556                        i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
    1557                        i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
    1558                        i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
    1559                        i.parent_index_number, i.is_folder, i.premiere_date
    1560                FROM items i
    1561                 INNER JOIN downloaded_items di ON i.id = di.id
    1562                 WHERE i.server_id = ? AND i.library_id = ?
    1563                 -- Collapse leaves into the container that was added: a new
    1564                 -- 14-track album should read as one album, not 14 songs. Only
    1565                 -- drops a leaf when its own container is present in the same
    1566                 -- result, so a standalone track or movie still appears.
    1567                 AND NOT EXISTS (
    1568                     SELECT 1 FROM downloaded_items parent
    1569                     WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
    1570                 )
    1571                 ORDER BY i.synced_at DESC
    1572                 LIMIT {}", limit_val
    1573            ),
    1574            vec![
    1575                QueryParam::String(self.server_id.clone()),
    1576                QueryParam::String(parent_id.to_string()),
    1577            ],
    1578        );
    1579
    1580        let cached_items: Vec<CachedItem> = self
    1581            .db_service
    1582            .query_many(query, row_to_cached_item)
    1583            .await
    1584            .map_err(|e| RepoError::Database { message: e })?;
    1585
    1586        let mut items = Vec::new();
    1587        for cached in cached_items {
    1588            let user_data = self.get_user_data(&cached.id).await;
    1589            items.push(Self::cached_item_to_media_item(cached, user_data));
    1590        }
    1591
    1592        Ok(items)
    1593    }
    1594
    1595    async fn get_resume_items(
    1596        &self,
    1597        parent_id: Option<&str>,
    1598        limit: Option<usize>,
    1599    ) -> Result<Vec<MediaItem>, RepoError> {
    1600        let limit_val = limit.unwrap_or(12);
    1601
    1602        // Resume items are video-only (Movie, Episode) - audio is handled by get_recently_played_audio
    1603        let (sql, params) = if let Some(pid) = parent_id {
    1604            (
    1605                format!(
    1606                    "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
    1607                            i.overview, i.genres, i.runtime_ticks, i.production_year,
    1608                            i.community_rating, i.official_rating, i.primary_image_tag,
    1609                            i.album_id, i.album_name, i.album_artist, i.artists,
    1610                            i.index_number, i.series_id, i.series_name, i.season_id,
    1611                            i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
    1612                    FROM items i
    1613                     JOIN user_data ud ON i.id = ud.item_id
    1614                     INNER JOIN downloads d ON i.id = d.item_id
    1615                     WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
    1616                       AND ud.playback_position_ticks > 0 AND ud.is_played = 0
    1617                       AND d.status = 'completed'
    1618                       AND i.item_type IN ('Movie', 'Episode')
    1619                     ORDER BY ud.last_played_at DESC
    1620                     LIMIT {}",
    1621                    limit_val
    1622                ),
    1623                vec![
    1624                    QueryParam::String(self.server_id.clone()),
    1625                    QueryParam::String(self.user_id.clone()),
    1626                    QueryParam::String(pid.to_string()),
    1627                ],
    1628            )
    1629        } else {
    1630            (
    1631                format!(
    1632                    "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
    1633                            i.overview, i.genres, i.runtime_ticks, i.production_year,
    1634                            i.community_rating, i.official_rating, i.primary_image_tag,
    1635                            i.album_id, i.album_name, i.album_artist, i.artists,
    1636                            i.index_number, i.series_id, i.series_name, i.season_id,
    1637                            i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
    1638                    FROM items i
    1639                     JOIN user_data ud ON i.id = ud.item_id
    1640                     INNER JOIN downloads d ON i.id = d.item_id
    1641                     WHERE i.server_id = ? AND ud.user_id = ?
    1642                       AND ud.playback_position_ticks > 0 AND ud.is_played = 0
    1643                       AND d.status = 'completed'
    1644                       AND i.item_type IN ('Movie', 'Episode')
    1645                     ORDER BY ud.last_played_at DESC
    1646                     LIMIT {}",
    1647                    limit_val
    1648                ),
    1649                vec![
    1650                    QueryParam::String(self.server_id.clone()),
    1651                    QueryParam::String(self.user_id.clone()),
    1652                ],
    1653            )
    1654        };
    1655
    1656        let query = Query::with_params(sql, params);
    1657
    1658        let cached_items: Vec<CachedItem> = self
    1659            .db_service
    1660            .query_many(query, row_to_cached_item)
    1661            .await
    1662            .map_err(|e| RepoError::Database { message: e })?;
    1663
    1664        let mut items = Vec::new();
    1665        for cached in cached_items {
    1666            let user_data = self.get_user_data(&cached.id).await;
    1667            items.push(Self::cached_item_to_media_item(cached, user_data));
    1668        }
    1669
    1670        Ok(items)
    1671    }
    1672
    1673    async fn get_next_up_episodes(
    1674        &self,
    1675        _series_id: Option<&str>,
    1676        _limit: Option<usize>,
    1677    ) -> Result<Vec<MediaItem>, RepoError> {
    1678        // Next up is complex - would need to track watched episodes and find the next unwatched
    1679        // For now, return empty for offline mode
    1680        Ok(Vec::new())
    1681    }
    1682
    1683    async fn get_recently_played_audio(
    1684        &self,
    1685        limit: Option<usize>,
    1686    ) -> Result<Vec<MediaItem>, RepoError> {
    1687        let limit_val = limit.unwrap_or(12);
    1688
    1689        // Use CTE to intelligently group by playback context and filter by downloads
    1690        // Shows containers (albums) when context_type='container', individual tracks when context_type='single'
    1691        // Falls back to album grouping for legacy data (NULL context)
    1692        // Only shows items that are downloaded or have downloaded children
    1693        let query = Query::with_params(
    1694            format!(
    1695                "WITH downloaded_items AS (
    1696                    -- Playable items with completed downloads (Audio tracks)
    1697                    SELECT DISTINCT i.id
    1698                    FROM items i
    1699                    INNER JOIN downloads d ON i.id = d.item_id
    1700                    WHERE d.status = 'completed'
    1701                      AND i.item_type = 'Audio'
    1702
    1703                    UNION
    1704
    1705                    -- Containers with downloaded children (Albums)
    1706                    SELECT DISTINCT i.id
    1707                    FROM items i
    1708                    INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
    1709                    INNER JOIN downloads d ON children.id = d.item_id
    1710                    WHERE d.status = 'completed'
    1711                      AND i.item_type = 'MusicAlbum'
    1712                ),
    1713                ranked_plays AS (
    1714                    SELECT
    1715                        CASE
    1716                            WHEN ud.playback_context_type = 'container' THEN ud.playback_context_id
    1717                            WHEN ud.playback_context_type = 'single' THEN ud.item_id
    1718                            ELSE COALESCE(i.album_id, ud.item_id)
    1719                        END AS display_id,
    1720                        MAX(ud.last_played_at) AS most_recent_play
    1721                    FROM user_data ud
    1722                    JOIN items i ON ud.item_id = i.id
    1723                    WHERE ud.user_id = ? AND i.server_id = ?
    1724                      AND i.item_type = 'Audio'
    1725                      AND ud.last_played_at IS NOT NULL
    1726                    GROUP BY display_id
    1727                    ORDER BY most_recent_play DESC
    1728                    LIMIT {}
    1729                )
    1730                SELECT DISTINCT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
    1731                       i.overview, i.genres, i.runtime_ticks, i.production_year,
    1732                       i.community_rating, i.official_rating, i.primary_image_tag,
    1733                       i.album_id, i.album_name, i.album_artist, i.artists,
    1734                       i.index_number, i.series_id, i.series_name, i.season_id,
    1735                       i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
    1736               FROM ranked_plays rp
    1737                JOIN items i ON rp.display_id = i.id
    1738                INNER JOIN downloaded_items di ON i.id = di.id
    1739                ORDER BY rp.most_recent_play DESC",
    1740                limit_val
    1741            ),
    1742            vec![
    1743                QueryParam::String(self.user_id.clone()),
    1744                QueryParam::String(self.server_id.clone()),
    1745            ],
    1746        );
    1747
    1748        let cached_items: Vec<CachedItem> = self
    1749            .db_service
    1750            .query_many(query, row_to_cached_item)
    1751            .await
    1752            .map_err(|e| RepoError::Database { message: e })?;
    1753
    1754        let mut items = Vec::new();
    1755        for cached in cached_items {
    1756            let user_data = self.get_user_data(&cached.id).await;
    1757            items.push(Self::cached_item_to_media_item(cached, user_data));
    1758        }
    1759
    1760        Ok(items)
    1761    }
    1762
    1763    async fn get_rediscover_albums(
    1764        &self,
    1765        _parent_id: Option<&str>,
    1766        _limit: Option<usize>,
    1767    ) -> Result<Vec<MediaItem>, RepoError> {
    1768        // "Rediscover" is a discovery feature over the full server library.
    1769        // Offline only holds downloaded items, so there is nothing meaningful
    1770        // to surface here; the hybrid repo serves this from the server instead.
    1771        Ok(Vec::new())
    1772    }
    1773
    1774    async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
    1775        let limit_val = limit.unwrap_or(12);
    1776
    1777        // Resume movies are playable items, so simple JOIN with downloads
    1778        let query = Query::with_params(
    1779            format!(
    1780                "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
    1781                        i.overview, i.genres, i.runtime_ticks, i.production_year,
    1782                        i.community_rating, i.official_rating, i.primary_image_tag,
    1783                        i.album_id, i.album_name, i.album_artist, i.artists,
    1784                        i.index_number, i.series_id, i.series_name, i.season_id,
    1785                        i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
    1786                FROM items i
    1787                 JOIN user_data ud ON i.id = ud.item_id
    1788                 INNER JOIN downloads d ON i.id = d.item_id
    1789                 WHERE i.server_id = ? AND ud.user_id = ? AND i.item_type = 'Movie'
    1790                   AND ud.playback_position_ticks > 0 AND ud.is_played = 0
    1791                   AND d.status = 'completed'
    1792                 ORDER BY ud.last_played_at DESC
    1793                 LIMIT {}",
    1794                limit_val
    1795            ),
    1796            vec![
    1797                QueryParam::String(self.server_id.clone()),
    1798                QueryParam::String(self.user_id.clone()),
    1799            ],
    1800        );
    1801
    1802        let cached_items: Vec<CachedItem> = self
    1803            .db_service
    1804            .query_many(query, row_to_cached_item)
    1805            .await
    1806            .map_err(|e| RepoError::Database { message: e })?;
    1807
    1808        let mut items = Vec::new();
    1809        for cached in cached_items {
    1810            let user_data = self.get_user_data(&cached.id).await;
    1811            items.push(Self::cached_item_to_media_item(cached, user_data));
    1812        }
    1813
    1814        Ok(items)
    1815    }
    1816
    1817    async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
    1818        // Read the cached server genre catalog (populated by the hybrid repo via
    1819        // save_genres_to_cache). This is the FULL genre list for the library, not
    1820        // just the genres derivable from locally-cached albums — so offline keeps
    1821        // the same variety the server has. library_id NULL is stored as ''.
    1822        let library_id = parent_id.unwrap_or("").to_string();
    1823
    1824        let query = Query::with_params(
    1825            "SELECT id, name, album_count FROM genres WHERE server_id = ? AND library_id = ?",
    1826            vec![
    1827                QueryParam::String(self.server_id.clone()),
    1828                QueryParam::String(library_id),
    1829            ],
    1830        );
    1831
    1832        let genres: Vec<Genre> = self
    1833            .db_service
    1834            .query_many(query, |row| {
    1835                Ok(Genre {
    1836                    id: row.get(0)?,
    1837                    name: row.get(1)?,
    1838                    album_count: row.get::<_, Option<i64>>(2)?.map(|c| c as u32),
    1839                })
    1840            })
    1841            .await
    1842            .map_err(|e| RepoError::Database { message: e })?;
    1843
    1844        Ok(genres)
    1845    }
    1846
    1847    async fn search(
    1848        &self,
    1849        query: &str,
    1850        options: Option<SearchOptions>,
    1851    ) -> Result<SearchResult, RepoError> {
    1852        let opts = options.unwrap_or_default();
    1853        let limit = opts.limit.unwrap_or(20);
    1854
    1855        // Nothing searchable (empty query, or pure punctuation): return an empty
    1856        // result rather than letting FTS5 reject the string and fail the search.
    1857        let Some(fts_query) = build_fts_prefix_query(query) else {
    1858            return Ok(SearchResult {
    1859                items: Vec::new(),
    1860                total_record_count: 0,
    1861            });
    1862        };
    1863
    1864        // Bind the type filter rather than interpolating it: `include_item_types`
    1865        // is settable straight from the frontend (GenericMediaListPage passes it),
    1866        // so a quote in a type must be data, not syntax.
    1867        let type_values: &[String] = opts
    1868            .include_item_types
    1869            .as_deref()
    1870            .filter(|types| !types.is_empty())
    1871            .unwrap_or(&[]);
    1872        let type_filter = if type_values.is_empty() {
    1873            String::new()
    1874        } else {
    1875            let placeholders = vec!["?"; type_values.len()].join(",");
    1876            format!(" AND i.item_type IN ({})", placeholders)
    1877        };
    1878
    1879        // Availability CTE — deliberately identical to the one `get_items` uses,
    1880        // including the `include_catalog_browse()` gate, so search and browse can
    1881        // never disagree about what is visible. Before DR-108 this leg was
    1882        // downloads-only, which meant a user with no downloads got nothing from
    1883        // the local index and every keystroke fell through to the server.
    1884        let catalog_branch = if include_catalog_browse() {
    1885            "UNION
    1886
    1887                -- Synced catalog: fast online search, or the offline
    1888                -- 'Show all server media' view. See set_include_catalog_browse.
    1889                SELECT DISTINCT i.id
    1890                FROM items i
    1891                WHERE i.synced_at IS NOT NULL"
    1892        } else {
    1893            ""
    1894        };
    1895
    1896        let sql = format!(
    1897            "WITH available_items AS (
    1898                -- Playable items with completed downloads
    1899                SELECT DISTINCT i.id
    1900                FROM items i
    1901                INNER JOIN downloads d ON i.id = d.item_id
    1902                WHERE d.status = 'completed'
    1903                  AND i.item_type IN ('Audio', 'Movie', 'Episode')
    1904
    1905                UNION
    1906
    1907                -- Containers with downloaded children
    1908                SELECT DISTINCT i.id
    1909                FROM items i
    1910                INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
    1911                INNER JOIN downloads d ON children.id = d.item_id
    1912                WHERE d.status = 'completed'
    1913                  AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
    1914
    1915                {}
    1916            )
    1917            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
    1918                    i.overview, i.genres, i.runtime_ticks, i.production_year,
    1919                    i.community_rating, i.official_rating, i.primary_image_tag,
    1920                    i.album_id, i.album_name, i.album_artist, i.artists,
    1921                    i.index_number, i.series_id, i.series_name, i.season_id,
    1922                    i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
    1923            FROM items i
    1924             JOIN items_fts fts ON fts.rowid = i.rowid
    1925             INNER JOIN available_items ai ON i.id = ai.id
    1926             WHERE i.server_id = ? AND items_fts MATCH ?{}
    1927             ORDER BY rank
    1928             LIMIT {}",
    1929            catalog_branch, type_filter, limit
    1930        );
    1931
    1932        let mut params = vec![
    1933            QueryParam::String(self.server_id.clone()),
    1934            QueryParam::String(fts_query.clone()),
    1935        ];
    1936        params.extend(type_values.iter().cloned().map(QueryParam::String));
    1937
    1938        let db_query = Query::with_params(sql, params);
    1939
    1940        let cached_items: Vec<CachedItem> = self
    1941            .db_service
    1942            .query_many(db_query, row_to_cached_item)
    1943            .await
    1944            .map_err(|e| RepoError::Database { message: e })?;
    1945
    1946        let mut items = Vec::new();
    1947        for cached in cached_items {
    1948            let user_data = self.get_user_data(&cached.id).await;
    1949            items.push(Self::cached_item_to_media_item(cached, user_data));
    1950        }
    1951
    1952        // People are not rows in `items` — they live in their own table — so
    1953        // they need a second lookup. Only when the scope admits them: an
    1954        // explicit type filter (Music/Movies/TV) never includes People, whereas
    1955        // `SearchScope::All` expands to *no* filter (DR-063), which is exactly
    1956        // the case where the People group should be populated.
    1957        if type_values.is_empty() {
    1958            items.extend(self.search_people(&fts_query, limit).await?);
    1959        }
    1960
    1961        let total_record_count = items.len();
    1962
    1963        Ok(SearchResult {
    1964            items,
    1965            total_record_count,
    1966        })
    1967    }
    1968
    1969    async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
    1970        // Playback info requires server communication for transcoding decisions
    1971        Err(RepoError::Offline)
    1972    }
    1973
    1974    async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
    1975        // Cannot get stream URLs while offline - offline tracks use local paths
    1976        Err(RepoError::Offline)
    1977    }
    1978
    1979    async fn get_audio_only_stream_url_for_video(
    1980        &self,
    1981        _item_id: &str,
    1982        _media_source_id: Option<&str>,
    1983        _start_time_seconds: Option<f64>,
    1984        _audio_stream_index: Option<i32>,
    1985    ) -> Result<String, RepoError> {
    1986        // Audio-only transcode requires the server; offline downloads play locally.
    1987        Err(RepoError::Offline)
    1988    }
    1989
    1990    async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
    1991        // Live TV is inherently online-only.
    1992        Err(RepoError::Offline)
    1993    }
    1994
    1995    async fn get_channels(&self) -> Result<SearchResult, RepoError> {
    1996        // Plugin channels are inherently online-only.
    1997        Err(RepoError::Offline)
    1998    }
    1999
    2000    async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
    2001        // Live streams cannot be opened offline.
    2002        Err(RepoError::Offline)
    2003    }
    2004
    2005    async fn report_playback_start(
    2006        &self,
    2007        _item_id: &str,
    2008        _position_ticks: i64,
    2009    ) -> Result<(), RepoError> {
    2010        // Cannot report to server while offline
    2011        Err(RepoError::Offline)
    2012    }
    2013
    2014    async fn report_playback_progress(
    2015        &self,
    2016        _item_id: &str,
    2017        _position_ticks: i64,
    2018    ) -> Result<(), RepoError> {
    2019        // Cannot report to server while offline
    2020        Err(RepoError::Offline)
    2021    }
    2022
    2023    async fn report_playback_stopped(
    2024        &self,
    2025        _item_id: &str,
    2026        _position_ticks: i64,
    2027    ) -> Result<(), RepoError> {
    2028        // Cannot report to server while offline
    2029        Err(RepoError::Offline)
    2030    }
    2031
    2032    fn get_image_url(
    2033        &self,
    2034        item_id: &str,
    2035        image_type: ImageType,
    2036        options: Option<ImageOptions>,
    2037    ) -> String {
    2038        // Return a placeholder path for offline image retrieval
    2039        // The actual image should be in thumbnail cache
    2040        let type_str = match image_type {
    2041            ImageType::Primary => "Primary",
    2042            ImageType::Backdrop => "Backdrop",
    2043            ImageType::Logo => "Logo",
    2044            ImageType::Thumb => "Thumb",
    2045            ImageType::Banner => "Banner",
    2046        };
    2047
    2048        if let Some(opts) = options {
    2049            if let Some(tag) = opts.tag {
    2050                return format!("offline://{}/{}/{}", item_id, type_str, tag);
    2051            }
    2052        }
    2053
    2054        format!("offline://{}/{}", item_id, type_str)
    2055    }
    2056
    2057    fn get_subtitle_url(
    2058        &self,
    2059        _item_id: &str,
    2060        _media_source_id: &str,
    2061        _stream_index: i32,
    2062        _format: &str,
    2063    ) -> String {
    2064        // Subtitles not available offline
    2065        String::new()
    2066    }
    2067
    2068    fn get_video_download_url(
    2069        &self,
    2070        _item_id: &str,
    2071        _quality: &str,
    2072        _media_source_id: Option<&str>,
    2073        _source_audio_codec: Option<&str>,
    2074    ) -> String {
    2075        // Cannot download while offline
    2076        String::new()
    2077    }
    2078
    2079    async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
    2080        // Cannot update server while offline
    2081        Err(RepoError::Offline)
    2082    }
    2083
    2084    async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
    2085        // Cannot update server while offline
    2086        Err(RepoError::Offline)
    2087    }
    2088
    2089    /// Favourites held locally — the ones mirrored from the server by
    2090    /// `save_to_cache` plus anything favourited on this device.
    2091    ///
    2092    /// Gated by the same `available_items` rules as browsing, so with "Show all
    2093    /// server media" off this returns favourites that are actually on the
    2094    /// device rather than the whole favourited catalog (DR-080).
    2095    ///
    2096    /// TRACES: UR-067 | DR-115 | UT-101
    2097    async fn get_favorites(
    2098        &self,
    2099        scope: SearchScope,
    2100        options: Option<GetItemsOptions>,
    2101    ) -> Result<SearchResult, RepoError> {
    2102        let opts = options.unwrap_or_default();
    2103        let limit = opts.limit.unwrap_or(10000);
    2104        let start_index = opts.start_index.unwrap_or(0);
    2105
    2106        // Scope → item types is expanded in Rust (DR-063); `All` yields no
    2107        // filter at all rather than a union.
    2108        let type_filter = match scope.item_types() {
    2109            Some(types) if !types.is_empty() => {
    2110                let placeholders = vec!["?"; types.len()].join(",");
    2111                format!(" AND i.item_type IN ({})", placeholders)
    2112            }
    2113            _ => String::new(),
    2114        };
    2115
    2116        let catalog_branch = if include_catalog_browse() {
    2117            "UNION
    2118
    2119                SELECT DISTINCT i.id
    2120                FROM items i
    2121                WHERE i.synced_at IS NOT NULL"
    2122        } else {
    2123            ""
    2124        };
    2125
    2126        let sql = format!(
    2127            "WITH available_items AS (
    2128                SELECT DISTINCT i.id
    2129                FROM items i
    2130                INNER JOIN downloads d ON i.id = d.item_id
    2131                WHERE d.status = 'completed'
    2132                  AND i.item_type IN ('Audio', 'Movie', 'Episode')
    2133
    2134                UNION
    2135
    2136                SELECT DISTINCT i.id
    2137                FROM items i
    2138                INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
    2139                INNER JOIN downloads d ON children.id = d.item_id
    2140                WHERE d.status = 'completed'
    2141                  AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
    2142
    2143                {catalog_branch}
    2144            )
    2145            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
    2146                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
    2147                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
    2148                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
    2149                    i.parent_index_number, i.is_folder, i.premiere_date
    2150            FROM items i
    2151             INNER JOIN available_items ai ON i.id = ai.id
    2152             INNER JOIN user_data ud ON ud.item_id = i.id
    2153             WHERE i.server_id = ?
    2154               AND ud.user_id = ?
    2155               AND ud.is_favorite = 1{}
    2156             ORDER BY i.sort_name ASC, i.name ASC
    2157             LIMIT {} OFFSET {}",
    2158            type_filter, limit, start_index
    2159        );
    2160
    2161        let mut params = vec![
    2162            QueryParam::String(self.server_id.clone()),
    2163            QueryParam::String(self.user_id.clone()),
    2164        ];
    2165        if let Some(types) = scope.item_types() {
    2166            params.extend(types.into_iter().map(QueryParam::String));
    2167        }
    2168
    2169        let cached_items: Vec<CachedItem> = self
    2170            .db_service
    2171            .query_many(Query::with_params(sql, params), row_to_cached_item)
    2172            .await
    2173            .map_err(|e| RepoError::Database { message: e })?;
    2174
    2175        let mut items = Vec::new();
    2176        for cached in cached_items {
    2177            let user_data = self.get_user_data(&cached.id).await;
    2178            items.push(Self::cached_item_to_media_item(cached, user_data));
    2179        }
    2180
    2181        let total_record_count = items.len();
    2182        debug!("[OfflineRepo] Returning {} favourites", total_record_count);
    2183
    2184        Ok(SearchResult {
    2185            items,
    2186            total_record_count,
    2187        })
    2188    }
    2189
    2190    async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
    2191        // Erasing history has to reach the server to be meaningful — clearing
    2192        // it only locally would be silently undone by the next sync.
    2193        Err(RepoError::Offline)
    2194    }
    2195
    2196    async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
    2197        // Offline the local flag is written by `storage_mark_played` and the
    2198        // server half is queued in `sync_queue`; this path has no server.
    2199        Err(RepoError::Offline)
    2200    }
    2201
    2202    async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
    2203        let query = Query::with_params(
    2204            "SELECT id, name, overview, primary_image_tag
    2205             FROM people WHERE id = ?",
    2206            vec![QueryParam::String(person_id.to_string())],
    2207        );
    2208
    2209        let person_data = self
    2210            .db_service
    2211            .query_optional(query, |row| {
    2212                Ok((
    2213                    row.get::<_, String>(0)?,
    2214                    row.get::<_, String>(1)?,
    2215                    row.get::<_, Option<String>>(2)?,
    2216                    row.get::<_, Option<String>>(3)?,
    2217                ))
    2218            })
    2219            .await
    2220            .map_err(|e| RepoError::Database { message: e })?
    2221            .ok_or_else(|| RepoError::NotFound {
    2222                message: format!("Person {} not found in cache", person_id),
    2223            })?;
    2224
    2225        Ok(MediaItem {
    2226            id: person_data.0,
    2227            name: person_data.1,
    2228            item_type: "Person".to_string(),
    2229            kind: crate::domain::MediaKind::Person,
    2230            is_folder: false,
    2231            server_id: self.server_id.clone(),
    2232            parent_id: None,
    2233            library_id: None,
    2234            overview: person_data.2,
    2235            genres: None,
    2236            runtime_ticks: None,
    2237            duration_ms: None,
    2238            production_year: None,
    2239            premiere_date: None,
    2240            community_rating: None,
    2241            official_rating: None,
    2242            primary_image_tag: person_data.3.clone(),
    2243            image_id: person_data.3,
    2244            backdrop_image_tags: None,
    2245            parent_backdrop_image_tags: None,
    2246            album_id: None,
    2247            album_name: None,
    2248            album_artist: None,
    2249            artists: None,
    2250            artist_items: None,
    2251            index_number: None,
    2252            series_id: None,
    2253            series_name: None,
    2254            season_id: None,
    2255            season_name: None,
    2256            parent_index_number: None,
    2257            user_data: None,
    2258            media_streams: None,
    2259            media_sources: None,
    2260            people: None,
    2261        })
    2262    }
    2263
    2264    async fn get_items_by_person(
    2265        &self,
    2266        person_id: &str,
    2267        options: Option<GetItemsOptions>,
    2268    ) -> Result<SearchResult, RepoError> {
    2269        let opts = options.unwrap_or_default();
    2270        let limit = opts.limit.unwrap_or(10000); // Match frontend limit
    2271
    2272        // Filter by downloads using CTE
    2273        let query = Query::with_params(
    2274            format!(
    2275                "WITH downloaded_items AS (
    2276                    -- Playable items with completed downloads
    2277                    SELECT DISTINCT i.id
    2278                    FROM items i
    2279                    INNER JOIN downloads d ON i.id = d.item_id
    2280                    WHERE d.status = 'completed'
    2281                      AND i.item_type IN ('Audio', 'Movie', 'Episode')
    2282
    2283                    UNION
    2284
    2285                    -- Containers with downloaded children
    2286                    SELECT DISTINCT i.id
    2287                    FROM items i
    2288                    INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
    2289                    INNER JOIN downloads d ON children.id = d.item_id
    2290                    WHERE d.status = 'completed'
    2291                      AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
    2292                )
    2293                SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
    2294                        i.overview, i.genres, i.runtime_ticks, i.production_year,
    2295                        i.community_rating, i.official_rating, i.primary_image_tag,
    2296                        i.album_id, i.album_name, i.album_artist, i.artists,
    2297                        i.index_number, i.series_id, i.series_name, i.season_id,
    2298                        i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
    2299                FROM items i
    2300                 JOIN item_people ip ON i.id = ip.item_id
    2301                 INNER JOIN downloaded_items di ON i.id = di.id
    2302                 WHERE i.server_id = ? AND ip.person_id = ?
    2303                 ORDER BY i.production_year DESC, i.sort_name ASC
    2304                 LIMIT {}", limit
    2305            ),
    2306            vec![
    2307                QueryParam::String(self.server_id.clone()),
    2308                QueryParam::String(person_id.to_string()),
    2309            ],
    2310        );
    2311
    2312        let cached_items: Vec<CachedItem> = self
    2313            .db_service
    2314            .query_many(query, row_to_cached_item)
    2315            .await
    2316            .map_err(|e| RepoError::Database { message: e })?;
    2317
    2318        let mut items = Vec::new();
    2319        for cached in cached_items {
    2320            let user_data = self.get_user_data(&cached.id).await;
    2321            items.push(Self::cached_item_to_media_item(cached, user_data));
    2322        }
    2323
    2324        let total_record_count = items.len();
    2325
    2326        Ok(SearchResult {
    2327            items,
    2328            total_record_count,
    2329        })
    2330    }
    2331
    2332    async fn get_similar_items(
    2333        &self,
    2334        _item_id: &str,
    2335        _limit: Option<usize>,
    2336    ) -> Result<SearchResult, RepoError> {
    2337        // Similar items require server-side computation and are not available offline
    2338        Err(RepoError::Offline)
    2339    }
    2340
    2341    // ===== Playlist Methods =====
    2342
    2343    async fn create_playlist(
    2344        &self,
    2345        name: &str,
    2346        item_ids: &[String],
    2347    ) -> Result<PlaylistCreatedResult, RepoError> {
    2348        let playlist_id = uuid::Uuid::new_v4().to_string();
    2349        let user_id = self.user_id.clone();
    2350        let name = name.to_string();
    2351        let item_ids = item_ids.to_vec();
    2352        let pid = playlist_id.clone();
    2353
    2354        self.db_service
    2355            .transaction(move |tx| {
    2356                use crate::storage::db_service::{Query, QueryParam};
    2357
    2358                tx.execute(Query::with_params(
    2359                    "INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)",
    2360                    vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)],
    2361                ))?;
    2362
    2363                for (i, item_id) in item_ids.iter().enumerate() {
    2364                    tx.execute(Query::with_params(
    2365                        "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
    2366                        vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)],
    2367                    ))?;
    2368                }
    2369
    2370                Ok(())
    2371            })
    2372            .await
    2373            .map_err(|e| RepoError::Database {
    2374                message: format!("Failed to create playlist: {}", e),
    2375            })?;
    2376
    2377        Ok(PlaylistCreatedResult { id: playlist_id })
    2378    }
    2379
    2380    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
    2381        let query = Query::with_params(
    2382            "DELETE FROM playlists WHERE id = ?",
    2383            vec![QueryParam::String(playlist_id.to_string())],
    2384        );
    2385        self.db_service
    2386            .execute(query)
    2387            .await
    2388            .map_err(|e| RepoError::Database {
    2389                message: format!("Failed to delete playlist: {}", e),
    2390            })?;
    2391        Ok(())
    2392    }
    2393
    2394    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
    2395        let query = Query::with_params(
    2396            "UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
    2397            vec![
    2398                QueryParam::String(name.to_string()),
    2399                QueryParam::String(playlist_id.to_string()),
    2400            ],
    2401        );
    2402        self.db_service
    2403            .execute(query)
    2404            .await
    2405            .map_err(|e| RepoError::Database {
    2406                message: format!("Failed to rename playlist: {}", e),
    2407            })?;
    2408        Ok(())
    2409    }
    2410
    2411    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
    2412        let query = Query::with_params(
    2413            "SELECT pi.id, \
    2414                    i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \
    2415                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
    2416                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
    2417                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
    2418                    i.parent_index_number, i.is_folder, i.premiere_date \
    2419             FROM playlist_items pi \
    2420             JOIN items i ON pi.item_id = i.id \
    2421             WHERE pi.playlist_id = ? \
    2422             ORDER BY pi.sort_order ASC",
    2423            vec![QueryParam::String(playlist_id.to_string())],
    2424        );
    2425
    2426        let items = self
    2427            .db_service
    2428            .query_many(query, |row| {
    2429                let entry_id: i64 = row.get(0)?;
    2430                // Columns offset by 1 because first column is pi.id
    2431                let cached = CachedItem {
    2432                    id: row.get(1)?,
    2433                    name: row.get(2)?,
    2434                    item_type: row.get(3)?,
    2435                    server_id: row.get(4)?,
    2436                    parent_id: row.get(5)?,
    2437                    library_id: row.get(6)?,
    2438                    overview: row.get(7)?,
    2439                    genres: row.get(8)?,
    2440                    runtime_ticks: row.get(9)?,
    2441                    production_year: row.get(10)?,
    2442                    community_rating: row.get(11)?,
    2443                    official_rating: row.get(12)?,
    2444                    primary_image_tag: row.get(13)?,
    2445                    backdrop_image_tags: None,
    2446                    parent_backdrop_image_tags: None,
    2447                    album_id: row.get(14)?,
    2448                    album_name: row.get(15)?,
    2449                    album_artist: row.get(16)?,
    2450                    artists: row.get(17)?,
    2451                    index_number: row.get(18)?,
    2452                    series_id: row.get(19)?,
    2453                    series_name: row.get(20)?,
    2454                    season_id: row.get(21)?,
    2455                    season_name: row.get(22)?,
    2456                    parent_index_number: row.get(23)?,
    2457                    is_folder: row.get::<_, Option<i64>>(24)?.unwrap_or(0) != 0,
    2458                    premiere_date: row.get(25)?,
    2459                };
    2460                Ok((entry_id.to_string(), cached))
    2461            })
    2462            .await
    2463            .map_err(|e| RepoError::Database {
    2464                message: format!("Failed to get playlist items: {}", e),
    2465            })?;
    2466
    2467        Ok(items
    2468            .into_iter()
    2469            .map(|(entry_id, cached)| PlaylistEntry {
    2470                playlist_item_id: entry_id,
    2471                item: Self::cached_item_to_media_item(cached, None),
    2472            })
    2473            .collect())
    2474    }
    2475
    2476    async fn add_to_playlist(
    2477        &self,
    2478        playlist_id: &str,
    2479        item_ids: &[String],
    2480    ) -> Result<(), RepoError> {
    2481        // Get current max sort_order
    2482        let max_query = Query::with_params(
    2483            "SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?",
    2484            vec![QueryParam::String(playlist_id.to_string())],
    2485        );
    2486        let max_order: i32 = self
    2487            .db_service
    2488            .query_one(max_query, |row| row.get(0))
    2489            .await
    2490            .unwrap_or(-1);
    2491
    2492        let playlist_id = playlist_id.to_string();
    2493        let item_ids = item_ids.to_vec();
    2494
    2495        self.db_service
    2496            .transaction(move |tx| {
    2497                use crate::storage::db_service::{Query, QueryParam};
    2498
    2499                for (i, item_id) in item_ids.iter().enumerate() {
    2500                    tx.execute(Query::with_params(
    2501                        "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
    2502                        vec![
    2503                            QueryParam::String(playlist_id.clone()),
    2504                            QueryParam::String(item_id.clone()),
    2505                            QueryParam::Int(max_order + 1 + i as i32),
    2506                        ],
    2507                    ))?;
    2508                }
    2509                Ok(())
    2510            })
    2511            .await
    2512            .map_err(|e| RepoError::Database {
    2513                message: format!("Failed to add items to playlist: {}", e),
    2514            })?;
    2515
    2516        Ok(())
    2517    }
    2518
    2519    async fn remove_from_playlist(
    2520        &self,
    2521        playlist_id: &str,
    2522        entry_ids: &[String],
    2523    ) -> Result<(), RepoError> {
    2524        let playlist_id = playlist_id.to_string();
    2525        let entry_ids = entry_ids.to_vec();
    2526
    2527        self.db_service
    2528            .transaction(move |tx| {
    2529                use crate::storage::db_service::{Query, QueryParam};
    2530
    2531                for entry_id in &entry_ids {
    2532                    tx.execute(Query::with_params(
    2533                        "DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?",
    2534                        vec![
    2535                            QueryParam::String(playlist_id.clone()),
    2536                            QueryParam::String(entry_id.clone()),
    2537                        ],
    2538                    ))?;
    2539                }
    2540                Ok(())
    2541            })
    2542            .await
    2543            .map_err(|e| RepoError::Database {
    2544                message: format!("Failed to remove items from playlist: {}", e),
    2545            })?;
    2546
    2547        Ok(())
    2548    }
    2549
    2550    async fn move_playlist_item(
    2551        &self,
    2552        playlist_id: &str,
    2553        item_id: &str,
    2554        new_index: u32,
    2555    ) -> Result<(), RepoError> {
    2556        let playlist_id = playlist_id.to_string();
    2557        let item_id = item_id.to_string();
    2558
    2559        self.db_service
    2560            .transaction(move |tx| {
    2561                use crate::storage::db_service::{Query, QueryParam};
    2562
    2563                // Get all items ordered by sort_order
    2564                let items: Vec<(i64, String)> = tx.query_many(
    2565                    Query::with_params(
    2566                        "SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order",
    2567                        vec![QueryParam::String(playlist_id)],
    2568                    ),
    2569                    |row| Ok((row.get(0)?, row.get(1)?)),
    2570                )?;
    2571
    2572                // Find the item to move
    2573                let old_idx = items.iter().position(|(_, iid)| iid == &item_id);
    2574                if let Some(old_pos) = old_idx {
    2575                    let mut ids = items;
    2576                    let entry = ids.remove(old_pos);
    2577                    let insert_at = (new_index as usize).min(ids.len());
    2578                    ids.insert(insert_at, entry);
    2579
    2580                    // Renumber all sort_orders
    2581                    for (i, (entry_id, _)) in ids.iter().enumerate() {
    2582                        tx.execute(Query::with_params(
    2583                            "UPDATE playlist_items SET sort_order = ? WHERE id = ?",
    2584                            vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)],
    2585                        ))?;
    2586                    }
    2587                }
    2588
    2589                Ok(())
    2590            })
    2591            .await
    2592            .map_err(|e| RepoError::Database {
    2593                message: format!("Failed to move playlist item: {}", e),
    2594            })?;
    2595
    2596        Ok(())
    2597    }
    2598}
    2599
    2600#[cfg(test)]
    2601mod tests {
    2602    // `CATALOG_BROWSE_LOCK` below serialises the tests that flip the
    2603    // process-global `INCLUDE_CATALOG_BROWSE` flag, so its guard is deliberately
    2604    // held across the `.await` of the query under test — that await *is* the
    2605    // critical section. This is not the production deadlock hazard the lint
    2606    // targets: the lock is test-only, uncontended outside these tests, and each
    2607    // `#[tokio::test]` runs on its own single-threaded runtime, so a held guard
    2608    // cannot block another task on the same worker. Restructuring around it
    2609    // would reintroduce the flag race the lock exists to prevent.
    2610    #![allow(clippy::await_holding_lock)]
    2611
    2612    use super::*;
    2613    use crate::storage::db_service::RusqliteService;
    2614    use rusqlite::Connection;
    2615    use std::sync::{Arc, Mutex};
    2616
    2617    /// Helper to create a test database with the necessary schema
    2618    /// `INCLUDE_CATALOG_BROWSE` is a process-global `AtomicBool`, so every test
    2619    /// that flips it must hold this lock — cargo runs tests in parallel threads
    2620    /// and would otherwise let them race, producing intermittent failures that
    2621    /// look like query bugs. Poisoning is recovered rather than propagated: a
    2622    /// panic in one such test should not cascade into unrelated ones.
    2623    static CATALOG_BROWSE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
    2624
    2625    fn lock_catalog_browse() -> std::sync::MutexGuard<'static, ()> {
    2626        use crate::utils::lock::MutexSafe;
    2627        CATALOG_BROWSE_LOCK.lock_safe()
    2628    }
    2629
    2630    /// TRACES: UR-065 | DR-108 | UT-111
    2631    #[test]
    2632    fn test_build_fts_prefix_query() {
    2633        // Plain input: quoted phrase plus the prefix operator.
    2634        assert_eq!(build_fts_prefix_query("Arr").as_deref(), Some("\"Arr\"*"));
    2635
    2636        // Multiple tokens stay implicitly ANDed; only the last is a prefix, so
    2637        // results still narrow while the user is typing.
    2638        assert_eq!(
    2639            build_fts_prefix_query("parks rec").as_deref(),
    2640            Some("\"parks\" \"rec\"*")
    2641        );
    2642
    2643        // Punctuation is data, not FTS5 syntax. Unquoted, each of these makes
    2644        // MATCH raise a syntax error and the whole search fail.
    2645        for query in ["Bob's Burgers", "Spider-Man", "AC/DC", "Wall-E", "9-1-1"] {
    2646            let built = build_fts_prefix_query(query).expect("should build");
    2647            assert!(
    2648                built.starts_with('"') && built.ends_with("*"),
    2649                "{query:?} produced {built:?}"
    2650            );
    2651        }
    2652
    2653        // An embedded double quote is escaped by doubling, not left to close
    2654        // the phrase early.
    2655        assert_eq!(
    2656            build_fts_prefix_query("say \"hi\"").as_deref(),
    2657            Some("\"say\" \"\"\"hi\"\"\"*")
    2658        );
    2659
    2660        // Nothing searchable => None, so callers skip the query instead of
    2661        // handing FTS5 a string it rejects. `search("")` is a real call site.
    2662        assert_eq!(build_fts_prefix_query(""), None);
    2663        assert_eq!(build_fts_prefix_query("   "), None);
    2664        assert_eq!(build_fts_prefix_query("-"), None);
    2665    }
    2666
    2667    /// An empty query must yield an empty result, not a database error — the
    2668    /// "list all playlists" call site passes one.
    2669    ///
    2670    /// TRACES: UR-065 | DR-108 | UT-111
    2671    #[tokio::test]
    2672    async fn test_search_empty_query_returns_empty_not_error() {
    2673        let db_service = create_test_db();
    2674        let repo = OfflineRepository::new(
    2675            db_service,
    2676            "test-server".to_string(),
    2677            "test-user".to_string(),
    2678        );
    2679
    2680        let result = repo.search("", None).await;
    2681        assert!(
    2682            result.is_ok(),
    2683            "empty query must not error: {:?}",
    2684            result.err()
    2685        );
    2686        assert!(result.unwrap().items.is_empty());
    2687    }
    2688
    2689    fn create_test_db() -> Arc<RusqliteService> {
    2690        let conn = Connection::open_in_memory().unwrap();
    2691
    2692        // Enable foreign key constraints (they're disabled by default in SQLite)
    2693        conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
    2694
    2695        // Create minimal schema for testing
    2696        conn.execute_batch(
    2697            r#"
    2698            CREATE TABLE servers (
    2699                id TEXT PRIMARY KEY,
    2700                name TEXT NOT NULL,
    2701                url TEXT NOT NULL UNIQUE
    2702            );
    2703
    2704            CREATE TABLE items (
    2705                id TEXT PRIMARY KEY,
    2706                server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
    2707                library_id TEXT,
    2708                parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
    2709                name TEXT NOT NULL,
    2710                item_type TEXT NOT NULL,
    2711                is_folder INTEGER DEFAULT 0,
    2712                overview TEXT,
    2713                genres TEXT,
    2714                runtime_ticks INTEGER,
    2715                production_year INTEGER,
    2716                premiere_date TEXT,
    2717                community_rating REAL,
    2718                official_rating TEXT,
    2719                primary_image_tag TEXT,
    2720                backdrop_image_tags TEXT,
    2721                album_id TEXT,
    2722                album_name TEXT,
    2723                album_artist TEXT,
    2724                artists TEXT,
    2725                index_number INTEGER,
    2726                series_id TEXT,
    2727                series_name TEXT,
    2728                season_id TEXT,
    2729                season_name TEXT,
    2730                parent_index_number INTEGER,
    2731                synced_at TEXT,
    2732                sort_name TEXT
    2733            );
    2734
    2735            -- Mirrors the real FTS5 index and its triggers (schema.rs migration
    2736            -- 001) so search can be exercised in tests at all.
    2737            CREATE VIRTUAL TABLE items_fts USING fts5(
    2738                name, overview, album_name, album_artist, artists, series_name,
    2739                content='items', content_rowid='rowid'
    2740            );
    2741
    2742            CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
    2743                INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
    2744                VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
    2745            END;
    2746
    2747            CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
    2748                INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
    2749                VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
    2750            END;
    2751
    2752            CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
    2753                INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
    2754                VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
    2755                INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
    2756                VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
    2757            END;
    2758
    2759            CREATE TABLE user_data (
    2760                user_id TEXT NOT NULL,
    2761                item_id TEXT NOT NULL,
    2762                playback_position_ticks INTEGER,
    2763                is_played INTEGER,
    2764                is_favorite INTEGER,
    2765                play_count INTEGER,
    2766                last_played_at TEXT,
    2767                playback_context_type TEXT,
    2768                playback_context_id TEXT,
    2769                synced_at TEXT,
    2770                pending_sync INTEGER DEFAULT 0,
    2771                PRIMARY KEY (user_id, item_id)
    2772            );
    2773
    2774            CREATE TABLE playlists (
    2775                id TEXT PRIMARY KEY,
    2776                user_id TEXT NOT NULL,
    2777                name TEXT NOT NULL,
    2778                is_local INTEGER DEFAULT 0,
    2779                jellyfin_id TEXT,
    2780                created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    2781                updated_at TEXT
    2782            );
    2783
    2784            CREATE TABLE playlist_items (
    2785                id INTEGER PRIMARY KEY AUTOINCREMENT,
    2786                playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
    2787                item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
    2788                sort_order INTEGER NOT NULL,
    2789                added_at TEXT DEFAULT CURRENT_TIMESTAMP,
    2790                UNIQUE(playlist_id, item_id)
    2791            );
    2792
    2793            CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
    2794
    2795            CREATE TABLE downloads (
    2796                id INTEGER PRIMARY KEY AUTOINCREMENT,
    2797                item_id TEXT NOT NULL,
    2798                status TEXT NOT NULL,
    2799                file_size INTEGER
    2800            );
    2801
    2802            CREATE TABLE libraries (
    2803                id TEXT PRIMARY KEY,
    2804                server_id TEXT NOT NULL,
    2805                name TEXT NOT NULL,
    2806                collection_type TEXT,
    2807                image_tag TEXT,
    2808                sort_order INTEGER DEFAULT 0,
    2809                synced_at TEXT
    2810            );
    2811
    2812            -- Mirrors migration 009 + the migration 022 FTS index.
    2813            CREATE TABLE people (
    2814                id TEXT PRIMARY KEY,
    2815                server_id TEXT NOT NULL,
    2816                name TEXT NOT NULL,
    2817                overview TEXT,
    2818                primary_image_tag TEXT,
    2819                premiere_date TEXT,
    2820                end_date TEXT,
    2821                synced_at TEXT DEFAULT CURRENT_TIMESTAMP
    2822            );
    2823
    2824            CREATE VIRTUAL TABLE people_fts USING fts5(
    2825                name, overview, content='people', content_rowid='rowid'
    2826            );
    2827
    2828            CREATE TRIGGER people_ai AFTER INSERT ON people BEGIN
    2829                INSERT INTO people_fts(rowid, name, overview)
    2830                VALUES (new.rowid, new.name, new.overview);
    2831            END;
    2832
    2833            CREATE TRIGGER people_ad AFTER DELETE ON people BEGIN
    2834                INSERT INTO people_fts(people_fts, rowid, name, overview)
    2835                VALUES('delete', old.rowid, old.name, old.overview);
    2836            END;
    2837
    2838            CREATE TRIGGER people_au AFTER UPDATE ON people BEGIN
    2839                INSERT INTO people_fts(people_fts, rowid, name, overview)
    2840                VALUES('delete', old.rowid, old.name, old.overview);
    2841                INSERT INTO people_fts(rowid, name, overview)
    2842                VALUES (new.rowid, new.name, new.overview);
    2843            END;
    2844
    2845            CREATE TABLE genres (
    2846                id TEXT NOT NULL,
    2847                server_id TEXT NOT NULL,
    2848                library_id TEXT,
    2849                name TEXT NOT NULL,
    2850                album_count INTEGER,
    2851                synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
    2852                PRIMARY KEY (server_id, library_id, name)
    2853            );
    2854        "#,
    2855        )
    2856        .unwrap();
    2857
    2858        // Insert a test server
    2859        conn.execute(
    2860            "INSERT INTO servers (id, name, url) VALUES ('test-server', 'Test Server', 'http://test')",
    2861            [],
    2862        ).unwrap();
    2863
    2864        Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
    2865    }
    2866
    2867    /// Helper to create a test MediaItem
    2868    fn create_test_item(id: &str, name: &str, parent_id: Option<&str>) -> MediaItem {
    2869        MediaItem {
    2870            id: id.to_string(),
    2871            name: name.to_string(),
    2872            item_type: "Audio".to_string(),
    2873            kind: crate::domain::MediaKind::Track,
    2874            is_folder: false,
    2875            server_id: "test-server".to_string(),
    2876            parent_id: parent_id.map(|s| s.to_string()),
    2877            library_id: None,
    2878            overview: None,
    2879            genres: None,
    2880            runtime_ticks: None,
    2881            duration_ms: None,
    2882            production_year: None,
    2883            premiere_date: None,
    2884            community_rating: None,
    2885            official_rating: None,
    2886            primary_image_tag: None,
    2887            image_id: None,
    2888            backdrop_image_tags: None,
    2889            parent_backdrop_image_tags: None,
    2890            album_id: None,
    2891            album_name: None,
    2892            album_artist: None,
    2893            artists: None,
    2894            artist_items: None,
    2895            index_number: None,
    2896            series_id: None,
    2897            series_name: None,
    2898            season_id: None,
    2899            season_name: None,
    2900            parent_index_number: None,
    2901            user_data: None,
    2902            media_streams: None,
    2903            media_sources: None,
    2904            people: None,
    2905        }
    2906    }
    2907
    2908    #[tokio::test]
    2909    async fn test_save_to_cache_with_missing_parent_fk() {
    2910        let db_service = create_test_db();
    2911
    2912        // Verify FK constraints are actually enabled
    2913        let fk_enabled: i32 = db_service
    2914            .query_one(Query::new("PRAGMA foreign_keys"), |row| row.get(0))
    2915            .await
    2916            .unwrap();
    2917        println!("Foreign keys enabled: {}", fk_enabled);
    2918        assert_eq!(fk_enabled, 1, "Foreign keys should be enabled");
    2919
    2920        let repo = OfflineRepository::new(
    2921            db_service.clone(),
    2922            "test-server".to_string(),
    2923            "test-user".to_string(),
    2924        );
    2925
    2926        // Create items where CHILDREN come BEFORE their PARENTS in the list
    2927        // This simulates the real-world scenario where Jellyfin's API
    2928        // returns items in an arbitrary order (e.g., alphabetically)
    2929        let items = vec![
    2930            // Tracks from Album 1 (album-1 doesn't exist yet!)
    2931            create_test_item("track-1", "Track 1", Some("album-1")),
    2932            create_test_item("track-2", "Track 2", Some("album-1")),
    2933            // Tracks from Album 2 (album-2 doesn't exist yet!)
    2934            create_test_item("track-3", "Track 3", Some("album-2")),
    2935            create_test_item("track-4", "Track 4", Some("album-2")),
    2936            // Albums come later in the list
    2937            create_test_item("album-1", "Album One", Some("library-123")),
    2938            create_test_item("album-2", "Album Two", Some("library-123")),
    2939        ];
    2940
    2941        println!("Attempting to save {} items...", items.len());
    2942        for (i, item) in items.iter().enumerate() {
    2943            println!("  Item {}: {} (parent: {:?})", i, item.id, item.parent_id);
    2944        }
    2945
    2946        // This should fail with the current implementation because:
    2947        // 1. It only creates a stub for "library-123" (the parent_id parameter)
    2948        // 2. When it tries to insert track-1 with parent_id = "album-1",
    2949        //    album-1 doesn't exist yet, causing FK constraint failure
    2950        let result = repo.save_to_cache("library-123", &items).await;
    2951
    2952        // After the fix, this should succeed and preserve parent relationships
    2953        match &result {
    2954            Ok(count) => {
    2955                println!("✓ Saved {} items", count);
    2956                assert_eq!(*count, 6);
    2957
    2958                // Debug: Check all items in the database
    2959                let all_items: Vec<(String, Option<String>)> = db_service
    2960                    .query_many(
    2961                        Query::new("SELECT id, parent_id FROM items ORDER BY id"),
    2962                        |row| Ok((row.get(0)?, row.get(1)?)),
    2963                    )
    2964                    .await
    2965                    .unwrap();
    2966
    2967                println!("\nAll items in database:");
    2968                for (id, parent) in &all_items {
    2969                    println!("  {} -> parent: {:?}", id, parent);
    2970                }
    2971
    2972                // Verify the ACTUAL parent_ids in the database are preserved correctly
    2973                let track1_parent: Option<String> = db_service
    2974                    .query_optional(
    2975                        Query::with_params(
    2976                            "SELECT parent_id FROM items WHERE id = ?",
    2977                            vec![QueryParam::String("track-1".to_string())],
    2978                        ),
    2979                        |row| row.get(0),
    2980                    )
    2981                    .await
    2982                    .unwrap()
    2983                    .flatten();
    2984
    2985                println!("\ntrack-1 parent_id in DB: {:?}", track1_parent);
    2986                println!("track-1 expected parent_id: Some(\"album-1\")");
    2987
    2988                // Verify parent relationships are preserved
    2989                assert_eq!(
    2990                    track1_parent,
    2991                    Some("album-1".to_string()),
    2992                    "track-1 should have parent_id='album-1'"
    2993                );
    2994
    2995                // Verify album-1 has the correct parent too
    2996                let album1_parent: Option<String> = db_service
    2997                    .query_optional(
    2998                        Query::with_params(
    2999                            "SELECT parent_id FROM items WHERE id = ?",
    3000                            vec![QueryParam::String("album-1".to_string())],
    3001                        ),
    3002                        |row| row.get(0),
    3003                    )
    3004                    .await
    3005                    .unwrap()
    3006                    .flatten();
    3007
    3008                assert_eq!(
    3009                    album1_parent,
    3010                    Some("library-123".to_string()),
    3011                    "album-1 should have parent_id='library-123'"
    3012                );
    3013            }
    3014            Err(e) => panic!("Unexpected error: {:?}", e),
    3015        }
    3016    }
    3017
    3018    #[tokio::test]
    3019    async fn test_save_to_cache_simple_case() {
    3020        let db_service = create_test_db();
    3021        let repo = OfflineRepository::new(
    3022            db_service.clone(),
    3023            "test-server".to_string(),
    3024            "test-user".to_string(),
    3025        );
    3026
    3027        // Simple case: all items have the same parent_id as the parameter
    3028        let items = vec![
    3029            create_test_item("item-1", "Item 1", Some("parent-123")),
    3030            create_test_item("item-2", "Item 2", Some("parent-123")),
    3031            create_test_item("item-3", "Item 3", Some("parent-123")),
    3032        ];
    3033
    3034        let result = repo.save_to_cache("parent-123", &items).await;
    3035        assert!(result.is_ok(), "Simple case should work: {:?}", result);
    3036        assert_eq!(result.unwrap(), 3);
    3037    }
    3038
    3039    /// Regression: a MusicAlbum whose tracks link via `album_id` (and have a
    3040    /// NULL `parent_id`, which is how the Jellyfin cache actually stores them)
    3041    /// must be recognized as available offline when a track is downloaded.
    3042    ///
    3043    /// Before the fix, `get_item(album_id)` only matched children by
    3044    /// `children.parent_id = i.id`, so a fully-downloaded album returned
    3045    /// NotFound offline and playback fell through to the (unreachable) server.
    3046    #[tokio::test]
    3047    async fn test_get_item_album_available_via_album_id_link() {
    3048        use crate::storage::db_service::DatabaseService;
    3049        let db_service = create_test_db();
    3050
    3051        for sql in [
    3052            // Album container (no children by parent_id).
    3053            "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
    3054             VALUES ('album-1', 'test-server', 'Hadestown', 'MusicAlbum', NULL, NULL)",
    3055            // Track linked to the album ONLY via album_id, parent_id NULL.
    3056            "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
    3057             VALUES ('track-1', 'test-server', 'Wait For Me', 'Audio', 'album-1', NULL)",
    3058            "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
    3059        ] {
    3060            db_service.execute(Query::new(sql)).await.unwrap();
    3061        }
    3062
    3063        let repo = OfflineRepository::new(
    3064            db_service.clone(),
    3065            "test-server".to_string(),
    3066            "test-user".to_string(),
    3067        );
    3068
    3069        // The downloaded track itself resolves offline.
    3070        assert!(
    3071            repo.get_item("track-1").await.is_ok(),
    3072            "downloaded track should be available offline"
    3073        );
    3074
    3075        // The album must also resolve offline because it has a downloaded child
    3076        // linked by album_id (not parent_id).
    3077        let album = repo.get_item("album-1").await;
    3078        assert!(
    3079            album.is_ok(),
    3080            "album with an album_id-linked downloaded track should be available offline, got {:?}",
    3081            album.err()
    3082        );
    3083        assert_eq!(album.unwrap().id, "album-1");
    3084
    3085        // Browsing into the album (get_items) must return its tracks even though
    3086        // they link by album_id and have a NULL parent_id. This is the call
    3087        // play_album_track makes to build the queue.
    3088        let tracks = repo.get_items("album-1", None).await.unwrap();
    3089        assert_eq!(
    3090            tracks.items.len(),
    3091            1,
    3092            "get_items(album_id) should return the track"
    3093        );
    3094        assert_eq!(tracks.items[0].id, "track-1");
    3095    }
    3096
    3097    /// Regression: offline library pages must honor the "Show all server media"
    3098    /// toggle. With `include_catalog_browse` off, `get_items` returns only
    3099    /// downloaded media — not the whole synced catalog. With it on, the full
    3100    /// (synced-but-not-downloaded) catalog is revealed. Fixes the bug where
    3101    /// offline library pages showed every server item regardless of the toggle.
    3102    ///
    3103    /// This is also the backend half of the end-to-end offline-listing scenario
    3104    /// IT-016: toggle off ⇒ downloaded media only; toggle on ⇒ the cached server
    3105    /// catalog is additionally revealed (greyed-out in the UI, distinguished by
    3106    /// the absence of a `downloads` row — see `MediaCard.isServerOnly`).
    3107    ///
    3108    /// TRACES: UR-052 | DR-078 | UT-067, IT-016
    3109    #[tokio::test]
    3110    async fn test_get_items_toggle_gates_synced_catalog() {
    3111        use crate::storage::db_service::DatabaseService;
    3112        let _guard = lock_catalog_browse();
    3113        let db_service = create_test_db();
    3114
    3115        for sql in [
    3116            // Two movies in a library, both merely synced (browsed) — no download.
    3117            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
    3118             VALUES ('movie-dl', 'test-server', 'Downloaded', 'Movie', 'lib-1', '2026-01-01')",
    3119            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
    3120             VALUES ('movie-cat', 'test-server', 'CatalogOnly', 'Movie', 'lib-1', '2026-01-01')",
    3121            // Only the first movie is actually downloaded.
    3122            "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
    3123            // A library row so the library-parent EXISTS clause matches.
    3124            "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
    3125        ] {
    3126            db_service.execute(Query::new(sql)).await.unwrap();
    3127        }
    3128
    3129        let repo = OfflineRepository::new(
    3130            db_service.clone(),
    3131            "test-server".to_string(),
    3132            "test-user".to_string(),
    3133        );
    3134        let opts = Some(GetItemsOptions {
    3135            include_item_types: Some(vec!["Movie".to_string()]),
    3136            ..Default::default()
    3137        });
    3138
    3139        // Toggle OFF: only the downloaded movie is returned.
    3140        set_include_catalog_browse(false);
    3141        let local_only = repo.get_items("lib-1", opts.clone()).await.unwrap();
    3142        let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
    3143        assert_eq!(
    3144            ids,
    3145            vec!["movie-dl"],
    3146            "toggle off should show downloaded media only"
    3147        );
    3148
    3149        // Toggle ON: both the downloaded and the catalog-only movie are returned.
    3150        set_include_catalog_browse(true);
    3151        let full_catalog = repo.get_items("lib-1", opts).await.unwrap();
    3152        let mut ids: Vec<&str> = full_catalog.items.iter().map(|i| i.id.as_str()).collect();
    3153        ids.sort();
    3154        assert_eq!(
    3155            ids,
    3156            vec!["movie-cat", "movie-dl"],
    3157            "toggle on should reveal the full catalog"
    3158        );
    3159
    3160        // Restore default for other tests sharing this process-global flag.
    3161        set_include_catalog_browse(true);
    3162    }
    3163
    3164    /// Searching an actor's name must reach them from the local index, and only
    3165    /// under a scope that admits People — `SearchScope::All`, which expands to
    3166    /// no type filter (DR-063). Before DR-111, `people` had no FTS index at all,
    3167    /// so the People group UR-060 requires could only be filled by the server.
    3168    ///
    3169    /// TRACES: UR-065, UR-060 | DR-111 | UT-114
    3170    #[tokio::test]
    3171    async fn test_search_includes_cached_people() {
    3172        use crate::storage::db_service::DatabaseService;
    3173        let _guard = lock_catalog_browse();
    3174        let db_service = create_test_db();
    3175
    3176        for sql in [
    3177            "INSERT INTO people (id, server_id, name, overview) \
    3178             VALUES ('p1', 'test-server', 'Tilda Swinton', 'Actor')",
    3179            // A movie that also matches, to prove people are added to — not
    3180            // substituted for — item results.
    3181            "INSERT INTO items (id, server_id, name, item_type, synced_at) \
    3182             VALUES ('m1', 'test-server', 'Tilda the Movie', 'Movie', '2026-01-01')",
    3183        ] {
    3184            db_service.execute(Query::new(sql)).await.unwrap();
    3185        }
    3186
    3187        let repo = OfflineRepository::new(
    3188            db_service.clone(),
    3189            "test-server".to_string(),
    3190            "test-user".to_string(),
    3191        );
    3192        set_include_catalog_browse(true);
    3193
    3194        // Unscoped search (SearchScope::All => no type filter) reaches people.
    3195        let all = repo.search("Tilda", None).await.unwrap();
    3196        let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
    3197        ids.sort();
    3198        assert_eq!(ids, vec!["m1", "p1"], "unscoped search must include people");
    3199
    3200        let person = all.items.iter().find(|i| i.id == "p1").unwrap();
    3201        assert_eq!(person.item_type, "Person");
    3202        assert_eq!(person.kind, crate::domain::MediaKind::Person);
    3203
    3204        // A scoped search names item types, and People are never among them.
    3205        let scoped = repo
    3206            .search(
    3207                "Tilda",
    3208                Some(SearchOptions {
    3209                    include_item_types: Some(vec!["Movie".to_string()]),
    3210                    ..Default::default()
    3211                }),
    3212            )
    3213            .await
    3214            .unwrap();
    3215        let ids: Vec<&str> = scoped.items.iter().map(|i| i.id.as_str()).collect();
    3216        assert_eq!(ids, vec!["m1"], "a scoped search must not leak people in");
    3217
    3218        set_include_catalog_browse(true);
    3219    }
    3220
    3221    /// The post-crawl sweep must remove what the server dropped, and nothing
    3222    /// else. Before DR-110 there was no `DELETE FROM items` anywhere in the
    3223    /// codebase, so the local catalog was append-only and media removed
    3224    /// server-side stayed searchable forever.
    3225    ///
    3226    /// TRACES: UR-065 | DR-110 | UT-113
    3227    #[tokio::test]
    3228    async fn test_prune_stale_catalog() {
    3229        use crate::storage::db_service::DatabaseService;
    3230        let db_service = create_test_db();
    3231
    3232        // "old" predates the crawl; "new" is what this crawl just wrote.
    3233        let old = "2026-01-01T00:00:00+00:00";
    3234        let new = "2026-06-01T00:00:00+00:00";
    3235        let cutoff = "2026-03-01T00:00:00+00:00";
    3236
    3237        for sql in [
    3238            // A second server, so the sweep can be shown to stay scoped to one.
    3239            "INSERT INTO servers (id, name, url) \
    3240             VALUES ('other-server', 'Other', 'http://other')"
    3241                .to_string(),
    3242            // Refreshed by the crawl => still on the server => keep.
    3243            format!(
    3244                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
    3245                     VALUES ('keep-fresh', 'test-server', 'Fresh', 'Movie', '{new}')"
    3246            ),
    3247            // Not refreshed => gone from the server => sweep.
    3248            format!(
    3249                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
    3250                     VALUES ('gone', 'test-server', 'Vanished', 'Movie', '{old}')"
    3251            ),
    3252            // Not refreshed, but downloaded => the file is on disk => keep.
    3253            format!(
    3254                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
    3255                     VALUES ('keep-dl', 'test-server', 'Downloaded', 'Movie', '{old}')"
    3256            ),
    3257            "INSERT INTO downloads (item_id, status) VALUES ('keep-dl', 'completed')".to_string(),
    3258            // Not refreshed, but a container whose child is downloaded => keep both.
    3259            format!(
    3260                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
    3261                     VALUES ('keep-album', 'test-server', 'Album', 'MusicAlbum', '{old}')"
    3262            ),
    3263            format!(
    3264                "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
    3265                     VALUES ('keep-track', 'test-server', 'Track', 'Audio', 'keep-album', '{old}')"
    3266            ),
    3267            "INSERT INTO downloads (item_id, status) VALUES ('keep-track', 'completed')"
    3268                .to_string(),
    3269            // A type the crawl never requests => it is never refreshed, so age
    3270            // says nothing about it => keep.
    3271            format!(
    3272                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
    3273                     VALUES ('keep-artist', 'test-server', 'Artist', 'MusicArtist', '{old}')"
    3274            ),
    3275            // Another server's row must be untouched.
    3276            format!(
    3277                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
    3278                     VALUES ('keep-other', 'other-server', 'Elsewhere', 'Movie', '{old}')"
    3279            ),
    3280        ] {
    3281            db_service.execute(Query::new(&sql)).await.unwrap();
    3282        }
    3283
    3284        let repo = OfflineRepository::new(
    3285            db_service.clone(),
    3286            "test-server".to_string(),
    3287            "test-user".to_string(),
    3288        );
    3289
    3290        let crawled_types = vec![
    3291            "Movie".to_string(),
    3292            "MusicAlbum".to_string(),
    3293            "Audio".to_string(),
    3294        ];
    3295        let removed = repo
    3296            .prune_stale_catalog(cutoff, &crawled_types)
    3297            .await
    3298            .unwrap();
    3299        assert_eq!(removed, 1, "only the vanished movie should be swept");
    3300
    3301        let mut surviving: Vec<String> = db_service
    3302            .query_many(Query::new("SELECT id FROM items"), |row| row.get(0))
    3303            .await
    3304            .unwrap();
    3305        surviving.sort();
    3306        assert_eq!(
    3307            surviving,
    3308            vec![
    3309                "keep-album",
    3310                "keep-artist",
    3311                "keep-dl",
    3312                "keep-fresh",
    3313                "keep-other",
    3314                "keep-track",
    3315            ]
    3316        );
    3317
    3318        // An empty type list is a no-op, not a "delete everything".
    3319        assert_eq!(repo.prune_stale_catalog(cutoff, &[]).await.unwrap(), 0);
    3320    }
    3321
    3322    /// Re-caching an item must not append a second FTS entry for it.
    3323    ///
    3324    /// `INSERT OR REPLACE` fires no `AFTER DELETE` trigger unless
    3325    /// `recursive_triggers` is on (it is not — storage/mod.rs sets only
    3326    /// `foreign_keys` and `journal_mode`), so `items_ad` never ran and the old
    3327    /// index row was orphaned; and because `items.id` is a `TEXT PRIMARY KEY`,
    3328    /// the replacement row also took a *fresh rowid* and inserted a second
    3329    /// entry. Every catalog pass therefore appended a duplicate index. Results
    3330    /// stayed correct (the rowid join hides orphans) but `MATCH` degraded
    3331    /// permanently — and once the DR-110 deletion sweep starts freeing rowids,
    3332    /// a reused rowid would collide with a stale orphan and produce a genuine
    3333    /// false positive.
    3334    ///
    3335    /// TRACES: UR-065 | DR-110 | UT-112
    3336    #[tokio::test]
    3337    async fn test_repeated_cache_does_not_duplicate_fts_entries() {
    3338        use crate::storage::db_service::DatabaseService;
    3339        let db_service = create_test_db();
    3340        let repo = OfflineRepository::new(
    3341            db_service.clone(),
    3342            "test-server".to_string(),
    3343            "test-user".to_string(),
    3344        );
    3345
    3346        let items = vec![create_test_item("track-1", "Wait For Me", None)];
    3347
    3348        // Three catalog passes over unchanged content, as happens on every app
    3349        // start.
    3350        for _ in 0..3 {
    3351            repo.save_to_cache("parent-1", &items).await.unwrap();
    3352        }
    3353
    3354        // (`save_to_cache` also inserts a stub row for the parent, so scope this
    3355        // to the item itself.)
    3356        let item_rows: i64 = db_service
    3357            .query_one(
    3358                Query::new("SELECT COUNT(*) FROM items WHERE id = 'track-1'"),
    3359                |row| row.get(0),
    3360            )
    3361            .await
    3362            .unwrap();
    3363        assert_eq!(item_rows, 1, "three passes must leave one item row");
    3364
    3365        let fts_hits: i64 = db_service
    3366            .query_one(
    3367                Query::with_params(
    3368                    "SELECT COUNT(*) FROM items_fts WHERE items_fts MATCH ?",
    3369                    vec![QueryParam::String("\"Wait\"*".to_string())],
    3370                ),
    3371                |row| row.get(0),
    3372            )
    3373            .await
    3374            .unwrap();
    3375        assert_eq!(
    3376            fts_hits, 1,
    3377            "the FTS index must hold one entry per item, not one per sync pass"
    3378        );
    3379    }
    3380
    3381    /// Search must read the *synced catalog*, not only downloaded media —
    3382    /// mirroring `get_items` and gated on the same `include_catalog_browse`
    3383    /// flag. Before DR-108 the cache leg wrapped its FTS query in a
    3384    /// `downloaded_items` CTE requiring `d.status = 'completed'`, so a user with
    3385    /// no downloads got an empty instant result on every keystroke and every
    3386    /// query fell through to a full `Recursive=true` server request.
    3387    ///
    3388    /// TRACES: UR-065 | DR-108 | UT-109
    3389    #[tokio::test]
    3390    async fn test_search_toggle_gates_synced_catalog() {
    3391        use crate::storage::db_service::DatabaseService;
    3392        let _guard = lock_catalog_browse();
    3393        let db_service = create_test_db();
    3394
    3395        for sql in [
    3396            // Two movies, both merely synced (indexed by the catalog crawl).
    3397            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
    3398             VALUES ('movie-dl', 'test-server', 'Arrival', 'Movie', 'lib-1', '2026-01-01')",
    3399            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
    3400             VALUES ('movie-cat', 'test-server', 'Arrakis', 'Movie', 'lib-1', '2026-01-01')",
    3401            // Only the first is actually downloaded.
    3402            "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
    3403        ] {
    3404            db_service.execute(Query::new(sql)).await.unwrap();
    3405        }
    3406
    3407        let repo = OfflineRepository::new(
    3408            db_service.clone(),
    3409            "test-server".to_string(),
    3410            "test-user".to_string(),
    3411        );
    3412
    3413        // Toggle ON (the online case, and the offline "Show all server media"
    3414        // case): the whole synced catalog is searchable.
    3415        set_include_catalog_browse(true);
    3416        let full = repo.search("Arr", None).await.unwrap();
    3417        let mut ids: Vec<&str> = full.items.iter().map(|i| i.id.as_str()).collect();
    3418        ids.sort();
    3419        assert_eq!(
    3420            ids,
    3421            vec!["movie-cat", "movie-dl"],
    3422            "with catalog browse on, search must cover synced-but-not-downloaded items"
    3423        );
    3424
    3425        // Toggle OFF (offline, downloads-only): unchanged from today.
    3426        set_include_catalog_browse(false);
    3427        let local_only = repo.search("Arr", None).await.unwrap();
    3428        let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
    3429        assert_eq!(
    3430            ids,
    3431            vec!["movie-dl"],
    3432            "with catalog browse off, search stays downloads-only"
    3433        );
    3434
    3435        // Restore default for other tests sharing this process-global flag.
    3436        set_include_catalog_browse(true);
    3437    }
    3438
    3439    /// The scope/type filter must be *bound*, not interpolated into the SQL
    3440    /// string. `SearchOptions.include_item_types` is settable from the frontend
    3441    /// (GenericMediaListPage passes it directly), so a value containing a quote
    3442    /// must not be able to alter the query.
    3443    ///
    3444    /// TRACES: UR-065 | DR-108 | UT-110
    3445    #[tokio::test]
    3446    async fn test_search_type_filter_is_parameterised() {
    3447        use crate::storage::db_service::DatabaseService;
    3448        let _guard = lock_catalog_browse();
    3449        let db_service = create_test_db();
    3450
    3451        db_service
    3452            .execute(Query::new(
    3453                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
    3454                 VALUES ('m1', 'test-server', 'Arrival', 'Movie', '2026-01-01')",
    3455            ))
    3456            .await
    3457            .unwrap();
    3458
    3459        let repo = OfflineRepository::new(
    3460            db_service.clone(),
    3461            "test-server".to_string(),
    3462            "test-user".to_string(),
    3463        );
    3464        set_include_catalog_browse(true);
    3465
    3466        // A quote-bearing type must be treated as data: no SQL error, no match.
    3467        let opts = SearchOptions {
    3468            include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
    3469            ..Default::default()
    3470        };
    3471        let result = repo.search("Arr", Some(opts)).await;
    3472        assert!(
    3473            result.is_ok(),
    3474            "a quote in an item type must not break the query: {:?}",
    3475            result.err()
    3476        );
    3477        assert!(
    3478            result.unwrap().items.is_empty(),
    3479            "an injected type filter must not widen the result set"
    3480        );
    3481
    3482        // The legitimate filter still works.
    3483        let opts = SearchOptions {
    3484            include_item_types: Some(vec!["Movie".to_string()]),
    3485            ..Default::default()
    3486        };
    3487        assert_eq!(repo.search("Arr", Some(opts)).await.unwrap().items.len(), 1);
    3488    }
    3489
    3490    /// Regression: TV episodes link to their season/series via `season_id` /
    3491    /// `series_id` (NOT `parent_id`, which is NULL in the cache). A downloaded
    3492    /// episode must make both its Season and Series available offline, and
    3493    /// browsing either container must return the episode.
    3494    #[tokio::test]
    3495    async fn test_get_item_tv_available_via_season_series_link() {
    3496        use crate::storage::db_service::DatabaseService;
    3497        let db_service = create_test_db();
    3498
    3499        for sql in [
    3500            "INSERT INTO items (id, server_id, name, item_type, parent_id) \
    3501             VALUES ('series-1', 'test-server', 'Gilmore Girls', 'Series', NULL)",
    3502            "INSERT INTO items (id, server_id, name, item_type, series_id, parent_id) \
    3503             VALUES ('season-1', 'test-server', 'Season 1', 'Season', 'series-1', NULL)",
    3504            // Episode links to both season and series; parent_id NULL.
    3505            "INSERT INTO items (id, server_id, name, item_type, season_id, series_id, parent_id) \
    3506             VALUES ('ep-1', 'test-server', 'Pilot', 'Episode', 'season-1', 'series-1', NULL)",
    3507            "INSERT INTO downloads (item_id, status) VALUES ('ep-1', 'completed')",
    3508        ] {
    3509            db_service.execute(Query::new(sql)).await.unwrap();
    3510        }
    3511
    3512        let repo = OfflineRepository::new(
    3513            db_service.clone(),
    3514            "test-server".to_string(),
    3515            "test-user".to_string(),
    3516        );
    3517
    3518        assert!(
    3519            repo.get_item("ep-1").await.is_ok(),
    3520            "downloaded episode available offline"
    3521        );
    3522        assert!(
    3523            repo.get_item("season-1").await.is_ok(),
    3524            "season with a season_id-linked downloaded episode should be available offline"
    3525        );
    3526        assert!(
    3527            repo.get_item("series-1").await.is_ok(),
    3528            "series with a series_id-linked downloaded episode should be available offline"
    3529        );
    3530
    3531        // Browsing the season returns the episode.
    3532        let season_items = repo.get_items("season-1", None).await.unwrap();
    3533        assert!(
    3534            season_items.items.iter().any(|i| i.id == "ep-1"),
    3535            "get_items(season_id) should return the episode"
    3536        );
    3537
    3538        // Browsing the series returns the episode (via series_id link).
    3539        let series_items = repo.get_items("series-1", None).await.unwrap();
    3540        assert!(
    3541            series_items.items.iter().any(|i| i.id == "ep-1"),
    3542            "get_items(series_id) should surface the downloaded episode"
    3543        );
    3544    }
    3545
    3546    /// Regression: offline startup must list cached libraries. Previously
    3547    /// `get_libraries` joined on `items.library_id` (always NULL in the cache),
    3548    /// so it returned nothing offline and the app showed no libraries at all.
    3549    /// The list must round-trip through `save_libraries_to_cache`.
    3550    #[tokio::test]
    3551    async fn test_libraries_cache_roundtrip_available_offline() {
    3552        let db_service = create_test_db();
    3553        let repo = OfflineRepository::new(
    3554            db_service.clone(),
    3555            "test-server".to_string(),
    3556            "test-user".to_string(),
    3557        );
    3558
    3559        // Empty cache → no libraries (this is the state that fell through to the
    3560        // server and hung offline).
    3561        assert!(repo.get_libraries().await.unwrap().is_empty());
    3562
    3563        // Simulate the online path persisting the server's library list.
    3564        let server_libs = vec![
    3565            Library::new("music".into(), "Music".into(), "music".into(), None),
    3566            Library::new(
    3567                "movies".into(),
    3568                "Movies".into(),
    3569                "movies".into(),
    3570                Some("tag".into()),
    3571            ),
    3572        ];
    3573        let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
    3574        assert_eq!(saved, 2);
    3575
    3576        // Now offline get_libraries returns them without touching the server.
    3577        let offline_libs = repo.get_libraries().await.unwrap();
    3578        let names: Vec<&str> = offline_libs.iter().map(|l| l.name.as_str()).collect();
    3579        assert_eq!(
    3580            names,
    3581            vec!["Music", "Movies"],
    3582            "cached libraries available offline in sort order"
    3583        );
    3584
    3585        // Re-saving is idempotent (INSERT OR REPLACE), not duplicating rows.
    3586        repo.save_libraries_to_cache(&server_libs).await.unwrap();
    3587        assert_eq!(repo.get_libraries().await.unwrap().len(), 2);
    3588    }
    3589
    3590    /// Regression: the music/TV/movie landing pages lost genre variety because
    3591    /// offline `get_genres` derived genres from cached albums only — so the
    3592    /// hybrid cache-first race pinned the list to whatever sparse set the local
    3593    /// albums yielded instead of the server's full catalog. The full genre list
    3594    /// must round-trip through `save_genres_to_cache` and come back scoped by
    3595    /// library.
    3596    #[tokio::test]
    3597    async fn test_genres_cache_roundtrip_scoped_by_library() {
    3598        let db_service = create_test_db();
    3599        let repo = OfflineRepository::new(
    3600            db_service.clone(),
    3601            "test-server".to_string(),
    3602            "test-user".to_string(),
    3603        );
    3604
    3605        // Empty cache → no genres.
    3606        assert!(repo.get_genres(Some("music-lib")).await.unwrap().is_empty());
    3607
    3608        // Simulate the online path persisting the server's full genre catalog.
    3609        let server_genres = vec![
    3610            Genre {
    3611                id: "g1".into(),
    3612                name: "Rock".into(),
    3613                album_count: Some(42),
    3614            },
    3615            Genre {
    3616                id: "g2".into(),
    3617                name: "Jazz".into(),
    3618                album_count: Some(17),
    3619            },
    3620            Genre {
    3621                id: "g3".into(),
    3622                name: "Ambient".into(),
    3623                album_count: None,
    3624            },
    3625        ];
    3626        let saved = repo
    3627            .save_genres_to_cache(Some("music-lib"), &server_genres)
    3628            .await
    3629            .unwrap();
    3630        assert_eq!(saved, 3);
    3631
    3632        // Offline get_genres returns the full set for that library, counts intact.
    3633        let mut offline_genres = repo.get_genres(Some("music-lib")).await.unwrap();
    3634        offline_genres.sort_by(|a, b| a.name.cmp(&b.name));
    3635        let names: Vec<&str> = offline_genres.iter().map(|g| g.name.as_str()).collect();
    3636        assert_eq!(names, vec!["Ambient", "Jazz", "Rock"]);
    3637        let rock = offline_genres.iter().find(|g| g.name == "Rock").unwrap();
    3638        assert_eq!(rock.album_count, Some(42));
    3639
    3640        // Genres are scoped: a different library sees nothing.
    3641        assert!(repo.get_genres(Some("other-lib")).await.unwrap().is_empty());
    3642
    3643        // Re-saving replaces the scope's rows (server removed "Jazz").
    3644        let updated = vec![Genre {
    3645            id: "g1".into(),
    3646            name: "Rock".into(),
    3647            album_count: Some(50),
    3648        }];
    3649        repo.save_genres_to_cache(Some("music-lib"), &updated)
    3650            .await
    3651            .unwrap();
    3652        let after = repo.get_genres(Some("music-lib")).await.unwrap();
    3653        assert_eq!(after.len(), 1, "stale genres removed on refresh");
    3654        assert_eq!(after[0].album_count, Some(50), "counts updated on refresh");
    3655    }
    3656
    3657    // ===== Playlist Tests =====
    3658
    3659    /// Helper to seed items into the DB for playlist tests
    3660    async fn seed_items(repo: &OfflineRepository, ids: &[&str]) {
    3661        let items: Vec<MediaItem> = ids
    3662            .iter()
    3663            .map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1")))
    3664            .collect();
    3665        repo.save_to_cache("library-1", &items).await.unwrap();
    3666    }
    3667
    3668    /// Insert a fully-formed item row of a given type (bypasses save_to_cache's
    3669    /// stub-parent machinery so containers/leaves can be linked precisely).
    3670    async fn insert_item(
    3671        db: &Arc<RusqliteService>,
    3672        id: &str,
    3673        item_type: &str,
    3674        album_id: Option<&str>,
    3675        series_id: Option<&str>,
    3676        season_id: Option<&str>,
    3677    ) {
    3678        db.execute(Query::with_params(
    3679            "INSERT INTO items (id, server_id, name, item_type, album_id, series_id, season_id, synced_at)
    3680             VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, ?6, '2024-01-01')",
    3681            vec![
    3682                QueryParam::String(id.to_string()),
    3683                QueryParam::String(format!("Name {id}")),
    3684                QueryParam::String(item_type.to_string()),
    3685                album_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
    3686                series_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
    3687                season_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
    3688            ],
    3689        ))
    3690        .await
    3691        .unwrap();
    3692    }
    3693
    3694    /// Like `insert_item`, but sets `library_id` — which `get_latest_items`
    3695    /// filters on, so rows without it are invisible to that query.
    3696    async fn insert_library_item(
    3697        db: &Arc<RusqliteService>,
    3698        id: &str,
    3699        item_type: &str,
    3700        library_id: &str,
    3701        album_id: Option<&str>,
    3702    ) {
    3703        db.execute(Query::with_params(
    3704            "INSERT INTO items (id, server_id, library_id, name, item_type, album_id, synced_at)
    3705             VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, '2024-01-01')",
    3706            vec![
    3707                QueryParam::String(id.to_string()),
    3708                QueryParam::String(library_id.to_string()),
    3709                QueryParam::String(format!("Name {id}")),
    3710                QueryParam::String(item_type.to_string()),
    3711                album_id
    3712                    .map(|s| QueryParam::String(s.to_string()))
    3713                    .unwrap_or(QueryParam::Null),
    3714            ],
    3715        ))
    3716        .await
    3717        .unwrap();
    3718    }
    3719
    3720    async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
    3721        db.execute(Query::with_params(
    3722            "INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
    3723            vec![
    3724                QueryParam::String(item_id.to_string()),
    3725                QueryParam::Int64(file_size),
    3726            ],
    3727        ))
    3728        .await
    3729        .unwrap();
    3730    }
    3731
    3732    async fn seed_library(db: &Arc<RusqliteService>, id: &str, collection_type: &str) {
    3733        db.execute(Query::with_params(
    3734            "INSERT INTO libraries (id, server_id, name, collection_type, sort_order)
    3735             VALUES (?1, 'test-server', ?2, ?3, 0)",
    3736            vec![
    3737                QueryParam::String(id.to_string()),
    3738                QueryParam::String(format!("Lib {id}")),
    3739                QueryParam::String(collection_type.to_string()),
    3740            ],
    3741        ))
    3742        .await
    3743        .unwrap();
    3744    }
    3745
    3746    fn make_repo(db: &Arc<RusqliteService>) -> OfflineRepository {
    3747        OfflineRepository::new(
    3748            db.clone(),
    3749            "test-server".to_string(),
    3750            "test-user".to_string(),
    3751        )
    3752    }
    3753
    3754    /// A newly-synced album appears once in "recently added", not once per track.
    3755    ///
    3756    /// The downloaded-items CTE deliberately matches both the leaves and their
    3757    /// container, which is right for browsing but wrong here: it made a 3-track
    3758    /// album occupy 4 slots in the row. Tracks whose album is itself in the
    3759    /// result are now collapsed into it.
    3760    #[tokio::test]
    3761    async fn test_get_latest_items_collapses_tracks_into_their_album() {
    3762        let db = create_test_db();
    3763        insert_library_item(&db, "album-1", "MusicAlbum", "lib-1", None).await;
    3764        for track in ["track-1", "track-2", "track-3"] {
    3765            insert_library_item(&db, track, "Audio", "lib-1", Some("album-1")).await;
    3766            seed_completed_download(&db, track, 1000).await;
    3767        }
    3768        // A movie has no container, so it must still show up on its own.
    3769        insert_library_item(&db, "movie-1", "Movie", "lib-1", None).await;
    3770        seed_completed_download(&db, "movie-1", 2000).await;
    3771
    3772        let repo = make_repo(&db);
    3773        let latest = repo.get_latest_items("lib-1", Some(16)).await.unwrap();
    3774        let ids: Vec<&str> = latest.iter().map(|i| i.id.as_str()).collect();
    3775
    3776        assert!(
    3777            !ids.iter().any(|id| id.starts_with("track-")),
    3778            "individual tracks must collapse into their album, got: {ids:?}"
    3779        );
    3780        assert!(ids.contains(&"album-1"), "the album itself is listed");
    3781        assert!(ids.contains(&"movie-1"), "containerless items still listed");
    3782    }
    3783
    3784    /// UT: downloaded-only browse returns a downloaded leaf AND its container,
    3785    /// filtered to the requested album parent. A non-downloaded sibling is omitted.
    3786    ///
    3787    /// TRACES: UR-055 | DR-082, DR-083 | UT-072
    3788    #[tokio::test]
    3789    async fn test_get_downloaded_items_returns_leaf_and_container() {
    3790        let db = create_test_db();
    3791        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
    3792        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
    3793        insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
    3794        // track-1 downloaded; track-2 is NOT downloaded.
    3795        seed_completed_download(&db, "track-1", 1000).await;
    3796
    3797        let repo = make_repo(&db);
    3798
    3799        // Browsing the album shows only the downloaded track.
    3800        let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
    3801        let ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
    3802        assert_eq!(ids, vec!["track-1"], "only the downloaded track is listed");
    3803    }
    3804
    3805    /// Regression: browsing a downloaded *library* (top level) lists containers,
    3806    /// not their leaves — a music library shows the album, not the individual
    3807    /// downloaded songs. The leaf is still reachable by drilling into the album.
    3808    ///
    3809    /// TRACES: UR-055 | DR-082, DR-083 | UT-076
    3810    #[tokio::test]
    3811    async fn test_get_downloaded_items_library_lists_albums_not_tracks() {
    3812        let db = create_test_db();
    3813        seed_library(&db, "music-lib", "music").await;
    3814        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
    3815        // Tracks link to the album via album_id (parent_id NULL in the cache).
    3816        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
    3817        insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
    3818        seed_completed_download(&db, "track-1", 1000).await;
    3819        seed_completed_download(&db, "track-2", 1000).await;
    3820
    3821        let repo = make_repo(&db);
    3822
    3823        // Library level: only the album shows, not the two tracks.
    3824        let at_library = repo.get_downloaded_items("music-lib", None).await.unwrap();
    3825        let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
    3826        assert_eq!(
    3827            ids,
    3828            vec!["album-1"],
    3829            "library browse lists the album container, not its tracks"
    3830        );
    3831
    3832        // Drilling into the album still returns the downloaded tracks.
    3833        let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
    3834        let mut track_ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
    3835        track_ids.sort();
    3836        assert_eq!(track_ids, vec!["track-1", "track-2"]);
    3837    }
    3838
    3839    /// Regression: each downloaded library shows **only its own media**.
    3840    ///
    3841    /// Cached items carry no link back to their library (`library_id`/`parent_id`
    3842    /// are NULL — [[offline-libraries-never-cached]]), and the library branch of
    3843    /// the query only asserted that the requested library *exists*, never that
    3844    /// the item belongs to it. So opening any downloaded library listed every
    3845    /// downloaded top-level item on the server: films in the music library,
    3846    /// albums under TV. The library's `collection_type` decides which item types
    3847    /// belong to it, the same mapping `get_downloaded_libraries` already uses.
    3848    ///
    3849    /// TRACES: UR-055 | DR-167 | UT-162
    3850    #[tokio::test]
    3851    async fn test_get_downloaded_items_library_does_not_mix_media_types() {
    3852        let db = create_test_db();
    3853        seed_library(&db, "music-lib", "music").await;
    3854        seed_library(&db, "movie-lib", "movies").await;
    3855        seed_library(&db, "tv-lib", "tvshows").await;
    3856
    3857        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
    3858        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
    3859        insert_item(&db, "movie-1", "Movie", None, None, None).await;
    3860        insert_item(&db, "series-1", "Series", None, None, None).await;
    3861        insert_item(&db, "episode-1", "Episode", None, Some("series-1"), None).await;
    3862
    3863        seed_completed_download(&db, "track-1", 1000).await;
    3864        seed_completed_download(&db, "movie-1", 2000).await;
    3865        seed_completed_download(&db, "episode-1", 3000).await;
    3866
    3867        let repo = make_repo(&db);
    3868
    3869        let music: Vec<String> = repo
    3870            .get_downloaded_items("music-lib", None)
    3871            .await
    3872            .unwrap()
    3873            .items
    3874            .iter()
    3875            .map(|i| i.id.clone())
    3876            .collect();
    3877        assert_eq!(
    3878            music,
    3879            vec!["album-1"],
    3880            "the music library must not list films or series; got {:?}",
    3881            music
    3882        );
    3883
    3884        let movies: Vec<String> = repo
    3885            .get_downloaded_items("movie-lib", None)
    3886            .await
    3887            .unwrap()
    3888            .items
    3889            .iter()
    3890            .map(|i| i.id.clone())
    3891            .collect();
    3892        assert_eq!(
    3893            movies,
    3894            vec!["movie-1"],
    3895            "the movie library must not list albums or series; got {:?}",
    3896            movies
    3897        );
    3898
    3899        let tv: Vec<String> = repo
    3900            .get_downloaded_items("tv-lib", None)
    3901            .await
    3902            .unwrap()
    3903            .items
    3904            .iter()
    3905            .map(|i| i.id.clone())
    3906            .collect();
    3907        assert_eq!(
    3908            tv,
    3909            vec!["series-1"],
    3910            "the TV library must not list albums or films; got {:?}",
    3911            tv
    3912        );
    3913    }
    3914
    3915    /// Regression: a downloaded TV library lists the Series, not its Seasons or
    3916    /// Episodes — the same "individual songs" bug seen for music, for TV. The
    3917    /// season and episode are still reachable by drilling into the series.
    3918    ///
    3919    /// TRACES: UR-055 | DR-082, DR-083 | UT-077
    3920    #[tokio::test]
    3921    async fn test_get_downloaded_items_library_lists_series_not_episodes() {
    3922        let db = create_test_db();
    3923        seed_library(&db, "tv-lib", "tvshows").await;
    3924        insert_item(&db, "series-1", "Series", None, None, None).await;
    3925        // Season links to its series; episode links to both season and series.
    3926        insert_item(&db, "season-1", "Season", None, Some("series-1"), None).await;
    3927        insert_item(
    3928            &db,
    3929            "ep-1",
    3930            "Episode",
    3931            None,
    3932            Some("series-1"),
    3933            Some("season-1"),
    3934        )
    3935        .await;
    3936        seed_completed_download(&db, "ep-1", 4000).await;
    3937
    3938        let repo = make_repo(&db);
    3939
    3940        // Library level: only the series shows.
    3941        let at_library = repo.get_downloaded_items("tv-lib", None).await.unwrap();
    3942        let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
    3943        assert_eq!(
    3944            ids,
    3945            vec!["series-1"],
    3946            "TV library browse lists the series, not seasons/episodes"
    3947        );
    3948
    3949        // Drilling into the series returns its season; into the season, the episode.
    3950        let in_series = repo.get_downloaded_items("series-1", None).await.unwrap();
    3951        assert!(
    3952            in_series.items.iter().any(|i| i.id == "season-1"),
    3953            "series drill returns the season"
    3954        );
    3955        let in_season = repo.get_downloaded_items("season-1", None).await.unwrap();
    3956        assert!(
    3957            in_season.items.iter().any(|i| i.id == "ep-1"),
    3958            "season drill returns the episode"
    3959        );
    3960    }
    3961
    3962    /// A downloaded leaf with no cached container (e.g. a Movie, or a track whose
    3963    /// album isn't in the cache) still surfaces at the library level.
    3964    ///
    3965    /// TRACES: UR-055 | DR-082, DR-083 | UT-078
    3966    #[tokio::test]
    3967    async fn test_get_downloaded_items_library_keeps_orphan_leaves() {
    3968        let db = create_test_db();
    3969        seed_library(&db, "movie-lib", "movies").await;
    3970        insert_item(&db, "movie-1", "Movie", None, None, None).await;
    3971        seed_completed_download(&db, "movie-1", 5000).await;
    3972
    3973        let repo = make_repo(&db);
    3974        let at_library = repo.get_downloaded_items("movie-lib", None).await.unwrap();
    3975        let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
    3976        assert_eq!(
    3977            ids,
    3978            vec!["movie-1"],
    3979            "a downloaded movie with no container shows"
    3980        );
    3981    }
    3982
    3983    /// UT: an empty downloaded-only browse is authoritative — no rows, no error,
    3984    /// regardless of the catalog-browse flag (which the DR-080 fallthrough uses).
    3985    ///
    3986    /// TRACES: UR-055 | DR-082 | UT-073
    3987    #[tokio::test]
    3988    async fn test_get_downloaded_items_empty_is_authoritative() {
    3989        let db = create_test_db();
    3990        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
    3991        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
    3992        // Nothing downloaded, and the catalog-browse flag is ON (online default).
    3993        set_include_catalog_browse(true);
    3994        let repo = make_repo(&db);
    3995
    3996        let result = repo.get_downloaded_items("album-1", None).await.unwrap();
    3997        assert!(
    3998            result.items.is_empty(),
    3999            "empty downloaded browse returns no items even with catalog-browse on"
    4000        );
    4001    }
    4002
    4003    /// UT: only libraries with downloaded content are listed; an empty one is omitted.
    4004    ///
    4005    /// TRACES: UR-055 | DR-082 | UT-074
    4006    #[tokio::test]
    4007    async fn test_get_downloaded_libraries_omits_empty() {
    4008        let db = create_test_db();
    4009        seed_library(&db, "music-lib", "music").await;
    4010        seed_library(&db, "movie-lib", "movies").await;
    4011        insert_item(&db, "track-1", "Audio", None, None, None).await;
    4012        seed_completed_download(&db, "track-1", 500).await;
    4013
    4014        let repo = make_repo(&db);
    4015        let libs = repo.get_downloaded_libraries().await.unwrap();
    4016        let ids: Vec<&str> = libs.iter().map(|l| l.id.as_str()).collect();
    4017        assert_eq!(
    4018            ids,
    4019            vec!["music-lib"],
    4020            "movie library with no downloads omitted"
    4021        );
    4022    }
    4023
    4024    /// UT: disk usage reports a leaf's own size, a container's summed descendants,
    4025    /// and reconciles the device total with the sum of leaves.
    4026    ///
    4027    /// TRACES: UR-056 | DR-085 | UT-075
    4028    #[tokio::test]
    4029    async fn test_download_disk_usage_aggregates_containers() {
    4030        let db = create_test_db();
    4031        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
    4032        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
    4033        insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
    4034        seed_completed_download(&db, "track-1", 1000).await;
    4035        seed_completed_download(&db, "track-2", 2000).await;
    4036
    4037        let repo = make_repo(&db);
    4038        let usage = repo.get_download_disk_usage().await.unwrap();
    4039
    4040        assert_eq!(usage.item_count, 2, "two leaf downloads");
    4041        assert_eq!(
    4042            usage.device_total_bytes, 3000,
    4043            "device total is the leaf sum"
    4044        );
    4045        assert_eq!(usage.sizes.get("track-1"), Some(&1000));
    4046        assert_eq!(
    4047            usage.sizes.get("album-1"),
    4048            Some(&3000),
    4049            "container = sum of children"
    4050        );
    4051        // Both children downloaded ⇒ album is NOT partial.
    4052        assert_eq!(
    4053            usage.partial_containers.get("album-1"),
    4054            None,
    4055            "fully downloaded album is not partial"
    4056        );
    4057        // Device total reconciles with the sum of the listed leaves.
    4058        let leaf_sum: i64 = ["track-1", "track-2"]
    4059            .iter()
    4060            .map(|id| usage.sizes[*id])
    4061            .sum();
    4062        assert_eq!(leaf_sum, usage.device_total_bytes);
    4063    }
    4064
    4065    /// UT: a container with a downloaded child AND a non-downloaded cached child
    4066    /// is flagged partial. TRACES: UR-055 | DR-083 | UT-051
    4067    #[tokio::test]
    4068    async fn test_download_disk_usage_flags_partial_container() {
    4069        let db = create_test_db();
    4070        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
    4071        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
    4072        insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
    4073        // Only track-1 downloaded; track-2 is cached but not downloaded.
    4074        seed_completed_download(&db, "track-1", 1000).await;
    4075
    4076        let repo = make_repo(&db);
    4077        let usage = repo.get_download_disk_usage().await.unwrap();
    4078        assert_eq!(
    4079            usage.partial_containers.get("album-1"),
    4080            Some(&true),
    4081            "album with a missing child is partial"
    4082        );
    4083    }
    4084
    4085    #[tokio::test]
    4086    async fn test_playlist_create_empty() {
    4087        let db_service = create_test_db();
    4088        let repo = OfflineRepository::new(
    4089            db_service.clone(),
    4090            "test-server".to_string(),
    4091            "test-user".to_string(),
    4092        );
    4093
    4094        let result = repo.create_playlist("My Playlist", &[]).await;
    4095        assert!(result.is_ok());
    4096        let created = result.unwrap();
    4097        assert!(
    4098            !created.id.is_empty(),
    4099            "Should return a non-empty playlist ID"
    4100        );
    4101
    4102        // Verify playlist exists in DB
    4103        let name: String = db_service
    4104            .query_one(
    4105                Query::with_params(
    4106                    "SELECT name FROM playlists WHERE id = ?",
    4107                    vec![QueryParam::String(created.id.clone())],
    4108                ),
    4109                |row| row.get(0),
    4110            )
    4111            .await
    4112            .unwrap();
    4113        assert_eq!(name, "My Playlist");
    4114    }
    4115
    4116    #[tokio::test]
    4117    async fn test_playlist_create_with_items() {
    4118        let db_service = create_test_db();
    4119        let repo = OfflineRepository::new(
    4120            db_service.clone(),
    4121            "test-server".to_string(),
    4122            "test-user".to_string(),
    4123        );
    4124        seed_items(&repo, &["t1", "t2", "t3"]).await;
    4125
    4126        let created = repo
    4127            .create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()])
    4128            .await
    4129            .unwrap();
    4130
    4131        let items = repo.get_playlist_items(&created.id).await.unwrap();
    4132        assert_eq!(items.len(), 3);
    4133        assert_eq!(items[0].item.id, "t1");
    4134        assert_eq!(items[1].item.id, "t2");
    4135        assert_eq!(items[2].item.id, "t3");
    4136    }
    4137
    4138    #[tokio::test]
    4139    async fn test_playlist_delete() {
    4140        let db_service = create_test_db();
    4141        let repo = OfflineRepository::new(
    4142            db_service.clone(),
    4143            "test-server".to_string(),
    4144            "test-user".to_string(),
    4145        );
    4146        seed_items(&repo, &["t1"]).await;
    4147
    4148        let created = repo
    4149            .create_playlist("To Delete", &["t1".into()])
    4150            .await
    4151            .unwrap();
    4152
    4153        // Delete it
    4154        repo.delete_playlist(&created.id).await.unwrap();
    4155
    4156        // Verify playlist is gone
    4157        let count: i32 = db_service
    4158            .query_one(
    4159                Query::with_params(
    4160                    "SELECT COUNT(*) FROM playlists WHERE id = ?",
    4161                    vec![QueryParam::String(created.id.clone())],
    4162                ),
    4163                |row| row.get(0),
    4164            )
    4165            .await
    4166            .unwrap();
    4167        assert_eq!(count, 0);
    4168
    4169        // Verify cascade deleted playlist_items
    4170        let item_count: i32 = db_service
    4171            .query_one(
    4172                Query::with_params(
    4173                    "SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?",
    4174                    vec![QueryParam::String(created.id)],
    4175                ),
    4176                |row| row.get(0),
    4177            )
    4178            .await
    4179            .unwrap();
    4180        assert_eq!(item_count, 0);
    4181    }
    4182
    4183    #[tokio::test]
    4184    async fn test_playlist_rename() {
    4185        let db_service = create_test_db();
    4186        let repo = OfflineRepository::new(
    4187            db_service.clone(),
    4188            "test-server".to_string(),
    4189            "test-user".to_string(),
    4190        );
    4191
    4192        let created = repo.create_playlist("Original Name", &[]).await.unwrap();
    4193        repo.rename_playlist(&created.id, "New Name").await.unwrap();
    4194
    4195        let name: String = db_service
    4196            .query_one(
    4197                Query::with_params(
    4198                    "SELECT name FROM playlists WHERE id = ?",
    4199                    vec![QueryParam::String(created.id)],
    4200                ),
    4201                |row| row.get(0),
    4202            )
    4203            .await
    4204            .unwrap();
    4205        assert_eq!(name, "New Name");
    4206    }
    4207
    4208    #[tokio::test]
    4209    async fn test_playlist_get_items_preserves_order() {
    4210        let db_service = create_test_db();
    4211        let repo = OfflineRepository::new(
    4212            db_service.clone(),
    4213            "test-server".to_string(),
    4214            "test-user".to_string(),
    4215        );
    4216        seed_items(&repo, &["a", "b", "c"]).await;
    4217
    4218        let created = repo
    4219            .create_playlist("Ordered", &["c".into(), "a".into(), "b".into()])
    4220            .await
    4221            .unwrap();
    4222        let items = repo.get_playlist_items(&created.id).await.unwrap();
    4223
    4224        assert_eq!(items.len(), 3);
    4225        // Order should match insertion order: c, a, b
    4226        assert_eq!(items[0].item.id, "c");
    4227        assert_eq!(items[1].item.id, "a");
    4228        assert_eq!(items[2].item.id, "b");
    4229        // Each entry should have a unique playlist_item_id
    4230        assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id);
    4231        assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id);
    4232    }
    4233
    4234    #[tokio::test]
    4235    async fn test_playlist_get_items_empty_playlist() {
    4236        let db_service = create_test_db();
    4237        let repo = OfflineRepository::new(
    4238            db_service.clone(),
    4239            "test-server".to_string(),
    4240            "test-user".to_string(),
    4241        );
    4242
    4243        let created = repo.create_playlist("Empty", &[]).await.unwrap();
    4244        let items = repo.get_playlist_items(&created.id).await.unwrap();
    4245        assert!(items.is_empty());
    4246    }
    4247
    4248    #[tokio::test]
    4249    async fn test_playlist_add_items() {
    4250        let db_service = create_test_db();
    4251        let repo = OfflineRepository::new(
    4252            db_service.clone(),
    4253            "test-server".to_string(),
    4254            "test-user".to_string(),
    4255        );
    4256        seed_items(&repo, &["t1", "t2", "t3"]).await;
    4257
    4258        let created = repo
    4259            .create_playlist("Addable", &["t1".into()])
    4260            .await
    4261            .unwrap();
    4262
    4263        // Add two more tracks
    4264        repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()])
    4265            .await
    4266            .unwrap();
    4267
    4268        let items = repo.get_playlist_items(&created.id).await.unwrap();
    4269        assert_eq!(items.len(), 3);
    4270        assert_eq!(items[0].item.id, "t1");
    4271        assert_eq!(items[1].item.id, "t2");
    4272        assert_eq!(items[2].item.id, "t3");
    4273    }
    4274
    4275    #[tokio::test]
    4276    async fn test_playlist_add_duplicate_items_ignored() {
    4277        let db_service = create_test_db();
    4278        let repo = OfflineRepository::new(
    4279            db_service.clone(),
    4280            "test-server".to_string(),
    4281            "test-user".to_string(),
    4282        );
    4283        seed_items(&repo, &["t1"]).await;
    4284
    4285        let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap();
    4286
    4287        // Try to add the same item again
    4288        repo.add_to_playlist(&created.id, &["t1".into()])
    4289            .await
    4290            .unwrap();
    4291
    4292        let items = repo.get_playlist_items(&created.id).await.unwrap();
    4293        assert_eq!(
    4294            items.len(),
    4295            1,
    4296            "Duplicate should be ignored (UNIQUE constraint)"
    4297        );
    4298    }
    4299
    4300    #[tokio::test]
    4301    async fn test_playlist_remove_items() {
    4302        let db_service = create_test_db();
    4303        let repo = OfflineRepository::new(
    4304            db_service.clone(),
    4305            "test-server".to_string(),
    4306            "test-user".to_string(),
    4307        );
    4308        seed_items(&repo, &["t1", "t2", "t3"]).await;
    4309
    4310        let created = repo
    4311            .create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()])
    4312            .await
    4313            .unwrap();
    4314        let items = repo.get_playlist_items(&created.id).await.unwrap();
    4315        assert_eq!(items.len(), 3);
    4316
    4317        // Remove the middle track by its entry ID
    4318        let entry_id_to_remove = items[1].playlist_item_id.clone();
    4319        repo.remove_from_playlist(&created.id, &[entry_id_to_remove])
    4320            .await
    4321            .unwrap();
    4322
    4323        let items_after = repo.get_playlist_items(&created.id).await.unwrap();
    4324        assert_eq!(items_after.len(), 2);
    4325        assert_eq!(items_after[0].item.id, "t1");
    4326        assert_eq!(items_after[1].item.id, "t3");
    4327    }
    4328
    4329    #[tokio::test]
    4330    async fn test_playlist_move_item_forward() {
    4331        let db_service = create_test_db();
    4332        let repo = OfflineRepository::new(
    4333            db_service.clone(),
    4334            "test-server".to_string(),
    4335            "test-user".to_string(),
    4336        );
    4337        seed_items(&repo, &["a", "b", "c", "d"]).await;
    4338
    4339        let created = repo
    4340            .create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()])
    4341            .await
    4342            .unwrap();
    4343
    4344        // Move 'a' (index 0) to index 2: expect b, c, a, d
    4345        repo.move_playlist_item(&created.id, "a", 2).await.unwrap();
    4346
    4347        let items = repo.get_playlist_items(&created.id).await.unwrap();
    4348        let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
    4349        assert_eq!(ids, vec!["b", "c", "a", "d"]);
    4350    }
    4351
    4352    #[tokio::test]
    4353    async fn test_playlist_move_item_backward() {
    4354        let db_service = create_test_db();
    4355        let repo = OfflineRepository::new(
    4356            db_service.clone(),
    4357            "test-server".to_string(),
    4358            "test-user".to_string(),
    4359        );
    4360        seed_items(&repo, &["a", "b", "c", "d"]).await;
    4361
    4362        let created = repo
    4363            .create_playlist(
    4364                "Reorder2",
    4365                &["a".into(), "b".into(), "c".into(), "d".into()],
    4366            )
    4367            .await
    4368            .unwrap();
    4369
    4370        // Move 'd' (index 3) to index 0: expect d, a, b, c
    4371        repo.move_playlist_item(&created.id, "d", 0).await.unwrap();
    4372
    4373        let items = repo.get_playlist_items(&created.id).await.unwrap();
    4374        let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
    4375        assert_eq!(ids, vec!["d", "a", "b", "c"]);
    4376    }
    4377
    4378    #[tokio::test]
    4379    async fn test_playlist_move_item_to_end() {
    4380        let db_service = create_test_db();
    4381        let repo = OfflineRepository::new(
    4382            db_service.clone(),
    4383            "test-server".to_string(),
    4384            "test-user".to_string(),
    4385        );
    4386        seed_items(&repo, &["a", "b", "c"]).await;
    4387
    4388        let created = repo
    4389            .create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()])
    4390            .await
    4391            .unwrap();
    4392
    4393        // Move 'a' to index 99 (beyond end, should clamp): expect b, c, a
    4394        repo.move_playlist_item(&created.id, "a", 99).await.unwrap();
    4395
    4396        let items = repo.get_playlist_items(&created.id).await.unwrap();
    4397        let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
    4398        assert_eq!(ids, vec!["b", "c", "a"]);
    4399    }
    4400
    4401    #[tokio::test]
    4402    async fn test_playlist_move_nonexistent_item_is_noop() {
    4403        let db_service = create_test_db();
    4404        let repo = OfflineRepository::new(
    4405            db_service.clone(),
    4406            "test-server".to_string(),
    4407            "test-user".to_string(),
    4408        );
    4409        seed_items(&repo, &["a", "b"]).await;
    4410
    4411        let created = repo
    4412            .create_playlist("NoOp", &["a".into(), "b".into()])
    4413            .await
    4414            .unwrap();
    4415
    4416        // Move a nonexistent item - should not error, just no-op
    4417        repo.move_playlist_item(&created.id, "nonexistent", 0)
    4418            .await
    4419            .unwrap();
    4420
    4421        let items = repo.get_playlist_items(&created.id).await.unwrap();
    4422        let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
    4423        assert_eq!(ids, vec!["a", "b"]);
    4424    }
    4425
    4426    /// Seed two movies and one album, favouriting a subset, for the favourites
    4427    /// query tests below.
    4428    async fn seed_favorites(db_service: &Arc<RusqliteService>) {
    4429        use crate::storage::db_service::DatabaseService;
    4430        for sql in [
    4431            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
    4432             VALUES ('movie-fav', 'test-server', 'Favourite Movie', 'Movie', 'lib-1', '2026-01-01', 'Favourite Movie')",
    4433            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
    4434             VALUES ('movie-plain', 'test-server', 'Ordinary Movie', 'Movie', 'lib-1', '2026-01-01', 'Ordinary Movie')",
    4435            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
    4436             VALUES ('album-fav', 'test-server', 'Favourite Album', 'MusicAlbum', 'lib-2', '2026-01-01', 'Favourite Album')",
    4437            "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
    4438            "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-fav', 1)",
    4439            "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-fav', 1)",
    4440            // Explicitly not a favourite — must never show up.
    4441            "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-plain', 0)",
    4442            // Another user's favourite must not leak into this user's list.
    4443            "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('other-user', 'movie-plain', 1)",
    4444        ] {
    4445            db_service.execute(Query::new(sql)).await.unwrap();
    4446        }
    4447    }
    4448
    4449    /// UT-101 — the offline favourites query returns only favourites, scoped,
    4450    /// and only for the current user.
    4451    ///
    4452    /// TRACES: UR-067 | DR-115 | UT-101
    4453    #[tokio::test]
    4454    async fn test_get_favorites_returns_only_scoped_favorites() {
    4455        let _guard = lock_catalog_browse();
    4456        set_include_catalog_browse(true);
    4457
    4458        let db_service = create_test_db();
    4459        seed_favorites(&db_service).await;
    4460        let repo = OfflineRepository::new(
    4461            db_service,
    4462            "test-server".to_string(),
    4463            "test-user".to_string(),
    4464        );
    4465
    4466        let all = repo.get_favorites(SearchScope::All, None).await.unwrap();
    4467        let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
    4468        ids.sort();
    4469        assert_eq!(
    4470            ids,
    4471            vec!["album-fav", "movie-fav"],
    4472            "All scope should return every favourite and nothing else"
    4473        );
    4474
    4475        let movies = repo.get_favorites(SearchScope::Movies, None).await.unwrap();
    4476        let ids: Vec<&str> = movies.items.iter().map(|i| i.id.as_str()).collect();
    4477        assert_eq!(ids, vec!["movie-fav"]);
    4478
    4479        let music = repo.get_favorites(SearchScope::Music, None).await.unwrap();
    4480        let ids: Vec<&str> = music.items.iter().map(|i| i.id.as_str()).collect();
    4481        assert_eq!(ids, vec!["album-fav"]);
    4482    }
    4483
    4484    /// With "Show all server media" off, favourites narrow to what is actually
    4485    /// on the device — the same gate browsing obeys (DR-080).
    4486    ///
    4487    /// TRACES: UR-067 | DR-115 | UT-101
    4488    #[tokio::test]
    4489    async fn test_get_favorites_respects_catalog_browse_gate() {
    4490        use crate::storage::db_service::DatabaseService;
    4491        let _guard = lock_catalog_browse();
    4492
    4493        let db_service = create_test_db();
    4494        seed_favorites(&db_service).await;
    4495        // Only the album is downloaded... via a child track, the container case.
    4496        db_service
    4497            .execute(Query::new(
    4498                "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
    4499                 VALUES ('track-1', 'test-server', 'Track', 'Audio', 'album-fav', '2026-01-01')",
    4500            ))
    4501            .await
    4502            .unwrap();
    4503        db_service
    4504            .execute(Query::new(
    4505                "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
    4506            ))
    4507            .await
    4508            .unwrap();
    4509
    4510        let repo = OfflineRepository::new(
    4511            db_service,
    4512            "test-server".to_string(),
    4513            "test-user".to_string(),
    4514        );
    4515
    4516        set_include_catalog_browse(false);
    4517        let offline_only = repo.get_favorites(SearchScope::All, None).await.unwrap();
    4518        let ids: Vec<&str> = offline_only.items.iter().map(|i| i.id.as_str()).collect();
    4519        assert_eq!(
    4520            ids,
    4521            vec!["album-fav"],
    4522            "with the gate off, only favourites on the device are listed"
    4523        );
    4524
    4525        set_include_catalog_browse(true);
    4526        let with_catalog = repo.get_favorites(SearchScope::All, None).await.unwrap();
    4527        assert_eq!(with_catalog.items.len(), 2);
    4528    }
    4529
    4530    /// UT-104 — the per-library favourites toggle narrows a normal listing.
    4531    ///
    4532    /// TRACES: UR-067 | DR-116 | UT-104
    4533    #[tokio::test]
    4534    async fn test_get_items_favorites_only_filters_listing() {
    4535        let _guard = lock_catalog_browse();
    4536        set_include_catalog_browse(true);
    4537
    4538        let db_service = create_test_db();
    4539        seed_favorites(&db_service).await;
    4540        let repo = OfflineRepository::new(
    4541            db_service,
    4542            "test-server".to_string(),
    4543            "test-user".to_string(),
    4544        );
    4545
    4546        let unfiltered = repo
    4547            .get_items(
    4548                "lib-1",
    4549                Some(GetItemsOptions {
    4550                    include_item_types: Some(vec!["Movie".to_string()]),
    4551                    ..Default::default()
    4552                }),
    4553            )
    4554            .await
    4555            .unwrap();
    4556        assert_eq!(unfiltered.items.len(), 2, "both movies without the filter");
    4557
    4558        let favourites = repo
    4559            .get_items(
    4560                "lib-1",
    4561                Some(GetItemsOptions {
    4562                    include_item_types: Some(vec!["Movie".to_string()]),
    4563                    favorites_only: Some(true),
    4564                    ..Default::default()
    4565                }),
    4566            )
    4567            .await
    4568            .unwrap();
    4569        let ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
    4570        assert_eq!(ids, vec!["movie-fav"]);
    4571    }
    4572
    4573    /// UT-206 — `include_item_types` reaches the listing query as bound
    4574    /// parameters, so a type name can only ever be compared as data.
    4575    ///
    4576    /// Interpolated, the type below closed the `IN (` list and commented out the
    4577    /// rest of the line, leaving `... AND i.item_type IN ('Movie') OR 1=1`, which
    4578    /// is true for every row — the listing then returned the whole cache
    4579    /// regardless of parent or type. Bound, it is just a type name that matches
    4580    /// nothing.
    4581    ///
    4582    /// TRACES: UR-065 | DR-212 | UT-206
    4583    #[tokio::test]
    4584    async fn test_get_items_type_filter_is_bound_not_interpolated() {
    4585        let _guard = lock_catalog_browse();
    4586        set_include_catalog_browse(true);
    4587
    4588        let db_service = create_test_db();
    4589        seed_favorites(&db_service).await;
    4590        let repo = OfflineRepository::new(
    4591            db_service,
    4592            "test-server".to_string(),
    4593            "test-user".to_string(),
    4594        );
    4595
    4596        let injected = repo
    4597            .get_items(
    4598                "lib-1",
    4599                Some(GetItemsOptions {
    4600                    include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
    4601                    ..Default::default()
    4602                }),
    4603            )
    4604            .await
    4605            .expect("a hostile type name must be data, not a broken query");
    4606        assert!(
    4607            injected.items.is_empty(),
    4608            "no cached item has that type, so nothing may come back; got {:?}",
    4609            injected
    4610                .items
    4611                .iter()
    4612                .map(|i| i.id.as_str())
    4613                .collect::<Vec<_>>()
    4614        );
    4615
    4616        // A quote on its own is likewise just a character in a type name.
    4617        let quoted = repo
    4618            .get_items(
    4619                "lib-1",
    4620                Some(GetItemsOptions {
    4621                    include_item_types: Some(vec!["Mo'vie".to_string()]),
    4622                    ..Default::default()
    4623                }),
    4624            )
    4625            .await
    4626            .expect("an embedded quote must not break the query");
    4627        assert!(quoted.items.is_empty());
    4628    }
    4629
    4630    /// UT-206 — binding the type filter must not disturb the positions of the
    4631    /// parameters around it: the parent ids bind before it and the favourites
    4632    /// user id after it. A misordered vec would silently compare `user_id`
    4633    /// against `item_type`, so this asserts the filters still compose.
    4634    ///
    4635    /// TRACES: UR-065, UR-067 | DR-212 | UT-206
    4636    #[tokio::test]
    4637    async fn test_get_items_binds_multiple_types_in_parameter_order() {
    4638        let _guard = lock_catalog_browse();
    4639        set_include_catalog_browse(true);
    4640
    4641        let db_service = create_test_db();
    4642        seed_favorites(&db_service).await;
    4643
    4644        // A favourite album *in lib-1*. The fixture's `album-fav` lives in
    4645        // lib-2, and this test used to expect it back from a lib-1 listing —
    4646        // which only held because the library clause matched every cached row
    4647        // regardless of which library it was in (DR-277). The assertions below
    4648        // still span both requested types, which is what UT-206 is really about;
    4649        // they now do it with an album that is actually in the library.
    4650        db_service
    4651            .execute(Query::new(
    4652                "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
    4653                 VALUES ('album-lib1', 'test-server', 'Album In Lib One', 'MusicAlbum', 'lib-1', '2026-01-01', 'Album In Lib One')",
    4654            ))
    4655            .await
    4656            .unwrap();
    4657        db_service
    4658            .execute(Query::new(
    4659                "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-lib1', 1)",
    4660            ))
    4661            .await
    4662            .unwrap();
    4663
    4664        let repo = OfflineRepository::new(
    4665            db_service,
    4666            "test-server".to_string(),
    4667            "test-user".to_string(),
    4668        );
    4669
    4670        let both = repo
    4671            .get_items(
    4672                "lib-1",
    4673                Some(GetItemsOptions {
    4674                    include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
    4675                    ..Default::default()
    4676                }),
    4677            )
    4678            .await
    4679            .unwrap();
    4680        let mut ids: Vec<&str> = both.items.iter().map(|i| i.id.as_str()).collect();
    4681        ids.sort();
    4682        assert_eq!(ids, vec!["album-lib1", "movie-fav", "movie-plain"]);
    4683        assert!(
    4684            !ids.contains(&"album-fav"),
    4685            "album-fav belongs to lib-2 and must not appear in a lib-1 listing"
    4686        );
    4687
    4688        // Two type placeholders *and* the favourites parameter after them.
    4689        let favourites = repo
    4690            .get_items(
    4691                "lib-1",
    4692                Some(GetItemsOptions {
    4693                    include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
    4694                    favorites_only: Some(true),
    4695                    ..Default::default()
    4696                }),
    4697            )
    4698            .await
    4699            .unwrap();
    4700        let mut ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
    4701        ids.sort();
    4702        assert_eq!(ids, vec!["album-lib1", "movie-fav"]);
    4703    }
    4704
    4705    /// UT-102 — caching a server result mirrors its favourite state locally,
    4706    /// but never over a row still waiting to be pushed.
    4707    ///
    4708    /// The second half is the one that matters: favourite something with the
    4709    /// server unreachable, and the next successful browse would otherwise
    4710    /// overwrite it with the server's stale `false` before the drain ever ran.
    4711    ///
    4712    /// TRACES: UR-069 | DR-114 | UT-102
    4713    #[tokio::test]
    4714    async fn test_save_to_cache_mirrors_favorites_without_clobbering_pending() {
    4715        use crate::storage::db_service::DatabaseService;
    4716        let db_service = create_test_db();
    4717        let repo = OfflineRepository::new(
    4718            db_service.clone(),
    4719            "test-server".to_string(),
    4720            "test-user".to_string(),
    4721        );
    4722
    4723        let favourite_flag = |id: &'static str| {
    4724            let db = db_service.clone();
    4725            async move {
    4726                db.query_optional(
    4727                    Query::with_params(
    4728                        "SELECT is_favorite, pending_sync FROM user_data \
    4729                         WHERE user_id = ? AND item_id = ?",
    4730                        vec![
    4731                            QueryParam::String("test-user".to_string()),
    4732                            QueryParam::String(id.to_string()),
    4733                        ],
    4734                    ),
    4735                    |row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
    4736                )
    4737                .await
    4738                .unwrap()
    4739            }
    4740        };
    4741
    4742        // A server item the user favourited elsewhere.
    4743        let mut favourited = create_test_item("fav-1", "Favourited Elsewhere", None);
    4744        favourited.user_data = Some(UserData {
    4745            is_favorite: Some(true),
    4746            ..Default::default()
    4747        });
    4748        // A server item with no user data at all — must not fabricate a row.
    4749        let untouched = create_test_item("plain-1", "No User Data", None);
    4750
    4751        repo.save_to_cache("parent-1", &[favourited.clone(), untouched])
    4752            .await
    4753            .unwrap();
    4754
    4755        assert_eq!(
    4756            favourite_flag("fav-1").await,
    4757            Some((Some(1), Some(0))),
    4758            "server favourite should be mirrored as synced"
    4759        );
    4760        assert_eq!(
    4761            favourite_flag("plain-1").await,
    4762            None,
    4763            "an item without UserData should not get an invented user_data row"
    4764        );
    4765
    4766        // The user un-favourites it while offline: local write, pending_sync = 1.
    4767        db_service
    4768            .execute(Query::with_params(
    4769                "UPDATE user_data SET is_favorite = 0, pending_sync = 1 \
    4770                 WHERE user_id = ? AND item_id = ?",
    4771                vec![
    4772                    QueryParam::String("test-user".to_string()),
    4773                    QueryParam::String("fav-1".to_string()),
    4774                ],
    4775            ))
    4776            .await
    4777            .unwrap();
    4778
    4779        // The server still reports it as a favourite; caching must not win.
    4780        repo.save_to_cache("parent-1", &[favourited]).await.unwrap();
    4781
    4782        assert_eq!(
    4783            favourite_flag("fav-1").await,
    4784            Some((Some(0), Some(1))),
    4785            "an unsynced local toggle must survive a cache write"
    4786        );
    4787    }
    4788
    4789    /// UT-152 — the server's watch position is mirrored locally, so an item
    4790    /// watched on another device resumes here.
    4791    ///
    4792    /// The resume check reads only the local `user_data` row, and the mirror
    4793    /// previously carried `is_favorite` alone — so a position set on any other
    4794    /// client never reached this device and cross-device resume silently did
    4795    /// nothing. The `pending_sync` guard is the same conflict rule favourites
    4796    /// use: a local position still waiting to be pushed must not be pulled
    4797    /// backwards by the stale value the server is still reporting.
    4798    ///
    4799    /// TRACES: UR-025, UR-069 | DR-155 | UT-152
    4800    #[tokio::test]
    4801    async fn test_save_to_cache_mirrors_playback_position_without_clobbering_pending() {
    4802        use crate::storage::db_service::DatabaseService;
    4803        let db_service = create_test_db();
    4804        let repo = OfflineRepository::new(
    4805            db_service.clone(),
    4806            "test-server".to_string(),
    4807            "test-user".to_string(),
    4808        );
    4809
    4810        let position = |id: &'static str| {
    4811            let db = db_service.clone();
    4812            async move {
    4813                db.query_optional(
    4814                    Query::with_params(
    4815                        "SELECT playback_position_ticks, pending_sync FROM user_data \
    4816                         WHERE user_id = ? AND item_id = ?",
    4817                        vec![
    4818                            QueryParam::String("test-user".to_string()),
    4819                            QueryParam::String(id.to_string()),
    4820                        ],
    4821                    ),
    4822                    |row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i32>>(1)?)),
    4823                )
    4824                .await
    4825                .unwrap()
    4826            }
    4827        };
    4828
    4829        // Watched 20 minutes into this episode on another device.
    4830        let mut watched = create_test_item("ep-1", "Watched Elsewhere", None);
    4831        watched.user_data = Some(UserData {
    4832            playback_position_ticks: Some(12_000_000_000),
    4833            ..Default::default()
    4834        });
    4835        // No user data at all — must not fabricate a position of 0.
    4836        let untouched = create_test_item("ep-2", "No User Data", None);
    4837
    4838        repo.save_to_cache("parent-1", &[watched.clone(), untouched])
    4839            .await
    4840            .unwrap();
    4841
    4842        assert_eq!(
    4843            position("ep-1").await,
    4844            Some((Some(12_000_000_000), Some(0))),
    4845            "the server's position should be mirrored as synced"
    4846        );
    4847        assert_eq!(
    4848            position("ep-2").await,
    4849            None,
    4850            "an item without UserData should not get an invented position"
    4851        );
    4852
    4853        // Watched further here while the server was unreachable: pending_sync = 1.
    4854        db_service
    4855            .execute(Query::with_params(
    4856                "UPDATE user_data SET playback_position_ticks = ?, pending_sync = 1 \
    4857                 WHERE user_id = ? AND item_id = ?",
    4858                vec![
    4859                    QueryParam::Int64(30_000_000_000),
    4860                    QueryParam::String("test-user".to_string()),
    4861                    QueryParam::String("ep-1".to_string()),
    4862                ],
    4863            ))
    4864            .await
    4865            .unwrap();
    4866
    4867        // The server still reports the older position; caching must not win.
    4868        repo.save_to_cache("parent-1", &[watched]).await.unwrap();
    4869
    4870        assert_eq!(
    4871            position("ep-1").await,
    4872            Some((Some(30_000_000_000), Some(1))),
    4873            "an unsynced local position must not be pulled backwards"
    4874        );
    4875    }
    4876
    4877    /// UT-240 — the server's *played* flag is mirrored locally, so an episode
    4878    /// watched anywhere is watched here.
    4879    ///
    4880    /// The mirror carried only the favourite flag and the position, so
    4881    /// `user_data.is_played` was written by nothing but an explicit local
    4882    /// toggle: a cached episode list reported every episode as unwatched, which
    4883    /// is the list `pick_current_episode` reads to decide what is up next
    4884    /// (DR-264), and the list the season view ticks.
    4885    ///
    4886    /// TRACES: UR-025, UR-062 | DR-264 | UT-240
    4887    #[tokio::test]
    4888    async fn test_save_to_cache_mirrors_played_flag_without_clobbering_pending() {
    4889        use crate::storage::db_service::DatabaseService;
    4890        let db_service = create_test_db();
    4891        let repo = OfflineRepository::new(
    4892            db_service.clone(),
    4893            "test-server".to_string(),
    4894            "test-user".to_string(),
    4895        );
    4896
    4897        let played_flag = |id: &'static str| {
    4898            let db = db_service.clone();
    4899            async move {
    4900                db.query_optional(
    4901                    Query::with_params(
    4902                        "SELECT is_played, pending_sync FROM user_data \
    4903                         WHERE user_id = ? AND item_id = ?",
    4904                        vec![
    4905                            QueryParam::String("test-user".to_string()),
    4906                            QueryParam::String(id.to_string()),
    4907                        ],
    4908                    ),
    4909                    |row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
    4910                )
    4911                .await
    4912                .unwrap()
    4913            }
    4914        };
    4915
    4916        // Watched to the end on another client.
    4917        let mut watched = create_test_item("ep-4", "Watched Elsewhere", None);
    4918        watched.user_data = Some(UserData {
    4919            is_played: Some(true),
    4920            ..Default::default()
    4921        });
    4922        // No user data at all — must not fabricate an "unwatched" record.
    4923        let untouched = create_test_item("ep-5", "No User Data", None);
    4924
    4925        repo.save_to_cache("parent-1", &[watched, untouched])
    4926            .await
    4927            .unwrap();
    4928
    4929        assert_eq!(
    4930            played_flag("ep-4").await,
    4931            Some((Some(1), Some(0))),
    4932            "the server's played flag should be mirrored as synced"
    4933        );
    4934        assert_eq!(
    4935            played_flag("ep-5").await,
    4936            None,
    4937            "an item without UserData should not get an invented played flag"
    4938        );
    4939
    4940        // Marked unwatched here while the server was unreachable.
    4941        db_service
    4942            .execute(Query::with_params(
    4943                "UPDATE user_data SET is_played = 0, pending_sync = 1 \
    4944                 WHERE user_id = ? AND item_id = ?",
    4945                vec![
    4946                    QueryParam::String("test-user".to_string()),
    4947                    QueryParam::String("ep-4".to_string()),
    4948                ],
    4949            ))
    4950            .await
    4951            .unwrap();
    4952
    4953        let mut still_played = create_test_item("ep-4", "Watched Elsewhere", None);
    4954        still_played.user_data = Some(UserData {
    4955            is_played: Some(true),
    4956            ..Default::default()
    4957        });
    4958        repo.save_to_cache("parent-1", &[still_played])
    4959            .await
    4960            .unwrap();
    4961
    4962        assert_eq!(
    4963            played_flag("ep-4").await,
    4964            Some((Some(0), Some(1))),
    4965            "an unsynced local toggle must survive a cache write"
    4966        );
    4967    }
    4968
    4969    /// UT-152 — a server item carrying *only* a position (no favourite flag)
    4970    /// still gets mirrored.
    4971    ///
    4972    /// The mirror used to return early whenever `is_favorite` was absent, which
    4973    /// is exactly the shape of an ordinary watched episode: Jellyfin reports
    4974    /// `PlaybackPositionTicks` with no favourite state. That early return is why
    4975    /// the position never landed.
    4976    ///
    4977    /// TRACES: UR-025 | DR-155 | UT-152
    4978    #[tokio::test]
    4979    async fn test_position_is_mirrored_even_when_no_favourite_flag_is_present() {
    4980        use crate::storage::db_service::DatabaseService;
    4981        let db_service = create_test_db();
    4982        let repo = OfflineRepository::new(
    4983            db_service.clone(),
    4984            "test-server".to_string(),
    4985            "test-user".to_string(),
    4986        );
    4987
    4988        let mut watched = create_test_item("ep-3", "Position Only", None);
    4989        watched.user_data = Some(UserData {
    4990            is_favorite: None,
    4991            playback_position_ticks: Some(9_000_000_000),
    4992            ..Default::default()
    4993        });
    4994
    4995        repo.save_to_cache("parent-1", &[watched]).await.unwrap();
    4996
    4997        let stored = db_service
    4998            .query_optional(
    4999                Query::with_params(
    5000                    "SELECT playback_position_ticks FROM user_data \
    5001                     WHERE user_id = ? AND item_id = ?",
    5002                    vec![
    5003                        QueryParam::String("test-user".to_string()),
    5004                        QueryParam::String("ep-3".to_string()),
    5005                    ],
    5006                ),
    5007                |row| row.get::<_, Option<i64>>(0),
    5008            )
    5009            .await
    5010            .unwrap();
    5011
    5012        assert_eq!(
    5013            stored,
    5014            Some(Some(9_000_000_000)),
    5015            "a position with no favourite flag must still be mirrored"
    5016        );
    5017    }
    5018
    5019    /// A library whose `collection_type` is not one of the three the app has
    5020    /// landing pages for — Books, Photos, Home Videos, Collections, or a mixed
    5021    /// library — must not show the entire server.
    5022    ///
    5023    /// The cache has no item→library link at all (`library_id`/`parent_id` are
    5024    /// NULL, see [[offline-libraries-never-cached]]), so `get_items` matched a
    5025    /// library parent with an EXISTS that never referenced the item:
    5026    ///
    5027    ///     OR EXISTS (SELECT 1 FROM libraries l WHERE l.id = ? AND ...)
    5028    ///
    5029    /// True for every cached row the moment the requested id is any library.
    5030    /// The music/movies/TV landing pages got away with it because each passes
    5031    /// `include_item_types`, which narrowed the result; the generic library page
    5032    /// passes none, so opening a Books library served whatever happened to be
    5033    /// cached — films, albums, episodes. Same defect the downloaded listing had
    5034    /// in DR-167, in the path nobody re-checked.
    5035    ///
    5036    /// TRACES: UR-007, UR-055 | DR-277 | UT-247
    5037    #[tokio::test]
    5038    async fn test_get_items_unknown_library_type_does_not_return_whole_server() {
    5039        let db = create_test_db();
    5040
    5041        insert_item(&db, "movie-1", "Movie", None, None, None).await;
    5042        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
    5043        insert_item(&db, "series-1", "Series", None, None, None).await;
    5044
    5045        // Every library kind the app has no landing page for, including the
    5046        // empty `collection_type` Jellyfin sends for a mixed library.
    5047        for collection_type in ["books", "boxsets", "photos", "homevideos", ""] {
    5048            let lib = format!("lib-{collection_type}");
    5049            seed_library(&db, &lib, collection_type).await;
    5050
    5051            let repo = make_repo(&db);
    5052            let ids: Vec<String> = repo
    5053                .get_items(&lib, None)
    5054                .await
    5055                .unwrap()
    5056                .items
    5057                .iter()
    5058                .map(|i| i.id.clone())
    5059                .collect();
    5060
    5061            assert!(
    5062                ids.is_empty(),
    5063                "a '{collection_type}' library must not serve the server's films, \
    5064                 albums and shows; got {:?}",
    5065                ids
    5066            );
    5067        }
    5068    }
    5069
    5070    /// Opening an individual collection is a different path and must keep
    5071    /// working: a BoxSet's children carry `parent_id`, which the cache does
    5072    /// store, so they are matched by the ordinary parent link rather than by
    5073    /// the library clause this fix narrowed.
    5074    ///
    5075    /// Worth pinning separately — narrowing the library clause could plausibly
    5076    /// have taken collections with it, and "Collections is empty" would look
    5077    /// identical to the bug it was meant to fix.
    5078    ///
    5079    /// TRACES: UR-007 | DR-277 | UT-249
    5080    #[tokio::test]
    5081    async fn test_get_items_collection_lists_its_own_children() {
    5082        let db = create_test_db();
    5083        seed_library(&db, "boxset-lib", "boxsets").await;
    5084
    5085        insert_item(&db, "boxset-1", "BoxSet", None, None, None).await;
    5086        insert_item(&db, "outsider", "Movie", None, None, None).await;
    5087
    5088        // A film inside the collection: linked by parent_id, which is what a
    5089        // BoxSet's children actually carry.
    5090        db.execute(Query::with_params(
    5091            "INSERT INTO items (id, server_id, name, item_type, parent_id, synced_at) \
    5092             VALUES ('in-set', 'test-server', 'In The Set', 'Movie', ?1, '2024-01-01')",
    5093            vec![QueryParam::String("boxset-1".to_string())],
    5094        ))
    5095        .await
    5096        .unwrap();
    5097
    5098        let repo = make_repo(&db);
    5099        let ids: Vec<String> = repo
    5100            .get_items("boxset-1", None)
    5101            .await
    5102            .unwrap()
    5103            .items
    5104            .iter()
    5105            .map(|i| i.id.clone())
    5106            .collect();
    5107
    5108        assert_eq!(
    5109            ids,
    5110            vec!["in-set".to_string()],
    5111            "a collection lists its own children and nothing else; got {:?}",
    5112            ids
    5113        );
    5114    }
    5115
    5116    /// The narrowing must not break the libraries that *do* have landing pages:
    5117    /// they reach the same query and must keep returning their own media.
    5118    ///
    5119    /// TRACES: UR-007 | DR-277 | UT-248
    5120    #[tokio::test]
    5121    async fn test_get_items_typed_libraries_still_return_their_own_media() {
    5122        let db = create_test_db();
    5123        seed_library(&db, "music-lib", "music").await;
    5124        seed_library(&db, "movie-lib", "movies").await;
    5125        seed_library(&db, "tv-lib", "tvshows").await;
    5126
    5127        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
    5128        insert_item(&db, "movie-1", "Movie", None, None, None).await;
    5129        insert_item(&db, "series-1", "Series", None, None, None).await;
    5130
    5131        let repo = make_repo(&db);
    5132
    5133        for (lib, expected, forbidden) in [
    5134            ("music-lib", "album-1", "movie-1"),
    5135            ("movie-lib", "movie-1", "album-1"),
    5136            ("tv-lib", "series-1", "album-1"),
    5137        ] {
    5138            let ids: Vec<String> = repo
    5139                .get_items(lib, None)
    5140                .await
    5141                .unwrap()
    5142                .items
    5143                .iter()
    5144                .map(|i| i.id.clone())
    5145                .collect();
    5146            assert!(
    5147                ids.contains(&expected.to_string()),
    5148                "{lib} should list {expected}; got {:?}",
    5149                ids
    5150            );
    5151            assert!(
    5152                !ids.contains(&forbidden.to_string()),
    5153                "{lib} must not list {forbidden}; got {:?}",
    5154                ids
    5155            );
    5156        }
    5157    }
    5158}