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