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