Skip to main content

jellytau_lib/repository/
offline.rs

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