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
87macro_rules! library_type_matches_item {
102 () => {
103 "(
104 (l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
105 OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
106 OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
107 )"
108 };
109}
110
111pub struct OfflineRepository {
112 db_service: Arc<RusqliteService>,
113 server_id: String,
114 user_id: String,
115}
116
117impl OfflineRepository {
118 pub async fn local_playback_info(
134 &self,
135 item_id: &str,
136 ) -> Result<Option<PlaybackInfo>, RepoError> {
137 let rows = self
138 .db_service
139 .query_many(
140 Query::with_params(
141 "SELECT file_path FROM downloads \
142 WHERE item_id = ? AND user_id = ? AND status = 'completed' \
143 AND file_path IS NOT NULL \
144 LIMIT 1",
145 vec![
146 QueryParam::String(item_id.to_string()),
147 QueryParam::String(self.user_id.clone()),
148 ],
149 ),
150 |row| row.get::<_, String>(0),
151 )
152 .await
153 .map_err(|e| RepoError::Database { message: e })?;
154
155 Ok(rows.into_iter().next().map(|file_path| PlaybackInfo {
156 media_source_id: item_id.to_string(),
157 play_session_id: String::new(),
160 stream_url: file_path,
161 direct_play: true,
162 needs_transcoding: false,
163 }))
164 }
165
166 pub fn new(db_service: Arc<RusqliteService>, server_id: String, user_id: String) -> Self {
167 Self {
168 db_service,
169 server_id,
170 user_id,
171 }
172 }
173
174 fn cached_item_to_media_item(item: CachedItem, user_data: Option<UserData>) -> MediaItem {
176 let artists_vec = item
177 .artists
178 .as_ref()
179 .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
180 .unwrap_or_default();
181
182 let kind = crate::domain::kind_from_jellyfin(&item.item_type, item.is_folder);
183
184 MediaItem {
185 id: item.id.clone(),
186 name: item.name,
187 item_type: item.item_type,
188 kind,
189 is_folder: item.is_folder,
190 server_id: item.server_id,
191 parent_id: item.parent_id,
192 library_id: item.library_id,
193 overview: item.overview,
194 genres: item
195 .genres
196 .as_ref()
197 .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
198 runtime_ticks: item.runtime_ticks,
199 duration_ms: item.runtime_ticks.map(crate::domain::ticks_to_ms),
200 production_year: item.production_year,
201 premiere_date: item.premiere_date,
202 community_rating: item.community_rating,
203 official_rating: item.official_rating,
204 primary_image_tag: item.primary_image_tag.clone(),
205 image_id: item.primary_image_tag,
206 backdrop_image_tags: item.backdrop_image_tags,
207 parent_backdrop_image_tags: item.parent_backdrop_image_tags,
208 album_id: item.album_id,
209 album_name: item.album_name,
210 album_artist: item.album_artist,
211 artists: Some(artists_vec),
212 artist_items: None, index_number: item.index_number,
214 series_id: item.series_id,
215 series_name: item.series_name,
216 season_id: item.season_id,
217 season_name: item.season_name,
218 parent_index_number: item.parent_index_number,
219 user_data,
220 media_streams: None, media_sources: None, people: None, }
224 }
225
226 async fn get_user_data(&self, item_id: &str) -> Option<UserData> {
228 let query = Query::with_params(
229 "SELECT playback_position_ticks, is_played, is_favorite, play_count, last_played_at, playback_context_type, playback_context_id
230 FROM user_data WHERE user_id = ? AND item_id = ?",
231 vec![
232 QueryParam::String(self.user_id.clone()),
233 QueryParam::String(item_id.to_string()),
234 ],
235 );
236
237 self.db_service
238 .query_optional(query, |row| {
239 let playback_position_ticks: Option<i64> = row.get(0).ok();
240 Ok(UserData {
241 playback_position_ticks,
242 playback_position_ms: playback_position_ticks.map(crate::domain::ticks_to_ms),
243 is_played: row.get::<_, Option<i32>>(1).ok().flatten().map(|v| v != 0),
244 is_favorite: row.get::<_, Option<i32>>(2).ok().flatten().map(|v| v != 0),
245 play_count: row.get(3).ok(),
246 last_played_date: row.get(4).ok(),
247 playback_context_type: row.get(5).ok(),
248 playback_context_id: row.get(6).ok(),
249 })
250 })
251 .await
252 .ok()
253 .flatten()
254 }
255}
256
257#[derive(Debug)]
259struct CachedItem {
260 id: String,
261 name: String,
262 item_type: String,
263 is_folder: bool,
264 server_id: String,
265 parent_id: Option<String>,
266 library_id: Option<String>,
267 overview: Option<String>,
268 genres: Option<String>,
269 runtime_ticks: Option<i64>,
270 production_year: Option<i32>,
271 premiere_date: Option<String>,
272 community_rating: Option<f64>,
273 official_rating: Option<String>,
274 primary_image_tag: Option<String>,
275 backdrop_image_tags: Option<Vec<String>>,
276 parent_backdrop_image_tags: Option<Vec<String>>,
277 album_id: Option<String>,
278 album_name: Option<String>,
279 album_artist: Option<String>,
280 artists: Option<String>,
281 index_number: Option<i32>,
282 series_id: Option<String>,
283 series_name: Option<String>,
284 season_id: Option<String>,
285 season_name: Option<String>,
286 parent_index_number: Option<i32>,
287}
288
289fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
290 Ok(CachedItem {
291 id: row.get(0)?,
292 name: row.get(1)?,
293 item_type: row.get(2)?,
294 server_id: row.get(3)?,
295 parent_id: row.get(4)?,
296 library_id: row.get(5)?,
297 overview: row.get(6)?,
298 genres: row.get(7)?,
299 runtime_ticks: row.get(8)?,
300 production_year: row.get(9)?,
301 community_rating: row.get(10)?,
302 official_rating: row.get(11)?,
303 primary_image_tag: row.get(12)?,
304 backdrop_image_tags: None, parent_backdrop_image_tags: None, album_id: row.get(13)?,
307 album_name: row.get(14)?,
308 album_artist: row.get(15)?,
309 artists: row.get(16)?,
310 index_number: row.get(17)?,
311 series_id: row.get(18)?,
312 series_name: row.get(19)?,
313 season_id: row.get(20)?,
314 season_name: row.get(21)?,
315 parent_index_number: row.get(22)?,
316 is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0,
318 premiere_date: row.get(24)?,
319 })
320}
321
322impl OfflineRepository {
323 pub async fn prune_stale_catalog(
350 &self,
351 cutoff: &str,
352 item_types: &[String],
353 ) -> Result<usize, RepoError> {
354 if item_types.is_empty() {
355 return Ok(0);
356 }
357 let placeholders = vec!["?"; item_types.len()].join(",");
358 let sql = format!(
359 "DELETE FROM items
360 WHERE server_id = ?
361 AND synced_at IS NOT NULL
362 AND synced_at < ?
363 AND item_type IN ({})
364 AND id NOT IN (
365 -- Playable items with completed downloads
366 SELECT i.id
367 FROM items i
368 INNER JOIN downloads d ON i.id = d.item_id
369 WHERE d.status = 'completed'
370
371 UNION
372
373 -- Containers with downloaded children
374 SELECT i.id
375 FROM items i
376 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)
377 INNER JOIN downloads d ON children.id = d.item_id
378 WHERE d.status = 'completed'
379 )",
380 placeholders
381 );
382
383 let mut params = vec![
384 QueryParam::String(self.server_id.clone()),
385 QueryParam::String(cutoff.to_string()),
386 ];
387 params.extend(item_types.iter().cloned().map(QueryParam::String));
388
389 let removed = self
390 .db_service
391 .execute(Query::with_params(sql, params))
392 .await
393 .map_err(|e| RepoError::Database { message: e })?;
394
395 Ok(removed)
396 }
397
398 async fn search_people(
407 &self,
408 fts_query: &str,
409 limit: usize,
410 ) -> Result<Vec<MediaItem>, RepoError> {
411 let sql = format!(
412 "SELECT p.id, p.name, p.overview, p.primary_image_tag, p.premiere_date
413 FROM people p
414 JOIN people_fts fts ON fts.rowid = p.rowid
415 WHERE p.server_id = ? AND people_fts MATCH ?
416 ORDER BY rank
417 LIMIT {}",
418 limit
419 );
420
421 let rows = self
422 .db_service
423 .query_many(
424 Query::with_params(
425 sql,
426 vec![
427 QueryParam::String(self.server_id.clone()),
428 QueryParam::String(fts_query.to_string()),
429 ],
430 ),
431 |row| {
432 Ok((
433 row.get::<_, String>(0)?,
434 row.get::<_, String>(1)?,
435 row.get::<_, Option<String>>(2)?,
436 row.get::<_, Option<String>>(3)?,
437 row.get::<_, Option<String>>(4)?,
438 ))
439 },
440 )
441 .await
442 .map_err(|e| RepoError::Database { message: e })?;
443
444 Ok(rows
445 .into_iter()
446 .map(
447 |(id, name, overview, primary_image_tag, premiere_date)| MediaItem {
448 id,
449 name,
450 item_type: "Person".to_string(),
451 kind: crate::domain::MediaKind::Person,
452 is_folder: false,
453 server_id: self.server_id.clone(),
454 overview,
455 primary_image_tag,
456 premiere_date,
457 ..Default::default()
458 },
459 )
460 .collect())
461 }
462
463 pub async fn save_to_cache(
468 &self,
469 parent_id: &str,
470 items: &[MediaItem],
471 ) -> Result<usize, RepoError> {
472 if items.is_empty() {
473 return Ok(0);
474 }
475
476 let now = chrono::Utc::now().to_rfc3339();
477
478 self.db_service
481 .execute(Query::new("PRAGMA foreign_keys = OFF"))
482 .await
483 .map_err(|e| RepoError::Database { message: e })?;
484
485 let result = self.save_to_cache_impl(parent_id, items, &now).await;
487
488 let _ = self
490 .db_service
491 .execute(Query::new("PRAGMA foreign_keys = ON"))
492 .await;
493
494 result
495 }
496
497 async fn resolve_owning_library(&self, parent_id: &str) -> Option<String> {
507 let is_library: Option<String> = self
508 .db_service
509 .query_optional(
510 Query::with_params(
511 "SELECT id FROM libraries WHERE id = ? AND server_id = ?",
512 vec![
513 QueryParam::String(parent_id.to_string()),
514 QueryParam::String(self.server_id.clone()),
515 ],
516 ),
517 |row| row.get(0),
518 )
519 .await
520 .ok()
521 .flatten();
522
523 if is_library.is_some() {
524 return is_library;
525 }
526
527 self.db_service
528 .query_optional(
529 Query::with_params(
530 "SELECT library_id FROM items WHERE id = ? AND library_id IS NOT NULL",
531 vec![QueryParam::String(parent_id.to_string())],
532 ),
533 |row| row.get(0),
534 )
535 .await
536 .ok()
537 .flatten()
538 }
539
540 async fn save_to_cache_impl(
541 &self,
542 parent_id: &str,
543 items: &[MediaItem],
544 now: &str,
545 ) -> Result<usize, RepoError> {
546 let owning_library = self.resolve_owning_library(parent_id).await;
571
572 let mut parent_ids = std::collections::HashSet::new();
574 parent_ids.insert(parent_id.to_string());
575
576 for item in items {
577 if let Some(pid) = &item.parent_id {
578 parent_ids.insert(pid.clone());
579 }
580 }
581
582 #[cfg(test)]
584 println!("Creating stub parents for: {:?}", parent_ids);
585
586 for pid in parent_ids {
587 let parent_query = Query::with_params(
588 "INSERT OR IGNORE INTO items (id, server_id, name, item_type, synced_at)
589 VALUES (?1, ?2, ?3, ?4, ?5)",
590 vec![
591 QueryParam::String(pid.clone()),
592 QueryParam::String(self.server_id.clone()),
593 QueryParam::String("Parent".to_string()),
594 QueryParam::String("Folder".to_string()),
595 QueryParam::String(now.to_string()),
596 ],
597 );
598
599 let _stub_rows = self
600 .db_service
601 .execute(parent_query)
602 .await
603 .map_err(|e| RepoError::Database { message: e })?;
604 #[cfg(test)]
605 println!(
606 " Created stub parent {} (rows affected: {})",
607 pid, _stub_rows
608 );
609 }
610
611 let mut count = 0;
612
613 for item in items {
614 let genres_json = item
616 .genres
617 .as_ref()
618 .map(|g| serde_json::to_string(g).unwrap_or_else(|_| "[]".to_string()));
619 let artists_json = item
620 .artists
621 .as_ref()
622 .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
623 let backdrop_tags_json = item
624 .backdrop_image_tags
625 .as_ref()
626 .map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".to_string()));
627
628 let query = Query::with_params(
644 "INSERT INTO items (
645 id, server_id, library_id, parent_id,
646 name, item_type, is_folder, overview,
647 genres, series_id, series_name,
648 season_id, season_name, index_number, parent_index_number,
649 album_id, album_name, album_artist, artists,
650 production_year, premiere_date, runtime_ticks,
651 primary_image_tag, backdrop_image_tags,
652 community_rating, official_rating,
653 synced_at
654 ) VALUES (
655 ?1, ?2, ?3, ?4,
656 ?5, ?6, ?7, ?8,
657 ?9, ?10, ?11,
658 ?12, ?13, ?14, ?15,
659 ?16, ?17, ?18, ?19,
660 ?20, ?21, ?22,
661 ?23, ?24,
662 ?25, ?26,
663 ?27
664 )
665 ON CONFLICT(id) DO UPDATE SET
666 server_id = excluded.server_id,
667 -- This call site never supplies library_id (it is always
668 -- bound NULL), so keep whatever another path recorded rather
669 -- than clearing it the way REPLACE did.
670 library_id = COALESCE(excluded.library_id, items.library_id),
671 parent_id = excluded.parent_id,
672 name = excluded.name,
673 item_type = excluded.item_type,
674 is_folder = excluded.is_folder,
675 overview = excluded.overview,
676 genres = excluded.genres,
677 series_id = excluded.series_id,
678 series_name = excluded.series_name,
679 season_id = excluded.season_id,
680 season_name = excluded.season_name,
681 index_number = excluded.index_number,
682 parent_index_number = excluded.parent_index_number,
683 album_id = excluded.album_id,
684 album_name = excluded.album_name,
685 album_artist = excluded.album_artist,
686 artists = excluded.artists,
687 production_year = excluded.production_year,
688 premiere_date = excluded.premiere_date,
689 runtime_ticks = excluded.runtime_ticks,
690 primary_image_tag = excluded.primary_image_tag,
691 backdrop_image_tags = excluded.backdrop_image_tags,
692 community_rating = excluded.community_rating,
693 official_rating = excluded.official_rating,
694 synced_at = excluded.synced_at",
695 vec![
696 QueryParam::String(item.id.clone()),
697 QueryParam::String(self.server_id.clone()),
698 match &owning_library {
701 Some(lib) => QueryParam::String(lib.clone()),
702 None => QueryParam::Null,
703 }, match &item.parent_id {
706 Some(pid) => QueryParam::String(pid.clone()),
707 None => QueryParam::Null,
708 },
709 QueryParam::String(item.name.clone()),
710 QueryParam::String(item.item_type.clone()),
711 QueryParam::Int(if item.is_folder { 1 } else { 0 }),
712 match &item.overview {
713 Some(o) => QueryParam::String(o.clone()),
714 None => QueryParam::Null,
715 },
716 match genres_json {
717 Some(g) => QueryParam::String(g),
718 None => QueryParam::Null,
719 },
720 match &item.series_id {
721 Some(s) => QueryParam::String(s.clone()),
722 None => QueryParam::Null,
723 },
724 match &item.series_name {
725 Some(s) => QueryParam::String(s.clone()),
726 None => QueryParam::Null,
727 },
728 match &item.season_id {
729 Some(s) => QueryParam::String(s.clone()),
730 None => QueryParam::Null,
731 },
732 match &item.season_name {
733 Some(s) => QueryParam::String(s.clone()),
734 None => QueryParam::Null,
735 },
736 match item.index_number {
737 Some(i) => QueryParam::Int(i),
738 None => QueryParam::Null,
739 },
740 match item.parent_index_number {
741 Some(i) => QueryParam::Int(i),
742 None => QueryParam::Null,
743 },
744 match &item.album_id {
745 Some(a) => QueryParam::String(a.clone()),
746 None => QueryParam::Null,
747 },
748 match &item.album_name {
749 Some(a) => QueryParam::String(a.clone()),
750 None => QueryParam::Null,
751 },
752 match &item.album_artist {
753 Some(a) => QueryParam::String(a.clone()),
754 None => QueryParam::Null,
755 },
756 match artists_json {
757 Some(a) => QueryParam::String(a),
758 None => QueryParam::Null,
759 },
760 match item.production_year {
761 Some(y) => QueryParam::Int(y),
762 None => QueryParam::Null,
763 },
764 match &item.premiere_date {
765 Some(d) => QueryParam::String(d.clone()),
766 None => QueryParam::Null,
767 },
768 match item.runtime_ticks {
769 Some(r) => QueryParam::Int64(r),
770 None => QueryParam::Null,
771 },
772 match &item.primary_image_tag {
773 Some(t) => QueryParam::String(t.clone()),
774 None => QueryParam::Null,
775 },
776 match backdrop_tags_json {
777 Some(b) => QueryParam::String(b),
778 None => QueryParam::Null,
779 },
780 match item.community_rating {
781 Some(r) => QueryParam::Float(r),
782 None => QueryParam::Null,
783 },
784 match &item.official_rating {
785 Some(r) => QueryParam::String(r.clone()),
786 None => QueryParam::Null,
787 },
788 QueryParam::String(now.to_string()),
789 ],
790 );
791
792 let _rows_affected =
793 self.db_service
794 .execute(query)
795 .await
796 .map_err(|e| RepoError::Database {
797 message: format!("Failed to insert item {}: {}", item.id, e),
798 })?;
799 #[cfg(test)]
800 println!(
801 " [save_to_cache] Saved item {} (rows affected: {})",
802 item.id, _rows_affected
803 );
804
805 self.mirror_user_data(item, now).await?;
806 count += 1;
807 }
808
809 Ok(count)
810 }
811
812 async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
839 let user_data = item.user_data.as_ref();
840 let is_favorite = user_data.and_then(|ud| ud.is_favorite);
841 let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
842 let is_played = user_data.and_then(|ud| ud.is_played);
843
844 if is_favorite.is_none() && position_ticks.is_none() && is_played.is_none() {
846 return Ok(());
847 }
848
849 let query = Query::with_params(
850 "INSERT INTO user_data
851 (user_id, item_id, is_favorite, playback_position_ticks, is_played,
852 synced_at, pending_sync)
853 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)
854 ON CONFLICT(user_id, item_id) DO UPDATE SET
855 is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
856 playback_position_ticks = COALESCE(
857 excluded.playback_position_ticks, user_data.playback_position_ticks),
858 is_played = COALESCE(excluded.is_played, user_data.is_played),
859 synced_at = excluded.synced_at
860 WHERE user_data.pending_sync = 0",
861 vec![
862 QueryParam::String(self.user_id.clone()),
863 QueryParam::String(item.id.clone()),
864 is_favorite
865 .map(|f| QueryParam::Int(if f { 1 } else { 0 }))
866 .unwrap_or(QueryParam::Null),
867 position_ticks
868 .map(QueryParam::Int64)
869 .unwrap_or(QueryParam::Null),
870 is_played
871 .map(|p| QueryParam::Int(if p { 1 } else { 0 }))
872 .unwrap_or(QueryParam::Null),
873 QueryParam::String(now.to_string()),
874 ],
875 );
876
877 if let Err(e) = self.db_service.execute(query).await {
881 debug!(
882 "[OfflineRepo] user_data mirror skipped for {}: {}",
883 item.id, e
884 );
885 }
886
887 Ok(())
888 }
889
890 pub async fn save_libraries_to_cache(&self, libraries: &[Library]) -> Result<usize, RepoError> {
895 if libraries.is_empty() {
896 return Ok(0);
897 }
898
899 let mut count = 0;
900 for (idx, lib) in libraries.iter().enumerate() {
901 let query = Query::with_params(
902 "INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
903 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
904 vec![
905 QueryParam::String(lib.id.clone()),
906 QueryParam::String(self.server_id.clone()),
907 QueryParam::String(lib.name.clone()),
908 QueryParam::String(lib.collection_type.clone()),
909 lib.image_tag.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
910 QueryParam::Int(idx as i32),
911 ],
912 );
913 self.db_service
914 .execute(query)
915 .await
916 .map_err(|e| RepoError::Database { message: e })?;
917 count += 1;
918 }
919 Ok(count)
920 }
921
922 pub async fn save_genres_to_cache(
927 &self,
928 parent_id: Option<&str>,
929 genres: &[Genre],
930 ) -> Result<usize, RepoError> {
931 if genres.is_empty() {
932 return Ok(0);
933 }
934
935 let library_id = parent_id.unwrap_or("").to_string();
938 let server_id = self.server_id.clone();
939 let genres: Vec<(String, String, Option<u32>)> = genres
940 .iter()
941 .map(|g| (g.id.clone(), g.name.clone(), g.album_count))
942 .collect();
943 let saved = genres.len();
944
945 self.db_service
946 .transaction(move |tx| {
947 use crate::storage::db_service::{Query, QueryParam};
948
949 tx.execute(Query::with_params(
951 "DELETE FROM genres WHERE server_id = ? AND library_id = ?",
952 vec![
953 QueryParam::String(server_id.clone()),
954 QueryParam::String(library_id.clone()),
955 ],
956 ))?;
957
958 for (id, name, album_count) in &genres {
959 tx.execute(Query::with_params(
960 "INSERT OR REPLACE INTO genres (id, server_id, library_id, name, album_count, synced_at)
961 VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
962 vec![
963 QueryParam::String(id.clone()),
964 QueryParam::String(server_id.clone()),
965 QueryParam::String(library_id.clone()),
966 QueryParam::String(name.clone()),
967 album_count.map(|c| QueryParam::Int(c as i32)).unwrap_or(QueryParam::Null),
968 ],
969 ))?;
970 }
971
972 Ok(())
973 })
974 .await
975 .map_err(|e| RepoError::Database { message: e })?;
976
977 Ok(saved)
978 }
979
980 const LIBRARY_HOLDS_ITEM: &'static str = concat!(
1005 "(",
1006 library_type_matches_item!(),
1007 " OR l.collection_type IS NULL
1008 OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
1009 )"
1010 );
1011
1012 const DOWNLOADED_ITEMS_CTE: &'static str = "
1014 WITH downloaded_items AS (
1015 SELECT DISTINCT i.id
1016 FROM items i
1017 INNER JOIN downloads d ON i.id = d.item_id
1018 WHERE d.status = 'completed'
1019 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1020
1021 UNION
1022
1023 SELECT DISTINCT i.id
1024 FROM items i
1025 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)
1026 INNER JOIN downloads d ON children.id = d.item_id
1027 WHERE d.status = 'completed'
1028 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1029 )";
1030
1031 pub async fn get_downloaded_items(
1041 &self,
1042 parent_id: &str,
1043 options: Option<GetItemsOptions>,
1044 ) -> Result<SearchResult, RepoError> {
1045 let opts = options.unwrap_or_default();
1046 let limit = opts.limit.unwrap_or(10000);
1047 let start_index = opts.start_index.unwrap_or(0);
1048
1049 let type_filter = if let Some(include_item_types) = &opts.include_item_types {
1050 if !include_item_types.is_empty() {
1051 let types = include_item_types
1052 .iter()
1053 .map(|t| format!("'{}'", t.replace('\'', "''")))
1054 .collect::<Vec<_>>()
1055 .join(",");
1056 format!(" AND i.item_type IN ({})", types)
1057 } else {
1058 String::new()
1059 }
1060 } else {
1061 String::new()
1062 };
1063
1064 let sql = format!(
1077 "{cte}
1078 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1079 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1080 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1081 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1082 i.parent_index_number, i.is_folder, i.premiere_date
1083 FROM items i
1084 INNER JOIN downloaded_items di ON i.id = di.id
1085 WHERE i.server_id = ?
1086 AND (
1087 i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
1088 OR (
1089 EXISTS (
1090 SELECT 1 FROM libraries l
1091 WHERE l.id = ? AND l.server_id = i.server_id
1092 AND {membership}
1093 )
1094 -- Top-level only: hide leaves whose container is downloaded.
1095 AND NOT EXISTS (
1096 SELECT 1 FROM downloaded_items parent
1097 WHERE parent.id = i.album_id
1098 OR parent.id = i.season_id
1099 OR parent.id = i.series_id
1100 OR parent.id = i.parent_id
1101 )
1102 )
1103 ){type_filter}
1104 ORDER BY i.sort_name ASC, i.name ASC
1105 LIMIT {limit} OFFSET {start_index}",
1106 cte = Self::DOWNLOADED_ITEMS_CTE,
1107 membership = Self::LIBRARY_HOLDS_ITEM,
1108 );
1109
1110 let query = Query::with_params(
1111 sql,
1112 vec![
1113 QueryParam::String(self.server_id.clone()),
1114 QueryParam::String(parent_id.to_string()),
1115 QueryParam::String(parent_id.to_string()),
1116 QueryParam::String(parent_id.to_string()),
1117 QueryParam::String(parent_id.to_string()),
1118 QueryParam::String(parent_id.to_string()),
1119 ],
1120 );
1121
1122 let cached_items: Vec<CachedItem> = self
1123 .db_service
1124 .query_many(query, row_to_cached_item)
1125 .await
1126 .map_err(|e| RepoError::Database { message: e })?;
1127
1128 let mut items = Vec::new();
1129 for cached in cached_items {
1130 let user_data = self.get_user_data(&cached.id).await;
1131 items.push(Self::cached_item_to_media_item(cached, user_data));
1132 }
1133
1134 let total_record_count = items.len();
1135 Ok(SearchResult {
1136 items,
1137 total_record_count,
1138 })
1139 }
1140
1141 pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
1147 let query = Query::with_params(
1152 format!(
1153 "{cte}
1154 SELECT l.id, l.name, l.collection_type, l.image_tag
1155 FROM libraries l
1156 WHERE l.server_id = ?
1157 AND EXISTS (
1158 SELECT 1 FROM items i
1159 INNER JOIN downloaded_items di ON i.id = di.id
1160 WHERE i.server_id = l.server_id
1161 AND {membership}
1162 )
1163 ORDER BY l.sort_order ASC, l.name ASC",
1164 cte = Self::DOWNLOADED_ITEMS_CTE,
1165 membership = Self::LIBRARY_HOLDS_ITEM,
1166 ),
1167 vec![QueryParam::String(self.server_id.clone())],
1168 );
1169
1170 self.db_service
1171 .query_many(query, |row| {
1172 Ok(Library::new(
1173 row.get(0)?,
1174 row.get(1)?,
1175 row.get::<_, Option<String>>(2)?
1176 .unwrap_or_else(|| "unknown".to_string()),
1177 row.get(3)?,
1178 ))
1179 })
1180 .await
1181 .map_err(|e| RepoError::Database { message: e })
1182 }
1183
1184 pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
1193 let leaf_query = Query::with_params(
1195 "SELECT d.item_id, COALESCE(d.file_size, 0)
1196 FROM downloads d
1197 INNER JOIN items i ON i.id = d.item_id
1198 WHERE d.status = 'completed'
1199 AND i.server_id = ?
1200 AND i.item_type IN ('Audio', 'Movie', 'Episode')",
1201 vec![QueryParam::String(self.server_id.clone())],
1202 );
1203 let leaves: Vec<(String, i64)> = self
1204 .db_service
1205 .query_many(leaf_query, |row| Ok((row.get(0)?, row.get(1)?)))
1206 .await
1207 .map_err(|e| RepoError::Database { message: e })?;
1208
1209 let container_query = Query::with_params(
1211 "SELECT c.id, COALESCE(SUM(d.file_size), 0)
1212 FROM items c
1213 INNER JOIN items children
1214 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1215 INNER JOIN downloads d ON children.id = d.item_id
1216 WHERE d.status = 'completed'
1217 AND c.server_id = ?
1218 AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1219 GROUP BY c.id",
1220 vec![QueryParam::String(self.server_id.clone())],
1221 );
1222 let containers: Vec<(String, i64)> = self
1223 .db_service
1224 .query_many(container_query, |row| Ok((row.get(0)?, row.get(1)?)))
1225 .await
1226 .map_err(|e| RepoError::Database { message: e })?;
1227
1228 let partial_query = Query::with_params(
1239 "WITH downloaded_containers AS (
1240 SELECT DISTINCT c.id
1241 FROM items c
1242 INNER JOIN items children
1243 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1244 INNER JOIN downloads d ON children.id = d.item_id
1245 WHERE d.status = 'completed'
1246 AND c.server_id = ?
1247 AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1248 )
1249 SELECT c.id,
1250 COUNT(children.id) AS total_children,
1251 SUM(CASE WHEN d.status = 'completed' THEN 1 ELSE 0 END) AS downloaded_children
1252 FROM items c
1253 INNER JOIN downloaded_containers dc ON dc.id = c.id
1254 INNER JOIN items children
1255 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1256 LEFT JOIN downloads d ON children.id = d.item_id AND d.status = 'completed'
1257 WHERE children.item_type IN ('Audio', 'Movie', 'Episode', 'Season')
1258 GROUP BY c.id",
1259 vec![QueryParam::String(self.server_id.clone())],
1260 );
1261 let partial_rows: Vec<(String, i64, i64)> = self
1262 .db_service
1263 .query_many(partial_query, |row| {
1264 Ok((
1265 row.get(0)?,
1266 row.get(1)?,
1267 row.get::<_, Option<i64>>(2)?.unwrap_or(0),
1268 ))
1269 })
1270 .await
1271 .map_err(|e| RepoError::Database { message: e })?;
1272
1273 let mut partial_containers = std::collections::HashMap::new();
1274 for (id, total, downloaded) in partial_rows {
1275 if downloaded > 0 && downloaded < total {
1278 partial_containers.insert(id, true);
1279 }
1280 }
1281
1282 let item_count = leaves.len() as u32;
1283 let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum();
1284
1285 let mut sizes = std::collections::HashMap::new();
1286 for (id, bytes) in leaves.into_iter().chain(containers) {
1287 *sizes.entry(id).or_insert(0) += bytes;
1290 }
1291
1292 Ok(DownloadDiskUsage {
1293 sizes,
1294 partial_containers,
1295 device_total_bytes,
1296 item_count,
1297 })
1298 }
1299
1300 pub async fn save_playlist_items_to_cache(
1303 &self,
1304 playlist_id: &str,
1305 entries: &[PlaylistEntry],
1306 ) -> Result<(), RepoError> {
1307 let playlist_id = playlist_id.to_string();
1308 let user_id = self.user_id.clone();
1309 let entries: Vec<(String, String, usize)> = entries
1310 .iter()
1311 .enumerate()
1312 .map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i))
1313 .collect();
1314
1315 self.db_service
1316 .transaction(move |tx| {
1317 use crate::storage::db_service::{Query, QueryParam};
1318
1319 tx.execute(Query::with_params(
1321 "INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)",
1322 vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)],
1323 ))?;
1324
1325 tx.execute(Query::with_params(
1327 "DELETE FROM playlist_items WHERE playlist_id = ?",
1328 vec![QueryParam::String(playlist_id.clone())],
1329 ))?;
1330
1331 for (_, item_id, sort_order) in &entries {
1332 tx.execute(Query::with_params(
1333 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
1334 vec![
1335 QueryParam::String(playlist_id.clone()),
1336 QueryParam::String(item_id.clone()),
1337 QueryParam::Int(*sort_order as i32),
1338 ],
1339 ))?;
1340 }
1341
1342 Ok(())
1343 })
1344 .await
1345 .map_err(|e| RepoError::Database {
1346 message: format!("Failed to cache playlist items: {}", e),
1347 })
1348 }
1349}
1350
1351#[async_trait]
1352impl MediaRepository for OfflineRepository {
1353 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1354 let query = Query::with_params(
1362 "SELECT l.id, l.name, l.collection_type, l.image_tag
1363 FROM libraries l
1364 WHERE l.server_id = ?
1365 ORDER BY l.sort_order ASC, l.name ASC",
1366 vec![QueryParam::String(self.server_id.clone())],
1367 );
1368
1369 self.db_service
1370 .query_many(query, |row| {
1371 Ok(Library::new(
1372 row.get(0)?,
1373 row.get(1)?,
1374 row.get::<_, Option<String>>(2)?
1375 .unwrap_or_else(|| "unknown".to_string()),
1376 row.get(3)?,
1377 ))
1378 })
1379 .await
1380 .map_err(|e| RepoError::Database { message: e })
1381 }
1382
1383 async fn get_items(
1384 &self,
1385 parent_id: &str,
1386 options: Option<GetItemsOptions>,
1387 ) -> Result<SearchResult, RepoError> {
1388 debug!(
1389 "[OfflineRepo] get_items called for parent_id: {}",
1390 &parent_id[..8.min(parent_id.len())]
1391 );
1392 let opts = options.unwrap_or_default();
1393 let limit = opts.limit.unwrap_or(10000); let start_index = opts.start_index.unwrap_or(0);
1395
1396 let default_sort = default_listing_sort(opts.parent_kind);
1404 let sort_field = opts
1405 .sort_by
1406 .as_deref()
1407 .or(default_sort.map(|(field, _)| field));
1408 let descending = opts
1409 .sort_order
1410 .as_deref()
1411 .or(default_sort.map(|(_, order)| order))
1412 == Some("Descending");
1413 let order_by = match sort_field {
1414 Some("Random") => "RANDOM()".to_string(),
1415 Some("PremiereDate") => format!(
1416 "i.premiere_date IS NULL, i.premiere_date {}, i.sort_name ASC",
1417 if descending { "DESC" } else { "ASC" }
1418 ),
1419 _ => "i.sort_name ASC, i.name ASC".to_string(),
1420 };
1421
1422 let type_values: &[String] = opts
1429 .include_item_types
1430 .as_deref()
1431 .filter(|types| !types.is_empty())
1432 .unwrap_or(&[]);
1433 let type_filter = if type_values.is_empty() {
1434 String::new()
1435 } else {
1436 let placeholders = vec!["?"; type_values.len()].join(",");
1437 format!(" AND i.item_type IN ({})", placeholders)
1438 };
1439
1440 let favorites_filter = if opts.favorites_only == Some(true) {
1445 " AND EXISTS (
1446 SELECT 1 FROM user_data ud
1447 WHERE ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1
1448 )"
1449 } else {
1450 ""
1451 };
1452
1453 let catalog_branch = if include_catalog_browse() {
1461 "UNION
1462
1463 -- Cached items for fast browsing (online) or the offline catalog view
1464 SELECT DISTINCT i.id
1465 FROM items i
1466 WHERE i.synced_at IS NOT NULL"
1467 } else {
1468 ""
1469 };
1470 let sql = format!(
1471 "WITH available_items AS (
1472 -- Playable items with completed downloads
1473 SELECT DISTINCT i.id
1474 FROM items i
1475 INNER JOIN downloads d ON i.id = d.item_id
1476 WHERE d.status = 'completed'
1477 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1478
1479 UNION
1480
1481 -- Containers with downloaded children
1482 SELECT DISTINCT i.id
1483 FROM items i
1484 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)
1485 INNER JOIN downloads d ON children.id = d.item_id
1486 WHERE d.status = 'completed'
1487 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1488
1489 {catalog_branch}
1490 )
1491 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1492 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1493 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1494 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1495 i.parent_index_number, i.is_folder, i.premiere_date
1496 FROM items i
1497 INNER JOIN available_items ai ON i.id = ai.id
1498 WHERE i.server_id = ?
1499 AND (
1500 i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
1501 -- When the requested parent is a LIBRARY, there is no per-item
1502 -- link back to it (library_id/parent_id are NULL in the cache),
1503 -- so match every item on the server and let the type filter
1504 -- (e.g. MusicAlbum / Movie / Series) narrow it. This is what
1505 -- makes library landing pages show albums/movies/shows offline.
1506 --
1507 -- The type correlation is NOT optional. Without it this
1508 -- EXISTS never mentions the item, so it is true for every
1509 -- cached row as soon as the requested parent is any library.
1510 -- Music/Movies/TV got away with that because their landing
1511 -- pages pass `include_item_types`, which narrowed the result;
1512 -- the generic library page passes none, so a Books or Photos
1513 -- library served the entire cached server (DR-277).
1514 --
1515 -- `library_id` wins wherever it survived the cache write:
1516 -- it is the server's own answer, and it is the only thing
1517 -- that can scope a library whose type has no mapping (Books,
1518 -- Photos, Collections) or none at all (a mixed library, where
1519 -- Jellyfin sends CollectionType null). The taxonomy is the
1520 -- fallback for rows that predate it being stored.
1521 --
1522 -- A library with neither a stored link nor a mapped type now
1523 -- matches nothing here and falls through to the server, which
1524 -- does know what is in it. Showing nothing briefly beats
1525 -- showing somebody else's films with confidence.
1526 OR EXISTS (
1527 SELECT 1 FROM libraries l
1528 WHERE l.id = ? AND l.server_id = i.server_id
1529 AND (
1530 i.library_id = l.id
1531 OR (i.library_id IS NULL AND {})
1532 )
1533 )
1534 ){}{}
1535 ORDER BY {}
1536 LIMIT {} OFFSET {}",
1537 library_type_matches_item!(),
1538 type_filter,
1539 favorites_filter,
1540 order_by,
1541 limit,
1542 start_index
1543 );
1544
1545 let mut params = vec![
1551 QueryParam::String(self.server_id.clone()),
1552 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()), ];
1558 params.extend(type_values.iter().cloned().map(QueryParam::String));
1563 if !favorites_filter.is_empty() {
1564 params.push(QueryParam::String(self.user_id.clone())); }
1566 let query = Query::with_params(sql, params);
1567
1568 let cached_items: Vec<CachedItem> = self
1569 .db_service
1570 .query_many(query, row_to_cached_item)
1571 .await
1572 .map_err(|e| RepoError::Database { message: e })?;
1573
1574 debug!(
1575 "[OfflineRepo] Found {} cached items for parent {}",
1576 cached_items.len(),
1577 &parent_id[..8.min(parent_id.len())]
1578 );
1579
1580 let mut items = Vec::new();
1582 for cached in cached_items {
1583 let user_data = self.get_user_data(&cached.id).await;
1584 items.push(Self::cached_item_to_media_item(cached, user_data));
1585 }
1586
1587 let total_record_count = items.len();
1588
1589 debug!(
1590 "[OfflineRepo] Returning {} items for parent {}",
1591 total_record_count,
1592 &parent_id[..8.min(parent_id.len())]
1593 );
1594
1595 Ok(SearchResult {
1596 items,
1597 total_record_count,
1598 })
1599 }
1600
1601 async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
1602 let query = Query::with_params(
1604 "WITH downloaded_items AS (
1605 -- Playable items with completed downloads
1606 SELECT DISTINCT i.id
1607 FROM items i
1608 INNER JOIN downloads d ON i.id = d.item_id
1609 WHERE d.status = 'completed'
1610 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1611
1612 UNION
1613
1614 -- Containers with downloaded children
1615 SELECT DISTINCT i.id
1616 FROM items i
1617 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)
1618 INNER JOIN downloads d ON children.id = d.item_id
1619 WHERE d.status = 'completed'
1620 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1621 )
1622 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1623 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1624 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1625 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1626 i.parent_index_number, i.is_folder, i.premiere_date
1627 FROM items i
1628 INNER JOIN downloaded_items di ON i.id = di.id
1629 WHERE i.id = ?",
1630 vec![QueryParam::String(item_id.to_string())],
1631 );
1632
1633 let cached = self
1634 .db_service
1635 .query_optional(query, row_to_cached_item)
1636 .await
1637 .map_err(|e| RepoError::Database { message: e })?
1638 .ok_or_else(|| RepoError::NotFound {
1639 message: format!(
1640 "Item {} not found in offline cache or not downloaded",
1641 item_id
1642 ),
1643 })?;
1644
1645 let user_data = self.get_user_data(item_id).await;
1646 Ok(Self::cached_item_to_media_item(cached, user_data))
1647 }
1648
1649 async fn get_latest_items(
1650 &self,
1651 parent_id: &str,
1652 limit: Option<usize>,
1653 ) -> Result<Vec<MediaItem>, RepoError> {
1654 let limit_val = limit.unwrap_or(16);
1655
1656 let query = Query::with_params(
1657 format!(
1658 "WITH downloaded_items AS (
1659 -- Playable items with completed downloads
1660 SELECT DISTINCT i.id
1661 FROM items i
1662 INNER JOIN downloads d ON i.id = d.item_id
1663 WHERE d.status = 'completed'
1664 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1665
1666 UNION
1667
1668 -- Containers with downloaded children
1669 SELECT DISTINCT i.id
1670 FROM items i
1671 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)
1672 INNER JOIN downloads d ON children.id = d.item_id
1673 WHERE d.status = 'completed'
1674 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1675 )
1676 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1677 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1678 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1679 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1680 i.parent_index_number, i.is_folder, i.premiere_date
1681 FROM items i
1682 INNER JOIN downloaded_items di ON i.id = di.id
1683 WHERE i.server_id = ? AND i.library_id = ?
1684 -- Collapse leaves into the container that was added: a new
1685 -- 14-track album should read as one album, not 14 songs. Only
1686 -- drops a leaf when its own container is present in the same
1687 -- result, so a standalone track or movie still appears.
1688 AND NOT EXISTS (
1689 SELECT 1 FROM downloaded_items parent
1690 WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
1691 )
1692 ORDER BY i.synced_at DESC
1693 LIMIT {}", limit_val
1694 ),
1695 vec![
1696 QueryParam::String(self.server_id.clone()),
1697 QueryParam::String(parent_id.to_string()),
1698 ],
1699 );
1700
1701 let cached_items: Vec<CachedItem> = self
1702 .db_service
1703 .query_many(query, row_to_cached_item)
1704 .await
1705 .map_err(|e| RepoError::Database { message: e })?;
1706
1707 let mut items = Vec::new();
1708 for cached in cached_items {
1709 let user_data = self.get_user_data(&cached.id).await;
1710 items.push(Self::cached_item_to_media_item(cached, user_data));
1711 }
1712
1713 Ok(items)
1714 }
1715
1716 async fn get_resume_items(
1717 &self,
1718 parent_id: Option<&str>,
1719 limit: Option<usize>,
1720 ) -> Result<Vec<MediaItem>, RepoError> {
1721 let limit_val = limit.unwrap_or(12);
1722
1723 let (sql, params) = if let Some(pid) = parent_id {
1725 (
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.library_id = ?
1737 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1738 AND d.status = 'completed'
1739 AND i.item_type IN ('Movie', 'Episode')
1740 ORDER BY ud.last_played_at DESC
1741 LIMIT {}",
1742 limit_val
1743 ),
1744 vec![
1745 QueryParam::String(self.server_id.clone()),
1746 QueryParam::String(self.user_id.clone()),
1747 QueryParam::String(pid.to_string()),
1748 ],
1749 )
1750 } else {
1751 (
1752 format!(
1753 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1754 i.overview, i.genres, i.runtime_ticks, i.production_year,
1755 i.community_rating, i.official_rating, i.primary_image_tag,
1756 i.album_id, i.album_name, i.album_artist, i.artists,
1757 i.index_number, i.series_id, i.series_name, i.season_id,
1758 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1759 FROM items i
1760 JOIN user_data ud ON i.id = ud.item_id
1761 INNER JOIN downloads d ON i.id = d.item_id
1762 WHERE i.server_id = ? AND ud.user_id = ?
1763 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1764 AND d.status = 'completed'
1765 AND i.item_type IN ('Movie', 'Episode')
1766 ORDER BY ud.last_played_at DESC
1767 LIMIT {}",
1768 limit_val
1769 ),
1770 vec![
1771 QueryParam::String(self.server_id.clone()),
1772 QueryParam::String(self.user_id.clone()),
1773 ],
1774 )
1775 };
1776
1777 let query = Query::with_params(sql, params);
1778
1779 let cached_items: Vec<CachedItem> = self
1780 .db_service
1781 .query_many(query, row_to_cached_item)
1782 .await
1783 .map_err(|e| RepoError::Database { message: e })?;
1784
1785 let mut items = Vec::new();
1786 for cached in cached_items {
1787 let user_data = self.get_user_data(&cached.id).await;
1788 items.push(Self::cached_item_to_media_item(cached, user_data));
1789 }
1790
1791 Ok(items)
1792 }
1793
1794 async fn get_next_up_episodes(
1795 &self,
1796 _series_id: Option<&str>,
1797 _limit: Option<usize>,
1798 ) -> Result<Vec<MediaItem>, RepoError> {
1799 Ok(Vec::new())
1802 }
1803
1804 async fn get_recently_played_audio(
1805 &self,
1806 limit: Option<usize>,
1807 ) -> Result<Vec<MediaItem>, RepoError> {
1808 let limit_val = limit.unwrap_or(12);
1809
1810 let query = Query::with_params(
1815 format!(
1816 "WITH downloaded_items AS (
1817 -- Playable items with completed downloads (Audio tracks)
1818 SELECT DISTINCT i.id
1819 FROM items i
1820 INNER JOIN downloads d ON i.id = d.item_id
1821 WHERE d.status = 'completed'
1822 AND i.item_type = 'Audio'
1823
1824 UNION
1825
1826 -- Containers with downloaded children (Albums)
1827 SELECT DISTINCT i.id
1828 FROM items i
1829 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)
1830 INNER JOIN downloads d ON children.id = d.item_id
1831 WHERE d.status = 'completed'
1832 AND i.item_type = 'MusicAlbum'
1833 ),
1834 ranked_plays AS (
1835 SELECT
1836 CASE
1837 WHEN ud.playback_context_type = 'container' THEN ud.playback_context_id
1838 WHEN ud.playback_context_type = 'single' THEN ud.item_id
1839 ELSE COALESCE(i.album_id, ud.item_id)
1840 END AS display_id,
1841 MAX(ud.last_played_at) AS most_recent_play
1842 FROM user_data ud
1843 JOIN items i ON ud.item_id = i.id
1844 WHERE ud.user_id = ? AND i.server_id = ?
1845 AND i.item_type = 'Audio'
1846 AND ud.last_played_at IS NOT NULL
1847 GROUP BY display_id
1848 ORDER BY most_recent_play DESC
1849 LIMIT {}
1850 )
1851 SELECT DISTINCT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1852 i.overview, i.genres, i.runtime_ticks, i.production_year,
1853 i.community_rating, i.official_rating, i.primary_image_tag,
1854 i.album_id, i.album_name, i.album_artist, i.artists,
1855 i.index_number, i.series_id, i.series_name, i.season_id,
1856 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1857 FROM ranked_plays rp
1858 JOIN items i ON rp.display_id = i.id
1859 INNER JOIN downloaded_items di ON i.id = di.id
1860 ORDER BY rp.most_recent_play DESC",
1861 limit_val
1862 ),
1863 vec![
1864 QueryParam::String(self.user_id.clone()),
1865 QueryParam::String(self.server_id.clone()),
1866 ],
1867 );
1868
1869 let cached_items: Vec<CachedItem> = self
1870 .db_service
1871 .query_many(query, row_to_cached_item)
1872 .await
1873 .map_err(|e| RepoError::Database { message: e })?;
1874
1875 let mut items = Vec::new();
1876 for cached in cached_items {
1877 let user_data = self.get_user_data(&cached.id).await;
1878 items.push(Self::cached_item_to_media_item(cached, user_data));
1879 }
1880
1881 Ok(items)
1882 }
1883
1884 async fn get_rediscover_albums(
1885 &self,
1886 _parent_id: Option<&str>,
1887 _limit: Option<usize>,
1888 ) -> Result<Vec<MediaItem>, RepoError> {
1889 Ok(Vec::new())
1893 }
1894
1895 async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
1896 let limit_val = limit.unwrap_or(12);
1897
1898 let query = Query::with_params(
1900 format!(
1901 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1902 i.overview, i.genres, i.runtime_ticks, i.production_year,
1903 i.community_rating, i.official_rating, i.primary_image_tag,
1904 i.album_id, i.album_name, i.album_artist, i.artists,
1905 i.index_number, i.series_id, i.series_name, i.season_id,
1906 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1907 FROM items i
1908 JOIN user_data ud ON i.id = ud.item_id
1909 INNER JOIN downloads d ON i.id = d.item_id
1910 WHERE i.server_id = ? AND ud.user_id = ? AND i.item_type = 'Movie'
1911 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1912 AND d.status = 'completed'
1913 ORDER BY ud.last_played_at DESC
1914 LIMIT {}",
1915 limit_val
1916 ),
1917 vec![
1918 QueryParam::String(self.server_id.clone()),
1919 QueryParam::String(self.user_id.clone()),
1920 ],
1921 );
1922
1923 let cached_items: Vec<CachedItem> = self
1924 .db_service
1925 .query_many(query, row_to_cached_item)
1926 .await
1927 .map_err(|e| RepoError::Database { message: e })?;
1928
1929 let mut items = Vec::new();
1930 for cached in cached_items {
1931 let user_data = self.get_user_data(&cached.id).await;
1932 items.push(Self::cached_item_to_media_item(cached, user_data));
1933 }
1934
1935 Ok(items)
1936 }
1937
1938 async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1939 let library_id = parent_id.unwrap_or("").to_string();
1944
1945 let query = Query::with_params(
1946 "SELECT id, name, album_count FROM genres WHERE server_id = ? AND library_id = ?",
1947 vec![
1948 QueryParam::String(self.server_id.clone()),
1949 QueryParam::String(library_id),
1950 ],
1951 );
1952
1953 let genres: Vec<Genre> = self
1954 .db_service
1955 .query_many(query, |row| {
1956 Ok(Genre {
1957 id: row.get(0)?,
1958 name: row.get(1)?,
1959 album_count: row.get::<_, Option<i64>>(2)?.map(|c| c as u32),
1960 })
1961 })
1962 .await
1963 .map_err(|e| RepoError::Database { message: e })?;
1964
1965 Ok(genres)
1966 }
1967
1968 async fn search(
1969 &self,
1970 query: &str,
1971 options: Option<SearchOptions>,
1972 ) -> Result<SearchResult, RepoError> {
1973 let opts = options.unwrap_or_default();
1974 let limit = opts.limit.unwrap_or(20);
1975
1976 let Some(fts_query) = build_fts_prefix_query(query) else {
1979 return Ok(SearchResult {
1980 items: Vec::new(),
1981 total_record_count: 0,
1982 });
1983 };
1984
1985 let type_values: &[String] = opts
1989 .include_item_types
1990 .as_deref()
1991 .filter(|types| !types.is_empty())
1992 .unwrap_or(&[]);
1993 let type_filter = if type_values.is_empty() {
1994 String::new()
1995 } else {
1996 let placeholders = vec!["?"; type_values.len()].join(",");
1997 format!(" AND i.item_type IN ({})", placeholders)
1998 };
1999
2000 let catalog_branch = if include_catalog_browse() {
2006 "UNION
2007
2008 -- Synced catalog: fast online search, or the offline
2009 -- 'Show all server media' view. See set_include_catalog_browse.
2010 SELECT DISTINCT i.id
2011 FROM items i
2012 WHERE i.synced_at IS NOT NULL"
2013 } else {
2014 ""
2015 };
2016
2017 let sql = format!(
2018 "WITH available_items AS (
2019 -- Playable items with completed downloads
2020 SELECT DISTINCT i.id
2021 FROM items i
2022 INNER JOIN downloads d ON i.id = d.item_id
2023 WHERE d.status = 'completed'
2024 AND i.item_type IN ('Audio', 'Movie', 'Episode')
2025
2026 UNION
2027
2028 -- Containers with downloaded children
2029 SELECT DISTINCT i.id
2030 FROM items i
2031 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)
2032 INNER JOIN downloads d ON children.id = d.item_id
2033 WHERE d.status = 'completed'
2034 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
2035
2036 {}
2037 )
2038 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
2039 i.overview, i.genres, i.runtime_ticks, i.production_year,
2040 i.community_rating, i.official_rating, i.primary_image_tag,
2041 i.album_id, i.album_name, i.album_artist, i.artists,
2042 i.index_number, i.series_id, i.series_name, i.season_id,
2043 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
2044 FROM items i
2045 JOIN items_fts fts ON fts.rowid = i.rowid
2046 INNER JOIN available_items ai ON i.id = ai.id
2047 WHERE i.server_id = ? AND items_fts MATCH ?{}
2048 ORDER BY rank
2049 LIMIT {}",
2050 catalog_branch, type_filter, limit
2051 );
2052
2053 let mut params = vec![
2054 QueryParam::String(self.server_id.clone()),
2055 QueryParam::String(fts_query.clone()),
2056 ];
2057 params.extend(type_values.iter().cloned().map(QueryParam::String));
2058
2059 let db_query = Query::with_params(sql, params);
2060
2061 let cached_items: Vec<CachedItem> = self
2062 .db_service
2063 .query_many(db_query, row_to_cached_item)
2064 .await
2065 .map_err(|e| RepoError::Database { message: e })?;
2066
2067 let mut items = Vec::new();
2068 for cached in cached_items {
2069 let user_data = self.get_user_data(&cached.id).await;
2070 items.push(Self::cached_item_to_media_item(cached, user_data));
2071 }
2072
2073 if type_values.is_empty() {
2079 items.extend(self.search_people(&fts_query, limit).await?);
2080 }
2081
2082 let total_record_count = items.len();
2083
2084 Ok(SearchResult {
2085 items,
2086 total_record_count,
2087 })
2088 }
2089
2090 async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
2091 Err(RepoError::Offline)
2093 }
2094
2095 async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
2096 Err(RepoError::Offline)
2098 }
2099
2100 async fn get_audio_only_stream_url_for_video(
2101 &self,
2102 _item_id: &str,
2103 _media_source_id: Option<&str>,
2104 _start_time_seconds: Option<f64>,
2105 _audio_stream_index: Option<i32>,
2106 ) -> Result<String, RepoError> {
2107 Err(RepoError::Offline)
2109 }
2110
2111 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
2112 Err(RepoError::Offline)
2114 }
2115
2116 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
2117 Err(RepoError::Offline)
2119 }
2120
2121 async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
2122 Err(RepoError::Offline)
2124 }
2125
2126 async fn report_playback_start(
2127 &self,
2128 _item_id: &str,
2129 _position_ticks: i64,
2130 ) -> Result<(), RepoError> {
2131 Err(RepoError::Offline)
2133 }
2134
2135 async fn report_playback_progress(
2136 &self,
2137 _item_id: &str,
2138 _position_ticks: i64,
2139 ) -> Result<(), RepoError> {
2140 Err(RepoError::Offline)
2142 }
2143
2144 async fn report_playback_stopped(
2145 &self,
2146 _item_id: &str,
2147 _position_ticks: i64,
2148 ) -> Result<(), RepoError> {
2149 Err(RepoError::Offline)
2151 }
2152
2153 fn get_image_url(
2154 &self,
2155 item_id: &str,
2156 image_type: ImageType,
2157 options: Option<ImageOptions>,
2158 ) -> String {
2159 let type_str = match image_type {
2162 ImageType::Primary => "Primary",
2163 ImageType::Backdrop => "Backdrop",
2164 ImageType::Logo => "Logo",
2165 ImageType::Thumb => "Thumb",
2166 ImageType::Banner => "Banner",
2167 };
2168
2169 if let Some(opts) = options {
2170 if let Some(tag) = opts.tag {
2171 return format!("offline://{}/{}/{}", item_id, type_str, tag);
2172 }
2173 }
2174
2175 format!("offline://{}/{}", item_id, type_str)
2176 }
2177
2178 fn get_subtitle_url(
2179 &self,
2180 _item_id: &str,
2181 _media_source_id: &str,
2182 _stream_index: i32,
2183 _format: &str,
2184 ) -> String {
2185 String::new()
2187 }
2188
2189 fn get_video_download_url(
2190 &self,
2191 _item_id: &str,
2192 _quality: &str,
2193 _media_source_id: Option<&str>,
2194 _source_audio_codec: Option<&str>,
2195 ) -> String {
2196 String::new()
2198 }
2199
2200 async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
2201 Err(RepoError::Offline)
2203 }
2204
2205 async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
2206 Err(RepoError::Offline)
2208 }
2209
2210 async fn get_favorites(
2219 &self,
2220 scope: SearchScope,
2221 options: Option<GetItemsOptions>,
2222 ) -> Result<SearchResult, RepoError> {
2223 let opts = options.unwrap_or_default();
2224 let limit = opts.limit.unwrap_or(10000);
2225 let start_index = opts.start_index.unwrap_or(0);
2226
2227 let type_filter = match scope.item_types() {
2230 Some(types) if !types.is_empty() => {
2231 let placeholders = vec!["?"; types.len()].join(",");
2232 format!(" AND i.item_type IN ({})", placeholders)
2233 }
2234 _ => String::new(),
2235 };
2236
2237 let catalog_branch = if include_catalog_browse() {
2238 "UNION
2239
2240 SELECT DISTINCT i.id
2241 FROM items i
2242 WHERE i.synced_at IS NOT NULL"
2243 } else {
2244 ""
2245 };
2246
2247 let sql = format!(
2248 "WITH available_items AS (
2249 SELECT DISTINCT i.id
2250 FROM items i
2251 INNER JOIN downloads d ON i.id = d.item_id
2252 WHERE d.status = 'completed'
2253 AND i.item_type IN ('Audio', 'Movie', 'Episode')
2254
2255 UNION
2256
2257 SELECT DISTINCT i.id
2258 FROM items i
2259 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)
2260 INNER JOIN downloads d ON children.id = d.item_id
2261 WHERE d.status = 'completed'
2262 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
2263
2264 {catalog_branch}
2265 )
2266 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
2267 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
2268 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
2269 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
2270 i.parent_index_number, i.is_folder, i.premiere_date
2271 FROM items i
2272 INNER JOIN available_items ai ON i.id = ai.id
2273 INNER JOIN user_data ud ON ud.item_id = i.id
2274 WHERE i.server_id = ?
2275 AND ud.user_id = ?
2276 AND ud.is_favorite = 1{}
2277 ORDER BY i.sort_name ASC, i.name ASC
2278 LIMIT {} OFFSET {}",
2279 type_filter, limit, start_index
2280 );
2281
2282 let mut params = vec![
2283 QueryParam::String(self.server_id.clone()),
2284 QueryParam::String(self.user_id.clone()),
2285 ];
2286 if let Some(types) = scope.item_types() {
2287 params.extend(types.into_iter().map(QueryParam::String));
2288 }
2289
2290 let cached_items: Vec<CachedItem> = self
2291 .db_service
2292 .query_many(Query::with_params(sql, params), row_to_cached_item)
2293 .await
2294 .map_err(|e| RepoError::Database { message: e })?;
2295
2296 let mut items = Vec::new();
2297 for cached in cached_items {
2298 let user_data = self.get_user_data(&cached.id).await;
2299 items.push(Self::cached_item_to_media_item(cached, user_data));
2300 }
2301
2302 let total_record_count = items.len();
2303 debug!("[OfflineRepo] Returning {} favourites", total_record_count);
2304
2305 Ok(SearchResult {
2306 items,
2307 total_record_count,
2308 })
2309 }
2310
2311 async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
2312 Err(RepoError::Offline)
2315 }
2316
2317 async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
2318 Err(RepoError::Offline)
2321 }
2322
2323 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
2324 let query = Query::with_params(
2325 "SELECT id, name, overview, primary_image_tag
2326 FROM people WHERE id = ?",
2327 vec![QueryParam::String(person_id.to_string())],
2328 );
2329
2330 let person_data = self
2331 .db_service
2332 .query_optional(query, |row| {
2333 Ok((
2334 row.get::<_, String>(0)?,
2335 row.get::<_, String>(1)?,
2336 row.get::<_, Option<String>>(2)?,
2337 row.get::<_, Option<String>>(3)?,
2338 ))
2339 })
2340 .await
2341 .map_err(|e| RepoError::Database { message: e })?
2342 .ok_or_else(|| RepoError::NotFound {
2343 message: format!("Person {} not found in cache", person_id),
2344 })?;
2345
2346 Ok(MediaItem {
2347 id: person_data.0,
2348 name: person_data.1,
2349 item_type: "Person".to_string(),
2350 kind: crate::domain::MediaKind::Person,
2351 is_folder: false,
2352 server_id: self.server_id.clone(),
2353 parent_id: None,
2354 library_id: None,
2355 overview: person_data.2,
2356 genres: None,
2357 runtime_ticks: None,
2358 duration_ms: None,
2359 production_year: None,
2360 premiere_date: None,
2361 community_rating: None,
2362 official_rating: None,
2363 primary_image_tag: person_data.3.clone(),
2364 image_id: person_data.3,
2365 backdrop_image_tags: None,
2366 parent_backdrop_image_tags: None,
2367 album_id: None,
2368 album_name: None,
2369 album_artist: None,
2370 artists: None,
2371 artist_items: None,
2372 index_number: None,
2373 series_id: None,
2374 series_name: None,
2375 season_id: None,
2376 season_name: None,
2377 parent_index_number: None,
2378 user_data: None,
2379 media_streams: None,
2380 media_sources: None,
2381 people: None,
2382 })
2383 }
2384
2385 async fn get_items_by_person(
2386 &self,
2387 person_id: &str,
2388 options: Option<GetItemsOptions>,
2389 ) -> Result<SearchResult, RepoError> {
2390 let opts = options.unwrap_or_default();
2391 let limit = opts.limit.unwrap_or(10000); let query = Query::with_params(
2395 format!(
2396 "WITH downloaded_items AS (
2397 -- Playable items with completed downloads
2398 SELECT DISTINCT i.id
2399 FROM items i
2400 INNER JOIN downloads d ON i.id = d.item_id
2401 WHERE d.status = 'completed'
2402 AND i.item_type IN ('Audio', 'Movie', 'Episode')
2403
2404 UNION
2405
2406 -- Containers with downloaded children
2407 SELECT DISTINCT i.id
2408 FROM items i
2409 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)
2410 INNER JOIN downloads d ON children.id = d.item_id
2411 WHERE d.status = 'completed'
2412 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
2413 )
2414 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
2415 i.overview, i.genres, i.runtime_ticks, i.production_year,
2416 i.community_rating, i.official_rating, i.primary_image_tag,
2417 i.album_id, i.album_name, i.album_artist, i.artists,
2418 i.index_number, i.series_id, i.series_name, i.season_id,
2419 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
2420 FROM items i
2421 JOIN item_people ip ON i.id = ip.item_id
2422 INNER JOIN downloaded_items di ON i.id = di.id
2423 WHERE i.server_id = ? AND ip.person_id = ?
2424 ORDER BY i.production_year DESC, i.sort_name ASC
2425 LIMIT {}", limit
2426 ),
2427 vec![
2428 QueryParam::String(self.server_id.clone()),
2429 QueryParam::String(person_id.to_string()),
2430 ],
2431 );
2432
2433 let cached_items: Vec<CachedItem> = self
2434 .db_service
2435 .query_many(query, row_to_cached_item)
2436 .await
2437 .map_err(|e| RepoError::Database { message: e })?;
2438
2439 let mut items = Vec::new();
2440 for cached in cached_items {
2441 let user_data = self.get_user_data(&cached.id).await;
2442 items.push(Self::cached_item_to_media_item(cached, user_data));
2443 }
2444
2445 let total_record_count = items.len();
2446
2447 Ok(SearchResult {
2448 items,
2449 total_record_count,
2450 })
2451 }
2452
2453 async fn get_similar_items(
2454 &self,
2455 _item_id: &str,
2456 _limit: Option<usize>,
2457 ) -> Result<SearchResult, RepoError> {
2458 Err(RepoError::Offline)
2460 }
2461
2462 async fn create_playlist(
2465 &self,
2466 name: &str,
2467 item_ids: &[String],
2468 ) -> Result<PlaylistCreatedResult, RepoError> {
2469 let playlist_id = uuid::Uuid::new_v4().to_string();
2470 let user_id = self.user_id.clone();
2471 let name = name.to_string();
2472 let item_ids = item_ids.to_vec();
2473 let pid = playlist_id.clone();
2474
2475 self.db_service
2476 .transaction(move |tx| {
2477 use crate::storage::db_service::{Query, QueryParam};
2478
2479 tx.execute(Query::with_params(
2480 "INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)",
2481 vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)],
2482 ))?;
2483
2484 for (i, item_id) in item_ids.iter().enumerate() {
2485 tx.execute(Query::with_params(
2486 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
2487 vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)],
2488 ))?;
2489 }
2490
2491 Ok(())
2492 })
2493 .await
2494 .map_err(|e| RepoError::Database {
2495 message: format!("Failed to create playlist: {}", e),
2496 })?;
2497
2498 Ok(PlaylistCreatedResult { id: playlist_id })
2499 }
2500
2501 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
2502 let query = Query::with_params(
2503 "DELETE FROM playlists WHERE id = ?",
2504 vec![QueryParam::String(playlist_id.to_string())],
2505 );
2506 self.db_service
2507 .execute(query)
2508 .await
2509 .map_err(|e| RepoError::Database {
2510 message: format!("Failed to delete playlist: {}", e),
2511 })?;
2512 Ok(())
2513 }
2514
2515 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
2516 let query = Query::with_params(
2517 "UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
2518 vec![
2519 QueryParam::String(name.to_string()),
2520 QueryParam::String(playlist_id.to_string()),
2521 ],
2522 );
2523 self.db_service
2524 .execute(query)
2525 .await
2526 .map_err(|e| RepoError::Database {
2527 message: format!("Failed to rename playlist: {}", e),
2528 })?;
2529 Ok(())
2530 }
2531
2532 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
2533 let query = Query::with_params(
2534 "SELECT pi.id, \
2535 i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \
2536 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
2537 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
2538 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
2539 i.parent_index_number, i.is_folder, i.premiere_date \
2540 FROM playlist_items pi \
2541 JOIN items i ON pi.item_id = i.id \
2542 WHERE pi.playlist_id = ? \
2543 ORDER BY pi.sort_order ASC",
2544 vec![QueryParam::String(playlist_id.to_string())],
2545 );
2546
2547 let items = self
2548 .db_service
2549 .query_many(query, |row| {
2550 let entry_id: i64 = row.get(0)?;
2551 let cached = CachedItem {
2553 id: row.get(1)?,
2554 name: row.get(2)?,
2555 item_type: row.get(3)?,
2556 server_id: row.get(4)?,
2557 parent_id: row.get(5)?,
2558 library_id: row.get(6)?,
2559 overview: row.get(7)?,
2560 genres: row.get(8)?,
2561 runtime_ticks: row.get(9)?,
2562 production_year: row.get(10)?,
2563 community_rating: row.get(11)?,
2564 official_rating: row.get(12)?,
2565 primary_image_tag: row.get(13)?,
2566 backdrop_image_tags: None,
2567 parent_backdrop_image_tags: None,
2568 album_id: row.get(14)?,
2569 album_name: row.get(15)?,
2570 album_artist: row.get(16)?,
2571 artists: row.get(17)?,
2572 index_number: row.get(18)?,
2573 series_id: row.get(19)?,
2574 series_name: row.get(20)?,
2575 season_id: row.get(21)?,
2576 season_name: row.get(22)?,
2577 parent_index_number: row.get(23)?,
2578 is_folder: row.get::<_, Option<i64>>(24)?.unwrap_or(0) != 0,
2579 premiere_date: row.get(25)?,
2580 };
2581 Ok((entry_id.to_string(), cached))
2582 })
2583 .await
2584 .map_err(|e| RepoError::Database {
2585 message: format!("Failed to get playlist items: {}", e),
2586 })?;
2587
2588 Ok(items
2589 .into_iter()
2590 .map(|(entry_id, cached)| PlaylistEntry {
2591 playlist_item_id: entry_id,
2592 item: Self::cached_item_to_media_item(cached, None),
2593 })
2594 .collect())
2595 }
2596
2597 async fn add_to_playlist(
2598 &self,
2599 playlist_id: &str,
2600 item_ids: &[String],
2601 ) -> Result<(), RepoError> {
2602 let max_query = Query::with_params(
2604 "SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?",
2605 vec![QueryParam::String(playlist_id.to_string())],
2606 );
2607 let max_order: i32 = self
2608 .db_service
2609 .query_one(max_query, |row| row.get(0))
2610 .await
2611 .unwrap_or(-1);
2612
2613 let playlist_id = playlist_id.to_string();
2614 let item_ids = item_ids.to_vec();
2615
2616 self.db_service
2617 .transaction(move |tx| {
2618 use crate::storage::db_service::{Query, QueryParam};
2619
2620 for (i, item_id) in item_ids.iter().enumerate() {
2621 tx.execute(Query::with_params(
2622 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
2623 vec![
2624 QueryParam::String(playlist_id.clone()),
2625 QueryParam::String(item_id.clone()),
2626 QueryParam::Int(max_order + 1 + i as i32),
2627 ],
2628 ))?;
2629 }
2630 Ok(())
2631 })
2632 .await
2633 .map_err(|e| RepoError::Database {
2634 message: format!("Failed to add items to playlist: {}", e),
2635 })?;
2636
2637 Ok(())
2638 }
2639
2640 async fn remove_from_playlist(
2641 &self,
2642 playlist_id: &str,
2643 entry_ids: &[String],
2644 ) -> Result<(), RepoError> {
2645 let playlist_id = playlist_id.to_string();
2646 let entry_ids = entry_ids.to_vec();
2647
2648 self.db_service
2649 .transaction(move |tx| {
2650 use crate::storage::db_service::{Query, QueryParam};
2651
2652 for entry_id in &entry_ids {
2653 tx.execute(Query::with_params(
2654 "DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?",
2655 vec![
2656 QueryParam::String(playlist_id.clone()),
2657 QueryParam::String(entry_id.clone()),
2658 ],
2659 ))?;
2660 }
2661 Ok(())
2662 })
2663 .await
2664 .map_err(|e| RepoError::Database {
2665 message: format!("Failed to remove items from playlist: {}", e),
2666 })?;
2667
2668 Ok(())
2669 }
2670
2671 async fn move_playlist_item(
2672 &self,
2673 playlist_id: &str,
2674 item_id: &str,
2675 new_index: u32,
2676 ) -> Result<(), RepoError> {
2677 let playlist_id = playlist_id.to_string();
2678 let item_id = item_id.to_string();
2679
2680 self.db_service
2681 .transaction(move |tx| {
2682 use crate::storage::db_service::{Query, QueryParam};
2683
2684 let items: Vec<(i64, String)> = tx.query_many(
2686 Query::with_params(
2687 "SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order",
2688 vec![QueryParam::String(playlist_id)],
2689 ),
2690 |row| Ok((row.get(0)?, row.get(1)?)),
2691 )?;
2692
2693 let old_idx = items.iter().position(|(_, iid)| iid == &item_id);
2695 if let Some(old_pos) = old_idx {
2696 let mut ids = items;
2697 let entry = ids.remove(old_pos);
2698 let insert_at = (new_index as usize).min(ids.len());
2699 ids.insert(insert_at, entry);
2700
2701 for (i, (entry_id, _)) in ids.iter().enumerate() {
2703 tx.execute(Query::with_params(
2704 "UPDATE playlist_items SET sort_order = ? WHERE id = ?",
2705 vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)],
2706 ))?;
2707 }
2708 }
2709
2710 Ok(())
2711 })
2712 .await
2713 .map_err(|e| RepoError::Database {
2714 message: format!("Failed to move playlist item: {}", e),
2715 })?;
2716
2717 Ok(())
2718 }
2719}
2720
2721#[cfg(test)]
2722mod tests {
2723 #![allow(clippy::await_holding_lock)]
2732
2733 use super::*;
2734 use crate::storage::db_service::RusqliteService;
2735 use rusqlite::Connection;
2736 use std::sync::{Arc, Mutex};
2737
2738 static CATALOG_BROWSE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2745
2746 fn lock_catalog_browse() -> std::sync::MutexGuard<'static, ()> {
2747 use crate::utils::lock::MutexSafe;
2748 CATALOG_BROWSE_LOCK.lock_safe()
2749 }
2750
2751 #[test]
2753 fn test_build_fts_prefix_query() {
2754 assert_eq!(build_fts_prefix_query("Arr").as_deref(), Some("\"Arr\"*"));
2756
2757 assert_eq!(
2760 build_fts_prefix_query("parks rec").as_deref(),
2761 Some("\"parks\" \"rec\"*")
2762 );
2763
2764 for query in ["Bob's Burgers", "Spider-Man", "AC/DC", "Wall-E", "9-1-1"] {
2767 let built = build_fts_prefix_query(query).expect("should build");
2768 assert!(
2769 built.starts_with('"') && built.ends_with("*"),
2770 "{query:?} produced {built:?}"
2771 );
2772 }
2773
2774 assert_eq!(
2777 build_fts_prefix_query("say \"hi\"").as_deref(),
2778 Some("\"say\" \"\"\"hi\"\"\"*")
2779 );
2780
2781 assert_eq!(build_fts_prefix_query(""), None);
2784 assert_eq!(build_fts_prefix_query(" "), None);
2785 assert_eq!(build_fts_prefix_query("-"), None);
2786 }
2787
2788 #[tokio::test]
2793 async fn test_search_empty_query_returns_empty_not_error() {
2794 let db_service = create_test_db();
2795 let repo = OfflineRepository::new(
2796 db_service,
2797 "test-server".to_string(),
2798 "test-user".to_string(),
2799 );
2800
2801 let result = repo.search("", None).await;
2802 assert!(
2803 result.is_ok(),
2804 "empty query must not error: {:?}",
2805 result.err()
2806 );
2807 assert!(result.unwrap().items.is_empty());
2808 }
2809
2810 fn create_test_db() -> Arc<RusqliteService> {
2811 Arc::new(RusqliteService::new(create_test_conn()))
2812 }
2813
2814 fn create_test_conn() -> Arc<Mutex<Connection>> {
2817 let conn = Connection::open_in_memory().unwrap();
2818
2819 conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
2821
2822 conn.execute_batch(
2824 r#"
2825 CREATE TABLE servers (
2826 id TEXT PRIMARY KEY,
2827 name TEXT NOT NULL,
2828 url TEXT NOT NULL UNIQUE
2829 );
2830
2831 CREATE TABLE items (
2832 id TEXT PRIMARY KEY,
2833 server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
2834 library_id TEXT,
2835 parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
2836 name TEXT NOT NULL,
2837 item_type TEXT NOT NULL,
2838 is_folder INTEGER DEFAULT 0,
2839 overview TEXT,
2840 genres TEXT,
2841 runtime_ticks INTEGER,
2842 production_year INTEGER,
2843 premiere_date TEXT,
2844 community_rating REAL,
2845 official_rating TEXT,
2846 primary_image_tag TEXT,
2847 backdrop_image_tags TEXT,
2848 album_id TEXT,
2849 album_name TEXT,
2850 album_artist TEXT,
2851 artists TEXT,
2852 index_number INTEGER,
2853 series_id TEXT,
2854 series_name TEXT,
2855 season_id TEXT,
2856 season_name TEXT,
2857 parent_index_number INTEGER,
2858 synced_at TEXT,
2859 sort_name TEXT
2860 );
2861
2862 -- Mirrors the real FTS5 index and its triggers (schema.rs migration
2863 -- 001) so search can be exercised in tests at all.
2864 CREATE VIRTUAL TABLE items_fts USING fts5(
2865 name, overview, album_name, album_artist, artists, series_name,
2866 content='items', content_rowid='rowid'
2867 );
2868
2869 CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
2870 INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
2871 VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
2872 END;
2873
2874 CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
2875 INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
2876 VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
2877 END;
2878
2879 CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
2880 INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
2881 VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
2882 INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
2883 VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
2884 END;
2885
2886 CREATE TABLE user_data (
2887 user_id TEXT NOT NULL,
2888 item_id TEXT NOT NULL,
2889 playback_position_ticks INTEGER,
2890 is_played INTEGER,
2891 is_favorite INTEGER,
2892 play_count INTEGER,
2893 last_played_at TEXT,
2894 playback_context_type TEXT,
2895 playback_context_id TEXT,
2896 synced_at TEXT,
2897 pending_sync INTEGER DEFAULT 0,
2898 PRIMARY KEY (user_id, item_id)
2899 );
2900
2901 CREATE TABLE playlists (
2902 id TEXT PRIMARY KEY,
2903 user_id TEXT NOT NULL,
2904 name TEXT NOT NULL,
2905 is_local INTEGER DEFAULT 0,
2906 jellyfin_id TEXT,
2907 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
2908 updated_at TEXT
2909 );
2910
2911 CREATE TABLE playlist_items (
2912 id INTEGER PRIMARY KEY AUTOINCREMENT,
2913 playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
2914 item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
2915 sort_order INTEGER NOT NULL,
2916 added_at TEXT DEFAULT CURRENT_TIMESTAMP,
2917 UNIQUE(playlist_id, item_id)
2918 );
2919
2920 CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
2921
2922 CREATE TABLE downloads (
2923 id INTEGER PRIMARY KEY AUTOINCREMENT,
2924 item_id TEXT NOT NULL,
2925 user_id TEXT,
2926 file_path TEXT,
2927 status TEXT NOT NULL,
2928 file_size INTEGER
2929 );
2930
2931 CREATE TABLE libraries (
2932 id TEXT PRIMARY KEY,
2933 server_id TEXT NOT NULL,
2934 name TEXT NOT NULL,
2935 collection_type TEXT,
2936 image_tag TEXT,
2937 sort_order INTEGER DEFAULT 0,
2938 synced_at TEXT
2939 );
2940
2941 -- Mirrors migration 009 + the migration 022 FTS index.
2942 CREATE TABLE people (
2943 id TEXT PRIMARY KEY,
2944 server_id TEXT NOT NULL,
2945 name TEXT NOT NULL,
2946 overview TEXT,
2947 primary_image_tag TEXT,
2948 premiere_date TEXT,
2949 end_date TEXT,
2950 synced_at TEXT DEFAULT CURRENT_TIMESTAMP
2951 );
2952
2953 CREATE VIRTUAL TABLE people_fts USING fts5(
2954 name, overview, content='people', content_rowid='rowid'
2955 );
2956
2957 CREATE TRIGGER people_ai AFTER INSERT ON people BEGIN
2958 INSERT INTO people_fts(rowid, name, overview)
2959 VALUES (new.rowid, new.name, new.overview);
2960 END;
2961
2962 CREATE TRIGGER people_ad AFTER DELETE ON people BEGIN
2963 INSERT INTO people_fts(people_fts, rowid, name, overview)
2964 VALUES('delete', old.rowid, old.name, old.overview);
2965 END;
2966
2967 CREATE TRIGGER people_au AFTER UPDATE ON people BEGIN
2968 INSERT INTO people_fts(people_fts, rowid, name, overview)
2969 VALUES('delete', old.rowid, old.name, old.overview);
2970 INSERT INTO people_fts(rowid, name, overview)
2971 VALUES (new.rowid, new.name, new.overview);
2972 END;
2973
2974 CREATE TABLE genres (
2975 id TEXT NOT NULL,
2976 server_id TEXT NOT NULL,
2977 library_id TEXT,
2978 name TEXT NOT NULL,
2979 album_count INTEGER,
2980 synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
2981 PRIMARY KEY (server_id, library_id, name)
2982 );
2983 "#,
2984 )
2985 .unwrap();
2986
2987 conn.execute(
2989 "INSERT INTO servers (id, name, url) VALUES ('test-server', 'Test Server', 'http://test')",
2990 [],
2991 ).unwrap();
2992
2993 Arc::new(Mutex::new(conn))
2994 }
2995
2996 #[tokio::test]
3009 async fn test_a_downloaded_item_gets_playback_info_without_the_server() {
3010 let db_service = create_test_db();
3011 for sql in [
3012 "INSERT INTO downloads (item_id, user_id, file_path, status) \
3013 VALUES ('ep-6', 'test-user', '/data/videos/S01E06.mp4', 'completed')",
3014 "INSERT INTO downloads (item_id, user_id, file_path, status) \
3016 VALUES ('ep-7', 'test-user', '/data/videos/S01E07.mp4', 'downloading')",
3017 "INSERT INTO downloads (item_id, user_id, file_path, status) \
3019 VALUES ('ep-8', 'other-user', '/data/videos/S01E08.mp4', 'completed')",
3020 ] {
3021 db_service.execute(Query::new(sql)).await.unwrap();
3022 }
3023 let offline = OfflineRepository::new(
3024 db_service.clone(),
3025 "test-server".to_string(),
3026 "test-user".to_string(),
3027 );
3028 let local = OfflineRepository::new(
3029 db_service,
3030 "test-server".to_string(),
3031 "test-user".to_string(),
3032 );
3033 let online = crate::repository::OnlineRepository::new(
3035 Arc::new(
3036 crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
3037 ),
3038 "http://127.0.0.1:9".to_string(),
3039 "test-user".to_string(),
3040 "test-token".to_string(),
3041 );
3042 let hybrid = crate::repository::HybridRepository::new(online, offline);
3043
3044 let started = std::time::Instant::now();
3045 let info = hybrid
3046 .get_playback_info("ep-6")
3047 .await
3048 .expect("a downloaded item must get playback info with no server");
3049 assert!(
3050 started.elapsed() < std::time::Duration::from_secs(1),
3051 "answered locally, not after the network gave up: {:?}",
3052 started.elapsed()
3053 );
3054 assert_eq!(info.stream_url, "/data/videos/S01E06.mp4");
3055 assert!(info.direct_play && !info.needs_transcoding);
3056 assert_eq!(info.media_source_id, "ep-6");
3059
3060 for not_local in ["ep-7", "ep-8", "never-downloaded"] {
3062 assert!(
3063 local
3064 .local_playback_info(not_local)
3065 .await
3066 .unwrap()
3067 .is_none(),
3068 "{not_local} has no completed local file for this user"
3069 );
3070 }
3071 }
3072
3073 #[tokio::test]
3081 async fn test_next_up_answers_from_the_cache_when_the_server_is_unreachable() {
3082 let offline = OfflineRepository::new(
3083 create_test_db(),
3084 "test-server".to_string(),
3085 "test-user".to_string(),
3086 );
3087 let online = crate::repository::OnlineRepository::new(
3088 Arc::new(
3089 crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
3090 ),
3091 "http://127.0.0.1:9".to_string(),
3092 "test-user".to_string(),
3093 "test-token".to_string(),
3094 );
3095 let hybrid = crate::repository::HybridRepository::new(online, offline);
3096
3097 let next_up = hybrid.get_next_up_episodes(None, Some(12)).await;
3098 assert!(
3099 next_up.is_ok(),
3100 "an unreachable server must not fail Next Up offline: {next_up:?}"
3101 );
3102 }
3103
3104 #[tokio::test]
3118 async fn test_get_items_waits_for_a_slow_cache_when_the_server_is_unreachable() {
3119 let _guard = lock_catalog_browse();
3120 set_include_catalog_browse(true);
3121 let conn = create_test_conn();
3122 let db_service = Arc::new(RusqliteService::new(Arc::clone(&conn)));
3123 for sql in [
3124 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3125 VALUES ('series-1', 'test-server', 'Show', 'Series', '2026-01-01')",
3126 "INSERT INTO items (id, server_id, parent_id, name, item_type, synced_at) \
3127 VALUES ('season-1', 'test-server', 'series-1', 'Season 1', 'Season', '2026-01-01')",
3128 ] {
3129 db_service.execute(Query::new(sql)).await.unwrap();
3130 }
3131 let offline = OfflineRepository::new(
3132 db_service,
3133 "test-server".to_string(),
3134 "test-user".to_string(),
3135 );
3136 let online = crate::repository::OnlineRepository::new(
3137 Arc::new(
3138 crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
3139 ),
3140 "http://127.0.0.1:9".to_string(),
3141 "test-user".to_string(),
3142 "test-token".to_string(),
3143 );
3144 let hybrid = crate::repository::HybridRepository::new(online, offline);
3145
3146 let quick = hybrid
3149 .get_items("series-1", None)
3150 .await
3151 .expect("unlocked read");
3152 assert_eq!(quick.items.len(), 1, "fixture: the season is cached");
3153
3154 let held = Arc::clone(&conn);
3156 let writer = std::thread::spawn(move || {
3157 let _lock = held.lock().unwrap();
3158 std::thread::sleep(std::time::Duration::from_millis(300));
3159 });
3160 std::thread::sleep(std::time::Duration::from_millis(20));
3161
3162 let slow = hybrid.get_items("series-1", None).await;
3163 writer.join().unwrap();
3164 let slow = slow.expect("a slow cache must still answer when the server cannot");
3165 assert_eq!(slow.items.len(), 1);
3166 assert_eq!(slow.items[0].id, "season-1");
3167 }
3168
3169 fn hybrid_without_server(conn: &Arc<Mutex<Connection>>) -> crate::repository::HybridRepository {
3171 let offline = OfflineRepository::new(
3172 Arc::new(RusqliteService::new(Arc::clone(conn))),
3173 "test-server".to_string(),
3174 "test-user".to_string(),
3175 );
3176 let online = crate::repository::OnlineRepository::new(
3177 Arc::new(
3178 crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
3179 ),
3180 "http://127.0.0.1:9".to_string(),
3181 "test-user".to_string(),
3182 "test-token".to_string(),
3183 );
3184 crate::repository::HybridRepository::new(online, offline)
3185 }
3186
3187 fn hold_database(conn: &Arc<Mutex<Connection>>, ms: u64) -> std::thread::JoinHandle<()> {
3189 let held = Arc::clone(conn);
3190 let writer = std::thread::spawn(move || {
3191 let _lock = held.lock().unwrap();
3192 std::thread::sleep(std::time::Duration::from_millis(ms));
3193 });
3194 std::thread::sleep(std::time::Duration::from_millis(20));
3195 writer
3196 }
3197
3198 #[tokio::test]
3205 async fn test_libraries_wait_for_a_slow_cache_when_the_server_is_unreachable() {
3206 let conn = create_test_conn();
3207 conn.lock()
3208 .unwrap()
3209 .execute(
3210 "INSERT INTO libraries (id, server_id, name, collection_type) \
3211 VALUES ('lib-tv', 'test-server', 'Shows', 'tvshows')",
3212 [],
3213 )
3214 .unwrap();
3215 let hybrid = hybrid_without_server(&conn);
3216
3217 let writer = hold_database(&conn, 300);
3218 let libs = hybrid.get_libraries().await;
3219 writer.join().unwrap();
3220
3221 let libs = libs.expect("a slow cache must still answer when the server cannot");
3222 assert_eq!(libs.len(), 1);
3223 assert_eq!(libs[0].id, "lib-tv");
3224 }
3225
3226 #[tokio::test]
3232 async fn test_cache_only_reads_wait_out_a_busy_database() {
3233 let conn = create_test_conn();
3234 let hybrid = hybrid_without_server(&conn);
3235 hybrid
3237 .search_cache_only("anything", None)
3238 .await
3239 .expect("unlocked search");
3240
3241 let writer = hold_database(&conn, 300);
3242 let search = hybrid.search_cache_only("anything", None).await;
3243 writer.join().unwrap();
3244 search.expect("a busy database delays a cache-only search, it must not fail it");
3245
3246 let writer = hold_database(&conn, 300);
3247 let favourites = hybrid
3248 .get_favorites_cache_only(crate::repository::SearchScope::All, None)
3249 .await;
3250 writer.join().unwrap();
3251 favourites.expect("a busy database delays cache-only favourites, it must not fail them");
3252 }
3253
3254 fn create_test_item(id: &str, name: &str, parent_id: Option<&str>) -> MediaItem {
3255 MediaItem {
3256 id: id.to_string(),
3257 name: name.to_string(),
3258 item_type: "Audio".to_string(),
3259 kind: crate::domain::MediaKind::Track,
3260 is_folder: false,
3261 server_id: "test-server".to_string(),
3262 parent_id: parent_id.map(|s| s.to_string()),
3263 library_id: None,
3264 overview: None,
3265 genres: None,
3266 runtime_ticks: None,
3267 duration_ms: None,
3268 production_year: None,
3269 premiere_date: None,
3270 community_rating: None,
3271 official_rating: None,
3272 primary_image_tag: None,
3273 image_id: None,
3274 backdrop_image_tags: None,
3275 parent_backdrop_image_tags: None,
3276 album_id: None,
3277 album_name: None,
3278 album_artist: None,
3279 artists: None,
3280 artist_items: None,
3281 index_number: None,
3282 series_id: None,
3283 series_name: None,
3284 season_id: None,
3285 season_name: None,
3286 parent_index_number: None,
3287 user_data: None,
3288 media_streams: None,
3289 media_sources: None,
3290 people: None,
3291 }
3292 }
3293
3294 #[tokio::test]
3295 async fn test_save_to_cache_with_missing_parent_fk() {
3296 let db_service = create_test_db();
3297
3298 let fk_enabled: i32 = db_service
3300 .query_one(Query::new("PRAGMA foreign_keys"), |row| row.get(0))
3301 .await
3302 .unwrap();
3303 println!("Foreign keys enabled: {}", fk_enabled);
3304 assert_eq!(fk_enabled, 1, "Foreign keys should be enabled");
3305
3306 let repo = OfflineRepository::new(
3307 db_service.clone(),
3308 "test-server".to_string(),
3309 "test-user".to_string(),
3310 );
3311
3312 let items = vec![
3316 create_test_item("track-1", "Track 1", Some("album-1")),
3318 create_test_item("track-2", "Track 2", Some("album-1")),
3319 create_test_item("track-3", "Track 3", Some("album-2")),
3321 create_test_item("track-4", "Track 4", Some("album-2")),
3322 create_test_item("album-1", "Album One", Some("library-123")),
3324 create_test_item("album-2", "Album Two", Some("library-123")),
3325 ];
3326
3327 println!("Attempting to save {} items...", items.len());
3328 for (i, item) in items.iter().enumerate() {
3329 println!(" Item {}: {} (parent: {:?})", i, item.id, item.parent_id);
3330 }
3331
3332 let result = repo.save_to_cache("library-123", &items).await;
3337
3338 match &result {
3340 Ok(count) => {
3341 println!("✓ Saved {} items", count);
3342 assert_eq!(*count, 6);
3343
3344 let all_items: Vec<(String, Option<String>)> = db_service
3346 .query_many(
3347 Query::new("SELECT id, parent_id FROM items ORDER BY id"),
3348 |row| Ok((row.get(0)?, row.get(1)?)),
3349 )
3350 .await
3351 .unwrap();
3352
3353 println!("\nAll items in database:");
3354 for (id, parent) in &all_items {
3355 println!(" {} -> parent: {:?}", id, parent);
3356 }
3357
3358 let track1_parent: Option<String> = db_service
3360 .query_optional(
3361 Query::with_params(
3362 "SELECT parent_id FROM items WHERE id = ?",
3363 vec![QueryParam::String("track-1".to_string())],
3364 ),
3365 |row| row.get(0),
3366 )
3367 .await
3368 .unwrap()
3369 .flatten();
3370
3371 println!("\ntrack-1 parent_id in DB: {:?}", track1_parent);
3372 println!("track-1 expected parent_id: Some(\"album-1\")");
3373
3374 assert_eq!(
3376 track1_parent,
3377 Some("album-1".to_string()),
3378 "track-1 should have parent_id='album-1'"
3379 );
3380
3381 let album1_parent: Option<String> = db_service
3383 .query_optional(
3384 Query::with_params(
3385 "SELECT parent_id FROM items WHERE id = ?",
3386 vec![QueryParam::String("album-1".to_string())],
3387 ),
3388 |row| row.get(0),
3389 )
3390 .await
3391 .unwrap()
3392 .flatten();
3393
3394 assert_eq!(
3395 album1_parent,
3396 Some("library-123".to_string()),
3397 "album-1 should have parent_id='library-123'"
3398 );
3399 }
3400 Err(e) => panic!("Unexpected error: {:?}", e),
3401 }
3402 }
3403
3404 #[tokio::test]
3405 async fn test_save_to_cache_simple_case() {
3406 let db_service = create_test_db();
3407 let repo = OfflineRepository::new(
3408 db_service.clone(),
3409 "test-server".to_string(),
3410 "test-user".to_string(),
3411 );
3412
3413 let items = vec![
3415 create_test_item("item-1", "Item 1", Some("parent-123")),
3416 create_test_item("item-2", "Item 2", Some("parent-123")),
3417 create_test_item("item-3", "Item 3", Some("parent-123")),
3418 ];
3419
3420 let result = repo.save_to_cache("parent-123", &items).await;
3421 assert!(result.is_ok(), "Simple case should work: {:?}", result);
3422 assert_eq!(result.unwrap(), 3);
3423 }
3424
3425 #[tokio::test]
3433 async fn test_get_item_album_available_via_album_id_link() {
3434 use crate::storage::db_service::DatabaseService;
3435 let db_service = create_test_db();
3436
3437 for sql in [
3438 "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
3440 VALUES ('album-1', 'test-server', 'Hadestown', 'MusicAlbum', NULL, NULL)",
3441 "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
3443 VALUES ('track-1', 'test-server', 'Wait For Me', 'Audio', 'album-1', NULL)",
3444 "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
3445 ] {
3446 db_service.execute(Query::new(sql)).await.unwrap();
3447 }
3448
3449 let repo = OfflineRepository::new(
3450 db_service.clone(),
3451 "test-server".to_string(),
3452 "test-user".to_string(),
3453 );
3454
3455 assert!(
3457 repo.get_item("track-1").await.is_ok(),
3458 "downloaded track should be available offline"
3459 );
3460
3461 let album = repo.get_item("album-1").await;
3464 assert!(
3465 album.is_ok(),
3466 "album with an album_id-linked downloaded track should be available offline, got {:?}",
3467 album.err()
3468 );
3469 assert_eq!(album.unwrap().id, "album-1");
3470
3471 let tracks = repo.get_items("album-1", None).await.unwrap();
3475 assert_eq!(
3476 tracks.items.len(),
3477 1,
3478 "get_items(album_id) should return the track"
3479 );
3480 assert_eq!(tracks.items[0].id, "track-1");
3481 }
3482
3483 #[tokio::test]
3496 async fn test_get_items_toggle_gates_synced_catalog() {
3497 use crate::storage::db_service::DatabaseService;
3498 let _guard = lock_catalog_browse();
3499 let db_service = create_test_db();
3500
3501 for sql in [
3502 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3504 VALUES ('movie-dl', 'test-server', 'Downloaded', 'Movie', 'lib-1', '2026-01-01')",
3505 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3506 VALUES ('movie-cat', 'test-server', 'CatalogOnly', 'Movie', 'lib-1', '2026-01-01')",
3507 "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
3509 "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
3511 ] {
3512 db_service.execute(Query::new(sql)).await.unwrap();
3513 }
3514
3515 let repo = OfflineRepository::new(
3516 db_service.clone(),
3517 "test-server".to_string(),
3518 "test-user".to_string(),
3519 );
3520 let opts = Some(GetItemsOptions {
3521 include_item_types: Some(vec!["Movie".to_string()]),
3522 ..Default::default()
3523 });
3524
3525 set_include_catalog_browse(false);
3527 let local_only = repo.get_items("lib-1", opts.clone()).await.unwrap();
3528 let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
3529 assert_eq!(
3530 ids,
3531 vec!["movie-dl"],
3532 "toggle off should show downloaded media only"
3533 );
3534
3535 set_include_catalog_browse(true);
3537 let full_catalog = repo.get_items("lib-1", opts).await.unwrap();
3538 let mut ids: Vec<&str> = full_catalog.items.iter().map(|i| i.id.as_str()).collect();
3539 ids.sort();
3540 assert_eq!(
3541 ids,
3542 vec!["movie-cat", "movie-dl"],
3543 "toggle on should reveal the full catalog"
3544 );
3545
3546 set_include_catalog_browse(true);
3548 }
3549
3550 #[tokio::test]
3557 async fn test_search_includes_cached_people() {
3558 use crate::storage::db_service::DatabaseService;
3559 let _guard = lock_catalog_browse();
3560 let db_service = create_test_db();
3561
3562 for sql in [
3563 "INSERT INTO people (id, server_id, name, overview) \
3564 VALUES ('p1', 'test-server', 'Tilda Swinton', 'Actor')",
3565 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3568 VALUES ('m1', 'test-server', 'Tilda the Movie', 'Movie', '2026-01-01')",
3569 ] {
3570 db_service.execute(Query::new(sql)).await.unwrap();
3571 }
3572
3573 let repo = OfflineRepository::new(
3574 db_service.clone(),
3575 "test-server".to_string(),
3576 "test-user".to_string(),
3577 );
3578 set_include_catalog_browse(true);
3579
3580 let all = repo.search("Tilda", None).await.unwrap();
3582 let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
3583 ids.sort();
3584 assert_eq!(ids, vec!["m1", "p1"], "unscoped search must include people");
3585
3586 let person = all.items.iter().find(|i| i.id == "p1").unwrap();
3587 assert_eq!(person.item_type, "Person");
3588 assert_eq!(person.kind, crate::domain::MediaKind::Person);
3589
3590 let scoped = repo
3592 .search(
3593 "Tilda",
3594 Some(SearchOptions {
3595 include_item_types: Some(vec!["Movie".to_string()]),
3596 ..Default::default()
3597 }),
3598 )
3599 .await
3600 .unwrap();
3601 let ids: Vec<&str> = scoped.items.iter().map(|i| i.id.as_str()).collect();
3602 assert_eq!(ids, vec!["m1"], "a scoped search must not leak people in");
3603
3604 set_include_catalog_browse(true);
3605 }
3606
3607 #[tokio::test]
3614 async fn test_prune_stale_catalog() {
3615 use crate::storage::db_service::DatabaseService;
3616 let db_service = create_test_db();
3617
3618 let old = "2026-01-01T00:00:00+00:00";
3620 let new = "2026-06-01T00:00:00+00:00";
3621 let cutoff = "2026-03-01T00:00:00+00:00";
3622
3623 for sql in [
3624 "INSERT INTO servers (id, name, url) \
3626 VALUES ('other-server', 'Other', 'http://other')"
3627 .to_string(),
3628 format!(
3630 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3631 VALUES ('keep-fresh', 'test-server', 'Fresh', 'Movie', '{new}')"
3632 ),
3633 format!(
3635 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3636 VALUES ('gone', 'test-server', 'Vanished', 'Movie', '{old}')"
3637 ),
3638 format!(
3640 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3641 VALUES ('keep-dl', 'test-server', 'Downloaded', 'Movie', '{old}')"
3642 ),
3643 "INSERT INTO downloads (item_id, status) VALUES ('keep-dl', 'completed')".to_string(),
3644 format!(
3646 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3647 VALUES ('keep-album', 'test-server', 'Album', 'MusicAlbum', '{old}')"
3648 ),
3649 format!(
3650 "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
3651 VALUES ('keep-track', 'test-server', 'Track', 'Audio', 'keep-album', '{old}')"
3652 ),
3653 "INSERT INTO downloads (item_id, status) VALUES ('keep-track', 'completed')"
3654 .to_string(),
3655 format!(
3658 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3659 VALUES ('keep-artist', 'test-server', 'Artist', 'MusicArtist', '{old}')"
3660 ),
3661 format!(
3663 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3664 VALUES ('keep-other', 'other-server', 'Elsewhere', 'Movie', '{old}')"
3665 ),
3666 ] {
3667 db_service.execute(Query::new(&sql)).await.unwrap();
3668 }
3669
3670 let repo = OfflineRepository::new(
3671 db_service.clone(),
3672 "test-server".to_string(),
3673 "test-user".to_string(),
3674 );
3675
3676 let crawled_types = vec![
3677 "Movie".to_string(),
3678 "MusicAlbum".to_string(),
3679 "Audio".to_string(),
3680 ];
3681 let removed = repo
3682 .prune_stale_catalog(cutoff, &crawled_types)
3683 .await
3684 .unwrap();
3685 assert_eq!(removed, 1, "only the vanished movie should be swept");
3686
3687 let mut surviving: Vec<String> = db_service
3688 .query_many(Query::new("SELECT id FROM items"), |row| row.get(0))
3689 .await
3690 .unwrap();
3691 surviving.sort();
3692 assert_eq!(
3693 surviving,
3694 vec![
3695 "keep-album",
3696 "keep-artist",
3697 "keep-dl",
3698 "keep-fresh",
3699 "keep-other",
3700 "keep-track",
3701 ]
3702 );
3703
3704 assert_eq!(repo.prune_stale_catalog(cutoff, &[]).await.unwrap(), 0);
3706 }
3707
3708 #[tokio::test]
3723 async fn test_repeated_cache_does_not_duplicate_fts_entries() {
3724 use crate::storage::db_service::DatabaseService;
3725 let db_service = create_test_db();
3726 let repo = OfflineRepository::new(
3727 db_service.clone(),
3728 "test-server".to_string(),
3729 "test-user".to_string(),
3730 );
3731
3732 let items = vec![create_test_item("track-1", "Wait For Me", None)];
3733
3734 for _ in 0..3 {
3737 repo.save_to_cache("parent-1", &items).await.unwrap();
3738 }
3739
3740 let item_rows: i64 = db_service
3743 .query_one(
3744 Query::new("SELECT COUNT(*) FROM items WHERE id = 'track-1'"),
3745 |row| row.get(0),
3746 )
3747 .await
3748 .unwrap();
3749 assert_eq!(item_rows, 1, "three passes must leave one item row");
3750
3751 let fts_hits: i64 = db_service
3752 .query_one(
3753 Query::with_params(
3754 "SELECT COUNT(*) FROM items_fts WHERE items_fts MATCH ?",
3755 vec![QueryParam::String("\"Wait\"*".to_string())],
3756 ),
3757 |row| row.get(0),
3758 )
3759 .await
3760 .unwrap();
3761 assert_eq!(
3762 fts_hits, 1,
3763 "the FTS index must hold one entry per item, not one per sync pass"
3764 );
3765 }
3766
3767 #[tokio::test]
3776 async fn test_search_toggle_gates_synced_catalog() {
3777 use crate::storage::db_service::DatabaseService;
3778 let _guard = lock_catalog_browse();
3779 let db_service = create_test_db();
3780
3781 for sql in [
3782 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3784 VALUES ('movie-dl', 'test-server', 'Arrival', 'Movie', 'lib-1', '2026-01-01')",
3785 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3786 VALUES ('movie-cat', 'test-server', 'Arrakis', 'Movie', 'lib-1', '2026-01-01')",
3787 "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
3789 ] {
3790 db_service.execute(Query::new(sql)).await.unwrap();
3791 }
3792
3793 let repo = OfflineRepository::new(
3794 db_service.clone(),
3795 "test-server".to_string(),
3796 "test-user".to_string(),
3797 );
3798
3799 set_include_catalog_browse(true);
3802 let full = repo.search("Arr", None).await.unwrap();
3803 let mut ids: Vec<&str> = full.items.iter().map(|i| i.id.as_str()).collect();
3804 ids.sort();
3805 assert_eq!(
3806 ids,
3807 vec!["movie-cat", "movie-dl"],
3808 "with catalog browse on, search must cover synced-but-not-downloaded items"
3809 );
3810
3811 set_include_catalog_browse(false);
3813 let local_only = repo.search("Arr", None).await.unwrap();
3814 let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
3815 assert_eq!(
3816 ids,
3817 vec!["movie-dl"],
3818 "with catalog browse off, search stays downloads-only"
3819 );
3820
3821 set_include_catalog_browse(true);
3823 }
3824
3825 #[tokio::test]
3832 async fn test_search_type_filter_is_parameterised() {
3833 use crate::storage::db_service::DatabaseService;
3834 let _guard = lock_catalog_browse();
3835 let db_service = create_test_db();
3836
3837 db_service
3838 .execute(Query::new(
3839 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3840 VALUES ('m1', 'test-server', 'Arrival', 'Movie', '2026-01-01')",
3841 ))
3842 .await
3843 .unwrap();
3844
3845 let repo = OfflineRepository::new(
3846 db_service.clone(),
3847 "test-server".to_string(),
3848 "test-user".to_string(),
3849 );
3850 set_include_catalog_browse(true);
3851
3852 let opts = SearchOptions {
3854 include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
3855 ..Default::default()
3856 };
3857 let result = repo.search("Arr", Some(opts)).await;
3858 assert!(
3859 result.is_ok(),
3860 "a quote in an item type must not break the query: {:?}",
3861 result.err()
3862 );
3863 assert!(
3864 result.unwrap().items.is_empty(),
3865 "an injected type filter must not widen the result set"
3866 );
3867
3868 let opts = SearchOptions {
3870 include_item_types: Some(vec!["Movie".to_string()]),
3871 ..Default::default()
3872 };
3873 assert_eq!(repo.search("Arr", Some(opts)).await.unwrap().items.len(), 1);
3874 }
3875
3876 #[tokio::test]
3881 async fn test_get_item_tv_available_via_season_series_link() {
3882 use crate::storage::db_service::DatabaseService;
3883 let db_service = create_test_db();
3884
3885 for sql in [
3886 "INSERT INTO items (id, server_id, name, item_type, parent_id) \
3887 VALUES ('series-1', 'test-server', 'Gilmore Girls', 'Series', NULL)",
3888 "INSERT INTO items (id, server_id, name, item_type, series_id, parent_id) \
3889 VALUES ('season-1', 'test-server', 'Season 1', 'Season', 'series-1', NULL)",
3890 "INSERT INTO items (id, server_id, name, item_type, season_id, series_id, parent_id) \
3892 VALUES ('ep-1', 'test-server', 'Pilot', 'Episode', 'season-1', 'series-1', NULL)",
3893 "INSERT INTO downloads (item_id, status) VALUES ('ep-1', 'completed')",
3894 ] {
3895 db_service.execute(Query::new(sql)).await.unwrap();
3896 }
3897
3898 let repo = OfflineRepository::new(
3899 db_service.clone(),
3900 "test-server".to_string(),
3901 "test-user".to_string(),
3902 );
3903
3904 assert!(
3905 repo.get_item("ep-1").await.is_ok(),
3906 "downloaded episode available offline"
3907 );
3908 assert!(
3909 repo.get_item("season-1").await.is_ok(),
3910 "season with a season_id-linked downloaded episode should be available offline"
3911 );
3912 assert!(
3913 repo.get_item("series-1").await.is_ok(),
3914 "series with a series_id-linked downloaded episode should be available offline"
3915 );
3916
3917 let season_items = repo.get_items("season-1", None).await.unwrap();
3919 assert!(
3920 season_items.items.iter().any(|i| i.id == "ep-1"),
3921 "get_items(season_id) should return the episode"
3922 );
3923
3924 let series_items = repo.get_items("series-1", None).await.unwrap();
3926 assert!(
3927 series_items.items.iter().any(|i| i.id == "ep-1"),
3928 "get_items(series_id) should surface the downloaded episode"
3929 );
3930 }
3931
3932 #[tokio::test]
3937 async fn test_libraries_cache_roundtrip_available_offline() {
3938 let db_service = create_test_db();
3939 let repo = OfflineRepository::new(
3940 db_service.clone(),
3941 "test-server".to_string(),
3942 "test-user".to_string(),
3943 );
3944
3945 assert!(repo.get_libraries().await.unwrap().is_empty());
3948
3949 let server_libs = vec![
3951 Library::new("music".into(), "Music".into(), "music".into(), None),
3952 Library::new(
3953 "movies".into(),
3954 "Movies".into(),
3955 "movies".into(),
3956 Some("tag".into()),
3957 ),
3958 ];
3959 let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
3960 assert_eq!(saved, 2);
3961
3962 let offline_libs = repo.get_libraries().await.unwrap();
3964 let names: Vec<&str> = offline_libs.iter().map(|l| l.name.as_str()).collect();
3965 assert_eq!(
3966 names,
3967 vec!["Music", "Movies"],
3968 "cached libraries available offline in sort order"
3969 );
3970
3971 repo.save_libraries_to_cache(&server_libs).await.unwrap();
3973 assert_eq!(repo.get_libraries().await.unwrap().len(), 2);
3974 }
3975
3976 #[tokio::test]
3983 async fn test_genres_cache_roundtrip_scoped_by_library() {
3984 let db_service = create_test_db();
3985 let repo = OfflineRepository::new(
3986 db_service.clone(),
3987 "test-server".to_string(),
3988 "test-user".to_string(),
3989 );
3990
3991 assert!(repo.get_genres(Some("music-lib")).await.unwrap().is_empty());
3993
3994 let server_genres = vec![
3996 Genre {
3997 id: "g1".into(),
3998 name: "Rock".into(),
3999 album_count: Some(42),
4000 },
4001 Genre {
4002 id: "g2".into(),
4003 name: "Jazz".into(),
4004 album_count: Some(17),
4005 },
4006 Genre {
4007 id: "g3".into(),
4008 name: "Ambient".into(),
4009 album_count: None,
4010 },
4011 ];
4012 let saved = repo
4013 .save_genres_to_cache(Some("music-lib"), &server_genres)
4014 .await
4015 .unwrap();
4016 assert_eq!(saved, 3);
4017
4018 let mut offline_genres = repo.get_genres(Some("music-lib")).await.unwrap();
4020 offline_genres.sort_by(|a, b| a.name.cmp(&b.name));
4021 let names: Vec<&str> = offline_genres.iter().map(|g| g.name.as_str()).collect();
4022 assert_eq!(names, vec!["Ambient", "Jazz", "Rock"]);
4023 let rock = offline_genres.iter().find(|g| g.name == "Rock").unwrap();
4024 assert_eq!(rock.album_count, Some(42));
4025
4026 assert!(repo.get_genres(Some("other-lib")).await.unwrap().is_empty());
4028
4029 let updated = vec![Genre {
4031 id: "g1".into(),
4032 name: "Rock".into(),
4033 album_count: Some(50),
4034 }];
4035 repo.save_genres_to_cache(Some("music-lib"), &updated)
4036 .await
4037 .unwrap();
4038 let after = repo.get_genres(Some("music-lib")).await.unwrap();
4039 assert_eq!(after.len(), 1, "stale genres removed on refresh");
4040 assert_eq!(after[0].album_count, Some(50), "counts updated on refresh");
4041 }
4042
4043 async fn seed_items(repo: &OfflineRepository, ids: &[&str]) {
4047 let items: Vec<MediaItem> = ids
4048 .iter()
4049 .map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1")))
4050 .collect();
4051 repo.save_to_cache("library-1", &items).await.unwrap();
4052 }
4053
4054 async fn insert_item(
4057 db: &Arc<RusqliteService>,
4058 id: &str,
4059 item_type: &str,
4060 album_id: Option<&str>,
4061 series_id: Option<&str>,
4062 season_id: Option<&str>,
4063 ) {
4064 db.execute(Query::with_params(
4065 "INSERT INTO items (id, server_id, name, item_type, album_id, series_id, season_id, synced_at)
4066 VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, ?6, '2024-01-01')",
4067 vec![
4068 QueryParam::String(id.to_string()),
4069 QueryParam::String(format!("Name {id}")),
4070 QueryParam::String(item_type.to_string()),
4071 album_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
4072 series_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
4073 season_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
4074 ],
4075 ))
4076 .await
4077 .unwrap();
4078 }
4079
4080 async fn insert_library_item(
4083 db: &Arc<RusqliteService>,
4084 id: &str,
4085 item_type: &str,
4086 library_id: &str,
4087 album_id: Option<&str>,
4088 ) {
4089 db.execute(Query::with_params(
4090 "INSERT INTO items (id, server_id, library_id, name, item_type, album_id, synced_at)
4091 VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, '2024-01-01')",
4092 vec![
4093 QueryParam::String(id.to_string()),
4094 QueryParam::String(library_id.to_string()),
4095 QueryParam::String(format!("Name {id}")),
4096 QueryParam::String(item_type.to_string()),
4097 album_id
4098 .map(|s| QueryParam::String(s.to_string()))
4099 .unwrap_or(QueryParam::Null),
4100 ],
4101 ))
4102 .await
4103 .unwrap();
4104 }
4105
4106 async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
4107 db.execute(Query::with_params(
4108 "INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
4109 vec![
4110 QueryParam::String(item_id.to_string()),
4111 QueryParam::Int64(file_size),
4112 ],
4113 ))
4114 .await
4115 .unwrap();
4116 }
4117
4118 async fn seed_library(db: &Arc<RusqliteService>, id: &str, collection_type: &str) {
4119 db.execute(Query::with_params(
4120 "INSERT INTO libraries (id, server_id, name, collection_type, sort_order)
4121 VALUES (?1, 'test-server', ?2, ?3, 0)",
4122 vec![
4123 QueryParam::String(id.to_string()),
4124 QueryParam::String(format!("Lib {id}")),
4125 QueryParam::String(collection_type.to_string()),
4126 ],
4127 ))
4128 .await
4129 .unwrap();
4130 }
4131
4132 fn make_repo(db: &Arc<RusqliteService>) -> OfflineRepository {
4133 OfflineRepository::new(
4134 db.clone(),
4135 "test-server".to_string(),
4136 "test-user".to_string(),
4137 )
4138 }
4139
4140 #[tokio::test]
4147 async fn test_get_latest_items_collapses_tracks_into_their_album() {
4148 let db = create_test_db();
4149 insert_library_item(&db, "album-1", "MusicAlbum", "lib-1", None).await;
4150 for track in ["track-1", "track-2", "track-3"] {
4151 insert_library_item(&db, track, "Audio", "lib-1", Some("album-1")).await;
4152 seed_completed_download(&db, track, 1000).await;
4153 }
4154 insert_library_item(&db, "movie-1", "Movie", "lib-1", None).await;
4156 seed_completed_download(&db, "movie-1", 2000).await;
4157
4158 let repo = make_repo(&db);
4159 let latest = repo.get_latest_items("lib-1", Some(16)).await.unwrap();
4160 let ids: Vec<&str> = latest.iter().map(|i| i.id.as_str()).collect();
4161
4162 assert!(
4163 !ids.iter().any(|id| id.starts_with("track-")),
4164 "individual tracks must collapse into their album, got: {ids:?}"
4165 );
4166 assert!(ids.contains(&"album-1"), "the album itself is listed");
4167 assert!(ids.contains(&"movie-1"), "containerless items still listed");
4168 }
4169
4170 #[tokio::test]
4175 async fn test_get_downloaded_items_returns_leaf_and_container() {
4176 let db = create_test_db();
4177 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4178 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4179 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
4180 seed_completed_download(&db, "track-1", 1000).await;
4182
4183 let repo = make_repo(&db);
4184
4185 let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
4187 let ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
4188 assert_eq!(ids, vec!["track-1"], "only the downloaded track is listed");
4189 }
4190
4191 #[tokio::test]
4197 async fn test_get_downloaded_items_library_lists_albums_not_tracks() {
4198 let db = create_test_db();
4199 seed_library(&db, "music-lib", "music").await;
4200 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4201 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4203 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
4204 seed_completed_download(&db, "track-1", 1000).await;
4205 seed_completed_download(&db, "track-2", 1000).await;
4206
4207 let repo = make_repo(&db);
4208
4209 let at_library = repo.get_downloaded_items("music-lib", None).await.unwrap();
4211 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
4212 assert_eq!(
4213 ids,
4214 vec!["album-1"],
4215 "library browse lists the album container, not its tracks"
4216 );
4217
4218 let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
4220 let mut track_ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
4221 track_ids.sort();
4222 assert_eq!(track_ids, vec!["track-1", "track-2"]);
4223 }
4224
4225 #[tokio::test]
4237 async fn test_get_downloaded_items_library_does_not_mix_media_types() {
4238 let db = create_test_db();
4239 seed_library(&db, "music-lib", "music").await;
4240 seed_library(&db, "movie-lib", "movies").await;
4241 seed_library(&db, "tv-lib", "tvshows").await;
4242
4243 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4244 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4245 insert_item(&db, "movie-1", "Movie", None, None, None).await;
4246 insert_item(&db, "series-1", "Series", None, None, None).await;
4247 insert_item(&db, "episode-1", "Episode", None, Some("series-1"), None).await;
4248
4249 seed_completed_download(&db, "track-1", 1000).await;
4250 seed_completed_download(&db, "movie-1", 2000).await;
4251 seed_completed_download(&db, "episode-1", 3000).await;
4252
4253 let repo = make_repo(&db);
4254
4255 let music: Vec<String> = repo
4256 .get_downloaded_items("music-lib", None)
4257 .await
4258 .unwrap()
4259 .items
4260 .iter()
4261 .map(|i| i.id.clone())
4262 .collect();
4263 assert_eq!(
4264 music,
4265 vec!["album-1"],
4266 "the music library must not list films or series; got {:?}",
4267 music
4268 );
4269
4270 let movies: Vec<String> = repo
4271 .get_downloaded_items("movie-lib", None)
4272 .await
4273 .unwrap()
4274 .items
4275 .iter()
4276 .map(|i| i.id.clone())
4277 .collect();
4278 assert_eq!(
4279 movies,
4280 vec!["movie-1"],
4281 "the movie library must not list albums or series; got {:?}",
4282 movies
4283 );
4284
4285 let tv: Vec<String> = repo
4286 .get_downloaded_items("tv-lib", None)
4287 .await
4288 .unwrap()
4289 .items
4290 .iter()
4291 .map(|i| i.id.clone())
4292 .collect();
4293 assert_eq!(
4294 tv,
4295 vec!["series-1"],
4296 "the TV library must not list albums or films; got {:?}",
4297 tv
4298 );
4299 }
4300
4301 #[tokio::test]
4307 async fn test_get_downloaded_items_library_lists_series_not_episodes() {
4308 let db = create_test_db();
4309 seed_library(&db, "tv-lib", "tvshows").await;
4310 insert_item(&db, "series-1", "Series", None, None, None).await;
4311 insert_item(&db, "season-1", "Season", None, Some("series-1"), None).await;
4313 insert_item(
4314 &db,
4315 "ep-1",
4316 "Episode",
4317 None,
4318 Some("series-1"),
4319 Some("season-1"),
4320 )
4321 .await;
4322 seed_completed_download(&db, "ep-1", 4000).await;
4323
4324 let repo = make_repo(&db);
4325
4326 let at_library = repo.get_downloaded_items("tv-lib", None).await.unwrap();
4328 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
4329 assert_eq!(
4330 ids,
4331 vec!["series-1"],
4332 "TV library browse lists the series, not seasons/episodes"
4333 );
4334
4335 let in_series = repo.get_downloaded_items("series-1", None).await.unwrap();
4337 assert!(
4338 in_series.items.iter().any(|i| i.id == "season-1"),
4339 "series drill returns the season"
4340 );
4341 let in_season = repo.get_downloaded_items("season-1", None).await.unwrap();
4342 assert!(
4343 in_season.items.iter().any(|i| i.id == "ep-1"),
4344 "season drill returns the episode"
4345 );
4346 }
4347
4348 #[tokio::test]
4353 async fn test_get_downloaded_items_library_keeps_orphan_leaves() {
4354 let db = create_test_db();
4355 seed_library(&db, "movie-lib", "movies").await;
4356 insert_item(&db, "movie-1", "Movie", None, None, None).await;
4357 seed_completed_download(&db, "movie-1", 5000).await;
4358
4359 let repo = make_repo(&db);
4360 let at_library = repo.get_downloaded_items("movie-lib", None).await.unwrap();
4361 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
4362 assert_eq!(
4363 ids,
4364 vec!["movie-1"],
4365 "a downloaded movie with no container shows"
4366 );
4367 }
4368
4369 #[tokio::test]
4374 async fn test_get_downloaded_items_empty_is_authoritative() {
4375 let db = create_test_db();
4376 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4377 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4378 set_include_catalog_browse(true);
4380 let repo = make_repo(&db);
4381
4382 let result = repo.get_downloaded_items("album-1", None).await.unwrap();
4383 assert!(
4384 result.items.is_empty(),
4385 "empty downloaded browse returns no items even with catalog-browse on"
4386 );
4387 }
4388
4389 #[tokio::test]
4393 async fn test_get_downloaded_libraries_omits_empty() {
4394 let db = create_test_db();
4395 seed_library(&db, "music-lib", "music").await;
4396 seed_library(&db, "movie-lib", "movies").await;
4397 insert_item(&db, "track-1", "Audio", None, None, None).await;
4398 seed_completed_download(&db, "track-1", 500).await;
4399
4400 let repo = make_repo(&db);
4401 let libs = repo.get_downloaded_libraries().await.unwrap();
4402 let ids: Vec<&str> = libs.iter().map(|l| l.id.as_str()).collect();
4403 assert_eq!(
4404 ids,
4405 vec!["music-lib"],
4406 "movie library with no downloads omitted"
4407 );
4408 }
4409
4410 #[tokio::test]
4415 async fn test_download_disk_usage_aggregates_containers() {
4416 let db = create_test_db();
4417 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4418 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4419 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
4420 seed_completed_download(&db, "track-1", 1000).await;
4421 seed_completed_download(&db, "track-2", 2000).await;
4422
4423 let repo = make_repo(&db);
4424 let usage = repo.get_download_disk_usage().await.unwrap();
4425
4426 assert_eq!(usage.item_count, 2, "two leaf downloads");
4427 assert_eq!(
4428 usage.device_total_bytes, 3000,
4429 "device total is the leaf sum"
4430 );
4431 assert_eq!(usage.sizes.get("track-1"), Some(&1000));
4432 assert_eq!(
4433 usage.sizes.get("album-1"),
4434 Some(&3000),
4435 "container = sum of children"
4436 );
4437 assert_eq!(
4439 usage.partial_containers.get("album-1"),
4440 None,
4441 "fully downloaded album is not partial"
4442 );
4443 let leaf_sum: i64 = ["track-1", "track-2"]
4445 .iter()
4446 .map(|id| usage.sizes[*id])
4447 .sum();
4448 assert_eq!(leaf_sum, usage.device_total_bytes);
4449 }
4450
4451 #[tokio::test]
4454 async fn test_download_disk_usage_flags_partial_container() {
4455 let db = create_test_db();
4456 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4457 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4458 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
4459 seed_completed_download(&db, "track-1", 1000).await;
4461
4462 let repo = make_repo(&db);
4463 let usage = repo.get_download_disk_usage().await.unwrap();
4464 assert_eq!(
4465 usage.partial_containers.get("album-1"),
4466 Some(&true),
4467 "album with a missing child is partial"
4468 );
4469 }
4470
4471 #[tokio::test]
4472 async fn test_playlist_create_empty() {
4473 let db_service = create_test_db();
4474 let repo = OfflineRepository::new(
4475 db_service.clone(),
4476 "test-server".to_string(),
4477 "test-user".to_string(),
4478 );
4479
4480 let result = repo.create_playlist("My Playlist", &[]).await;
4481 assert!(result.is_ok());
4482 let created = result.unwrap();
4483 assert!(
4484 !created.id.is_empty(),
4485 "Should return a non-empty playlist ID"
4486 );
4487
4488 let name: String = db_service
4490 .query_one(
4491 Query::with_params(
4492 "SELECT name FROM playlists WHERE id = ?",
4493 vec![QueryParam::String(created.id.clone())],
4494 ),
4495 |row| row.get(0),
4496 )
4497 .await
4498 .unwrap();
4499 assert_eq!(name, "My Playlist");
4500 }
4501
4502 #[tokio::test]
4503 async fn test_playlist_create_with_items() {
4504 let db_service = create_test_db();
4505 let repo = OfflineRepository::new(
4506 db_service.clone(),
4507 "test-server".to_string(),
4508 "test-user".to_string(),
4509 );
4510 seed_items(&repo, &["t1", "t2", "t3"]).await;
4511
4512 let created = repo
4513 .create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()])
4514 .await
4515 .unwrap();
4516
4517 let items = repo.get_playlist_items(&created.id).await.unwrap();
4518 assert_eq!(items.len(), 3);
4519 assert_eq!(items[0].item.id, "t1");
4520 assert_eq!(items[1].item.id, "t2");
4521 assert_eq!(items[2].item.id, "t3");
4522 }
4523
4524 #[tokio::test]
4525 async fn test_playlist_delete() {
4526 let db_service = create_test_db();
4527 let repo = OfflineRepository::new(
4528 db_service.clone(),
4529 "test-server".to_string(),
4530 "test-user".to_string(),
4531 );
4532 seed_items(&repo, &["t1"]).await;
4533
4534 let created = repo
4535 .create_playlist("To Delete", &["t1".into()])
4536 .await
4537 .unwrap();
4538
4539 repo.delete_playlist(&created.id).await.unwrap();
4541
4542 let count: i32 = db_service
4544 .query_one(
4545 Query::with_params(
4546 "SELECT COUNT(*) FROM playlists WHERE id = ?",
4547 vec![QueryParam::String(created.id.clone())],
4548 ),
4549 |row| row.get(0),
4550 )
4551 .await
4552 .unwrap();
4553 assert_eq!(count, 0);
4554
4555 let item_count: i32 = db_service
4557 .query_one(
4558 Query::with_params(
4559 "SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?",
4560 vec![QueryParam::String(created.id)],
4561 ),
4562 |row| row.get(0),
4563 )
4564 .await
4565 .unwrap();
4566 assert_eq!(item_count, 0);
4567 }
4568
4569 #[tokio::test]
4570 async fn test_playlist_rename() {
4571 let db_service = create_test_db();
4572 let repo = OfflineRepository::new(
4573 db_service.clone(),
4574 "test-server".to_string(),
4575 "test-user".to_string(),
4576 );
4577
4578 let created = repo.create_playlist("Original Name", &[]).await.unwrap();
4579 repo.rename_playlist(&created.id, "New Name").await.unwrap();
4580
4581 let name: String = db_service
4582 .query_one(
4583 Query::with_params(
4584 "SELECT name FROM playlists WHERE id = ?",
4585 vec![QueryParam::String(created.id)],
4586 ),
4587 |row| row.get(0),
4588 )
4589 .await
4590 .unwrap();
4591 assert_eq!(name, "New Name");
4592 }
4593
4594 #[tokio::test]
4595 async fn test_playlist_get_items_preserves_order() {
4596 let db_service = create_test_db();
4597 let repo = OfflineRepository::new(
4598 db_service.clone(),
4599 "test-server".to_string(),
4600 "test-user".to_string(),
4601 );
4602 seed_items(&repo, &["a", "b", "c"]).await;
4603
4604 let created = repo
4605 .create_playlist("Ordered", &["c".into(), "a".into(), "b".into()])
4606 .await
4607 .unwrap();
4608 let items = repo.get_playlist_items(&created.id).await.unwrap();
4609
4610 assert_eq!(items.len(), 3);
4611 assert_eq!(items[0].item.id, "c");
4613 assert_eq!(items[1].item.id, "a");
4614 assert_eq!(items[2].item.id, "b");
4615 assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id);
4617 assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id);
4618 }
4619
4620 #[tokio::test]
4621 async fn test_playlist_get_items_empty_playlist() {
4622 let db_service = create_test_db();
4623 let repo = OfflineRepository::new(
4624 db_service.clone(),
4625 "test-server".to_string(),
4626 "test-user".to_string(),
4627 );
4628
4629 let created = repo.create_playlist("Empty", &[]).await.unwrap();
4630 let items = repo.get_playlist_items(&created.id).await.unwrap();
4631 assert!(items.is_empty());
4632 }
4633
4634 #[tokio::test]
4635 async fn test_playlist_add_items() {
4636 let db_service = create_test_db();
4637 let repo = OfflineRepository::new(
4638 db_service.clone(),
4639 "test-server".to_string(),
4640 "test-user".to_string(),
4641 );
4642 seed_items(&repo, &["t1", "t2", "t3"]).await;
4643
4644 let created = repo
4645 .create_playlist("Addable", &["t1".into()])
4646 .await
4647 .unwrap();
4648
4649 repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()])
4651 .await
4652 .unwrap();
4653
4654 let items = repo.get_playlist_items(&created.id).await.unwrap();
4655 assert_eq!(items.len(), 3);
4656 assert_eq!(items[0].item.id, "t1");
4657 assert_eq!(items[1].item.id, "t2");
4658 assert_eq!(items[2].item.id, "t3");
4659 }
4660
4661 #[tokio::test]
4662 async fn test_playlist_add_duplicate_items_ignored() {
4663 let db_service = create_test_db();
4664 let repo = OfflineRepository::new(
4665 db_service.clone(),
4666 "test-server".to_string(),
4667 "test-user".to_string(),
4668 );
4669 seed_items(&repo, &["t1"]).await;
4670
4671 let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap();
4672
4673 repo.add_to_playlist(&created.id, &["t1".into()])
4675 .await
4676 .unwrap();
4677
4678 let items = repo.get_playlist_items(&created.id).await.unwrap();
4679 assert_eq!(
4680 items.len(),
4681 1,
4682 "Duplicate should be ignored (UNIQUE constraint)"
4683 );
4684 }
4685
4686 #[tokio::test]
4687 async fn test_playlist_remove_items() {
4688 let db_service = create_test_db();
4689 let repo = OfflineRepository::new(
4690 db_service.clone(),
4691 "test-server".to_string(),
4692 "test-user".to_string(),
4693 );
4694 seed_items(&repo, &["t1", "t2", "t3"]).await;
4695
4696 let created = repo
4697 .create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()])
4698 .await
4699 .unwrap();
4700 let items = repo.get_playlist_items(&created.id).await.unwrap();
4701 assert_eq!(items.len(), 3);
4702
4703 let entry_id_to_remove = items[1].playlist_item_id.clone();
4705 repo.remove_from_playlist(&created.id, &[entry_id_to_remove])
4706 .await
4707 .unwrap();
4708
4709 let items_after = repo.get_playlist_items(&created.id).await.unwrap();
4710 assert_eq!(items_after.len(), 2);
4711 assert_eq!(items_after[0].item.id, "t1");
4712 assert_eq!(items_after[1].item.id, "t3");
4713 }
4714
4715 #[tokio::test]
4716 async fn test_playlist_move_item_forward() {
4717 let db_service = create_test_db();
4718 let repo = OfflineRepository::new(
4719 db_service.clone(),
4720 "test-server".to_string(),
4721 "test-user".to_string(),
4722 );
4723 seed_items(&repo, &["a", "b", "c", "d"]).await;
4724
4725 let created = repo
4726 .create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()])
4727 .await
4728 .unwrap();
4729
4730 repo.move_playlist_item(&created.id, "a", 2).await.unwrap();
4732
4733 let items = repo.get_playlist_items(&created.id).await.unwrap();
4734 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4735 assert_eq!(ids, vec!["b", "c", "a", "d"]);
4736 }
4737
4738 #[tokio::test]
4739 async fn test_playlist_move_item_backward() {
4740 let db_service = create_test_db();
4741 let repo = OfflineRepository::new(
4742 db_service.clone(),
4743 "test-server".to_string(),
4744 "test-user".to_string(),
4745 );
4746 seed_items(&repo, &["a", "b", "c", "d"]).await;
4747
4748 let created = repo
4749 .create_playlist(
4750 "Reorder2",
4751 &["a".into(), "b".into(), "c".into(), "d".into()],
4752 )
4753 .await
4754 .unwrap();
4755
4756 repo.move_playlist_item(&created.id, "d", 0).await.unwrap();
4758
4759 let items = repo.get_playlist_items(&created.id).await.unwrap();
4760 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4761 assert_eq!(ids, vec!["d", "a", "b", "c"]);
4762 }
4763
4764 #[tokio::test]
4765 async fn test_playlist_move_item_to_end() {
4766 let db_service = create_test_db();
4767 let repo = OfflineRepository::new(
4768 db_service.clone(),
4769 "test-server".to_string(),
4770 "test-user".to_string(),
4771 );
4772 seed_items(&repo, &["a", "b", "c"]).await;
4773
4774 let created = repo
4775 .create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()])
4776 .await
4777 .unwrap();
4778
4779 repo.move_playlist_item(&created.id, "a", 99).await.unwrap();
4781
4782 let items = repo.get_playlist_items(&created.id).await.unwrap();
4783 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4784 assert_eq!(ids, vec!["b", "c", "a"]);
4785 }
4786
4787 #[tokio::test]
4788 async fn test_playlist_move_nonexistent_item_is_noop() {
4789 let db_service = create_test_db();
4790 let repo = OfflineRepository::new(
4791 db_service.clone(),
4792 "test-server".to_string(),
4793 "test-user".to_string(),
4794 );
4795 seed_items(&repo, &["a", "b"]).await;
4796
4797 let created = repo
4798 .create_playlist("NoOp", &["a".into(), "b".into()])
4799 .await
4800 .unwrap();
4801
4802 repo.move_playlist_item(&created.id, "nonexistent", 0)
4804 .await
4805 .unwrap();
4806
4807 let items = repo.get_playlist_items(&created.id).await.unwrap();
4808 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4809 assert_eq!(ids, vec!["a", "b"]);
4810 }
4811
4812 async fn seed_favorites(db_service: &Arc<RusqliteService>) {
4815 use crate::storage::db_service::DatabaseService;
4816 for sql in [
4817 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4818 VALUES ('movie-fav', 'test-server', 'Favourite Movie', 'Movie', 'lib-1', '2026-01-01', 'Favourite Movie')",
4819 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4820 VALUES ('movie-plain', 'test-server', 'Ordinary Movie', 'Movie', 'lib-1', '2026-01-01', 'Ordinary Movie')",
4821 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4822 VALUES ('album-fav', 'test-server', 'Favourite Album', 'MusicAlbum', 'lib-2', '2026-01-01', 'Favourite Album')",
4823 "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
4824 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-fav', 1)",
4825 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-fav', 1)",
4826 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-plain', 0)",
4828 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('other-user', 'movie-plain', 1)",
4830 ] {
4831 db_service.execute(Query::new(sql)).await.unwrap();
4832 }
4833 }
4834
4835 #[tokio::test]
4840 async fn test_get_favorites_returns_only_scoped_favorites() {
4841 let _guard = lock_catalog_browse();
4842 set_include_catalog_browse(true);
4843
4844 let db_service = create_test_db();
4845 seed_favorites(&db_service).await;
4846 let repo = OfflineRepository::new(
4847 db_service,
4848 "test-server".to_string(),
4849 "test-user".to_string(),
4850 );
4851
4852 let all = repo.get_favorites(SearchScope::All, None).await.unwrap();
4853 let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
4854 ids.sort();
4855 assert_eq!(
4856 ids,
4857 vec!["album-fav", "movie-fav"],
4858 "All scope should return every favourite and nothing else"
4859 );
4860
4861 let movies = repo.get_favorites(SearchScope::Movies, None).await.unwrap();
4862 let ids: Vec<&str> = movies.items.iter().map(|i| i.id.as_str()).collect();
4863 assert_eq!(ids, vec!["movie-fav"]);
4864
4865 let music = repo.get_favorites(SearchScope::Music, None).await.unwrap();
4866 let ids: Vec<&str> = music.items.iter().map(|i| i.id.as_str()).collect();
4867 assert_eq!(ids, vec!["album-fav"]);
4868 }
4869
4870 #[tokio::test]
4875 async fn test_get_favorites_respects_catalog_browse_gate() {
4876 use crate::storage::db_service::DatabaseService;
4877 let _guard = lock_catalog_browse();
4878
4879 let db_service = create_test_db();
4880 seed_favorites(&db_service).await;
4881 db_service
4883 .execute(Query::new(
4884 "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
4885 VALUES ('track-1', 'test-server', 'Track', 'Audio', 'album-fav', '2026-01-01')",
4886 ))
4887 .await
4888 .unwrap();
4889 db_service
4890 .execute(Query::new(
4891 "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
4892 ))
4893 .await
4894 .unwrap();
4895
4896 let repo = OfflineRepository::new(
4897 db_service,
4898 "test-server".to_string(),
4899 "test-user".to_string(),
4900 );
4901
4902 set_include_catalog_browse(false);
4903 let offline_only = repo.get_favorites(SearchScope::All, None).await.unwrap();
4904 let ids: Vec<&str> = offline_only.items.iter().map(|i| i.id.as_str()).collect();
4905 assert_eq!(
4906 ids,
4907 vec!["album-fav"],
4908 "with the gate off, only favourites on the device are listed"
4909 );
4910
4911 set_include_catalog_browse(true);
4912 let with_catalog = repo.get_favorites(SearchScope::All, None).await.unwrap();
4913 assert_eq!(with_catalog.items.len(), 2);
4914 }
4915
4916 #[tokio::test]
4920 async fn test_get_items_favorites_only_filters_listing() {
4921 let _guard = lock_catalog_browse();
4922 set_include_catalog_browse(true);
4923
4924 let db_service = create_test_db();
4925 seed_favorites(&db_service).await;
4926 let repo = OfflineRepository::new(
4927 db_service,
4928 "test-server".to_string(),
4929 "test-user".to_string(),
4930 );
4931
4932 let unfiltered = repo
4933 .get_items(
4934 "lib-1",
4935 Some(GetItemsOptions {
4936 include_item_types: Some(vec!["Movie".to_string()]),
4937 ..Default::default()
4938 }),
4939 )
4940 .await
4941 .unwrap();
4942 assert_eq!(unfiltered.items.len(), 2, "both movies without the filter");
4943
4944 let favourites = repo
4945 .get_items(
4946 "lib-1",
4947 Some(GetItemsOptions {
4948 include_item_types: Some(vec!["Movie".to_string()]),
4949 favorites_only: Some(true),
4950 ..Default::default()
4951 }),
4952 )
4953 .await
4954 .unwrap();
4955 let ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
4956 assert_eq!(ids, vec!["movie-fav"]);
4957 }
4958
4959 #[tokio::test]
4970 async fn test_get_items_type_filter_is_bound_not_interpolated() {
4971 let _guard = lock_catalog_browse();
4972 set_include_catalog_browse(true);
4973
4974 let db_service = create_test_db();
4975 seed_favorites(&db_service).await;
4976 let repo = OfflineRepository::new(
4977 db_service,
4978 "test-server".to_string(),
4979 "test-user".to_string(),
4980 );
4981
4982 let injected = repo
4983 .get_items(
4984 "lib-1",
4985 Some(GetItemsOptions {
4986 include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
4987 ..Default::default()
4988 }),
4989 )
4990 .await
4991 .expect("a hostile type name must be data, not a broken query");
4992 assert!(
4993 injected.items.is_empty(),
4994 "no cached item has that type, so nothing may come back; got {:?}",
4995 injected
4996 .items
4997 .iter()
4998 .map(|i| i.id.as_str())
4999 .collect::<Vec<_>>()
5000 );
5001
5002 let quoted = repo
5004 .get_items(
5005 "lib-1",
5006 Some(GetItemsOptions {
5007 include_item_types: Some(vec!["Mo'vie".to_string()]),
5008 ..Default::default()
5009 }),
5010 )
5011 .await
5012 .expect("an embedded quote must not break the query");
5013 assert!(quoted.items.is_empty());
5014 }
5015
5016 #[tokio::test]
5023 async fn test_get_items_binds_multiple_types_in_parameter_order() {
5024 let _guard = lock_catalog_browse();
5025 set_include_catalog_browse(true);
5026
5027 let db_service = create_test_db();
5028 seed_favorites(&db_service).await;
5029
5030 db_service
5037 .execute(Query::new(
5038 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
5039 VALUES ('album-lib1', 'test-server', 'Album In Lib One', 'MusicAlbum', 'lib-1', '2026-01-01', 'Album In Lib One')",
5040 ))
5041 .await
5042 .unwrap();
5043 db_service
5044 .execute(Query::new(
5045 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-lib1', 1)",
5046 ))
5047 .await
5048 .unwrap();
5049
5050 let repo = OfflineRepository::new(
5051 db_service,
5052 "test-server".to_string(),
5053 "test-user".to_string(),
5054 );
5055
5056 let both = repo
5057 .get_items(
5058 "lib-1",
5059 Some(GetItemsOptions {
5060 include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
5061 ..Default::default()
5062 }),
5063 )
5064 .await
5065 .unwrap();
5066 let mut ids: Vec<&str> = both.items.iter().map(|i| i.id.as_str()).collect();
5067 ids.sort();
5068 assert_eq!(ids, vec!["album-lib1", "movie-fav", "movie-plain"]);
5069 assert!(
5070 !ids.contains(&"album-fav"),
5071 "album-fav belongs to lib-2 and must not appear in a lib-1 listing"
5072 );
5073
5074 let favourites = repo
5076 .get_items(
5077 "lib-1",
5078 Some(GetItemsOptions {
5079 include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
5080 favorites_only: Some(true),
5081 ..Default::default()
5082 }),
5083 )
5084 .await
5085 .unwrap();
5086 let mut ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
5087 ids.sort();
5088 assert_eq!(ids, vec!["album-lib1", "movie-fav"]);
5089 }
5090
5091 #[tokio::test]
5100 async fn test_save_to_cache_mirrors_favorites_without_clobbering_pending() {
5101 use crate::storage::db_service::DatabaseService;
5102 let db_service = create_test_db();
5103 let repo = OfflineRepository::new(
5104 db_service.clone(),
5105 "test-server".to_string(),
5106 "test-user".to_string(),
5107 );
5108
5109 let favourite_flag = |id: &'static str| {
5110 let db = db_service.clone();
5111 async move {
5112 db.query_optional(
5113 Query::with_params(
5114 "SELECT is_favorite, pending_sync FROM user_data \
5115 WHERE user_id = ? AND item_id = ?",
5116 vec![
5117 QueryParam::String("test-user".to_string()),
5118 QueryParam::String(id.to_string()),
5119 ],
5120 ),
5121 |row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
5122 )
5123 .await
5124 .unwrap()
5125 }
5126 };
5127
5128 let mut favourited = create_test_item("fav-1", "Favourited Elsewhere", None);
5130 favourited.user_data = Some(UserData {
5131 is_favorite: Some(true),
5132 ..Default::default()
5133 });
5134 let untouched = create_test_item("plain-1", "No User Data", None);
5136
5137 repo.save_to_cache("parent-1", &[favourited.clone(), untouched])
5138 .await
5139 .unwrap();
5140
5141 assert_eq!(
5142 favourite_flag("fav-1").await,
5143 Some((Some(1), Some(0))),
5144 "server favourite should be mirrored as synced"
5145 );
5146 assert_eq!(
5147 favourite_flag("plain-1").await,
5148 None,
5149 "an item without UserData should not get an invented user_data row"
5150 );
5151
5152 db_service
5154 .execute(Query::with_params(
5155 "UPDATE user_data SET is_favorite = 0, pending_sync = 1 \
5156 WHERE user_id = ? AND item_id = ?",
5157 vec![
5158 QueryParam::String("test-user".to_string()),
5159 QueryParam::String("fav-1".to_string()),
5160 ],
5161 ))
5162 .await
5163 .unwrap();
5164
5165 repo.save_to_cache("parent-1", &[favourited]).await.unwrap();
5167
5168 assert_eq!(
5169 favourite_flag("fav-1").await,
5170 Some((Some(0), Some(1))),
5171 "an unsynced local toggle must survive a cache write"
5172 );
5173 }
5174
5175 #[tokio::test]
5187 async fn test_save_to_cache_mirrors_playback_position_without_clobbering_pending() {
5188 use crate::storage::db_service::DatabaseService;
5189 let db_service = create_test_db();
5190 let repo = OfflineRepository::new(
5191 db_service.clone(),
5192 "test-server".to_string(),
5193 "test-user".to_string(),
5194 );
5195
5196 let position = |id: &'static str| {
5197 let db = db_service.clone();
5198 async move {
5199 db.query_optional(
5200 Query::with_params(
5201 "SELECT playback_position_ticks, pending_sync FROM user_data \
5202 WHERE user_id = ? AND item_id = ?",
5203 vec![
5204 QueryParam::String("test-user".to_string()),
5205 QueryParam::String(id.to_string()),
5206 ],
5207 ),
5208 |row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i32>>(1)?)),
5209 )
5210 .await
5211 .unwrap()
5212 }
5213 };
5214
5215 let mut watched = create_test_item("ep-1", "Watched Elsewhere", None);
5217 watched.user_data = Some(UserData {
5218 playback_position_ticks: Some(12_000_000_000),
5219 ..Default::default()
5220 });
5221 let untouched = create_test_item("ep-2", "No User Data", None);
5223
5224 repo.save_to_cache("parent-1", &[watched.clone(), untouched])
5225 .await
5226 .unwrap();
5227
5228 assert_eq!(
5229 position("ep-1").await,
5230 Some((Some(12_000_000_000), Some(0))),
5231 "the server's position should be mirrored as synced"
5232 );
5233 assert_eq!(
5234 position("ep-2").await,
5235 None,
5236 "an item without UserData should not get an invented position"
5237 );
5238
5239 db_service
5241 .execute(Query::with_params(
5242 "UPDATE user_data SET playback_position_ticks = ?, pending_sync = 1 \
5243 WHERE user_id = ? AND item_id = ?",
5244 vec![
5245 QueryParam::Int64(30_000_000_000),
5246 QueryParam::String("test-user".to_string()),
5247 QueryParam::String("ep-1".to_string()),
5248 ],
5249 ))
5250 .await
5251 .unwrap();
5252
5253 repo.save_to_cache("parent-1", &[watched]).await.unwrap();
5255
5256 assert_eq!(
5257 position("ep-1").await,
5258 Some((Some(30_000_000_000), Some(1))),
5259 "an unsynced local position must not be pulled backwards"
5260 );
5261 }
5262
5263 #[tokio::test]
5274 async fn test_save_to_cache_mirrors_played_flag_without_clobbering_pending() {
5275 use crate::storage::db_service::DatabaseService;
5276 let db_service = create_test_db();
5277 let repo = OfflineRepository::new(
5278 db_service.clone(),
5279 "test-server".to_string(),
5280 "test-user".to_string(),
5281 );
5282
5283 let played_flag = |id: &'static str| {
5284 let db = db_service.clone();
5285 async move {
5286 db.query_optional(
5287 Query::with_params(
5288 "SELECT is_played, pending_sync FROM user_data \
5289 WHERE user_id = ? AND item_id = ?",
5290 vec![
5291 QueryParam::String("test-user".to_string()),
5292 QueryParam::String(id.to_string()),
5293 ],
5294 ),
5295 |row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
5296 )
5297 .await
5298 .unwrap()
5299 }
5300 };
5301
5302 let mut watched = create_test_item("ep-4", "Watched Elsewhere", None);
5304 watched.user_data = Some(UserData {
5305 is_played: Some(true),
5306 ..Default::default()
5307 });
5308 let untouched = create_test_item("ep-5", "No User Data", None);
5310
5311 repo.save_to_cache("parent-1", &[watched, untouched])
5312 .await
5313 .unwrap();
5314
5315 assert_eq!(
5316 played_flag("ep-4").await,
5317 Some((Some(1), Some(0))),
5318 "the server's played flag should be mirrored as synced"
5319 );
5320 assert_eq!(
5321 played_flag("ep-5").await,
5322 None,
5323 "an item without UserData should not get an invented played flag"
5324 );
5325
5326 db_service
5328 .execute(Query::with_params(
5329 "UPDATE user_data SET is_played = 0, pending_sync = 1 \
5330 WHERE user_id = ? AND item_id = ?",
5331 vec![
5332 QueryParam::String("test-user".to_string()),
5333 QueryParam::String("ep-4".to_string()),
5334 ],
5335 ))
5336 .await
5337 .unwrap();
5338
5339 let mut still_played = create_test_item("ep-4", "Watched Elsewhere", None);
5340 still_played.user_data = Some(UserData {
5341 is_played: Some(true),
5342 ..Default::default()
5343 });
5344 repo.save_to_cache("parent-1", &[still_played])
5345 .await
5346 .unwrap();
5347
5348 assert_eq!(
5349 played_flag("ep-4").await,
5350 Some((Some(0), Some(1))),
5351 "an unsynced local toggle must survive a cache write"
5352 );
5353 }
5354
5355 #[tokio::test]
5365 async fn test_position_is_mirrored_even_when_no_favourite_flag_is_present() {
5366 use crate::storage::db_service::DatabaseService;
5367 let db_service = create_test_db();
5368 let repo = OfflineRepository::new(
5369 db_service.clone(),
5370 "test-server".to_string(),
5371 "test-user".to_string(),
5372 );
5373
5374 let mut watched = create_test_item("ep-3", "Position Only", None);
5375 watched.user_data = Some(UserData {
5376 is_favorite: None,
5377 playback_position_ticks: Some(9_000_000_000),
5378 ..Default::default()
5379 });
5380
5381 repo.save_to_cache("parent-1", &[watched]).await.unwrap();
5382
5383 let stored = db_service
5384 .query_optional(
5385 Query::with_params(
5386 "SELECT playback_position_ticks FROM user_data \
5387 WHERE user_id = ? AND item_id = ?",
5388 vec![
5389 QueryParam::String("test-user".to_string()),
5390 QueryParam::String("ep-3".to_string()),
5391 ],
5392 ),
5393 |row| row.get::<_, Option<i64>>(0),
5394 )
5395 .await
5396 .unwrap();
5397
5398 assert_eq!(
5399 stored,
5400 Some(Some(9_000_000_000)),
5401 "a position with no favourite flag must still be mirrored"
5402 );
5403 }
5404
5405 #[tokio::test]
5424 async fn test_get_items_unknown_library_type_does_not_return_whole_server() {
5425 let _guard = lock_catalog_browse();
5428 set_include_catalog_browse(true);
5429
5430 let db = create_test_db();
5431
5432 insert_item(&db, "movie-1", "Movie", None, None, None).await;
5433 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
5434 insert_item(&db, "series-1", "Series", None, None, None).await;
5435
5436 for collection_type in ["books", "boxsets", "photos", "homevideos", ""] {
5439 let lib = format!("lib-{collection_type}");
5440 seed_library(&db, &lib, collection_type).await;
5441
5442 let repo = make_repo(&db);
5443 let ids: Vec<String> = repo
5444 .get_items(&lib, None)
5445 .await
5446 .unwrap()
5447 .items
5448 .iter()
5449 .map(|i| i.id.clone())
5450 .collect();
5451
5452 assert!(
5453 ids.is_empty(),
5454 "a '{collection_type}' library must not serve the server's films, \
5455 albums and shows; got {:?}",
5456 ids
5457 );
5458 }
5459 }
5460
5461 #[tokio::test]
5472 async fn test_get_items_two_libraries_of_one_type_are_not_interchangeable() {
5473 let _guard = lock_catalog_browse();
5476 set_include_catalog_browse(true);
5477
5478 let db = create_test_db();
5479 seed_library(&db, "tv-lib", "tvshows").await;
5480 seed_library(&db, "shows-lib", "tvshows").await;
5481
5482 let repo = make_repo(&db);
5483
5484 for (id, lib) in [("series-a", "tv-lib"), ("series-b", "shows-lib")] {
5487 let mut item = create_test_item(id, id, None);
5488 item.item_type = "Series".to_string();
5489 item.kind = crate::domain::MediaKind::Series;
5490 repo.save_to_cache(lib, &[item]).await.unwrap();
5491 }
5492 for (lib, own, other) in [
5493 ("tv-lib", "series-a", "series-b"),
5494 ("shows-lib", "series-b", "series-a"),
5495 ] {
5496 let ids: Vec<String> = repo
5497 .get_items(lib, None)
5498 .await
5499 .unwrap()
5500 .items
5501 .iter()
5502 .map(|i| i.id.clone())
5503 .collect();
5504 assert!(
5505 ids.contains(&own.to_string()),
5506 "{lib} should list {own}; got {:?}",
5507 ids
5508 );
5509 assert!(
5510 !ids.contains(&other.to_string()),
5511 "{lib} must not list {other}, which lives in the other library; got {:?}",
5512 ids
5513 );
5514 }
5515 }
5516
5517 #[tokio::test]
5528 async fn test_get_items_collection_lists_its_own_children() {
5529 let _guard = lock_catalog_browse();
5532 set_include_catalog_browse(true);
5533
5534 let db = create_test_db();
5535 seed_library(&db, "boxset-lib", "boxsets").await;
5536
5537 insert_item(&db, "boxset-1", "BoxSet", None, None, None).await;
5538 insert_item(&db, "outsider", "Movie", None, None, None).await;
5539
5540 db.execute(Query::with_params(
5543 "INSERT INTO items (id, server_id, name, item_type, parent_id, synced_at) \
5544 VALUES ('in-set', 'test-server', 'In The Set', 'Movie', ?1, '2024-01-01')",
5545 vec![QueryParam::String("boxset-1".to_string())],
5546 ))
5547 .await
5548 .unwrap();
5549
5550 let repo = make_repo(&db);
5551 let ids: Vec<String> = repo
5552 .get_items("boxset-1", None)
5553 .await
5554 .unwrap()
5555 .items
5556 .iter()
5557 .map(|i| i.id.clone())
5558 .collect();
5559
5560 assert_eq!(
5561 ids,
5562 vec!["in-set".to_string()],
5563 "a collection lists its own children and nothing else; got {:?}",
5564 ids
5565 );
5566 }
5567
5568 #[tokio::test]
5573 async fn test_get_items_typed_libraries_still_return_their_own_media() {
5574 let _guard = lock_catalog_browse();
5577 set_include_catalog_browse(true);
5578
5579 let db = create_test_db();
5580 seed_library(&db, "music-lib", "music").await;
5581 seed_library(&db, "movie-lib", "movies").await;
5582 seed_library(&db, "tv-lib", "tvshows").await;
5583
5584 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
5585 insert_item(&db, "movie-1", "Movie", None, None, None).await;
5586 insert_item(&db, "series-1", "Series", None, None, None).await;
5587
5588 let repo = make_repo(&db);
5589
5590 for (lib, expected, forbidden) in [
5591 ("music-lib", "album-1", "movie-1"),
5592 ("movie-lib", "movie-1", "album-1"),
5593 ("tv-lib", "series-1", "album-1"),
5594 ] {
5595 let ids: Vec<String> = repo
5596 .get_items(lib, None)
5597 .await
5598 .unwrap()
5599 .items
5600 .iter()
5601 .map(|i| i.id.clone())
5602 .collect();
5603 assert!(
5604 ids.contains(&expected.to_string()),
5605 "{lib} should list {expected}; got {:?}",
5606 ids
5607 );
5608 assert!(
5609 !ids.contains(&forbidden.to_string()),
5610 "{lib} must not list {forbidden}; got {:?}",
5611 ids
5612 );
5613 }
5614 }
5615}