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> {
694 let user_data = item.user_data.as_ref();
695 let is_favorite = user_data.and_then(|ud| ud.is_favorite);
696 let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
697 let is_played = user_data.and_then(|ud| ud.is_played);
698
699 if is_favorite.is_none() && position_ticks.is_none() && is_played.is_none() {
701 return Ok(());
702 }
703
704 let query = Query::with_params(
705 "INSERT INTO user_data
706 (user_id, item_id, is_favorite, playback_position_ticks, is_played,
707 synced_at, pending_sync)
708 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)
709 ON CONFLICT(user_id, item_id) DO UPDATE SET
710 is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
711 playback_position_ticks = COALESCE(
712 excluded.playback_position_ticks, user_data.playback_position_ticks),
713 is_played = COALESCE(excluded.is_played, user_data.is_played),
714 synced_at = excluded.synced_at
715 WHERE user_data.pending_sync = 0",
716 vec![
717 QueryParam::String(self.user_id.clone()),
718 QueryParam::String(item.id.clone()),
719 is_favorite
720 .map(|f| QueryParam::Int(if f { 1 } else { 0 }))
721 .unwrap_or(QueryParam::Null),
722 position_ticks
723 .map(QueryParam::Int64)
724 .unwrap_or(QueryParam::Null),
725 is_played
726 .map(|p| QueryParam::Int(if p { 1 } else { 0 }))
727 .unwrap_or(QueryParam::Null),
728 QueryParam::String(now.to_string()),
729 ],
730 );
731
732 if let Err(e) = self.db_service.execute(query).await {
736 debug!(
737 "[OfflineRepo] user_data mirror skipped for {}: {}",
738 item.id, e
739 );
740 }
741
742 Ok(())
743 }
744
745 pub async fn save_libraries_to_cache(&self, libraries: &[Library]) -> Result<usize, RepoError> {
750 if libraries.is_empty() {
751 return Ok(0);
752 }
753
754 let mut count = 0;
755 for (idx, lib) in libraries.iter().enumerate() {
756 let query = Query::with_params(
757 "INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
758 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
759 vec![
760 QueryParam::String(lib.id.clone()),
761 QueryParam::String(self.server_id.clone()),
762 QueryParam::String(lib.name.clone()),
763 QueryParam::String(lib.collection_type.clone()),
764 lib.image_tag.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
765 QueryParam::Int(idx as i32),
766 ],
767 );
768 self.db_service
769 .execute(query)
770 .await
771 .map_err(|e| RepoError::Database { message: e })?;
772 count += 1;
773 }
774 Ok(count)
775 }
776
777 pub async fn save_genres_to_cache(
782 &self,
783 parent_id: Option<&str>,
784 genres: &[Genre],
785 ) -> Result<usize, RepoError> {
786 if genres.is_empty() {
787 return Ok(0);
788 }
789
790 let library_id = parent_id.unwrap_or("").to_string();
793 let server_id = self.server_id.clone();
794 let genres: Vec<(String, String, Option<u32>)> = genres
795 .iter()
796 .map(|g| (g.id.clone(), g.name.clone(), g.album_count))
797 .collect();
798 let saved = genres.len();
799
800 self.db_service
801 .transaction(move |tx| {
802 use crate::storage::db_service::{Query, QueryParam};
803
804 tx.execute(Query::with_params(
806 "DELETE FROM genres WHERE server_id = ? AND library_id = ?",
807 vec![
808 QueryParam::String(server_id.clone()),
809 QueryParam::String(library_id.clone()),
810 ],
811 ))?;
812
813 for (id, name, album_count) in &genres {
814 tx.execute(Query::with_params(
815 "INSERT OR REPLACE INTO genres (id, server_id, library_id, name, album_count, synced_at)
816 VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
817 vec![
818 QueryParam::String(id.clone()),
819 QueryParam::String(server_id.clone()),
820 QueryParam::String(library_id.clone()),
821 QueryParam::String(name.clone()),
822 album_count.map(|c| QueryParam::Int(c as i32)).unwrap_or(QueryParam::Null),
823 ],
824 ))?;
825 }
826
827 Ok(())
828 })
829 .await
830 .map_err(|e| RepoError::Database { message: e })?;
831
832 Ok(saved)
833 }
834
835 const LIBRARY_HOLDS_ITEM: &'static str = "(
860 (l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
861 OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
862 OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
863 OR l.collection_type IS NULL
864 OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
865 )";
866
867 const DOWNLOADED_ITEMS_CTE: &'static str = "
869 WITH downloaded_items AS (
870 SELECT DISTINCT i.id
871 FROM items i
872 INNER JOIN downloads d ON i.id = d.item_id
873 WHERE d.status = 'completed'
874 AND i.item_type IN ('Audio', 'Movie', 'Episode')
875
876 UNION
877
878 SELECT DISTINCT i.id
879 FROM items i
880 INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
881 INNER JOIN downloads d ON children.id = d.item_id
882 WHERE d.status = 'completed'
883 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
884 )";
885
886 pub async fn get_downloaded_items(
896 &self,
897 parent_id: &str,
898 options: Option<GetItemsOptions>,
899 ) -> Result<SearchResult, RepoError> {
900 let opts = options.unwrap_or_default();
901 let limit = opts.limit.unwrap_or(10000);
902 let start_index = opts.start_index.unwrap_or(0);
903
904 let type_filter = if let Some(include_item_types) = &opts.include_item_types {
905 if !include_item_types.is_empty() {
906 let types = include_item_types
907 .iter()
908 .map(|t| format!("'{}'", t.replace('\'', "''")))
909 .collect::<Vec<_>>()
910 .join(",");
911 format!(" AND i.item_type IN ({})", types)
912 } else {
913 String::new()
914 }
915 } else {
916 String::new()
917 };
918
919 let sql = format!(
932 "{cte}
933 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
934 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
935 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
936 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
937 i.parent_index_number, i.is_folder, i.premiere_date
938 FROM items i
939 INNER JOIN downloaded_items di ON i.id = di.id
940 WHERE i.server_id = ?
941 AND (
942 i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
943 OR (
944 EXISTS (
945 SELECT 1 FROM libraries l
946 WHERE l.id = ? AND l.server_id = i.server_id
947 AND {membership}
948 )
949 -- Top-level only: hide leaves whose container is downloaded.
950 AND NOT EXISTS (
951 SELECT 1 FROM downloaded_items parent
952 WHERE parent.id = i.album_id
953 OR parent.id = i.season_id
954 OR parent.id = i.series_id
955 OR parent.id = i.parent_id
956 )
957 )
958 ){type_filter}
959 ORDER BY i.sort_name ASC, i.name ASC
960 LIMIT {limit} OFFSET {start_index}",
961 cte = Self::DOWNLOADED_ITEMS_CTE,
962 membership = Self::LIBRARY_HOLDS_ITEM,
963 );
964
965 let query = Query::with_params(
966 sql,
967 vec![
968 QueryParam::String(self.server_id.clone()),
969 QueryParam::String(parent_id.to_string()),
970 QueryParam::String(parent_id.to_string()),
971 QueryParam::String(parent_id.to_string()),
972 QueryParam::String(parent_id.to_string()),
973 QueryParam::String(parent_id.to_string()),
974 ],
975 );
976
977 let cached_items: Vec<CachedItem> = self
978 .db_service
979 .query_many(query, row_to_cached_item)
980 .await
981 .map_err(|e| RepoError::Database { message: e })?;
982
983 let mut items = Vec::new();
984 for cached in cached_items {
985 let user_data = self.get_user_data(&cached.id).await;
986 items.push(Self::cached_item_to_media_item(cached, user_data));
987 }
988
989 let total_record_count = items.len();
990 Ok(SearchResult {
991 items,
992 total_record_count,
993 })
994 }
995
996 pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
1002 let query = Query::with_params(
1007 format!(
1008 "{cte}
1009 SELECT l.id, l.name, l.collection_type, l.image_tag
1010 FROM libraries l
1011 WHERE l.server_id = ?
1012 AND EXISTS (
1013 SELECT 1 FROM items i
1014 INNER JOIN downloaded_items di ON i.id = di.id
1015 WHERE i.server_id = l.server_id
1016 AND {membership}
1017 )
1018 ORDER BY l.sort_order ASC, l.name ASC",
1019 cte = Self::DOWNLOADED_ITEMS_CTE,
1020 membership = Self::LIBRARY_HOLDS_ITEM,
1021 ),
1022 vec![QueryParam::String(self.server_id.clone())],
1023 );
1024
1025 self.db_service
1026 .query_many(query, |row| {
1027 Ok(Library::new(
1028 row.get(0)?,
1029 row.get(1)?,
1030 row.get::<_, Option<String>>(2)?
1031 .unwrap_or_else(|| "unknown".to_string()),
1032 row.get(3)?,
1033 ))
1034 })
1035 .await
1036 .map_err(|e| RepoError::Database { message: e })
1037 }
1038
1039 pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
1048 let leaf_query = Query::with_params(
1050 "SELECT d.item_id, COALESCE(d.file_size, 0)
1051 FROM downloads d
1052 INNER JOIN items i ON i.id = d.item_id
1053 WHERE d.status = 'completed'
1054 AND i.server_id = ?
1055 AND i.item_type IN ('Audio', 'Movie', 'Episode')",
1056 vec![QueryParam::String(self.server_id.clone())],
1057 );
1058 let leaves: Vec<(String, i64)> = self
1059 .db_service
1060 .query_many(leaf_query, |row| Ok((row.get(0)?, row.get(1)?)))
1061 .await
1062 .map_err(|e| RepoError::Database { message: e })?;
1063
1064 let container_query = Query::with_params(
1066 "SELECT c.id, COALESCE(SUM(d.file_size), 0)
1067 FROM items c
1068 INNER JOIN items children
1069 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1070 INNER JOIN downloads d ON children.id = d.item_id
1071 WHERE d.status = 'completed'
1072 AND c.server_id = ?
1073 AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1074 GROUP BY c.id",
1075 vec![QueryParam::String(self.server_id.clone())],
1076 );
1077 let containers: Vec<(String, i64)> = self
1078 .db_service
1079 .query_many(container_query, |row| Ok((row.get(0)?, row.get(1)?)))
1080 .await
1081 .map_err(|e| RepoError::Database { message: e })?;
1082
1083 let partial_query = Query::with_params(
1094 "WITH downloaded_containers AS (
1095 SELECT DISTINCT c.id
1096 FROM items c
1097 INNER JOIN items children
1098 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1099 INNER JOIN downloads d ON children.id = d.item_id
1100 WHERE d.status = 'completed'
1101 AND c.server_id = ?
1102 AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1103 )
1104 SELECT c.id,
1105 COUNT(children.id) AS total_children,
1106 SUM(CASE WHEN d.status = 'completed' THEN 1 ELSE 0 END) AS downloaded_children
1107 FROM items c
1108 INNER JOIN downloaded_containers dc ON dc.id = c.id
1109 INNER JOIN items children
1110 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1111 LEFT JOIN downloads d ON children.id = d.item_id AND d.status = 'completed'
1112 WHERE children.item_type IN ('Audio', 'Movie', 'Episode', 'Season')
1113 GROUP BY c.id",
1114 vec![QueryParam::String(self.server_id.clone())],
1115 );
1116 let partial_rows: Vec<(String, i64, i64)> = self
1117 .db_service
1118 .query_many(partial_query, |row| {
1119 Ok((
1120 row.get(0)?,
1121 row.get(1)?,
1122 row.get::<_, Option<i64>>(2)?.unwrap_or(0),
1123 ))
1124 })
1125 .await
1126 .map_err(|e| RepoError::Database { message: e })?;
1127
1128 let mut partial_containers = std::collections::HashMap::new();
1129 for (id, total, downloaded) in partial_rows {
1130 if downloaded > 0 && downloaded < total {
1133 partial_containers.insert(id, true);
1134 }
1135 }
1136
1137 let item_count = leaves.len() as u32;
1138 let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum();
1139
1140 let mut sizes = std::collections::HashMap::new();
1141 for (id, bytes) in leaves.into_iter().chain(containers) {
1142 *sizes.entry(id).or_insert(0) += bytes;
1145 }
1146
1147 Ok(DownloadDiskUsage {
1148 sizes,
1149 partial_containers,
1150 device_total_bytes,
1151 item_count,
1152 })
1153 }
1154
1155 pub async fn save_playlist_items_to_cache(
1158 &self,
1159 playlist_id: &str,
1160 entries: &[PlaylistEntry],
1161 ) -> Result<(), RepoError> {
1162 let playlist_id = playlist_id.to_string();
1163 let user_id = self.user_id.clone();
1164 let entries: Vec<(String, String, usize)> = entries
1165 .iter()
1166 .enumerate()
1167 .map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i))
1168 .collect();
1169
1170 self.db_service
1171 .transaction(move |tx| {
1172 use crate::storage::db_service::{Query, QueryParam};
1173
1174 tx.execute(Query::with_params(
1176 "INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)",
1177 vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)],
1178 ))?;
1179
1180 tx.execute(Query::with_params(
1182 "DELETE FROM playlist_items WHERE playlist_id = ?",
1183 vec![QueryParam::String(playlist_id.clone())],
1184 ))?;
1185
1186 for (_, item_id, sort_order) in &entries {
1187 tx.execute(Query::with_params(
1188 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
1189 vec![
1190 QueryParam::String(playlist_id.clone()),
1191 QueryParam::String(item_id.clone()),
1192 QueryParam::Int(*sort_order as i32),
1193 ],
1194 ))?;
1195 }
1196
1197 Ok(())
1198 })
1199 .await
1200 .map_err(|e| RepoError::Database {
1201 message: format!("Failed to cache playlist items: {}", e),
1202 })
1203 }
1204}
1205
1206#[async_trait]
1207impl MediaRepository for OfflineRepository {
1208 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1209 let query = Query::with_params(
1217 "SELECT l.id, l.name, l.collection_type, l.image_tag
1218 FROM libraries l
1219 WHERE l.server_id = ?
1220 ORDER BY l.sort_order ASC, l.name ASC",
1221 vec![QueryParam::String(self.server_id.clone())],
1222 );
1223
1224 self.db_service
1225 .query_many(query, |row| {
1226 Ok(Library::new(
1227 row.get(0)?,
1228 row.get(1)?,
1229 row.get::<_, Option<String>>(2)?
1230 .unwrap_or_else(|| "unknown".to_string()),
1231 row.get(3)?,
1232 ))
1233 })
1234 .await
1235 .map_err(|e| RepoError::Database { message: e })
1236 }
1237
1238 async fn get_items(
1239 &self,
1240 parent_id: &str,
1241 options: Option<GetItemsOptions>,
1242 ) -> Result<SearchResult, RepoError> {
1243 debug!(
1244 "[OfflineRepo] get_items called for parent_id: {}",
1245 &parent_id[..8.min(parent_id.len())]
1246 );
1247 let opts = options.unwrap_or_default();
1248 let limit = opts.limit.unwrap_or(10000); let start_index = opts.start_index.unwrap_or(0);
1250
1251 let default_sort = default_listing_sort(opts.parent_kind);
1259 let sort_field = opts
1260 .sort_by
1261 .as_deref()
1262 .or(default_sort.map(|(field, _)| field));
1263 let descending = opts
1264 .sort_order
1265 .as_deref()
1266 .or(default_sort.map(|(_, order)| order))
1267 == Some("Descending");
1268 let order_by = match sort_field {
1269 Some("Random") => "RANDOM()".to_string(),
1270 Some("PremiereDate") => format!(
1271 "i.premiere_date IS NULL, i.premiere_date {}, i.sort_name ASC",
1272 if descending { "DESC" } else { "ASC" }
1273 ),
1274 _ => "i.sort_name ASC, i.name ASC".to_string(),
1275 };
1276
1277 let type_values: &[String] = opts
1284 .include_item_types
1285 .as_deref()
1286 .filter(|types| !types.is_empty())
1287 .unwrap_or(&[]);
1288 let type_filter = if type_values.is_empty() {
1289 String::new()
1290 } else {
1291 let placeholders = vec!["?"; type_values.len()].join(",");
1292 format!(" AND i.item_type IN ({})", placeholders)
1293 };
1294
1295 let favorites_filter = if opts.favorites_only == Some(true) {
1300 " AND EXISTS (
1301 SELECT 1 FROM user_data ud
1302 WHERE ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1
1303 )"
1304 } else {
1305 ""
1306 };
1307
1308 let catalog_branch = if include_catalog_browse() {
1316 "UNION
1317
1318 -- Cached items for fast browsing (online) or the offline catalog view
1319 SELECT DISTINCT i.id
1320 FROM items i
1321 WHERE i.synced_at IS NOT NULL"
1322 } else {
1323 ""
1324 };
1325 let sql = format!(
1326 "WITH available_items AS (
1327 -- Playable items with completed downloads
1328 SELECT DISTINCT i.id
1329 FROM items i
1330 INNER JOIN downloads d ON i.id = d.item_id
1331 WHERE d.status = 'completed'
1332 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1333
1334 UNION
1335
1336 -- Containers with downloaded children
1337 SELECT DISTINCT i.id
1338 FROM items i
1339 INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
1340 INNER JOIN downloads d ON children.id = d.item_id
1341 WHERE d.status = 'completed'
1342 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1343
1344 {catalog_branch}
1345 )
1346 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1347 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1348 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1349 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1350 i.parent_index_number, i.is_folder, i.premiere_date
1351 FROM items i
1352 INNER JOIN available_items ai ON i.id = ai.id
1353 WHERE i.server_id = ?
1354 AND (
1355 i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
1356 -- When the requested parent is a LIBRARY, there is no per-item
1357 -- link back to it (library_id/parent_id are NULL in the cache),
1358 -- so match every item on the server and let the type filter
1359 -- (e.g. MusicAlbum / Movie / Series) narrow it. This is what
1360 -- makes library landing pages show albums/movies/shows offline.
1361 OR EXISTS (
1362 SELECT 1 FROM libraries l
1363 WHERE l.id = ? AND l.server_id = i.server_id
1364 )
1365 ){}{}
1366 ORDER BY {}
1367 LIMIT {} OFFSET {}",
1368 type_filter, favorites_filter, order_by, limit, start_index
1369 );
1370
1371 let mut params = vec![
1377 QueryParam::String(self.server_id.clone()),
1378 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()), ];
1384 params.extend(type_values.iter().cloned().map(QueryParam::String));
1389 if !favorites_filter.is_empty() {
1390 params.push(QueryParam::String(self.user_id.clone())); }
1392 let query = Query::with_params(sql, params);
1393
1394 let cached_items: Vec<CachedItem> = self
1395 .db_service
1396 .query_many(query, row_to_cached_item)
1397 .await
1398 .map_err(|e| RepoError::Database { message: e })?;
1399
1400 debug!(
1401 "[OfflineRepo] Found {} cached items for parent {}",
1402 cached_items.len(),
1403 &parent_id[..8.min(parent_id.len())]
1404 );
1405
1406 let mut items = Vec::new();
1408 for cached in cached_items {
1409 let user_data = self.get_user_data(&cached.id).await;
1410 items.push(Self::cached_item_to_media_item(cached, user_data));
1411 }
1412
1413 let total_record_count = items.len();
1414
1415 debug!(
1416 "[OfflineRepo] Returning {} items for parent {}",
1417 total_record_count,
1418 &parent_id[..8.min(parent_id.len())]
1419 );
1420
1421 Ok(SearchResult {
1422 items,
1423 total_record_count,
1424 })
1425 }
1426
1427 async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
1428 let query = Query::with_params(
1430 "WITH downloaded_items AS (
1431 -- Playable items with completed downloads
1432 SELECT DISTINCT i.id
1433 FROM items i
1434 INNER JOIN downloads d ON i.id = d.item_id
1435 WHERE d.status = 'completed'
1436 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1437
1438 UNION
1439
1440 -- Containers with downloaded children
1441 SELECT DISTINCT i.id
1442 FROM items i
1443 INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
1444 INNER JOIN downloads d ON children.id = d.item_id
1445 WHERE d.status = 'completed'
1446 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1447 )
1448 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1449 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1450 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1451 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1452 i.parent_index_number, i.is_folder, i.premiere_date
1453 FROM items i
1454 INNER JOIN downloaded_items di ON i.id = di.id
1455 WHERE i.id = ?",
1456 vec![QueryParam::String(item_id.to_string())],
1457 );
1458
1459 let cached = self
1460 .db_service
1461 .query_optional(query, row_to_cached_item)
1462 .await
1463 .map_err(|e| RepoError::Database { message: e })?
1464 .ok_or_else(|| RepoError::NotFound {
1465 message: format!(
1466 "Item {} not found in offline cache or not downloaded",
1467 item_id
1468 ),
1469 })?;
1470
1471 let user_data = self.get_user_data(item_id).await;
1472 Ok(Self::cached_item_to_media_item(cached, user_data))
1473 }
1474
1475 async fn get_latest_items(
1476 &self,
1477 parent_id: &str,
1478 limit: Option<usize>,
1479 ) -> Result<Vec<MediaItem>, RepoError> {
1480 let limit_val = limit.unwrap_or(16);
1481
1482 let query = Query::with_params(
1483 format!(
1484 "WITH downloaded_items AS (
1485 -- Playable items with completed downloads
1486 SELECT DISTINCT i.id
1487 FROM items i
1488 INNER JOIN downloads d ON i.id = d.item_id
1489 WHERE d.status = 'completed'
1490 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1491
1492 UNION
1493
1494 -- Containers with downloaded children
1495 SELECT DISTINCT i.id
1496 FROM items i
1497 INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
1498 INNER JOIN downloads d ON children.id = d.item_id
1499 WHERE d.status = 'completed'
1500 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1501 )
1502 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1503 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1504 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1505 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1506 i.parent_index_number, i.is_folder, i.premiere_date
1507 FROM items i
1508 INNER JOIN downloaded_items di ON i.id = di.id
1509 WHERE i.server_id = ? AND i.library_id = ?
1510 -- Collapse leaves into the container that was added: a new
1511 -- 14-track album should read as one album, not 14 songs. Only
1512 -- drops a leaf when its own container is present in the same
1513 -- result, so a standalone track or movie still appears.
1514 AND NOT EXISTS (
1515 SELECT 1 FROM downloaded_items parent
1516 WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
1517 )
1518 ORDER BY i.synced_at DESC
1519 LIMIT {}", limit_val
1520 ),
1521 vec![
1522 QueryParam::String(self.server_id.clone()),
1523 QueryParam::String(parent_id.to_string()),
1524 ],
1525 );
1526
1527 let cached_items: Vec<CachedItem> = self
1528 .db_service
1529 .query_many(query, row_to_cached_item)
1530 .await
1531 .map_err(|e| RepoError::Database { message: e })?;
1532
1533 let mut items = Vec::new();
1534 for cached in cached_items {
1535 let user_data = self.get_user_data(&cached.id).await;
1536 items.push(Self::cached_item_to_media_item(cached, user_data));
1537 }
1538
1539 Ok(items)
1540 }
1541
1542 async fn get_resume_items(
1543 &self,
1544 parent_id: Option<&str>,
1545 limit: Option<usize>,
1546 ) -> Result<Vec<MediaItem>, RepoError> {
1547 let limit_val = limit.unwrap_or(12);
1548
1549 let (sql, params) = if let Some(pid) = parent_id {
1551 (
1552 format!(
1553 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1554 i.overview, i.genres, i.runtime_ticks, i.production_year,
1555 i.community_rating, i.official_rating, i.primary_image_tag,
1556 i.album_id, i.album_name, i.album_artist, i.artists,
1557 i.index_number, i.series_id, i.series_name, i.season_id,
1558 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1559 FROM items i
1560 JOIN user_data ud ON i.id = ud.item_id
1561 INNER JOIN downloads d ON i.id = d.item_id
1562 WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
1563 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1564 AND d.status = 'completed'
1565 AND i.item_type IN ('Movie', 'Episode')
1566 ORDER BY ud.last_played_at DESC
1567 LIMIT {}",
1568 limit_val
1569 ),
1570 vec![
1571 QueryParam::String(self.server_id.clone()),
1572 QueryParam::String(self.user_id.clone()),
1573 QueryParam::String(pid.to_string()),
1574 ],
1575 )
1576 } else {
1577 (
1578 format!(
1579 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1580 i.overview, i.genres, i.runtime_ticks, i.production_year,
1581 i.community_rating, i.official_rating, i.primary_image_tag,
1582 i.album_id, i.album_name, i.album_artist, i.artists,
1583 i.index_number, i.series_id, i.series_name, i.season_id,
1584 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1585 FROM items i
1586 JOIN user_data ud ON i.id = ud.item_id
1587 INNER JOIN downloads d ON i.id = d.item_id
1588 WHERE i.server_id = ? AND ud.user_id = ?
1589 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1590 AND d.status = 'completed'
1591 AND i.item_type IN ('Movie', 'Episode')
1592 ORDER BY ud.last_played_at DESC
1593 LIMIT {}",
1594 limit_val
1595 ),
1596 vec![
1597 QueryParam::String(self.server_id.clone()),
1598 QueryParam::String(self.user_id.clone()),
1599 ],
1600 )
1601 };
1602
1603 let query = Query::with_params(sql, params);
1604
1605 let cached_items: Vec<CachedItem> = self
1606 .db_service
1607 .query_many(query, row_to_cached_item)
1608 .await
1609 .map_err(|e| RepoError::Database { message: e })?;
1610
1611 let mut items = Vec::new();
1612 for cached in cached_items {
1613 let user_data = self.get_user_data(&cached.id).await;
1614 items.push(Self::cached_item_to_media_item(cached, user_data));
1615 }
1616
1617 Ok(items)
1618 }
1619
1620 async fn get_next_up_episodes(
1621 &self,
1622 _series_id: Option<&str>,
1623 _limit: Option<usize>,
1624 ) -> Result<Vec<MediaItem>, RepoError> {
1625 Ok(Vec::new())
1628 }
1629
1630 async fn get_recently_played_audio(
1631 &self,
1632 limit: Option<usize>,
1633 ) -> Result<Vec<MediaItem>, RepoError> {
1634 let limit_val = limit.unwrap_or(12);
1635
1636 let query = Query::with_params(
1641 format!(
1642 "WITH downloaded_items AS (
1643 -- Playable items with completed downloads (Audio tracks)
1644 SELECT DISTINCT i.id
1645 FROM items i
1646 INNER JOIN downloads d ON i.id = d.item_id
1647 WHERE d.status = 'completed'
1648 AND i.item_type = 'Audio'
1649
1650 UNION
1651
1652 -- Containers with downloaded children (Albums)
1653 SELECT DISTINCT i.id
1654 FROM items i
1655 INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
1656 INNER JOIN downloads d ON children.id = d.item_id
1657 WHERE d.status = 'completed'
1658 AND i.item_type = 'MusicAlbum'
1659 ),
1660 ranked_plays AS (
1661 SELECT
1662 CASE
1663 WHEN ud.playback_context_type = 'container' THEN ud.playback_context_id
1664 WHEN ud.playback_context_type = 'single' THEN ud.item_id
1665 ELSE COALESCE(i.album_id, ud.item_id)
1666 END AS display_id,
1667 MAX(ud.last_played_at) AS most_recent_play
1668 FROM user_data ud
1669 JOIN items i ON ud.item_id = i.id
1670 WHERE ud.user_id = ? AND i.server_id = ?
1671 AND i.item_type = 'Audio'
1672 AND ud.last_played_at IS NOT NULL
1673 GROUP BY display_id
1674 ORDER BY most_recent_play DESC
1675 LIMIT {}
1676 )
1677 SELECT DISTINCT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1678 i.overview, i.genres, i.runtime_ticks, i.production_year,
1679 i.community_rating, i.official_rating, i.primary_image_tag,
1680 i.album_id, i.album_name, i.album_artist, i.artists,
1681 i.index_number, i.series_id, i.series_name, i.season_id,
1682 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1683 FROM ranked_plays rp
1684 JOIN items i ON rp.display_id = i.id
1685 INNER JOIN downloaded_items di ON i.id = di.id
1686 ORDER BY rp.most_recent_play DESC",
1687 limit_val
1688 ),
1689 vec![
1690 QueryParam::String(self.user_id.clone()),
1691 QueryParam::String(self.server_id.clone()),
1692 ],
1693 );
1694
1695 let cached_items: Vec<CachedItem> = self
1696 .db_service
1697 .query_many(query, row_to_cached_item)
1698 .await
1699 .map_err(|e| RepoError::Database { message: e })?;
1700
1701 let mut items = Vec::new();
1702 for cached in cached_items {
1703 let user_data = self.get_user_data(&cached.id).await;
1704 items.push(Self::cached_item_to_media_item(cached, user_data));
1705 }
1706
1707 Ok(items)
1708 }
1709
1710 async fn get_rediscover_albums(
1711 &self,
1712 _parent_id: Option<&str>,
1713 _limit: Option<usize>,
1714 ) -> Result<Vec<MediaItem>, RepoError> {
1715 Ok(Vec::new())
1719 }
1720
1721 async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
1722 let limit_val = limit.unwrap_or(12);
1723
1724 let query = Query::with_params(
1726 format!(
1727 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1728 i.overview, i.genres, i.runtime_ticks, i.production_year,
1729 i.community_rating, i.official_rating, i.primary_image_tag,
1730 i.album_id, i.album_name, i.album_artist, i.artists,
1731 i.index_number, i.series_id, i.series_name, i.season_id,
1732 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1733 FROM items i
1734 JOIN user_data ud ON i.id = ud.item_id
1735 INNER JOIN downloads d ON i.id = d.item_id
1736 WHERE i.server_id = ? AND ud.user_id = ? AND i.item_type = 'Movie'
1737 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1738 AND d.status = 'completed'
1739 ORDER BY ud.last_played_at DESC
1740 LIMIT {}",
1741 limit_val
1742 ),
1743 vec![
1744 QueryParam::String(self.server_id.clone()),
1745 QueryParam::String(self.user_id.clone()),
1746 ],
1747 );
1748
1749 let cached_items: Vec<CachedItem> = self
1750 .db_service
1751 .query_many(query, row_to_cached_item)
1752 .await
1753 .map_err(|e| RepoError::Database { message: e })?;
1754
1755 let mut items = Vec::new();
1756 for cached in cached_items {
1757 let user_data = self.get_user_data(&cached.id).await;
1758 items.push(Self::cached_item_to_media_item(cached, user_data));
1759 }
1760
1761 Ok(items)
1762 }
1763
1764 async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1765 let library_id = parent_id.unwrap_or("").to_string();
1770
1771 let query = Query::with_params(
1772 "SELECT id, name, album_count FROM genres WHERE server_id = ? AND library_id = ?",
1773 vec![
1774 QueryParam::String(self.server_id.clone()),
1775 QueryParam::String(library_id),
1776 ],
1777 );
1778
1779 let genres: Vec<Genre> = self
1780 .db_service
1781 .query_many(query, |row| {
1782 Ok(Genre {
1783 id: row.get(0)?,
1784 name: row.get(1)?,
1785 album_count: row.get::<_, Option<i64>>(2)?.map(|c| c as u32),
1786 })
1787 })
1788 .await
1789 .map_err(|e| RepoError::Database { message: e })?;
1790
1791 Ok(genres)
1792 }
1793
1794 async fn search(
1795 &self,
1796 query: &str,
1797 options: Option<SearchOptions>,
1798 ) -> Result<SearchResult, RepoError> {
1799 let opts = options.unwrap_or_default();
1800 let limit = opts.limit.unwrap_or(20);
1801
1802 let Some(fts_query) = build_fts_prefix_query(query) else {
1805 return Ok(SearchResult {
1806 items: Vec::new(),
1807 total_record_count: 0,
1808 });
1809 };
1810
1811 let type_values: &[String] = opts
1815 .include_item_types
1816 .as_deref()
1817 .filter(|types| !types.is_empty())
1818 .unwrap_or(&[]);
1819 let type_filter = if type_values.is_empty() {
1820 String::new()
1821 } else {
1822 let placeholders = vec!["?"; type_values.len()].join(",");
1823 format!(" AND i.item_type IN ({})", placeholders)
1824 };
1825
1826 let catalog_branch = if include_catalog_browse() {
1832 "UNION
1833
1834 -- Synced catalog: fast online search, or the offline
1835 -- 'Show all server media' view. See set_include_catalog_browse.
1836 SELECT DISTINCT i.id
1837 FROM items i
1838 WHERE i.synced_at IS NOT NULL"
1839 } else {
1840 ""
1841 };
1842
1843 let sql = format!(
1844 "WITH available_items AS (
1845 -- Playable items with completed downloads
1846 SELECT DISTINCT i.id
1847 FROM items i
1848 INNER JOIN downloads d ON i.id = d.item_id
1849 WHERE d.status = 'completed'
1850 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1851
1852 UNION
1853
1854 -- Containers with downloaded children
1855 SELECT DISTINCT i.id
1856 FROM items i
1857 INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
1858 INNER JOIN downloads d ON children.id = d.item_id
1859 WHERE d.status = 'completed'
1860 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1861
1862 {}
1863 )
1864 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1865 i.overview, i.genres, i.runtime_ticks, i.production_year,
1866 i.community_rating, i.official_rating, i.primary_image_tag,
1867 i.album_id, i.album_name, i.album_artist, i.artists,
1868 i.index_number, i.series_id, i.series_name, i.season_id,
1869 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1870 FROM items i
1871 JOIN items_fts fts ON fts.rowid = i.rowid
1872 INNER JOIN available_items ai ON i.id = ai.id
1873 WHERE i.server_id = ? AND items_fts MATCH ?{}
1874 ORDER BY rank
1875 LIMIT {}",
1876 catalog_branch, type_filter, limit
1877 );
1878
1879 let mut params = vec![
1880 QueryParam::String(self.server_id.clone()),
1881 QueryParam::String(fts_query.clone()),
1882 ];
1883 params.extend(type_values.iter().cloned().map(QueryParam::String));
1884
1885 let db_query = Query::with_params(sql, params);
1886
1887 let cached_items: Vec<CachedItem> = self
1888 .db_service
1889 .query_many(db_query, row_to_cached_item)
1890 .await
1891 .map_err(|e| RepoError::Database { message: e })?;
1892
1893 let mut items = Vec::new();
1894 for cached in cached_items {
1895 let user_data = self.get_user_data(&cached.id).await;
1896 items.push(Self::cached_item_to_media_item(cached, user_data));
1897 }
1898
1899 if type_values.is_empty() {
1905 items.extend(self.search_people(&fts_query, limit).await?);
1906 }
1907
1908 let total_record_count = items.len();
1909
1910 Ok(SearchResult {
1911 items,
1912 total_record_count,
1913 })
1914 }
1915
1916 async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
1917 Err(RepoError::Offline)
1919 }
1920
1921 async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
1922 Err(RepoError::Offline)
1924 }
1925
1926 async fn get_audio_only_stream_url_for_video(
1927 &self,
1928 _item_id: &str,
1929 _media_source_id: Option<&str>,
1930 _start_time_seconds: Option<f64>,
1931 _audio_stream_index: Option<i32>,
1932 ) -> Result<String, RepoError> {
1933 Err(RepoError::Offline)
1935 }
1936
1937 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
1938 Err(RepoError::Offline)
1940 }
1941
1942 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
1943 Err(RepoError::Offline)
1945 }
1946
1947 async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
1948 Err(RepoError::Offline)
1950 }
1951
1952 async fn report_playback_start(
1953 &self,
1954 _item_id: &str,
1955 _position_ticks: i64,
1956 ) -> Result<(), RepoError> {
1957 Err(RepoError::Offline)
1959 }
1960
1961 async fn report_playback_progress(
1962 &self,
1963 _item_id: &str,
1964 _position_ticks: i64,
1965 ) -> Result<(), RepoError> {
1966 Err(RepoError::Offline)
1968 }
1969
1970 async fn report_playback_stopped(
1971 &self,
1972 _item_id: &str,
1973 _position_ticks: i64,
1974 ) -> Result<(), RepoError> {
1975 Err(RepoError::Offline)
1977 }
1978
1979 fn get_image_url(
1980 &self,
1981 item_id: &str,
1982 image_type: ImageType,
1983 options: Option<ImageOptions>,
1984 ) -> String {
1985 let type_str = match image_type {
1988 ImageType::Primary => "Primary",
1989 ImageType::Backdrop => "Backdrop",
1990 ImageType::Logo => "Logo",
1991 ImageType::Thumb => "Thumb",
1992 ImageType::Banner => "Banner",
1993 };
1994
1995 if let Some(opts) = options {
1996 if let Some(tag) = opts.tag {
1997 return format!("offline://{}/{}/{}", item_id, type_str, tag);
1998 }
1999 }
2000
2001 format!("offline://{}/{}", item_id, type_str)
2002 }
2003
2004 fn get_subtitle_url(
2005 &self,
2006 _item_id: &str,
2007 _media_source_id: &str,
2008 _stream_index: i32,
2009 _format: &str,
2010 ) -> String {
2011 String::new()
2013 }
2014
2015 fn get_video_download_url(
2016 &self,
2017 _item_id: &str,
2018 _quality: &str,
2019 _media_source_id: Option<&str>,
2020 _source_audio_codec: Option<&str>,
2021 ) -> String {
2022 String::new()
2024 }
2025
2026 async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
2027 Err(RepoError::Offline)
2029 }
2030
2031 async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
2032 Err(RepoError::Offline)
2034 }
2035
2036 async fn get_favorites(
2045 &self,
2046 scope: SearchScope,
2047 options: Option<GetItemsOptions>,
2048 ) -> Result<SearchResult, RepoError> {
2049 let opts = options.unwrap_or_default();
2050 let limit = opts.limit.unwrap_or(10000);
2051 let start_index = opts.start_index.unwrap_or(0);
2052
2053 let type_filter = match scope.item_types() {
2056 Some(types) if !types.is_empty() => {
2057 let placeholders = vec!["?"; types.len()].join(",");
2058 format!(" AND i.item_type IN ({})", placeholders)
2059 }
2060 _ => String::new(),
2061 };
2062
2063 let catalog_branch = if include_catalog_browse() {
2064 "UNION
2065
2066 SELECT DISTINCT i.id
2067 FROM items i
2068 WHERE i.synced_at IS NOT NULL"
2069 } else {
2070 ""
2071 };
2072
2073 let sql = format!(
2074 "WITH available_items AS (
2075 SELECT DISTINCT i.id
2076 FROM items i
2077 INNER JOIN downloads d ON i.id = d.item_id
2078 WHERE d.status = 'completed'
2079 AND i.item_type IN ('Audio', 'Movie', 'Episode')
2080
2081 UNION
2082
2083 SELECT DISTINCT i.id
2084 FROM items i
2085 INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
2086 INNER JOIN downloads d ON children.id = d.item_id
2087 WHERE d.status = 'completed'
2088 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
2089
2090 {catalog_branch}
2091 )
2092 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
2093 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
2094 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
2095 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
2096 i.parent_index_number, i.is_folder, i.premiere_date
2097 FROM items i
2098 INNER JOIN available_items ai ON i.id = ai.id
2099 INNER JOIN user_data ud ON ud.item_id = i.id
2100 WHERE i.server_id = ?
2101 AND ud.user_id = ?
2102 AND ud.is_favorite = 1{}
2103 ORDER BY i.sort_name ASC, i.name ASC
2104 LIMIT {} OFFSET {}",
2105 type_filter, limit, start_index
2106 );
2107
2108 let mut params = vec![
2109 QueryParam::String(self.server_id.clone()),
2110 QueryParam::String(self.user_id.clone()),
2111 ];
2112 if let Some(types) = scope.item_types() {
2113 params.extend(types.into_iter().map(QueryParam::String));
2114 }
2115
2116 let cached_items: Vec<CachedItem> = self
2117 .db_service
2118 .query_many(Query::with_params(sql, params), row_to_cached_item)
2119 .await
2120 .map_err(|e| RepoError::Database { message: e })?;
2121
2122 let mut items = Vec::new();
2123 for cached in cached_items {
2124 let user_data = self.get_user_data(&cached.id).await;
2125 items.push(Self::cached_item_to_media_item(cached, user_data));
2126 }
2127
2128 let total_record_count = items.len();
2129 debug!("[OfflineRepo] Returning {} favourites", total_record_count);
2130
2131 Ok(SearchResult {
2132 items,
2133 total_record_count,
2134 })
2135 }
2136
2137 async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
2138 Err(RepoError::Offline)
2141 }
2142
2143 async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
2144 Err(RepoError::Offline)
2147 }
2148
2149 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
2150 let query = Query::with_params(
2151 "SELECT id, name, overview, primary_image_tag
2152 FROM people WHERE id = ?",
2153 vec![QueryParam::String(person_id.to_string())],
2154 );
2155
2156 let person_data = self
2157 .db_service
2158 .query_optional(query, |row| {
2159 Ok((
2160 row.get::<_, String>(0)?,
2161 row.get::<_, String>(1)?,
2162 row.get::<_, Option<String>>(2)?,
2163 row.get::<_, Option<String>>(3)?,
2164 ))
2165 })
2166 .await
2167 .map_err(|e| RepoError::Database { message: e })?
2168 .ok_or_else(|| RepoError::NotFound {
2169 message: format!("Person {} not found in cache", person_id),
2170 })?;
2171
2172 Ok(MediaItem {
2173 id: person_data.0,
2174 name: person_data.1,
2175 item_type: "Person".to_string(),
2176 kind: crate::domain::MediaKind::Person,
2177 is_folder: false,
2178 server_id: self.server_id.clone(),
2179 parent_id: None,
2180 library_id: None,
2181 overview: person_data.2,
2182 genres: None,
2183 runtime_ticks: None,
2184 duration_ms: None,
2185 production_year: None,
2186 premiere_date: None,
2187 community_rating: None,
2188 official_rating: None,
2189 primary_image_tag: person_data.3.clone(),
2190 image_id: person_data.3,
2191 backdrop_image_tags: None,
2192 parent_backdrop_image_tags: None,
2193 album_id: None,
2194 album_name: None,
2195 album_artist: None,
2196 artists: None,
2197 artist_items: None,
2198 index_number: None,
2199 series_id: None,
2200 series_name: None,
2201 season_id: None,
2202 season_name: None,
2203 parent_index_number: None,
2204 user_data: None,
2205 media_streams: None,
2206 media_sources: None,
2207 people: None,
2208 })
2209 }
2210
2211 async fn get_items_by_person(
2212 &self,
2213 person_id: &str,
2214 options: Option<GetItemsOptions>,
2215 ) -> Result<SearchResult, RepoError> {
2216 let opts = options.unwrap_or_default();
2217 let limit = opts.limit.unwrap_or(10000); let query = Query::with_params(
2221 format!(
2222 "WITH downloaded_items AS (
2223 -- Playable items with completed downloads
2224 SELECT DISTINCT i.id
2225 FROM items i
2226 INNER JOIN downloads d ON i.id = d.item_id
2227 WHERE d.status = 'completed'
2228 AND i.item_type IN ('Audio', 'Movie', 'Episode')
2229
2230 UNION
2231
2232 -- Containers with downloaded children
2233 SELECT DISTINCT i.id
2234 FROM items i
2235 INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
2236 INNER JOIN downloads d ON children.id = d.item_id
2237 WHERE d.status = 'completed'
2238 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
2239 )
2240 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
2241 i.overview, i.genres, i.runtime_ticks, i.production_year,
2242 i.community_rating, i.official_rating, i.primary_image_tag,
2243 i.album_id, i.album_name, i.album_artist, i.artists,
2244 i.index_number, i.series_id, i.series_name, i.season_id,
2245 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
2246 FROM items i
2247 JOIN item_people ip ON i.id = ip.item_id
2248 INNER JOIN downloaded_items di ON i.id = di.id
2249 WHERE i.server_id = ? AND ip.person_id = ?
2250 ORDER BY i.production_year DESC, i.sort_name ASC
2251 LIMIT {}", limit
2252 ),
2253 vec![
2254 QueryParam::String(self.server_id.clone()),
2255 QueryParam::String(person_id.to_string()),
2256 ],
2257 );
2258
2259 let cached_items: Vec<CachedItem> = self
2260 .db_service
2261 .query_many(query, row_to_cached_item)
2262 .await
2263 .map_err(|e| RepoError::Database { message: e })?;
2264
2265 let mut items = Vec::new();
2266 for cached in cached_items {
2267 let user_data = self.get_user_data(&cached.id).await;
2268 items.push(Self::cached_item_to_media_item(cached, user_data));
2269 }
2270
2271 let total_record_count = items.len();
2272
2273 Ok(SearchResult {
2274 items,
2275 total_record_count,
2276 })
2277 }
2278
2279 async fn get_similar_items(
2280 &self,
2281 _item_id: &str,
2282 _limit: Option<usize>,
2283 ) -> Result<SearchResult, RepoError> {
2284 Err(RepoError::Offline)
2286 }
2287
2288 async fn create_playlist(
2291 &self,
2292 name: &str,
2293 item_ids: &[String],
2294 ) -> Result<PlaylistCreatedResult, RepoError> {
2295 let playlist_id = uuid::Uuid::new_v4().to_string();
2296 let user_id = self.user_id.clone();
2297 let name = name.to_string();
2298 let item_ids = item_ids.to_vec();
2299 let pid = playlist_id.clone();
2300
2301 self.db_service
2302 .transaction(move |tx| {
2303 use crate::storage::db_service::{Query, QueryParam};
2304
2305 tx.execute(Query::with_params(
2306 "INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)",
2307 vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)],
2308 ))?;
2309
2310 for (i, item_id) in item_ids.iter().enumerate() {
2311 tx.execute(Query::with_params(
2312 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
2313 vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)],
2314 ))?;
2315 }
2316
2317 Ok(())
2318 })
2319 .await
2320 .map_err(|e| RepoError::Database {
2321 message: format!("Failed to create playlist: {}", e),
2322 })?;
2323
2324 Ok(PlaylistCreatedResult { id: playlist_id })
2325 }
2326
2327 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
2328 let query = Query::with_params(
2329 "DELETE FROM playlists WHERE id = ?",
2330 vec![QueryParam::String(playlist_id.to_string())],
2331 );
2332 self.db_service
2333 .execute(query)
2334 .await
2335 .map_err(|e| RepoError::Database {
2336 message: format!("Failed to delete playlist: {}", e),
2337 })?;
2338 Ok(())
2339 }
2340
2341 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
2342 let query = Query::with_params(
2343 "UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
2344 vec![
2345 QueryParam::String(name.to_string()),
2346 QueryParam::String(playlist_id.to_string()),
2347 ],
2348 );
2349 self.db_service
2350 .execute(query)
2351 .await
2352 .map_err(|e| RepoError::Database {
2353 message: format!("Failed to rename playlist: {}", e),
2354 })?;
2355 Ok(())
2356 }
2357
2358 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
2359 let query = Query::with_params(
2360 "SELECT pi.id, \
2361 i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \
2362 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
2363 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
2364 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
2365 i.parent_index_number, i.is_folder, i.premiere_date \
2366 FROM playlist_items pi \
2367 JOIN items i ON pi.item_id = i.id \
2368 WHERE pi.playlist_id = ? \
2369 ORDER BY pi.sort_order ASC",
2370 vec![QueryParam::String(playlist_id.to_string())],
2371 );
2372
2373 let items = self
2374 .db_service
2375 .query_many(query, |row| {
2376 let entry_id: i64 = row.get(0)?;
2377 let cached = CachedItem {
2379 id: row.get(1)?,
2380 name: row.get(2)?,
2381 item_type: row.get(3)?,
2382 server_id: row.get(4)?,
2383 parent_id: row.get(5)?,
2384 library_id: row.get(6)?,
2385 overview: row.get(7)?,
2386 genres: row.get(8)?,
2387 runtime_ticks: row.get(9)?,
2388 production_year: row.get(10)?,
2389 community_rating: row.get(11)?,
2390 official_rating: row.get(12)?,
2391 primary_image_tag: row.get(13)?,
2392 backdrop_image_tags: None,
2393 parent_backdrop_image_tags: None,
2394 album_id: row.get(14)?,
2395 album_name: row.get(15)?,
2396 album_artist: row.get(16)?,
2397 artists: row.get(17)?,
2398 index_number: row.get(18)?,
2399 series_id: row.get(19)?,
2400 series_name: row.get(20)?,
2401 season_id: row.get(21)?,
2402 season_name: row.get(22)?,
2403 parent_index_number: row.get(23)?,
2404 is_folder: row.get::<_, Option<i64>>(24)?.unwrap_or(0) != 0,
2405 premiere_date: row.get(25)?,
2406 };
2407 Ok((entry_id.to_string(), cached))
2408 })
2409 .await
2410 .map_err(|e| RepoError::Database {
2411 message: format!("Failed to get playlist items: {}", e),
2412 })?;
2413
2414 Ok(items
2415 .into_iter()
2416 .map(|(entry_id, cached)| PlaylistEntry {
2417 playlist_item_id: entry_id,
2418 item: Self::cached_item_to_media_item(cached, None),
2419 })
2420 .collect())
2421 }
2422
2423 async fn add_to_playlist(
2424 &self,
2425 playlist_id: &str,
2426 item_ids: &[String],
2427 ) -> Result<(), RepoError> {
2428 let max_query = Query::with_params(
2430 "SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?",
2431 vec![QueryParam::String(playlist_id.to_string())],
2432 );
2433 let max_order: i32 = self
2434 .db_service
2435 .query_one(max_query, |row| row.get(0))
2436 .await
2437 .unwrap_or(-1);
2438
2439 let playlist_id = playlist_id.to_string();
2440 let item_ids = item_ids.to_vec();
2441
2442 self.db_service
2443 .transaction(move |tx| {
2444 use crate::storage::db_service::{Query, QueryParam};
2445
2446 for (i, item_id) in item_ids.iter().enumerate() {
2447 tx.execute(Query::with_params(
2448 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
2449 vec![
2450 QueryParam::String(playlist_id.clone()),
2451 QueryParam::String(item_id.clone()),
2452 QueryParam::Int(max_order + 1 + i as i32),
2453 ],
2454 ))?;
2455 }
2456 Ok(())
2457 })
2458 .await
2459 .map_err(|e| RepoError::Database {
2460 message: format!("Failed to add items to playlist: {}", e),
2461 })?;
2462
2463 Ok(())
2464 }
2465
2466 async fn remove_from_playlist(
2467 &self,
2468 playlist_id: &str,
2469 entry_ids: &[String],
2470 ) -> Result<(), RepoError> {
2471 let playlist_id = playlist_id.to_string();
2472 let entry_ids = entry_ids.to_vec();
2473
2474 self.db_service
2475 .transaction(move |tx| {
2476 use crate::storage::db_service::{Query, QueryParam};
2477
2478 for entry_id in &entry_ids {
2479 tx.execute(Query::with_params(
2480 "DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?",
2481 vec![
2482 QueryParam::String(playlist_id.clone()),
2483 QueryParam::String(entry_id.clone()),
2484 ],
2485 ))?;
2486 }
2487 Ok(())
2488 })
2489 .await
2490 .map_err(|e| RepoError::Database {
2491 message: format!("Failed to remove items from playlist: {}", e),
2492 })?;
2493
2494 Ok(())
2495 }
2496
2497 async fn move_playlist_item(
2498 &self,
2499 playlist_id: &str,
2500 item_id: &str,
2501 new_index: u32,
2502 ) -> Result<(), RepoError> {
2503 let playlist_id = playlist_id.to_string();
2504 let item_id = item_id.to_string();
2505
2506 self.db_service
2507 .transaction(move |tx| {
2508 use crate::storage::db_service::{Query, QueryParam};
2509
2510 let items: Vec<(i64, String)> = tx.query_many(
2512 Query::with_params(
2513 "SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order",
2514 vec![QueryParam::String(playlist_id)],
2515 ),
2516 |row| Ok((row.get(0)?, row.get(1)?)),
2517 )?;
2518
2519 let old_idx = items.iter().position(|(_, iid)| iid == &item_id);
2521 if let Some(old_pos) = old_idx {
2522 let mut ids = items;
2523 let entry = ids.remove(old_pos);
2524 let insert_at = (new_index as usize).min(ids.len());
2525 ids.insert(insert_at, entry);
2526
2527 for (i, (entry_id, _)) in ids.iter().enumerate() {
2529 tx.execute(Query::with_params(
2530 "UPDATE playlist_items SET sort_order = ? WHERE id = ?",
2531 vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)],
2532 ))?;
2533 }
2534 }
2535
2536 Ok(())
2537 })
2538 .await
2539 .map_err(|e| RepoError::Database {
2540 message: format!("Failed to move playlist item: {}", e),
2541 })?;
2542
2543 Ok(())
2544 }
2545}
2546
2547#[cfg(test)]
2548mod tests {
2549 #![allow(clippy::await_holding_lock)]
2558
2559 use super::*;
2560 use crate::storage::db_service::RusqliteService;
2561 use rusqlite::Connection;
2562 use std::sync::{Arc, Mutex};
2563
2564 static CATALOG_BROWSE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2571
2572 fn lock_catalog_browse() -> std::sync::MutexGuard<'static, ()> {
2573 use crate::utils::lock::MutexSafe;
2574 CATALOG_BROWSE_LOCK.lock_safe()
2575 }
2576
2577 #[test]
2579 fn test_build_fts_prefix_query() {
2580 assert_eq!(build_fts_prefix_query("Arr").as_deref(), Some("\"Arr\"*"));
2582
2583 assert_eq!(
2586 build_fts_prefix_query("parks rec").as_deref(),
2587 Some("\"parks\" \"rec\"*")
2588 );
2589
2590 for query in ["Bob's Burgers", "Spider-Man", "AC/DC", "Wall-E", "9-1-1"] {
2593 let built = build_fts_prefix_query(query).expect("should build");
2594 assert!(
2595 built.starts_with('"') && built.ends_with("*"),
2596 "{query:?} produced {built:?}"
2597 );
2598 }
2599
2600 assert_eq!(
2603 build_fts_prefix_query("say \"hi\"").as_deref(),
2604 Some("\"say\" \"\"\"hi\"\"\"*")
2605 );
2606
2607 assert_eq!(build_fts_prefix_query(""), None);
2610 assert_eq!(build_fts_prefix_query(" "), None);
2611 assert_eq!(build_fts_prefix_query("-"), None);
2612 }
2613
2614 #[tokio::test]
2619 async fn test_search_empty_query_returns_empty_not_error() {
2620 let db_service = create_test_db();
2621 let repo = OfflineRepository::new(
2622 db_service,
2623 "test-server".to_string(),
2624 "test-user".to_string(),
2625 );
2626
2627 let result = repo.search("", None).await;
2628 assert!(
2629 result.is_ok(),
2630 "empty query must not error: {:?}",
2631 result.err()
2632 );
2633 assert!(result.unwrap().items.is_empty());
2634 }
2635
2636 fn create_test_db() -> Arc<RusqliteService> {
2637 let conn = Connection::open_in_memory().unwrap();
2638
2639 conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
2641
2642 conn.execute_batch(
2644 r#"
2645 CREATE TABLE servers (
2646 id TEXT PRIMARY KEY,
2647 name TEXT NOT NULL,
2648 url TEXT NOT NULL UNIQUE
2649 );
2650
2651 CREATE TABLE items (
2652 id TEXT PRIMARY KEY,
2653 server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
2654 library_id TEXT,
2655 parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
2656 name TEXT NOT NULL,
2657 item_type TEXT NOT NULL,
2658 is_folder INTEGER DEFAULT 0,
2659 overview TEXT,
2660 genres TEXT,
2661 runtime_ticks INTEGER,
2662 production_year INTEGER,
2663 premiere_date TEXT,
2664 community_rating REAL,
2665 official_rating TEXT,
2666 primary_image_tag TEXT,
2667 backdrop_image_tags TEXT,
2668 album_id TEXT,
2669 album_name TEXT,
2670 album_artist TEXT,
2671 artists TEXT,
2672 index_number INTEGER,
2673 series_id TEXT,
2674 series_name TEXT,
2675 season_id TEXT,
2676 season_name TEXT,
2677 parent_index_number INTEGER,
2678 synced_at TEXT,
2679 sort_name TEXT
2680 );
2681
2682 -- Mirrors the real FTS5 index and its triggers (schema.rs migration
2683 -- 001) so search can be exercised in tests at all.
2684 CREATE VIRTUAL TABLE items_fts USING fts5(
2685 name, overview, album_name, album_artist, artists, series_name,
2686 content='items', content_rowid='rowid'
2687 );
2688
2689 CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
2690 INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
2691 VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
2692 END;
2693
2694 CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
2695 INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
2696 VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
2697 END;
2698
2699 CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
2700 INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
2701 VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
2702 INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
2703 VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
2704 END;
2705
2706 CREATE TABLE user_data (
2707 user_id TEXT NOT NULL,
2708 item_id TEXT NOT NULL,
2709 playback_position_ticks INTEGER,
2710 is_played INTEGER,
2711 is_favorite INTEGER,
2712 play_count INTEGER,
2713 last_played_at TEXT,
2714 playback_context_type TEXT,
2715 playback_context_id TEXT,
2716 synced_at TEXT,
2717 pending_sync INTEGER DEFAULT 0,
2718 PRIMARY KEY (user_id, item_id)
2719 );
2720
2721 CREATE TABLE playlists (
2722 id TEXT PRIMARY KEY,
2723 user_id TEXT NOT NULL,
2724 name TEXT NOT NULL,
2725 is_local INTEGER DEFAULT 0,
2726 jellyfin_id TEXT,
2727 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
2728 updated_at TEXT
2729 );
2730
2731 CREATE TABLE playlist_items (
2732 id INTEGER PRIMARY KEY AUTOINCREMENT,
2733 playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
2734 item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
2735 sort_order INTEGER NOT NULL,
2736 added_at TEXT DEFAULT CURRENT_TIMESTAMP,
2737 UNIQUE(playlist_id, item_id)
2738 );
2739
2740 CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
2741
2742 CREATE TABLE downloads (
2743 id INTEGER PRIMARY KEY AUTOINCREMENT,
2744 item_id TEXT NOT NULL,
2745 status TEXT NOT NULL,
2746 file_size INTEGER
2747 );
2748
2749 CREATE TABLE libraries (
2750 id TEXT PRIMARY KEY,
2751 server_id TEXT NOT NULL,
2752 name TEXT NOT NULL,
2753 collection_type TEXT,
2754 image_tag TEXT,
2755 sort_order INTEGER DEFAULT 0,
2756 synced_at TEXT
2757 );
2758
2759 -- Mirrors migration 009 + the migration 022 FTS index.
2760 CREATE TABLE people (
2761 id TEXT PRIMARY KEY,
2762 server_id TEXT NOT NULL,
2763 name TEXT NOT NULL,
2764 overview TEXT,
2765 primary_image_tag TEXT,
2766 premiere_date TEXT,
2767 end_date TEXT,
2768 synced_at TEXT DEFAULT CURRENT_TIMESTAMP
2769 );
2770
2771 CREATE VIRTUAL TABLE people_fts USING fts5(
2772 name, overview, content='people', content_rowid='rowid'
2773 );
2774
2775 CREATE TRIGGER people_ai AFTER INSERT ON people BEGIN
2776 INSERT INTO people_fts(rowid, name, overview)
2777 VALUES (new.rowid, new.name, new.overview);
2778 END;
2779
2780 CREATE TRIGGER people_ad AFTER DELETE ON people BEGIN
2781 INSERT INTO people_fts(people_fts, rowid, name, overview)
2782 VALUES('delete', old.rowid, old.name, old.overview);
2783 END;
2784
2785 CREATE TRIGGER people_au AFTER UPDATE ON people BEGIN
2786 INSERT INTO people_fts(people_fts, rowid, name, overview)
2787 VALUES('delete', old.rowid, old.name, old.overview);
2788 INSERT INTO people_fts(rowid, name, overview)
2789 VALUES (new.rowid, new.name, new.overview);
2790 END;
2791
2792 CREATE TABLE genres (
2793 id TEXT NOT NULL,
2794 server_id TEXT NOT NULL,
2795 library_id TEXT,
2796 name TEXT NOT NULL,
2797 album_count INTEGER,
2798 synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
2799 PRIMARY KEY (server_id, library_id, name)
2800 );
2801 "#,
2802 )
2803 .unwrap();
2804
2805 conn.execute(
2807 "INSERT INTO servers (id, name, url) VALUES ('test-server', 'Test Server', 'http://test')",
2808 [],
2809 ).unwrap();
2810
2811 Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
2812 }
2813
2814 fn create_test_item(id: &str, name: &str, parent_id: Option<&str>) -> MediaItem {
2816 MediaItem {
2817 id: id.to_string(),
2818 name: name.to_string(),
2819 item_type: "Audio".to_string(),
2820 kind: crate::domain::MediaKind::Track,
2821 is_folder: false,
2822 server_id: "test-server".to_string(),
2823 parent_id: parent_id.map(|s| s.to_string()),
2824 library_id: None,
2825 overview: None,
2826 genres: None,
2827 runtime_ticks: None,
2828 duration_ms: None,
2829 production_year: None,
2830 premiere_date: None,
2831 community_rating: None,
2832 official_rating: None,
2833 primary_image_tag: None,
2834 image_id: None,
2835 backdrop_image_tags: None,
2836 parent_backdrop_image_tags: None,
2837 album_id: None,
2838 album_name: None,
2839 album_artist: None,
2840 artists: None,
2841 artist_items: None,
2842 index_number: None,
2843 series_id: None,
2844 series_name: None,
2845 season_id: None,
2846 season_name: None,
2847 parent_index_number: None,
2848 user_data: None,
2849 media_streams: None,
2850 media_sources: None,
2851 people: None,
2852 }
2853 }
2854
2855 #[tokio::test]
2856 async fn test_save_to_cache_with_missing_parent_fk() {
2857 let db_service = create_test_db();
2858
2859 let fk_enabled: i32 = db_service
2861 .query_one(Query::new("PRAGMA foreign_keys"), |row| row.get(0))
2862 .await
2863 .unwrap();
2864 println!("Foreign keys enabled: {}", fk_enabled);
2865 assert_eq!(fk_enabled, 1, "Foreign keys should be enabled");
2866
2867 let repo = OfflineRepository::new(
2868 db_service.clone(),
2869 "test-server".to_string(),
2870 "test-user".to_string(),
2871 );
2872
2873 let items = vec![
2877 create_test_item("track-1", "Track 1", Some("album-1")),
2879 create_test_item("track-2", "Track 2", Some("album-1")),
2880 create_test_item("track-3", "Track 3", Some("album-2")),
2882 create_test_item("track-4", "Track 4", Some("album-2")),
2883 create_test_item("album-1", "Album One", Some("library-123")),
2885 create_test_item("album-2", "Album Two", Some("library-123")),
2886 ];
2887
2888 println!("Attempting to save {} items...", items.len());
2889 for (i, item) in items.iter().enumerate() {
2890 println!(" Item {}: {} (parent: {:?})", i, item.id, item.parent_id);
2891 }
2892
2893 let result = repo.save_to_cache("library-123", &items).await;
2898
2899 match &result {
2901 Ok(count) => {
2902 println!("✓ Saved {} items", count);
2903 assert_eq!(*count, 6);
2904
2905 let all_items: Vec<(String, Option<String>)> = db_service
2907 .query_many(
2908 Query::new("SELECT id, parent_id FROM items ORDER BY id"),
2909 |row| Ok((row.get(0)?, row.get(1)?)),
2910 )
2911 .await
2912 .unwrap();
2913
2914 println!("\nAll items in database:");
2915 for (id, parent) in &all_items {
2916 println!(" {} -> parent: {:?}", id, parent);
2917 }
2918
2919 let track1_parent: Option<String> = db_service
2921 .query_optional(
2922 Query::with_params(
2923 "SELECT parent_id FROM items WHERE id = ?",
2924 vec![QueryParam::String("track-1".to_string())],
2925 ),
2926 |row| row.get(0),
2927 )
2928 .await
2929 .unwrap()
2930 .flatten();
2931
2932 println!("\ntrack-1 parent_id in DB: {:?}", track1_parent);
2933 println!("track-1 expected parent_id: Some(\"album-1\")");
2934
2935 assert_eq!(
2937 track1_parent,
2938 Some("album-1".to_string()),
2939 "track-1 should have parent_id='album-1'"
2940 );
2941
2942 let album1_parent: Option<String> = db_service
2944 .query_optional(
2945 Query::with_params(
2946 "SELECT parent_id FROM items WHERE id = ?",
2947 vec![QueryParam::String("album-1".to_string())],
2948 ),
2949 |row| row.get(0),
2950 )
2951 .await
2952 .unwrap()
2953 .flatten();
2954
2955 assert_eq!(
2956 album1_parent,
2957 Some("library-123".to_string()),
2958 "album-1 should have parent_id='library-123'"
2959 );
2960 }
2961 Err(e) => panic!("Unexpected error: {:?}", e),
2962 }
2963 }
2964
2965 #[tokio::test]
2966 async fn test_save_to_cache_simple_case() {
2967 let db_service = create_test_db();
2968 let repo = OfflineRepository::new(
2969 db_service.clone(),
2970 "test-server".to_string(),
2971 "test-user".to_string(),
2972 );
2973
2974 let items = vec![
2976 create_test_item("item-1", "Item 1", Some("parent-123")),
2977 create_test_item("item-2", "Item 2", Some("parent-123")),
2978 create_test_item("item-3", "Item 3", Some("parent-123")),
2979 ];
2980
2981 let result = repo.save_to_cache("parent-123", &items).await;
2982 assert!(result.is_ok(), "Simple case should work: {:?}", result);
2983 assert_eq!(result.unwrap(), 3);
2984 }
2985
2986 #[tokio::test]
2994 async fn test_get_item_album_available_via_album_id_link() {
2995 use crate::storage::db_service::DatabaseService;
2996 let db_service = create_test_db();
2997
2998 for sql in [
2999 "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
3001 VALUES ('album-1', 'test-server', 'Hadestown', 'MusicAlbum', NULL, NULL)",
3002 "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
3004 VALUES ('track-1', 'test-server', 'Wait For Me', 'Audio', 'album-1', NULL)",
3005 "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
3006 ] {
3007 db_service.execute(Query::new(sql)).await.unwrap();
3008 }
3009
3010 let repo = OfflineRepository::new(
3011 db_service.clone(),
3012 "test-server".to_string(),
3013 "test-user".to_string(),
3014 );
3015
3016 assert!(
3018 repo.get_item("track-1").await.is_ok(),
3019 "downloaded track should be available offline"
3020 );
3021
3022 let album = repo.get_item("album-1").await;
3025 assert!(
3026 album.is_ok(),
3027 "album with an album_id-linked downloaded track should be available offline, got {:?}",
3028 album.err()
3029 );
3030 assert_eq!(album.unwrap().id, "album-1");
3031
3032 let tracks = repo.get_items("album-1", None).await.unwrap();
3036 assert_eq!(
3037 tracks.items.len(),
3038 1,
3039 "get_items(album_id) should return the track"
3040 );
3041 assert_eq!(tracks.items[0].id, "track-1");
3042 }
3043
3044 #[tokio::test]
3057 async fn test_get_items_toggle_gates_synced_catalog() {
3058 use crate::storage::db_service::DatabaseService;
3059 let _guard = lock_catalog_browse();
3060 let db_service = create_test_db();
3061
3062 for sql in [
3063 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3065 VALUES ('movie-dl', 'test-server', 'Downloaded', 'Movie', 'lib-1', '2026-01-01')",
3066 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3067 VALUES ('movie-cat', 'test-server', 'CatalogOnly', 'Movie', 'lib-1', '2026-01-01')",
3068 "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
3070 "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
3072 ] {
3073 db_service.execute(Query::new(sql)).await.unwrap();
3074 }
3075
3076 let repo = OfflineRepository::new(
3077 db_service.clone(),
3078 "test-server".to_string(),
3079 "test-user".to_string(),
3080 );
3081 let opts = Some(GetItemsOptions {
3082 include_item_types: Some(vec!["Movie".to_string()]),
3083 ..Default::default()
3084 });
3085
3086 set_include_catalog_browse(false);
3088 let local_only = repo.get_items("lib-1", opts.clone()).await.unwrap();
3089 let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
3090 assert_eq!(
3091 ids,
3092 vec!["movie-dl"],
3093 "toggle off should show downloaded media only"
3094 );
3095
3096 set_include_catalog_browse(true);
3098 let full_catalog = repo.get_items("lib-1", opts).await.unwrap();
3099 let mut ids: Vec<&str> = full_catalog.items.iter().map(|i| i.id.as_str()).collect();
3100 ids.sort();
3101 assert_eq!(
3102 ids,
3103 vec!["movie-cat", "movie-dl"],
3104 "toggle on should reveal the full catalog"
3105 );
3106
3107 set_include_catalog_browse(true);
3109 }
3110
3111 #[tokio::test]
3118 async fn test_search_includes_cached_people() {
3119 use crate::storage::db_service::DatabaseService;
3120 let _guard = lock_catalog_browse();
3121 let db_service = create_test_db();
3122
3123 for sql in [
3124 "INSERT INTO people (id, server_id, name, overview) \
3125 VALUES ('p1', 'test-server', 'Tilda Swinton', 'Actor')",
3126 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3129 VALUES ('m1', 'test-server', 'Tilda the Movie', 'Movie', '2026-01-01')",
3130 ] {
3131 db_service.execute(Query::new(sql)).await.unwrap();
3132 }
3133
3134 let repo = OfflineRepository::new(
3135 db_service.clone(),
3136 "test-server".to_string(),
3137 "test-user".to_string(),
3138 );
3139 set_include_catalog_browse(true);
3140
3141 let all = repo.search("Tilda", None).await.unwrap();
3143 let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
3144 ids.sort();
3145 assert_eq!(ids, vec!["m1", "p1"], "unscoped search must include people");
3146
3147 let person = all.items.iter().find(|i| i.id == "p1").unwrap();
3148 assert_eq!(person.item_type, "Person");
3149 assert_eq!(person.kind, crate::domain::MediaKind::Person);
3150
3151 let scoped = repo
3153 .search(
3154 "Tilda",
3155 Some(SearchOptions {
3156 include_item_types: Some(vec!["Movie".to_string()]),
3157 ..Default::default()
3158 }),
3159 )
3160 .await
3161 .unwrap();
3162 let ids: Vec<&str> = scoped.items.iter().map(|i| i.id.as_str()).collect();
3163 assert_eq!(ids, vec!["m1"], "a scoped search must not leak people in");
3164
3165 set_include_catalog_browse(true);
3166 }
3167
3168 #[tokio::test]
3175 async fn test_prune_stale_catalog() {
3176 use crate::storage::db_service::DatabaseService;
3177 let db_service = create_test_db();
3178
3179 let old = "2026-01-01T00:00:00+00:00";
3181 let new = "2026-06-01T00:00:00+00:00";
3182 let cutoff = "2026-03-01T00:00:00+00:00";
3183
3184 for sql in [
3185 "INSERT INTO servers (id, name, url) \
3187 VALUES ('other-server', 'Other', 'http://other')"
3188 .to_string(),
3189 format!(
3191 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3192 VALUES ('keep-fresh', 'test-server', 'Fresh', 'Movie', '{new}')"
3193 ),
3194 format!(
3196 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3197 VALUES ('gone', 'test-server', 'Vanished', 'Movie', '{old}')"
3198 ),
3199 format!(
3201 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3202 VALUES ('keep-dl', 'test-server', 'Downloaded', 'Movie', '{old}')"
3203 ),
3204 "INSERT INTO downloads (item_id, status) VALUES ('keep-dl', 'completed')".to_string(),
3205 format!(
3207 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3208 VALUES ('keep-album', 'test-server', 'Album', 'MusicAlbum', '{old}')"
3209 ),
3210 format!(
3211 "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
3212 VALUES ('keep-track', 'test-server', 'Track', 'Audio', 'keep-album', '{old}')"
3213 ),
3214 "INSERT INTO downloads (item_id, status) VALUES ('keep-track', 'completed')"
3215 .to_string(),
3216 format!(
3219 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3220 VALUES ('keep-artist', 'test-server', 'Artist', 'MusicArtist', '{old}')"
3221 ),
3222 format!(
3224 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3225 VALUES ('keep-other', 'other-server', 'Elsewhere', 'Movie', '{old}')"
3226 ),
3227 ] {
3228 db_service.execute(Query::new(&sql)).await.unwrap();
3229 }
3230
3231 let repo = OfflineRepository::new(
3232 db_service.clone(),
3233 "test-server".to_string(),
3234 "test-user".to_string(),
3235 );
3236
3237 let crawled_types = vec![
3238 "Movie".to_string(),
3239 "MusicAlbum".to_string(),
3240 "Audio".to_string(),
3241 ];
3242 let removed = repo
3243 .prune_stale_catalog(cutoff, &crawled_types)
3244 .await
3245 .unwrap();
3246 assert_eq!(removed, 1, "only the vanished movie should be swept");
3247
3248 let mut surviving: Vec<String> = db_service
3249 .query_many(Query::new("SELECT id FROM items"), |row| row.get(0))
3250 .await
3251 .unwrap();
3252 surviving.sort();
3253 assert_eq!(
3254 surviving,
3255 vec![
3256 "keep-album",
3257 "keep-artist",
3258 "keep-dl",
3259 "keep-fresh",
3260 "keep-other",
3261 "keep-track",
3262 ]
3263 );
3264
3265 assert_eq!(repo.prune_stale_catalog(cutoff, &[]).await.unwrap(), 0);
3267 }
3268
3269 #[tokio::test]
3284 async fn test_repeated_cache_does_not_duplicate_fts_entries() {
3285 use crate::storage::db_service::DatabaseService;
3286 let db_service = create_test_db();
3287 let repo = OfflineRepository::new(
3288 db_service.clone(),
3289 "test-server".to_string(),
3290 "test-user".to_string(),
3291 );
3292
3293 let items = vec![create_test_item("track-1", "Wait For Me", None)];
3294
3295 for _ in 0..3 {
3298 repo.save_to_cache("parent-1", &items).await.unwrap();
3299 }
3300
3301 let item_rows: i64 = db_service
3304 .query_one(
3305 Query::new("SELECT COUNT(*) FROM items WHERE id = 'track-1'"),
3306 |row| row.get(0),
3307 )
3308 .await
3309 .unwrap();
3310 assert_eq!(item_rows, 1, "three passes must leave one item row");
3311
3312 let fts_hits: i64 = db_service
3313 .query_one(
3314 Query::with_params(
3315 "SELECT COUNT(*) FROM items_fts WHERE items_fts MATCH ?",
3316 vec![QueryParam::String("\"Wait\"*".to_string())],
3317 ),
3318 |row| row.get(0),
3319 )
3320 .await
3321 .unwrap();
3322 assert_eq!(
3323 fts_hits, 1,
3324 "the FTS index must hold one entry per item, not one per sync pass"
3325 );
3326 }
3327
3328 #[tokio::test]
3337 async fn test_search_toggle_gates_synced_catalog() {
3338 use crate::storage::db_service::DatabaseService;
3339 let _guard = lock_catalog_browse();
3340 let db_service = create_test_db();
3341
3342 for sql in [
3343 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3345 VALUES ('movie-dl', 'test-server', 'Arrival', 'Movie', 'lib-1', '2026-01-01')",
3346 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3347 VALUES ('movie-cat', 'test-server', 'Arrakis', 'Movie', 'lib-1', '2026-01-01')",
3348 "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
3350 ] {
3351 db_service.execute(Query::new(sql)).await.unwrap();
3352 }
3353
3354 let repo = OfflineRepository::new(
3355 db_service.clone(),
3356 "test-server".to_string(),
3357 "test-user".to_string(),
3358 );
3359
3360 set_include_catalog_browse(true);
3363 let full = repo.search("Arr", None).await.unwrap();
3364 let mut ids: Vec<&str> = full.items.iter().map(|i| i.id.as_str()).collect();
3365 ids.sort();
3366 assert_eq!(
3367 ids,
3368 vec!["movie-cat", "movie-dl"],
3369 "with catalog browse on, search must cover synced-but-not-downloaded items"
3370 );
3371
3372 set_include_catalog_browse(false);
3374 let local_only = repo.search("Arr", None).await.unwrap();
3375 let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
3376 assert_eq!(
3377 ids,
3378 vec!["movie-dl"],
3379 "with catalog browse off, search stays downloads-only"
3380 );
3381
3382 set_include_catalog_browse(true);
3384 }
3385
3386 #[tokio::test]
3393 async fn test_search_type_filter_is_parameterised() {
3394 use crate::storage::db_service::DatabaseService;
3395 let _guard = lock_catalog_browse();
3396 let db_service = create_test_db();
3397
3398 db_service
3399 .execute(Query::new(
3400 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3401 VALUES ('m1', 'test-server', 'Arrival', 'Movie', '2026-01-01')",
3402 ))
3403 .await
3404 .unwrap();
3405
3406 let repo = OfflineRepository::new(
3407 db_service.clone(),
3408 "test-server".to_string(),
3409 "test-user".to_string(),
3410 );
3411 set_include_catalog_browse(true);
3412
3413 let opts = SearchOptions {
3415 include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
3416 ..Default::default()
3417 };
3418 let result = repo.search("Arr", Some(opts)).await;
3419 assert!(
3420 result.is_ok(),
3421 "a quote in an item type must not break the query: {:?}",
3422 result.err()
3423 );
3424 assert!(
3425 result.unwrap().items.is_empty(),
3426 "an injected type filter must not widen the result set"
3427 );
3428
3429 let opts = SearchOptions {
3431 include_item_types: Some(vec!["Movie".to_string()]),
3432 ..Default::default()
3433 };
3434 assert_eq!(repo.search("Arr", Some(opts)).await.unwrap().items.len(), 1);
3435 }
3436
3437 #[tokio::test]
3442 async fn test_get_item_tv_available_via_season_series_link() {
3443 use crate::storage::db_service::DatabaseService;
3444 let db_service = create_test_db();
3445
3446 for sql in [
3447 "INSERT INTO items (id, server_id, name, item_type, parent_id) \
3448 VALUES ('series-1', 'test-server', 'Gilmore Girls', 'Series', NULL)",
3449 "INSERT INTO items (id, server_id, name, item_type, series_id, parent_id) \
3450 VALUES ('season-1', 'test-server', 'Season 1', 'Season', 'series-1', NULL)",
3451 "INSERT INTO items (id, server_id, name, item_type, season_id, series_id, parent_id) \
3453 VALUES ('ep-1', 'test-server', 'Pilot', 'Episode', 'season-1', 'series-1', NULL)",
3454 "INSERT INTO downloads (item_id, status) VALUES ('ep-1', 'completed')",
3455 ] {
3456 db_service.execute(Query::new(sql)).await.unwrap();
3457 }
3458
3459 let repo = OfflineRepository::new(
3460 db_service.clone(),
3461 "test-server".to_string(),
3462 "test-user".to_string(),
3463 );
3464
3465 assert!(
3466 repo.get_item("ep-1").await.is_ok(),
3467 "downloaded episode available offline"
3468 );
3469 assert!(
3470 repo.get_item("season-1").await.is_ok(),
3471 "season with a season_id-linked downloaded episode should be available offline"
3472 );
3473 assert!(
3474 repo.get_item("series-1").await.is_ok(),
3475 "series with a series_id-linked downloaded episode should be available offline"
3476 );
3477
3478 let season_items = repo.get_items("season-1", None).await.unwrap();
3480 assert!(
3481 season_items.items.iter().any(|i| i.id == "ep-1"),
3482 "get_items(season_id) should return the episode"
3483 );
3484
3485 let series_items = repo.get_items("series-1", None).await.unwrap();
3487 assert!(
3488 series_items.items.iter().any(|i| i.id == "ep-1"),
3489 "get_items(series_id) should surface the downloaded episode"
3490 );
3491 }
3492
3493 #[tokio::test]
3498 async fn test_libraries_cache_roundtrip_available_offline() {
3499 let db_service = create_test_db();
3500 let repo = OfflineRepository::new(
3501 db_service.clone(),
3502 "test-server".to_string(),
3503 "test-user".to_string(),
3504 );
3505
3506 assert!(repo.get_libraries().await.unwrap().is_empty());
3509
3510 let server_libs = vec![
3512 Library::new("music".into(), "Music".into(), "music".into(), None),
3513 Library::new(
3514 "movies".into(),
3515 "Movies".into(),
3516 "movies".into(),
3517 Some("tag".into()),
3518 ),
3519 ];
3520 let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
3521 assert_eq!(saved, 2);
3522
3523 let offline_libs = repo.get_libraries().await.unwrap();
3525 let names: Vec<&str> = offline_libs.iter().map(|l| l.name.as_str()).collect();
3526 assert_eq!(
3527 names,
3528 vec!["Music", "Movies"],
3529 "cached libraries available offline in sort order"
3530 );
3531
3532 repo.save_libraries_to_cache(&server_libs).await.unwrap();
3534 assert_eq!(repo.get_libraries().await.unwrap().len(), 2);
3535 }
3536
3537 #[tokio::test]
3544 async fn test_genres_cache_roundtrip_scoped_by_library() {
3545 let db_service = create_test_db();
3546 let repo = OfflineRepository::new(
3547 db_service.clone(),
3548 "test-server".to_string(),
3549 "test-user".to_string(),
3550 );
3551
3552 assert!(repo.get_genres(Some("music-lib")).await.unwrap().is_empty());
3554
3555 let server_genres = vec![
3557 Genre {
3558 id: "g1".into(),
3559 name: "Rock".into(),
3560 album_count: Some(42),
3561 },
3562 Genre {
3563 id: "g2".into(),
3564 name: "Jazz".into(),
3565 album_count: Some(17),
3566 },
3567 Genre {
3568 id: "g3".into(),
3569 name: "Ambient".into(),
3570 album_count: None,
3571 },
3572 ];
3573 let saved = repo
3574 .save_genres_to_cache(Some("music-lib"), &server_genres)
3575 .await
3576 .unwrap();
3577 assert_eq!(saved, 3);
3578
3579 let mut offline_genres = repo.get_genres(Some("music-lib")).await.unwrap();
3581 offline_genres.sort_by(|a, b| a.name.cmp(&b.name));
3582 let names: Vec<&str> = offline_genres.iter().map(|g| g.name.as_str()).collect();
3583 assert_eq!(names, vec!["Ambient", "Jazz", "Rock"]);
3584 let rock = offline_genres.iter().find(|g| g.name == "Rock").unwrap();
3585 assert_eq!(rock.album_count, Some(42));
3586
3587 assert!(repo.get_genres(Some("other-lib")).await.unwrap().is_empty());
3589
3590 let updated = vec![Genre {
3592 id: "g1".into(),
3593 name: "Rock".into(),
3594 album_count: Some(50),
3595 }];
3596 repo.save_genres_to_cache(Some("music-lib"), &updated)
3597 .await
3598 .unwrap();
3599 let after = repo.get_genres(Some("music-lib")).await.unwrap();
3600 assert_eq!(after.len(), 1, "stale genres removed on refresh");
3601 assert_eq!(after[0].album_count, Some(50), "counts updated on refresh");
3602 }
3603
3604 async fn seed_items(repo: &OfflineRepository, ids: &[&str]) {
3608 let items: Vec<MediaItem> = ids
3609 .iter()
3610 .map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1")))
3611 .collect();
3612 repo.save_to_cache("library-1", &items).await.unwrap();
3613 }
3614
3615 async fn insert_item(
3618 db: &Arc<RusqliteService>,
3619 id: &str,
3620 item_type: &str,
3621 album_id: Option<&str>,
3622 series_id: Option<&str>,
3623 season_id: Option<&str>,
3624 ) {
3625 db.execute(Query::with_params(
3626 "INSERT INTO items (id, server_id, name, item_type, album_id, series_id, season_id, synced_at)
3627 VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, ?6, '2024-01-01')",
3628 vec![
3629 QueryParam::String(id.to_string()),
3630 QueryParam::String(format!("Name {id}")),
3631 QueryParam::String(item_type.to_string()),
3632 album_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
3633 series_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
3634 season_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
3635 ],
3636 ))
3637 .await
3638 .unwrap();
3639 }
3640
3641 async fn insert_library_item(
3644 db: &Arc<RusqliteService>,
3645 id: &str,
3646 item_type: &str,
3647 library_id: &str,
3648 album_id: Option<&str>,
3649 ) {
3650 db.execute(Query::with_params(
3651 "INSERT INTO items (id, server_id, library_id, name, item_type, album_id, synced_at)
3652 VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, '2024-01-01')",
3653 vec![
3654 QueryParam::String(id.to_string()),
3655 QueryParam::String(library_id.to_string()),
3656 QueryParam::String(format!("Name {id}")),
3657 QueryParam::String(item_type.to_string()),
3658 album_id
3659 .map(|s| QueryParam::String(s.to_string()))
3660 .unwrap_or(QueryParam::Null),
3661 ],
3662 ))
3663 .await
3664 .unwrap();
3665 }
3666
3667 async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
3668 db.execute(Query::with_params(
3669 "INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
3670 vec![
3671 QueryParam::String(item_id.to_string()),
3672 QueryParam::Int64(file_size),
3673 ],
3674 ))
3675 .await
3676 .unwrap();
3677 }
3678
3679 async fn seed_library(db: &Arc<RusqliteService>, id: &str, collection_type: &str) {
3680 db.execute(Query::with_params(
3681 "INSERT INTO libraries (id, server_id, name, collection_type, sort_order)
3682 VALUES (?1, 'test-server', ?2, ?3, 0)",
3683 vec![
3684 QueryParam::String(id.to_string()),
3685 QueryParam::String(format!("Lib {id}")),
3686 QueryParam::String(collection_type.to_string()),
3687 ],
3688 ))
3689 .await
3690 .unwrap();
3691 }
3692
3693 fn make_repo(db: &Arc<RusqliteService>) -> OfflineRepository {
3694 OfflineRepository::new(
3695 db.clone(),
3696 "test-server".to_string(),
3697 "test-user".to_string(),
3698 )
3699 }
3700
3701 #[tokio::test]
3708 async fn test_get_latest_items_collapses_tracks_into_their_album() {
3709 let db = create_test_db();
3710 insert_library_item(&db, "album-1", "MusicAlbum", "lib-1", None).await;
3711 for track in ["track-1", "track-2", "track-3"] {
3712 insert_library_item(&db, track, "Audio", "lib-1", Some("album-1")).await;
3713 seed_completed_download(&db, track, 1000).await;
3714 }
3715 insert_library_item(&db, "movie-1", "Movie", "lib-1", None).await;
3717 seed_completed_download(&db, "movie-1", 2000).await;
3718
3719 let repo = make_repo(&db);
3720 let latest = repo.get_latest_items("lib-1", Some(16)).await.unwrap();
3721 let ids: Vec<&str> = latest.iter().map(|i| i.id.as_str()).collect();
3722
3723 assert!(
3724 !ids.iter().any(|id| id.starts_with("track-")),
3725 "individual tracks must collapse into their album, got: {ids:?}"
3726 );
3727 assert!(ids.contains(&"album-1"), "the album itself is listed");
3728 assert!(ids.contains(&"movie-1"), "containerless items still listed");
3729 }
3730
3731 #[tokio::test]
3736 async fn test_get_downloaded_items_returns_leaf_and_container() {
3737 let db = create_test_db();
3738 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
3739 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
3740 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
3741 seed_completed_download(&db, "track-1", 1000).await;
3743
3744 let repo = make_repo(&db);
3745
3746 let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
3748 let ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
3749 assert_eq!(ids, vec!["track-1"], "only the downloaded track is listed");
3750 }
3751
3752 #[tokio::test]
3758 async fn test_get_downloaded_items_library_lists_albums_not_tracks() {
3759 let db = create_test_db();
3760 seed_library(&db, "music-lib", "music").await;
3761 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
3762 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
3764 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
3765 seed_completed_download(&db, "track-1", 1000).await;
3766 seed_completed_download(&db, "track-2", 1000).await;
3767
3768 let repo = make_repo(&db);
3769
3770 let at_library = repo.get_downloaded_items("music-lib", None).await.unwrap();
3772 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
3773 assert_eq!(
3774 ids,
3775 vec!["album-1"],
3776 "library browse lists the album container, not its tracks"
3777 );
3778
3779 let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
3781 let mut track_ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
3782 track_ids.sort();
3783 assert_eq!(track_ids, vec!["track-1", "track-2"]);
3784 }
3785
3786 #[tokio::test]
3798 async fn test_get_downloaded_items_library_does_not_mix_media_types() {
3799 let db = create_test_db();
3800 seed_library(&db, "music-lib", "music").await;
3801 seed_library(&db, "movie-lib", "movies").await;
3802 seed_library(&db, "tv-lib", "tvshows").await;
3803
3804 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
3805 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
3806 insert_item(&db, "movie-1", "Movie", None, None, None).await;
3807 insert_item(&db, "series-1", "Series", None, None, None).await;
3808 insert_item(&db, "episode-1", "Episode", None, Some("series-1"), None).await;
3809
3810 seed_completed_download(&db, "track-1", 1000).await;
3811 seed_completed_download(&db, "movie-1", 2000).await;
3812 seed_completed_download(&db, "episode-1", 3000).await;
3813
3814 let repo = make_repo(&db);
3815
3816 let music: Vec<String> = repo
3817 .get_downloaded_items("music-lib", None)
3818 .await
3819 .unwrap()
3820 .items
3821 .iter()
3822 .map(|i| i.id.clone())
3823 .collect();
3824 assert_eq!(
3825 music,
3826 vec!["album-1"],
3827 "the music library must not list films or series; got {:?}",
3828 music
3829 );
3830
3831 let movies: Vec<String> = repo
3832 .get_downloaded_items("movie-lib", None)
3833 .await
3834 .unwrap()
3835 .items
3836 .iter()
3837 .map(|i| i.id.clone())
3838 .collect();
3839 assert_eq!(
3840 movies,
3841 vec!["movie-1"],
3842 "the movie library must not list albums or series; got {:?}",
3843 movies
3844 );
3845
3846 let tv: Vec<String> = repo
3847 .get_downloaded_items("tv-lib", None)
3848 .await
3849 .unwrap()
3850 .items
3851 .iter()
3852 .map(|i| i.id.clone())
3853 .collect();
3854 assert_eq!(
3855 tv,
3856 vec!["series-1"],
3857 "the TV library must not list albums or films; got {:?}",
3858 tv
3859 );
3860 }
3861
3862 #[tokio::test]
3868 async fn test_get_downloaded_items_library_lists_series_not_episodes() {
3869 let db = create_test_db();
3870 seed_library(&db, "tv-lib", "tvshows").await;
3871 insert_item(&db, "series-1", "Series", None, None, None).await;
3872 insert_item(&db, "season-1", "Season", None, Some("series-1"), None).await;
3874 insert_item(
3875 &db,
3876 "ep-1",
3877 "Episode",
3878 None,
3879 Some("series-1"),
3880 Some("season-1"),
3881 )
3882 .await;
3883 seed_completed_download(&db, "ep-1", 4000).await;
3884
3885 let repo = make_repo(&db);
3886
3887 let at_library = repo.get_downloaded_items("tv-lib", None).await.unwrap();
3889 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
3890 assert_eq!(
3891 ids,
3892 vec!["series-1"],
3893 "TV library browse lists the series, not seasons/episodes"
3894 );
3895
3896 let in_series = repo.get_downloaded_items("series-1", None).await.unwrap();
3898 assert!(
3899 in_series.items.iter().any(|i| i.id == "season-1"),
3900 "series drill returns the season"
3901 );
3902 let in_season = repo.get_downloaded_items("season-1", None).await.unwrap();
3903 assert!(
3904 in_season.items.iter().any(|i| i.id == "ep-1"),
3905 "season drill returns the episode"
3906 );
3907 }
3908
3909 #[tokio::test]
3914 async fn test_get_downloaded_items_library_keeps_orphan_leaves() {
3915 let db = create_test_db();
3916 seed_library(&db, "movie-lib", "movies").await;
3917 insert_item(&db, "movie-1", "Movie", None, None, None).await;
3918 seed_completed_download(&db, "movie-1", 5000).await;
3919
3920 let repo = make_repo(&db);
3921 let at_library = repo.get_downloaded_items("movie-lib", None).await.unwrap();
3922 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
3923 assert_eq!(
3924 ids,
3925 vec!["movie-1"],
3926 "a downloaded movie with no container shows"
3927 );
3928 }
3929
3930 #[tokio::test]
3935 async fn test_get_downloaded_items_empty_is_authoritative() {
3936 let db = create_test_db();
3937 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
3938 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
3939 set_include_catalog_browse(true);
3941 let repo = make_repo(&db);
3942
3943 let result = repo.get_downloaded_items("album-1", None).await.unwrap();
3944 assert!(
3945 result.items.is_empty(),
3946 "empty downloaded browse returns no items even with catalog-browse on"
3947 );
3948 }
3949
3950 #[tokio::test]
3954 async fn test_get_downloaded_libraries_omits_empty() {
3955 let db = create_test_db();
3956 seed_library(&db, "music-lib", "music").await;
3957 seed_library(&db, "movie-lib", "movies").await;
3958 insert_item(&db, "track-1", "Audio", None, None, None).await;
3959 seed_completed_download(&db, "track-1", 500).await;
3960
3961 let repo = make_repo(&db);
3962 let libs = repo.get_downloaded_libraries().await.unwrap();
3963 let ids: Vec<&str> = libs.iter().map(|l| l.id.as_str()).collect();
3964 assert_eq!(
3965 ids,
3966 vec!["music-lib"],
3967 "movie library with no downloads omitted"
3968 );
3969 }
3970
3971 #[tokio::test]
3976 async fn test_download_disk_usage_aggregates_containers() {
3977 let db = create_test_db();
3978 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
3979 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
3980 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
3981 seed_completed_download(&db, "track-1", 1000).await;
3982 seed_completed_download(&db, "track-2", 2000).await;
3983
3984 let repo = make_repo(&db);
3985 let usage = repo.get_download_disk_usage().await.unwrap();
3986
3987 assert_eq!(usage.item_count, 2, "two leaf downloads");
3988 assert_eq!(
3989 usage.device_total_bytes, 3000,
3990 "device total is the leaf sum"
3991 );
3992 assert_eq!(usage.sizes.get("track-1"), Some(&1000));
3993 assert_eq!(
3994 usage.sizes.get("album-1"),
3995 Some(&3000),
3996 "container = sum of children"
3997 );
3998 assert_eq!(
4000 usage.partial_containers.get("album-1"),
4001 None,
4002 "fully downloaded album is not partial"
4003 );
4004 let leaf_sum: i64 = ["track-1", "track-2"]
4006 .iter()
4007 .map(|id| usage.sizes[*id])
4008 .sum();
4009 assert_eq!(leaf_sum, usage.device_total_bytes);
4010 }
4011
4012 #[tokio::test]
4015 async fn test_download_disk_usage_flags_partial_container() {
4016 let db = create_test_db();
4017 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4018 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4019 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
4020 seed_completed_download(&db, "track-1", 1000).await;
4022
4023 let repo = make_repo(&db);
4024 let usage = repo.get_download_disk_usage().await.unwrap();
4025 assert_eq!(
4026 usage.partial_containers.get("album-1"),
4027 Some(&true),
4028 "album with a missing child is partial"
4029 );
4030 }
4031
4032 #[tokio::test]
4033 async fn test_playlist_create_empty() {
4034 let db_service = create_test_db();
4035 let repo = OfflineRepository::new(
4036 db_service.clone(),
4037 "test-server".to_string(),
4038 "test-user".to_string(),
4039 );
4040
4041 let result = repo.create_playlist("My Playlist", &[]).await;
4042 assert!(result.is_ok());
4043 let created = result.unwrap();
4044 assert!(
4045 !created.id.is_empty(),
4046 "Should return a non-empty playlist ID"
4047 );
4048
4049 let name: String = db_service
4051 .query_one(
4052 Query::with_params(
4053 "SELECT name FROM playlists WHERE id = ?",
4054 vec![QueryParam::String(created.id.clone())],
4055 ),
4056 |row| row.get(0),
4057 )
4058 .await
4059 .unwrap();
4060 assert_eq!(name, "My Playlist");
4061 }
4062
4063 #[tokio::test]
4064 async fn test_playlist_create_with_items() {
4065 let db_service = create_test_db();
4066 let repo = OfflineRepository::new(
4067 db_service.clone(),
4068 "test-server".to_string(),
4069 "test-user".to_string(),
4070 );
4071 seed_items(&repo, &["t1", "t2", "t3"]).await;
4072
4073 let created = repo
4074 .create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()])
4075 .await
4076 .unwrap();
4077
4078 let items = repo.get_playlist_items(&created.id).await.unwrap();
4079 assert_eq!(items.len(), 3);
4080 assert_eq!(items[0].item.id, "t1");
4081 assert_eq!(items[1].item.id, "t2");
4082 assert_eq!(items[2].item.id, "t3");
4083 }
4084
4085 #[tokio::test]
4086 async fn test_playlist_delete() {
4087 let db_service = create_test_db();
4088 let repo = OfflineRepository::new(
4089 db_service.clone(),
4090 "test-server".to_string(),
4091 "test-user".to_string(),
4092 );
4093 seed_items(&repo, &["t1"]).await;
4094
4095 let created = repo
4096 .create_playlist("To Delete", &["t1".into()])
4097 .await
4098 .unwrap();
4099
4100 repo.delete_playlist(&created.id).await.unwrap();
4102
4103 let count: i32 = db_service
4105 .query_one(
4106 Query::with_params(
4107 "SELECT COUNT(*) FROM playlists WHERE id = ?",
4108 vec![QueryParam::String(created.id.clone())],
4109 ),
4110 |row| row.get(0),
4111 )
4112 .await
4113 .unwrap();
4114 assert_eq!(count, 0);
4115
4116 let item_count: i32 = db_service
4118 .query_one(
4119 Query::with_params(
4120 "SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?",
4121 vec![QueryParam::String(created.id)],
4122 ),
4123 |row| row.get(0),
4124 )
4125 .await
4126 .unwrap();
4127 assert_eq!(item_count, 0);
4128 }
4129
4130 #[tokio::test]
4131 async fn test_playlist_rename() {
4132 let db_service = create_test_db();
4133 let repo = OfflineRepository::new(
4134 db_service.clone(),
4135 "test-server".to_string(),
4136 "test-user".to_string(),
4137 );
4138
4139 let created = repo.create_playlist("Original Name", &[]).await.unwrap();
4140 repo.rename_playlist(&created.id, "New Name").await.unwrap();
4141
4142 let name: String = db_service
4143 .query_one(
4144 Query::with_params(
4145 "SELECT name FROM playlists WHERE id = ?",
4146 vec![QueryParam::String(created.id)],
4147 ),
4148 |row| row.get(0),
4149 )
4150 .await
4151 .unwrap();
4152 assert_eq!(name, "New Name");
4153 }
4154
4155 #[tokio::test]
4156 async fn test_playlist_get_items_preserves_order() {
4157 let db_service = create_test_db();
4158 let repo = OfflineRepository::new(
4159 db_service.clone(),
4160 "test-server".to_string(),
4161 "test-user".to_string(),
4162 );
4163 seed_items(&repo, &["a", "b", "c"]).await;
4164
4165 let created = repo
4166 .create_playlist("Ordered", &["c".into(), "a".into(), "b".into()])
4167 .await
4168 .unwrap();
4169 let items = repo.get_playlist_items(&created.id).await.unwrap();
4170
4171 assert_eq!(items.len(), 3);
4172 assert_eq!(items[0].item.id, "c");
4174 assert_eq!(items[1].item.id, "a");
4175 assert_eq!(items[2].item.id, "b");
4176 assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id);
4178 assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id);
4179 }
4180
4181 #[tokio::test]
4182 async fn test_playlist_get_items_empty_playlist() {
4183 let db_service = create_test_db();
4184 let repo = OfflineRepository::new(
4185 db_service.clone(),
4186 "test-server".to_string(),
4187 "test-user".to_string(),
4188 );
4189
4190 let created = repo.create_playlist("Empty", &[]).await.unwrap();
4191 let items = repo.get_playlist_items(&created.id).await.unwrap();
4192 assert!(items.is_empty());
4193 }
4194
4195 #[tokio::test]
4196 async fn test_playlist_add_items() {
4197 let db_service = create_test_db();
4198 let repo = OfflineRepository::new(
4199 db_service.clone(),
4200 "test-server".to_string(),
4201 "test-user".to_string(),
4202 );
4203 seed_items(&repo, &["t1", "t2", "t3"]).await;
4204
4205 let created = repo
4206 .create_playlist("Addable", &["t1".into()])
4207 .await
4208 .unwrap();
4209
4210 repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()])
4212 .await
4213 .unwrap();
4214
4215 let items = repo.get_playlist_items(&created.id).await.unwrap();
4216 assert_eq!(items.len(), 3);
4217 assert_eq!(items[0].item.id, "t1");
4218 assert_eq!(items[1].item.id, "t2");
4219 assert_eq!(items[2].item.id, "t3");
4220 }
4221
4222 #[tokio::test]
4223 async fn test_playlist_add_duplicate_items_ignored() {
4224 let db_service = create_test_db();
4225 let repo = OfflineRepository::new(
4226 db_service.clone(),
4227 "test-server".to_string(),
4228 "test-user".to_string(),
4229 );
4230 seed_items(&repo, &["t1"]).await;
4231
4232 let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap();
4233
4234 repo.add_to_playlist(&created.id, &["t1".into()])
4236 .await
4237 .unwrap();
4238
4239 let items = repo.get_playlist_items(&created.id).await.unwrap();
4240 assert_eq!(
4241 items.len(),
4242 1,
4243 "Duplicate should be ignored (UNIQUE constraint)"
4244 );
4245 }
4246
4247 #[tokio::test]
4248 async fn test_playlist_remove_items() {
4249 let db_service = create_test_db();
4250 let repo = OfflineRepository::new(
4251 db_service.clone(),
4252 "test-server".to_string(),
4253 "test-user".to_string(),
4254 );
4255 seed_items(&repo, &["t1", "t2", "t3"]).await;
4256
4257 let created = repo
4258 .create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()])
4259 .await
4260 .unwrap();
4261 let items = repo.get_playlist_items(&created.id).await.unwrap();
4262 assert_eq!(items.len(), 3);
4263
4264 let entry_id_to_remove = items[1].playlist_item_id.clone();
4266 repo.remove_from_playlist(&created.id, &[entry_id_to_remove])
4267 .await
4268 .unwrap();
4269
4270 let items_after = repo.get_playlist_items(&created.id).await.unwrap();
4271 assert_eq!(items_after.len(), 2);
4272 assert_eq!(items_after[0].item.id, "t1");
4273 assert_eq!(items_after[1].item.id, "t3");
4274 }
4275
4276 #[tokio::test]
4277 async fn test_playlist_move_item_forward() {
4278 let db_service = create_test_db();
4279 let repo = OfflineRepository::new(
4280 db_service.clone(),
4281 "test-server".to_string(),
4282 "test-user".to_string(),
4283 );
4284 seed_items(&repo, &["a", "b", "c", "d"]).await;
4285
4286 let created = repo
4287 .create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()])
4288 .await
4289 .unwrap();
4290
4291 repo.move_playlist_item(&created.id, "a", 2).await.unwrap();
4293
4294 let items = repo.get_playlist_items(&created.id).await.unwrap();
4295 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4296 assert_eq!(ids, vec!["b", "c", "a", "d"]);
4297 }
4298
4299 #[tokio::test]
4300 async fn test_playlist_move_item_backward() {
4301 let db_service = create_test_db();
4302 let repo = OfflineRepository::new(
4303 db_service.clone(),
4304 "test-server".to_string(),
4305 "test-user".to_string(),
4306 );
4307 seed_items(&repo, &["a", "b", "c", "d"]).await;
4308
4309 let created = repo
4310 .create_playlist(
4311 "Reorder2",
4312 &["a".into(), "b".into(), "c".into(), "d".into()],
4313 )
4314 .await
4315 .unwrap();
4316
4317 repo.move_playlist_item(&created.id, "d", 0).await.unwrap();
4319
4320 let items = repo.get_playlist_items(&created.id).await.unwrap();
4321 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4322 assert_eq!(ids, vec!["d", "a", "b", "c"]);
4323 }
4324
4325 #[tokio::test]
4326 async fn test_playlist_move_item_to_end() {
4327 let db_service = create_test_db();
4328 let repo = OfflineRepository::new(
4329 db_service.clone(),
4330 "test-server".to_string(),
4331 "test-user".to_string(),
4332 );
4333 seed_items(&repo, &["a", "b", "c"]).await;
4334
4335 let created = repo
4336 .create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()])
4337 .await
4338 .unwrap();
4339
4340 repo.move_playlist_item(&created.id, "a", 99).await.unwrap();
4342
4343 let items = repo.get_playlist_items(&created.id).await.unwrap();
4344 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4345 assert_eq!(ids, vec!["b", "c", "a"]);
4346 }
4347
4348 #[tokio::test]
4349 async fn test_playlist_move_nonexistent_item_is_noop() {
4350 let db_service = create_test_db();
4351 let repo = OfflineRepository::new(
4352 db_service.clone(),
4353 "test-server".to_string(),
4354 "test-user".to_string(),
4355 );
4356 seed_items(&repo, &["a", "b"]).await;
4357
4358 let created = repo
4359 .create_playlist("NoOp", &["a".into(), "b".into()])
4360 .await
4361 .unwrap();
4362
4363 repo.move_playlist_item(&created.id, "nonexistent", 0)
4365 .await
4366 .unwrap();
4367
4368 let items = repo.get_playlist_items(&created.id).await.unwrap();
4369 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4370 assert_eq!(ids, vec!["a", "b"]);
4371 }
4372
4373 async fn seed_favorites(db_service: &Arc<RusqliteService>) {
4376 use crate::storage::db_service::DatabaseService;
4377 for sql in [
4378 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4379 VALUES ('movie-fav', 'test-server', 'Favourite Movie', 'Movie', 'lib-1', '2026-01-01', 'Favourite Movie')",
4380 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4381 VALUES ('movie-plain', 'test-server', 'Ordinary Movie', 'Movie', 'lib-1', '2026-01-01', 'Ordinary Movie')",
4382 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4383 VALUES ('album-fav', 'test-server', 'Favourite Album', 'MusicAlbum', 'lib-2', '2026-01-01', 'Favourite Album')",
4384 "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
4385 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-fav', 1)",
4386 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-fav', 1)",
4387 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-plain', 0)",
4389 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('other-user', 'movie-plain', 1)",
4391 ] {
4392 db_service.execute(Query::new(sql)).await.unwrap();
4393 }
4394 }
4395
4396 #[tokio::test]
4401 async fn test_get_favorites_returns_only_scoped_favorites() {
4402 let _guard = lock_catalog_browse();
4403 set_include_catalog_browse(true);
4404
4405 let db_service = create_test_db();
4406 seed_favorites(&db_service).await;
4407 let repo = OfflineRepository::new(
4408 db_service,
4409 "test-server".to_string(),
4410 "test-user".to_string(),
4411 );
4412
4413 let all = repo.get_favorites(SearchScope::All, None).await.unwrap();
4414 let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
4415 ids.sort();
4416 assert_eq!(
4417 ids,
4418 vec!["album-fav", "movie-fav"],
4419 "All scope should return every favourite and nothing else"
4420 );
4421
4422 let movies = repo.get_favorites(SearchScope::Movies, None).await.unwrap();
4423 let ids: Vec<&str> = movies.items.iter().map(|i| i.id.as_str()).collect();
4424 assert_eq!(ids, vec!["movie-fav"]);
4425
4426 let music = repo.get_favorites(SearchScope::Music, None).await.unwrap();
4427 let ids: Vec<&str> = music.items.iter().map(|i| i.id.as_str()).collect();
4428 assert_eq!(ids, vec!["album-fav"]);
4429 }
4430
4431 #[tokio::test]
4436 async fn test_get_favorites_respects_catalog_browse_gate() {
4437 use crate::storage::db_service::DatabaseService;
4438 let _guard = lock_catalog_browse();
4439
4440 let db_service = create_test_db();
4441 seed_favorites(&db_service).await;
4442 db_service
4444 .execute(Query::new(
4445 "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
4446 VALUES ('track-1', 'test-server', 'Track', 'Audio', 'album-fav', '2026-01-01')",
4447 ))
4448 .await
4449 .unwrap();
4450 db_service
4451 .execute(Query::new(
4452 "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
4453 ))
4454 .await
4455 .unwrap();
4456
4457 let repo = OfflineRepository::new(
4458 db_service,
4459 "test-server".to_string(),
4460 "test-user".to_string(),
4461 );
4462
4463 set_include_catalog_browse(false);
4464 let offline_only = repo.get_favorites(SearchScope::All, None).await.unwrap();
4465 let ids: Vec<&str> = offline_only.items.iter().map(|i| i.id.as_str()).collect();
4466 assert_eq!(
4467 ids,
4468 vec!["album-fav"],
4469 "with the gate off, only favourites on the device are listed"
4470 );
4471
4472 set_include_catalog_browse(true);
4473 let with_catalog = repo.get_favorites(SearchScope::All, None).await.unwrap();
4474 assert_eq!(with_catalog.items.len(), 2);
4475 }
4476
4477 #[tokio::test]
4481 async fn test_get_items_favorites_only_filters_listing() {
4482 let _guard = lock_catalog_browse();
4483 set_include_catalog_browse(true);
4484
4485 let db_service = create_test_db();
4486 seed_favorites(&db_service).await;
4487 let repo = OfflineRepository::new(
4488 db_service,
4489 "test-server".to_string(),
4490 "test-user".to_string(),
4491 );
4492
4493 let unfiltered = repo
4494 .get_items(
4495 "lib-1",
4496 Some(GetItemsOptions {
4497 include_item_types: Some(vec!["Movie".to_string()]),
4498 ..Default::default()
4499 }),
4500 )
4501 .await
4502 .unwrap();
4503 assert_eq!(unfiltered.items.len(), 2, "both movies without the filter");
4504
4505 let favourites = repo
4506 .get_items(
4507 "lib-1",
4508 Some(GetItemsOptions {
4509 include_item_types: Some(vec!["Movie".to_string()]),
4510 favorites_only: Some(true),
4511 ..Default::default()
4512 }),
4513 )
4514 .await
4515 .unwrap();
4516 let ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
4517 assert_eq!(ids, vec!["movie-fav"]);
4518 }
4519
4520 #[tokio::test]
4531 async fn test_get_items_type_filter_is_bound_not_interpolated() {
4532 let _guard = lock_catalog_browse();
4533 set_include_catalog_browse(true);
4534
4535 let db_service = create_test_db();
4536 seed_favorites(&db_service).await;
4537 let repo = OfflineRepository::new(
4538 db_service,
4539 "test-server".to_string(),
4540 "test-user".to_string(),
4541 );
4542
4543 let injected = repo
4544 .get_items(
4545 "lib-1",
4546 Some(GetItemsOptions {
4547 include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
4548 ..Default::default()
4549 }),
4550 )
4551 .await
4552 .expect("a hostile type name must be data, not a broken query");
4553 assert!(
4554 injected.items.is_empty(),
4555 "no cached item has that type, so nothing may come back; got {:?}",
4556 injected
4557 .items
4558 .iter()
4559 .map(|i| i.id.as_str())
4560 .collect::<Vec<_>>()
4561 );
4562
4563 let quoted = repo
4565 .get_items(
4566 "lib-1",
4567 Some(GetItemsOptions {
4568 include_item_types: Some(vec!["Mo'vie".to_string()]),
4569 ..Default::default()
4570 }),
4571 )
4572 .await
4573 .expect("an embedded quote must not break the query");
4574 assert!(quoted.items.is_empty());
4575 }
4576
4577 #[tokio::test]
4584 async fn test_get_items_binds_multiple_types_in_parameter_order() {
4585 let _guard = lock_catalog_browse();
4586 set_include_catalog_browse(true);
4587
4588 let db_service = create_test_db();
4589 seed_favorites(&db_service).await;
4590 let repo = OfflineRepository::new(
4591 db_service,
4592 "test-server".to_string(),
4593 "test-user".to_string(),
4594 );
4595
4596 let both = repo
4597 .get_items(
4598 "lib-1",
4599 Some(GetItemsOptions {
4600 include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
4601 ..Default::default()
4602 }),
4603 )
4604 .await
4605 .unwrap();
4606 let mut ids: Vec<&str> = both.items.iter().map(|i| i.id.as_str()).collect();
4607 ids.sort();
4608 assert_eq!(ids, vec!["album-fav", "movie-fav", "movie-plain"]);
4609
4610 let favourites = repo
4612 .get_items(
4613 "lib-1",
4614 Some(GetItemsOptions {
4615 include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
4616 favorites_only: Some(true),
4617 ..Default::default()
4618 }),
4619 )
4620 .await
4621 .unwrap();
4622 let mut ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
4623 ids.sort();
4624 assert_eq!(ids, vec!["album-fav", "movie-fav"]);
4625 }
4626
4627 #[tokio::test]
4636 async fn test_save_to_cache_mirrors_favorites_without_clobbering_pending() {
4637 use crate::storage::db_service::DatabaseService;
4638 let db_service = create_test_db();
4639 let repo = OfflineRepository::new(
4640 db_service.clone(),
4641 "test-server".to_string(),
4642 "test-user".to_string(),
4643 );
4644
4645 let favourite_flag = |id: &'static str| {
4646 let db = db_service.clone();
4647 async move {
4648 db.query_optional(
4649 Query::with_params(
4650 "SELECT is_favorite, pending_sync FROM user_data \
4651 WHERE user_id = ? AND item_id = ?",
4652 vec![
4653 QueryParam::String("test-user".to_string()),
4654 QueryParam::String(id.to_string()),
4655 ],
4656 ),
4657 |row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
4658 )
4659 .await
4660 .unwrap()
4661 }
4662 };
4663
4664 let mut favourited = create_test_item("fav-1", "Favourited Elsewhere", None);
4666 favourited.user_data = Some(UserData {
4667 is_favorite: Some(true),
4668 ..Default::default()
4669 });
4670 let untouched = create_test_item("plain-1", "No User Data", None);
4672
4673 repo.save_to_cache("parent-1", &[favourited.clone(), untouched])
4674 .await
4675 .unwrap();
4676
4677 assert_eq!(
4678 favourite_flag("fav-1").await,
4679 Some((Some(1), Some(0))),
4680 "server favourite should be mirrored as synced"
4681 );
4682 assert_eq!(
4683 favourite_flag("plain-1").await,
4684 None,
4685 "an item without UserData should not get an invented user_data row"
4686 );
4687
4688 db_service
4690 .execute(Query::with_params(
4691 "UPDATE user_data SET is_favorite = 0, pending_sync = 1 \
4692 WHERE user_id = ? AND item_id = ?",
4693 vec![
4694 QueryParam::String("test-user".to_string()),
4695 QueryParam::String("fav-1".to_string()),
4696 ],
4697 ))
4698 .await
4699 .unwrap();
4700
4701 repo.save_to_cache("parent-1", &[favourited]).await.unwrap();
4703
4704 assert_eq!(
4705 favourite_flag("fav-1").await,
4706 Some((Some(0), Some(1))),
4707 "an unsynced local toggle must survive a cache write"
4708 );
4709 }
4710
4711 #[tokio::test]
4723 async fn test_save_to_cache_mirrors_playback_position_without_clobbering_pending() {
4724 use crate::storage::db_service::DatabaseService;
4725 let db_service = create_test_db();
4726 let repo = OfflineRepository::new(
4727 db_service.clone(),
4728 "test-server".to_string(),
4729 "test-user".to_string(),
4730 );
4731
4732 let position = |id: &'static str| {
4733 let db = db_service.clone();
4734 async move {
4735 db.query_optional(
4736 Query::with_params(
4737 "SELECT playback_position_ticks, pending_sync FROM user_data \
4738 WHERE user_id = ? AND item_id = ?",
4739 vec![
4740 QueryParam::String("test-user".to_string()),
4741 QueryParam::String(id.to_string()),
4742 ],
4743 ),
4744 |row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i32>>(1)?)),
4745 )
4746 .await
4747 .unwrap()
4748 }
4749 };
4750
4751 let mut watched = create_test_item("ep-1", "Watched Elsewhere", None);
4753 watched.user_data = Some(UserData {
4754 playback_position_ticks: Some(12_000_000_000),
4755 ..Default::default()
4756 });
4757 let untouched = create_test_item("ep-2", "No User Data", None);
4759
4760 repo.save_to_cache("parent-1", &[watched.clone(), untouched])
4761 .await
4762 .unwrap();
4763
4764 assert_eq!(
4765 position("ep-1").await,
4766 Some((Some(12_000_000_000), Some(0))),
4767 "the server's position should be mirrored as synced"
4768 );
4769 assert_eq!(
4770 position("ep-2").await,
4771 None,
4772 "an item without UserData should not get an invented position"
4773 );
4774
4775 db_service
4777 .execute(Query::with_params(
4778 "UPDATE user_data SET playback_position_ticks = ?, pending_sync = 1 \
4779 WHERE user_id = ? AND item_id = ?",
4780 vec![
4781 QueryParam::Int64(30_000_000_000),
4782 QueryParam::String("test-user".to_string()),
4783 QueryParam::String("ep-1".to_string()),
4784 ],
4785 ))
4786 .await
4787 .unwrap();
4788
4789 repo.save_to_cache("parent-1", &[watched]).await.unwrap();
4791
4792 assert_eq!(
4793 position("ep-1").await,
4794 Some((Some(30_000_000_000), Some(1))),
4795 "an unsynced local position must not be pulled backwards"
4796 );
4797 }
4798
4799 #[tokio::test]
4810 async fn test_save_to_cache_mirrors_played_flag_without_clobbering_pending() {
4811 use crate::storage::db_service::DatabaseService;
4812 let db_service = create_test_db();
4813 let repo = OfflineRepository::new(
4814 db_service.clone(),
4815 "test-server".to_string(),
4816 "test-user".to_string(),
4817 );
4818
4819 let played_flag = |id: &'static str| {
4820 let db = db_service.clone();
4821 async move {
4822 db.query_optional(
4823 Query::with_params(
4824 "SELECT is_played, pending_sync FROM user_data \
4825 WHERE user_id = ? AND item_id = ?",
4826 vec![
4827 QueryParam::String("test-user".to_string()),
4828 QueryParam::String(id.to_string()),
4829 ],
4830 ),
4831 |row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
4832 )
4833 .await
4834 .unwrap()
4835 }
4836 };
4837
4838 let mut watched = create_test_item("ep-4", "Watched Elsewhere", None);
4840 watched.user_data = Some(UserData {
4841 is_played: Some(true),
4842 ..Default::default()
4843 });
4844 let untouched = create_test_item("ep-5", "No User Data", None);
4846
4847 repo.save_to_cache("parent-1", &[watched, untouched])
4848 .await
4849 .unwrap();
4850
4851 assert_eq!(
4852 played_flag("ep-4").await,
4853 Some((Some(1), Some(0))),
4854 "the server's played flag should be mirrored as synced"
4855 );
4856 assert_eq!(
4857 played_flag("ep-5").await,
4858 None,
4859 "an item without UserData should not get an invented played flag"
4860 );
4861
4862 db_service
4864 .execute(Query::with_params(
4865 "UPDATE user_data SET is_played = 0, pending_sync = 1 \
4866 WHERE user_id = ? AND item_id = ?",
4867 vec![
4868 QueryParam::String("test-user".to_string()),
4869 QueryParam::String("ep-4".to_string()),
4870 ],
4871 ))
4872 .await
4873 .unwrap();
4874
4875 let mut still_played = create_test_item("ep-4", "Watched Elsewhere", None);
4876 still_played.user_data = Some(UserData {
4877 is_played: Some(true),
4878 ..Default::default()
4879 });
4880 repo.save_to_cache("parent-1", &[still_played])
4881 .await
4882 .unwrap();
4883
4884 assert_eq!(
4885 played_flag("ep-4").await,
4886 Some((Some(0), Some(1))),
4887 "an unsynced local toggle must survive a cache write"
4888 );
4889 }
4890
4891 #[tokio::test]
4901 async fn test_position_is_mirrored_even_when_no_favourite_flag_is_present() {
4902 use crate::storage::db_service::DatabaseService;
4903 let db_service = create_test_db();
4904 let repo = OfflineRepository::new(
4905 db_service.clone(),
4906 "test-server".to_string(),
4907 "test-user".to_string(),
4908 );
4909
4910 let mut watched = create_test_item("ep-3", "Position Only", None);
4911 watched.user_data = Some(UserData {
4912 is_favorite: None,
4913 playback_position_ticks: Some(9_000_000_000),
4914 ..Default::default()
4915 });
4916
4917 repo.save_to_cache("parent-1", &[watched]).await.unwrap();
4918
4919 let stored = db_service
4920 .query_optional(
4921 Query::with_params(
4922 "SELECT playback_position_ticks FROM user_data \
4923 WHERE user_id = ? AND item_id = ?",
4924 vec![
4925 QueryParam::String("test-user".to_string()),
4926 QueryParam::String("ep-3".to_string()),
4927 ],
4928 ),
4929 |row| row.get::<_, Option<i64>>(0),
4930 )
4931 .await
4932 .unwrap();
4933
4934 assert_eq!(
4935 stored,
4936 Some(Some(9_000_000_000)),
4937 "a position with no favourite flag must still be mirrored"
4938 );
4939 }
4940}