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