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