1use 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
13static INCLUDE_CATALOG_BROWSE: AtomicBool = AtomicBool::new(true);
26
27pub fn set_include_catalog_browse(include: bool) {
32 INCLUDE_CATALOG_BROWSE.store(include, Ordering::Relaxed);
33}
34
35pub fn include_catalog_browse() -> bool {
45 INCLUDE_CATALOG_BROWSE.load(Ordering::Relaxed)
46}
47
48fn 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 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, 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, media_sources: None, people: None, }
152 }
153
154 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#[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, parent_backdrop_image_tags: None, 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 is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0,
246 premiere_date: row.get(24)?,
247 })
248}
249
250impl OfflineRepository {
251 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 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 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 self.db_service
409 .execute(Query::new("PRAGMA foreign_keys = OFF"))
410 .await
411 .map_err(|e| RepoError::Database { message: e })?;
412
413 let result = self.save_to_cache_impl(parent_id, items, &now).await;
415
416 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 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 #[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 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 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 QueryParam::Null, 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
992 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 pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
1038 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 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 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 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 *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 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 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 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 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); let start_index = opts.start_index.unwrap_or(0);
1240
1241 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 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 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 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 let mut params = vec![
1367 QueryParam::String(self.server_id.clone()),
1368 QueryParam::String(parent_id.to_string()), QueryParam::String(parent_id.to_string()), QueryParam::String(parent_id.to_string()), QueryParam::String(parent_id.to_string()), QueryParam::String(parent_id.to_string()), ];
1374 params.extend(type_values.iter().cloned().map(QueryParam::String));
1379 if !favorites_filter.is_empty() {
1380 params.push(QueryParam::String(self.user_id.clone())); }
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 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 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 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 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 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 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 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 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 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 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 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 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 Err(RepoError::Offline)
1909 }
1910
1911 async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
1912 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 Err(RepoError::Offline)
1925 }
1926
1927 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
1928 Err(RepoError::Offline)
1930 }
1931
1932 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
1933 Err(RepoError::Offline)
1935 }
1936
1937 async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
1938 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 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 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 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 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 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 String::new()
2014 }
2015
2016 async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
2017 Err(RepoError::Offline)
2019 }
2020
2021 async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
2022 Err(RepoError::Offline)
2024 }
2025
2026 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 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 Err(RepoError::Offline)
2131 }
2132
2133 async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
2134 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); 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 Err(RepoError::Offline)
2276 }
2277
2278 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 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 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 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 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 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 #![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 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 #[test]
2569 fn test_build_fts_prefix_query() {
2570 assert_eq!(build_fts_prefix_query("Arr").as_deref(), Some("\"Arr\"*"));
2572
2573 assert_eq!(
2576 build_fts_prefix_query("parks rec").as_deref(),
2577 Some("\"parks\" \"rec\"*")
2578 );
2579
2580 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 assert_eq!(
2593 build_fts_prefix_query("say \"hi\"").as_deref(),
2594 Some("\"say\" \"\"\"hi\"\"\"*")
2595 );
2596
2597 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 #[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 conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
2631
2632 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 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 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 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 let items = vec![
2867 create_test_item("track-1", "Track 1", Some("album-1")),
2869 create_test_item("track-2", "Track 2", Some("album-1")),
2870 create_test_item("track-3", "Track 3", Some("album-2")),
2872 create_test_item("track-4", "Track 4", Some("album-2")),
2873 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 let result = repo.save_to_cache("library-123", &items).await;
2888
2889 match &result {
2891 Ok(count) => {
2892 println!("✓ Saved {} items", count);
2893 assert_eq!(*count, 6);
2894
2895 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 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 assert_eq!(
2927 track1_parent,
2928 Some("album-1".to_string()),
2929 "track-1 should have parent_id='album-1'"
2930 );
2931
2932 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 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 #[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 "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
2991 VALUES ('album-1', 'test-server', 'Hadestown', 'MusicAlbum', NULL, NULL)",
2992 "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 assert!(
3008 repo.get_item("track-1").await.is_ok(),
3009 "downloaded track should be available offline"
3010 );
3011
3012 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 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 #[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 "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 "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
3060 "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 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 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 set_include_catalog_browse(true);
3099 }
3100
3101 #[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 "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 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 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 #[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 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 "INSERT INTO servers (id, name, url) \
3177 VALUES ('other-server', 'Other', 'http://other')"
3178 .to_string(),
3179 format!(
3181 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3182 VALUES ('keep-fresh', 'test-server', 'Fresh', 'Movie', '{new}')"
3183 ),
3184 format!(
3186 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3187 VALUES ('gone', 'test-server', 'Vanished', 'Movie', '{old}')"
3188 ),
3189 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 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 format!(
3209 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3210 VALUES ('keep-artist', 'test-server', 'Artist', 'MusicArtist', '{old}')"
3211 ),
3212 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 assert_eq!(repo.prune_stale_catalog(cutoff, &[]).await.unwrap(), 0);
3257 }
3258
3259 #[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 for _ in 0..3 {
3288 repo.save_to_cache("parent-1", &items).await.unwrap();
3289 }
3290
3291 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 #[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 "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 "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 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 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 set_include_catalog_browse(true);
3374 }
3375
3376 #[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 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 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 #[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 "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 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 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 #[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 assert!(repo.get_libraries().await.unwrap().is_empty());
3499
3500 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 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 repo.save_libraries_to_cache(&server_libs).await.unwrap();
3524 assert_eq!(repo.get_libraries().await.unwrap().len(), 2);
3525 }
3526
3527 #[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 assert!(repo.get_genres(Some("music-lib")).await.unwrap().is_empty());
3544
3545 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 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 assert!(repo.get_genres(Some("other-lib")).await.unwrap().is_empty());
3579
3580 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 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 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 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 #[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 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 #[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 seed_completed_download(&db, "track-1", 1000).await;
3733
3734 let repo = make_repo(&db);
3735
3736 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 #[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 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 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 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 #[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 #[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 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 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 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 #[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 #[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 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 #[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 #[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 assert_eq!(
3990 usage.partial_containers.get("album-1"),
3991 None,
3992 "fully downloaded album is not partial"
3993 );
3994 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 #[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 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 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 repo.delete_playlist(&created.id).await.unwrap();
4092
4093 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 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 assert_eq!(items[0].item.id, "c");
4164 assert_eq!(items[1].item.id, "a");
4165 assert_eq!(items[2].item.id, "b");
4166 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 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 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 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 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 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 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 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 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 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-plain', 0)",
4379 "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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 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 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 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 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 #[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 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 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 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 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 #[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}