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