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 fn new(db_service: Arc<RusqliteService>, server_id: String, user_id: String) -> Self {
119 Self {
120 db_service,
121 server_id,
122 user_id,
123 }
124 }
125
126 fn cached_item_to_media_item(item: CachedItem, user_data: Option<UserData>) -> MediaItem {
128 let artists_vec = item
129 .artists
130 .as_ref()
131 .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
132 .unwrap_or_default();
133
134 let kind = crate::domain::kind_from_jellyfin(&item.item_type, item.is_folder);
135
136 MediaItem {
137 id: item.id.clone(),
138 name: item.name,
139 item_type: item.item_type,
140 kind,
141 is_folder: item.is_folder,
142 server_id: item.server_id,
143 parent_id: item.parent_id,
144 library_id: item.library_id,
145 overview: item.overview,
146 genres: item
147 .genres
148 .as_ref()
149 .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
150 runtime_ticks: item.runtime_ticks,
151 duration_ms: item.runtime_ticks.map(crate::domain::ticks_to_ms),
152 production_year: item.production_year,
153 premiere_date: item.premiere_date,
154 community_rating: item.community_rating,
155 official_rating: item.official_rating,
156 primary_image_tag: item.primary_image_tag.clone(),
157 image_id: item.primary_image_tag,
158 backdrop_image_tags: item.backdrop_image_tags,
159 parent_backdrop_image_tags: item.parent_backdrop_image_tags,
160 album_id: item.album_id,
161 album_name: item.album_name,
162 album_artist: item.album_artist,
163 artists: Some(artists_vec),
164 artist_items: None, index_number: item.index_number,
166 series_id: item.series_id,
167 series_name: item.series_name,
168 season_id: item.season_id,
169 season_name: item.season_name,
170 parent_index_number: item.parent_index_number,
171 user_data,
172 media_streams: None, media_sources: None, people: None, }
176 }
177
178 async fn get_user_data(&self, item_id: &str) -> Option<UserData> {
180 let query = Query::with_params(
181 "SELECT playback_position_ticks, is_played, is_favorite, play_count, last_played_at, playback_context_type, playback_context_id
182 FROM user_data WHERE user_id = ? AND item_id = ?",
183 vec![
184 QueryParam::String(self.user_id.clone()),
185 QueryParam::String(item_id.to_string()),
186 ],
187 );
188
189 self.db_service
190 .query_optional(query, |row| {
191 let playback_position_ticks: Option<i64> = row.get(0).ok();
192 Ok(UserData {
193 playback_position_ticks,
194 playback_position_ms: playback_position_ticks.map(crate::domain::ticks_to_ms),
195 is_played: row.get::<_, Option<i32>>(1).ok().flatten().map(|v| v != 0),
196 is_favorite: row.get::<_, Option<i32>>(2).ok().flatten().map(|v| v != 0),
197 play_count: row.get(3).ok(),
198 last_played_date: row.get(4).ok(),
199 playback_context_type: row.get(5).ok(),
200 playback_context_id: row.get(6).ok(),
201 })
202 })
203 .await
204 .ok()
205 .flatten()
206 }
207}
208
209#[derive(Debug)]
211struct CachedItem {
212 id: String,
213 name: String,
214 item_type: String,
215 is_folder: bool,
216 server_id: String,
217 parent_id: Option<String>,
218 library_id: Option<String>,
219 overview: Option<String>,
220 genres: Option<String>,
221 runtime_ticks: Option<i64>,
222 production_year: Option<i32>,
223 premiere_date: Option<String>,
224 community_rating: Option<f64>,
225 official_rating: Option<String>,
226 primary_image_tag: Option<String>,
227 backdrop_image_tags: Option<Vec<String>>,
228 parent_backdrop_image_tags: Option<Vec<String>>,
229 album_id: Option<String>,
230 album_name: Option<String>,
231 album_artist: Option<String>,
232 artists: Option<String>,
233 index_number: Option<i32>,
234 series_id: Option<String>,
235 series_name: Option<String>,
236 season_id: Option<String>,
237 season_name: Option<String>,
238 parent_index_number: Option<i32>,
239}
240
241fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
242 Ok(CachedItem {
243 id: row.get(0)?,
244 name: row.get(1)?,
245 item_type: row.get(2)?,
246 server_id: row.get(3)?,
247 parent_id: row.get(4)?,
248 library_id: row.get(5)?,
249 overview: row.get(6)?,
250 genres: row.get(7)?,
251 runtime_ticks: row.get(8)?,
252 production_year: row.get(9)?,
253 community_rating: row.get(10)?,
254 official_rating: row.get(11)?,
255 primary_image_tag: row.get(12)?,
256 backdrop_image_tags: None, parent_backdrop_image_tags: None, album_id: row.get(13)?,
259 album_name: row.get(14)?,
260 album_artist: row.get(15)?,
261 artists: row.get(16)?,
262 index_number: row.get(17)?,
263 series_id: row.get(18)?,
264 series_name: row.get(19)?,
265 season_id: row.get(20)?,
266 season_name: row.get(21)?,
267 parent_index_number: row.get(22)?,
268 is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0,
270 premiere_date: row.get(24)?,
271 })
272}
273
274impl OfflineRepository {
275 pub async fn prune_stale_catalog(
302 &self,
303 cutoff: &str,
304 item_types: &[String],
305 ) -> Result<usize, RepoError> {
306 if item_types.is_empty() {
307 return Ok(0);
308 }
309 let placeholders = vec!["?"; item_types.len()].join(",");
310 let sql = format!(
311 "DELETE FROM items
312 WHERE server_id = ?
313 AND synced_at IS NOT NULL
314 AND synced_at < ?
315 AND item_type IN ({})
316 AND id NOT IN (
317 -- Playable items with completed downloads
318 SELECT i.id
319 FROM items i
320 INNER JOIN downloads d ON i.id = d.item_id
321 WHERE d.status = 'completed'
322
323 UNION
324
325 -- Containers with downloaded children
326 SELECT i.id
327 FROM items i
328 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)
329 INNER JOIN downloads d ON children.id = d.item_id
330 WHERE d.status = 'completed'
331 )",
332 placeholders
333 );
334
335 let mut params = vec![
336 QueryParam::String(self.server_id.clone()),
337 QueryParam::String(cutoff.to_string()),
338 ];
339 params.extend(item_types.iter().cloned().map(QueryParam::String));
340
341 let removed = self
342 .db_service
343 .execute(Query::with_params(sql, params))
344 .await
345 .map_err(|e| RepoError::Database { message: e })?;
346
347 Ok(removed)
348 }
349
350 async fn search_people(
359 &self,
360 fts_query: &str,
361 limit: usize,
362 ) -> Result<Vec<MediaItem>, RepoError> {
363 let sql = format!(
364 "SELECT p.id, p.name, p.overview, p.primary_image_tag, p.premiere_date
365 FROM people p
366 JOIN people_fts fts ON fts.rowid = p.rowid
367 WHERE p.server_id = ? AND people_fts MATCH ?
368 ORDER BY rank
369 LIMIT {}",
370 limit
371 );
372
373 let rows = self
374 .db_service
375 .query_many(
376 Query::with_params(
377 sql,
378 vec![
379 QueryParam::String(self.server_id.clone()),
380 QueryParam::String(fts_query.to_string()),
381 ],
382 ),
383 |row| {
384 Ok((
385 row.get::<_, String>(0)?,
386 row.get::<_, String>(1)?,
387 row.get::<_, Option<String>>(2)?,
388 row.get::<_, Option<String>>(3)?,
389 row.get::<_, Option<String>>(4)?,
390 ))
391 },
392 )
393 .await
394 .map_err(|e| RepoError::Database { message: e })?;
395
396 Ok(rows
397 .into_iter()
398 .map(
399 |(id, name, overview, primary_image_tag, premiere_date)| MediaItem {
400 id,
401 name,
402 item_type: "Person".to_string(),
403 kind: crate::domain::MediaKind::Person,
404 is_folder: false,
405 server_id: self.server_id.clone(),
406 overview,
407 primary_image_tag,
408 premiere_date,
409 ..Default::default()
410 },
411 )
412 .collect())
413 }
414
415 pub async fn save_to_cache(
420 &self,
421 parent_id: &str,
422 items: &[MediaItem],
423 ) -> Result<usize, RepoError> {
424 if items.is_empty() {
425 return Ok(0);
426 }
427
428 let now = chrono::Utc::now().to_rfc3339();
429
430 self.db_service
433 .execute(Query::new("PRAGMA foreign_keys = OFF"))
434 .await
435 .map_err(|e| RepoError::Database { message: e })?;
436
437 let result = self.save_to_cache_impl(parent_id, items, &now).await;
439
440 let _ = self
442 .db_service
443 .execute(Query::new("PRAGMA foreign_keys = ON"))
444 .await;
445
446 result
447 }
448
449 async fn resolve_owning_library(&self, parent_id: &str) -> Option<String> {
459 let is_library: Option<String> = self
460 .db_service
461 .query_optional(
462 Query::with_params(
463 "SELECT id FROM libraries WHERE id = ? AND server_id = ?",
464 vec![
465 QueryParam::String(parent_id.to_string()),
466 QueryParam::String(self.server_id.clone()),
467 ],
468 ),
469 |row| row.get(0),
470 )
471 .await
472 .ok()
473 .flatten();
474
475 if is_library.is_some() {
476 return is_library;
477 }
478
479 self.db_service
480 .query_optional(
481 Query::with_params(
482 "SELECT library_id FROM items WHERE id = ? AND library_id IS NOT NULL",
483 vec![QueryParam::String(parent_id.to_string())],
484 ),
485 |row| row.get(0),
486 )
487 .await
488 .ok()
489 .flatten()
490 }
491
492 async fn save_to_cache_impl(
493 &self,
494 parent_id: &str,
495 items: &[MediaItem],
496 now: &str,
497 ) -> Result<usize, RepoError> {
498 let owning_library = self.resolve_owning_library(parent_id).await;
523
524 let mut parent_ids = std::collections::HashSet::new();
526 parent_ids.insert(parent_id.to_string());
527
528 for item in items {
529 if let Some(pid) = &item.parent_id {
530 parent_ids.insert(pid.clone());
531 }
532 }
533
534 #[cfg(test)]
536 println!("Creating stub parents for: {:?}", parent_ids);
537
538 for pid in parent_ids {
539 let parent_query = Query::with_params(
540 "INSERT OR IGNORE INTO items (id, server_id, name, item_type, synced_at)
541 VALUES (?1, ?2, ?3, ?4, ?5)",
542 vec![
543 QueryParam::String(pid.clone()),
544 QueryParam::String(self.server_id.clone()),
545 QueryParam::String("Parent".to_string()),
546 QueryParam::String("Folder".to_string()),
547 QueryParam::String(now.to_string()),
548 ],
549 );
550
551 let _stub_rows = self
552 .db_service
553 .execute(parent_query)
554 .await
555 .map_err(|e| RepoError::Database { message: e })?;
556 #[cfg(test)]
557 println!(
558 " Created stub parent {} (rows affected: {})",
559 pid, _stub_rows
560 );
561 }
562
563 let mut count = 0;
564
565 for item in items {
566 let genres_json = item
568 .genres
569 .as_ref()
570 .map(|g| serde_json::to_string(g).unwrap_or_else(|_| "[]".to_string()));
571 let artists_json = item
572 .artists
573 .as_ref()
574 .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
575 let backdrop_tags_json = item
576 .backdrop_image_tags
577 .as_ref()
578 .map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".to_string()));
579
580 let query = Query::with_params(
596 "INSERT INTO items (
597 id, server_id, library_id, parent_id,
598 name, item_type, is_folder, overview,
599 genres, series_id, series_name,
600 season_id, season_name, index_number, parent_index_number,
601 album_id, album_name, album_artist, artists,
602 production_year, premiere_date, runtime_ticks,
603 primary_image_tag, backdrop_image_tags,
604 community_rating, official_rating,
605 synced_at
606 ) VALUES (
607 ?1, ?2, ?3, ?4,
608 ?5, ?6, ?7, ?8,
609 ?9, ?10, ?11,
610 ?12, ?13, ?14, ?15,
611 ?16, ?17, ?18, ?19,
612 ?20, ?21, ?22,
613 ?23, ?24,
614 ?25, ?26,
615 ?27
616 )
617 ON CONFLICT(id) DO UPDATE SET
618 server_id = excluded.server_id,
619 -- This call site never supplies library_id (it is always
620 -- bound NULL), so keep whatever another path recorded rather
621 -- than clearing it the way REPLACE did.
622 library_id = COALESCE(excluded.library_id, items.library_id),
623 parent_id = excluded.parent_id,
624 name = excluded.name,
625 item_type = excluded.item_type,
626 is_folder = excluded.is_folder,
627 overview = excluded.overview,
628 genres = excluded.genres,
629 series_id = excluded.series_id,
630 series_name = excluded.series_name,
631 season_id = excluded.season_id,
632 season_name = excluded.season_name,
633 index_number = excluded.index_number,
634 parent_index_number = excluded.parent_index_number,
635 album_id = excluded.album_id,
636 album_name = excluded.album_name,
637 album_artist = excluded.album_artist,
638 artists = excluded.artists,
639 production_year = excluded.production_year,
640 premiere_date = excluded.premiere_date,
641 runtime_ticks = excluded.runtime_ticks,
642 primary_image_tag = excluded.primary_image_tag,
643 backdrop_image_tags = excluded.backdrop_image_tags,
644 community_rating = excluded.community_rating,
645 official_rating = excluded.official_rating,
646 synced_at = excluded.synced_at",
647 vec![
648 QueryParam::String(item.id.clone()),
649 QueryParam::String(self.server_id.clone()),
650 match &owning_library {
653 Some(lib) => QueryParam::String(lib.clone()),
654 None => QueryParam::Null,
655 }, match &item.parent_id {
658 Some(pid) => QueryParam::String(pid.clone()),
659 None => QueryParam::Null,
660 },
661 QueryParam::String(item.name.clone()),
662 QueryParam::String(item.item_type.clone()),
663 QueryParam::Int(if item.is_folder { 1 } else { 0 }),
664 match &item.overview {
665 Some(o) => QueryParam::String(o.clone()),
666 None => QueryParam::Null,
667 },
668 match genres_json {
669 Some(g) => QueryParam::String(g),
670 None => QueryParam::Null,
671 },
672 match &item.series_id {
673 Some(s) => QueryParam::String(s.clone()),
674 None => QueryParam::Null,
675 },
676 match &item.series_name {
677 Some(s) => QueryParam::String(s.clone()),
678 None => QueryParam::Null,
679 },
680 match &item.season_id {
681 Some(s) => QueryParam::String(s.clone()),
682 None => QueryParam::Null,
683 },
684 match &item.season_name {
685 Some(s) => QueryParam::String(s.clone()),
686 None => QueryParam::Null,
687 },
688 match item.index_number {
689 Some(i) => QueryParam::Int(i),
690 None => QueryParam::Null,
691 },
692 match item.parent_index_number {
693 Some(i) => QueryParam::Int(i),
694 None => QueryParam::Null,
695 },
696 match &item.album_id {
697 Some(a) => QueryParam::String(a.clone()),
698 None => QueryParam::Null,
699 },
700 match &item.album_name {
701 Some(a) => QueryParam::String(a.clone()),
702 None => QueryParam::Null,
703 },
704 match &item.album_artist {
705 Some(a) => QueryParam::String(a.clone()),
706 None => QueryParam::Null,
707 },
708 match artists_json {
709 Some(a) => QueryParam::String(a),
710 None => QueryParam::Null,
711 },
712 match item.production_year {
713 Some(y) => QueryParam::Int(y),
714 None => QueryParam::Null,
715 },
716 match &item.premiere_date {
717 Some(d) => QueryParam::String(d.clone()),
718 None => QueryParam::Null,
719 },
720 match item.runtime_ticks {
721 Some(r) => QueryParam::Int64(r),
722 None => QueryParam::Null,
723 },
724 match &item.primary_image_tag {
725 Some(t) => QueryParam::String(t.clone()),
726 None => QueryParam::Null,
727 },
728 match backdrop_tags_json {
729 Some(b) => QueryParam::String(b),
730 None => QueryParam::Null,
731 },
732 match item.community_rating {
733 Some(r) => QueryParam::Float(r),
734 None => QueryParam::Null,
735 },
736 match &item.official_rating {
737 Some(r) => QueryParam::String(r.clone()),
738 None => QueryParam::Null,
739 },
740 QueryParam::String(now.to_string()),
741 ],
742 );
743
744 let _rows_affected =
745 self.db_service
746 .execute(query)
747 .await
748 .map_err(|e| RepoError::Database {
749 message: format!("Failed to insert item {}: {}", item.id, e),
750 })?;
751 #[cfg(test)]
752 println!(
753 " [save_to_cache] Saved item {} (rows affected: {})",
754 item.id, _rows_affected
755 );
756
757 self.mirror_user_data(item, now).await?;
758 count += 1;
759 }
760
761 Ok(count)
762 }
763
764 async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
791 let user_data = item.user_data.as_ref();
792 let is_favorite = user_data.and_then(|ud| ud.is_favorite);
793 let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
794 let is_played = user_data.and_then(|ud| ud.is_played);
795
796 if is_favorite.is_none() && position_ticks.is_none() && is_played.is_none() {
798 return Ok(());
799 }
800
801 let query = Query::with_params(
802 "INSERT INTO user_data
803 (user_id, item_id, is_favorite, playback_position_ticks, is_played,
804 synced_at, pending_sync)
805 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)
806 ON CONFLICT(user_id, item_id) DO UPDATE SET
807 is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
808 playback_position_ticks = COALESCE(
809 excluded.playback_position_ticks, user_data.playback_position_ticks),
810 is_played = COALESCE(excluded.is_played, user_data.is_played),
811 synced_at = excluded.synced_at
812 WHERE user_data.pending_sync = 0",
813 vec![
814 QueryParam::String(self.user_id.clone()),
815 QueryParam::String(item.id.clone()),
816 is_favorite
817 .map(|f| QueryParam::Int(if f { 1 } else { 0 }))
818 .unwrap_or(QueryParam::Null),
819 position_ticks
820 .map(QueryParam::Int64)
821 .unwrap_or(QueryParam::Null),
822 is_played
823 .map(|p| QueryParam::Int(if p { 1 } else { 0 }))
824 .unwrap_or(QueryParam::Null),
825 QueryParam::String(now.to_string()),
826 ],
827 );
828
829 if let Err(e) = self.db_service.execute(query).await {
833 debug!(
834 "[OfflineRepo] user_data mirror skipped for {}: {}",
835 item.id, e
836 );
837 }
838
839 Ok(())
840 }
841
842 pub async fn save_libraries_to_cache(&self, libraries: &[Library]) -> Result<usize, RepoError> {
847 if libraries.is_empty() {
848 return Ok(0);
849 }
850
851 let mut count = 0;
852 for (idx, lib) in libraries.iter().enumerate() {
853 let query = Query::with_params(
854 "INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
855 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
856 vec![
857 QueryParam::String(lib.id.clone()),
858 QueryParam::String(self.server_id.clone()),
859 QueryParam::String(lib.name.clone()),
860 QueryParam::String(lib.collection_type.clone()),
861 lib.image_tag.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
862 QueryParam::Int(idx as i32),
863 ],
864 );
865 self.db_service
866 .execute(query)
867 .await
868 .map_err(|e| RepoError::Database { message: e })?;
869 count += 1;
870 }
871 Ok(count)
872 }
873
874 pub async fn save_genres_to_cache(
879 &self,
880 parent_id: Option<&str>,
881 genres: &[Genre],
882 ) -> Result<usize, RepoError> {
883 if genres.is_empty() {
884 return Ok(0);
885 }
886
887 let library_id = parent_id.unwrap_or("").to_string();
890 let server_id = self.server_id.clone();
891 let genres: Vec<(String, String, Option<u32>)> = genres
892 .iter()
893 .map(|g| (g.id.clone(), g.name.clone(), g.album_count))
894 .collect();
895 let saved = genres.len();
896
897 self.db_service
898 .transaction(move |tx| {
899 use crate::storage::db_service::{Query, QueryParam};
900
901 tx.execute(Query::with_params(
903 "DELETE FROM genres WHERE server_id = ? AND library_id = ?",
904 vec![
905 QueryParam::String(server_id.clone()),
906 QueryParam::String(library_id.clone()),
907 ],
908 ))?;
909
910 for (id, name, album_count) in &genres {
911 tx.execute(Query::with_params(
912 "INSERT OR REPLACE INTO genres (id, server_id, library_id, name, album_count, synced_at)
913 VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
914 vec![
915 QueryParam::String(id.clone()),
916 QueryParam::String(server_id.clone()),
917 QueryParam::String(library_id.clone()),
918 QueryParam::String(name.clone()),
919 album_count.map(|c| QueryParam::Int(c as i32)).unwrap_or(QueryParam::Null),
920 ],
921 ))?;
922 }
923
924 Ok(())
925 })
926 .await
927 .map_err(|e| RepoError::Database { message: e })?;
928
929 Ok(saved)
930 }
931
932 const LIBRARY_HOLDS_ITEM: &'static str = concat!(
957 "(",
958 library_type_matches_item!(),
959 " OR l.collection_type IS NULL
960 OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
961 )"
962 );
963
964 const DOWNLOADED_ITEMS_CTE: &'static str = "
966 WITH downloaded_items AS (
967 SELECT DISTINCT i.id
968 FROM items i
969 INNER JOIN downloads d ON i.id = d.item_id
970 WHERE d.status = 'completed'
971 AND i.item_type IN ('Audio', 'Movie', 'Episode')
972
973 UNION
974
975 SELECT DISTINCT i.id
976 FROM items i
977 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)
978 INNER JOIN downloads d ON children.id = d.item_id
979 WHERE d.status = 'completed'
980 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
981 )";
982
983 pub async fn get_downloaded_items(
993 &self,
994 parent_id: &str,
995 options: Option<GetItemsOptions>,
996 ) -> Result<SearchResult, RepoError> {
997 let opts = options.unwrap_or_default();
998 let limit = opts.limit.unwrap_or(10000);
999 let start_index = opts.start_index.unwrap_or(0);
1000
1001 let type_filter = if let Some(include_item_types) = &opts.include_item_types {
1002 if !include_item_types.is_empty() {
1003 let types = include_item_types
1004 .iter()
1005 .map(|t| format!("'{}'", t.replace('\'', "''")))
1006 .collect::<Vec<_>>()
1007 .join(",");
1008 format!(" AND i.item_type IN ({})", types)
1009 } else {
1010 String::new()
1011 }
1012 } else {
1013 String::new()
1014 };
1015
1016 let sql = format!(
1029 "{cte}
1030 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1031 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1032 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1033 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1034 i.parent_index_number, i.is_folder, i.premiere_date
1035 FROM items i
1036 INNER JOIN downloaded_items di ON i.id = di.id
1037 WHERE i.server_id = ?
1038 AND (
1039 i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
1040 OR (
1041 EXISTS (
1042 SELECT 1 FROM libraries l
1043 WHERE l.id = ? AND l.server_id = i.server_id
1044 AND {membership}
1045 )
1046 -- Top-level only: hide leaves whose container is downloaded.
1047 AND NOT EXISTS (
1048 SELECT 1 FROM downloaded_items parent
1049 WHERE parent.id = i.album_id
1050 OR parent.id = i.season_id
1051 OR parent.id = i.series_id
1052 OR parent.id = i.parent_id
1053 )
1054 )
1055 ){type_filter}
1056 ORDER BY i.sort_name ASC, i.name ASC
1057 LIMIT {limit} OFFSET {start_index}",
1058 cte = Self::DOWNLOADED_ITEMS_CTE,
1059 membership = Self::LIBRARY_HOLDS_ITEM,
1060 );
1061
1062 let query = Query::with_params(
1063 sql,
1064 vec![
1065 QueryParam::String(self.server_id.clone()),
1066 QueryParam::String(parent_id.to_string()),
1067 QueryParam::String(parent_id.to_string()),
1068 QueryParam::String(parent_id.to_string()),
1069 QueryParam::String(parent_id.to_string()),
1070 QueryParam::String(parent_id.to_string()),
1071 ],
1072 );
1073
1074 let cached_items: Vec<CachedItem> = self
1075 .db_service
1076 .query_many(query, row_to_cached_item)
1077 .await
1078 .map_err(|e| RepoError::Database { message: e })?;
1079
1080 let mut items = Vec::new();
1081 for cached in cached_items {
1082 let user_data = self.get_user_data(&cached.id).await;
1083 items.push(Self::cached_item_to_media_item(cached, user_data));
1084 }
1085
1086 let total_record_count = items.len();
1087 Ok(SearchResult {
1088 items,
1089 total_record_count,
1090 })
1091 }
1092
1093 pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
1099 let query = Query::with_params(
1104 format!(
1105 "{cte}
1106 SELECT l.id, l.name, l.collection_type, l.image_tag
1107 FROM libraries l
1108 WHERE l.server_id = ?
1109 AND EXISTS (
1110 SELECT 1 FROM items i
1111 INNER JOIN downloaded_items di ON i.id = di.id
1112 WHERE i.server_id = l.server_id
1113 AND {membership}
1114 )
1115 ORDER BY l.sort_order ASC, l.name ASC",
1116 cte = Self::DOWNLOADED_ITEMS_CTE,
1117 membership = Self::LIBRARY_HOLDS_ITEM,
1118 ),
1119 vec![QueryParam::String(self.server_id.clone())],
1120 );
1121
1122 self.db_service
1123 .query_many(query, |row| {
1124 Ok(Library::new(
1125 row.get(0)?,
1126 row.get(1)?,
1127 row.get::<_, Option<String>>(2)?
1128 .unwrap_or_else(|| "unknown".to_string()),
1129 row.get(3)?,
1130 ))
1131 })
1132 .await
1133 .map_err(|e| RepoError::Database { message: e })
1134 }
1135
1136 pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
1145 let leaf_query = Query::with_params(
1147 "SELECT d.item_id, COALESCE(d.file_size, 0)
1148 FROM downloads d
1149 INNER JOIN items i ON i.id = d.item_id
1150 WHERE d.status = 'completed'
1151 AND i.server_id = ?
1152 AND i.item_type IN ('Audio', 'Movie', 'Episode')",
1153 vec![QueryParam::String(self.server_id.clone())],
1154 );
1155 let leaves: Vec<(String, i64)> = self
1156 .db_service
1157 .query_many(leaf_query, |row| Ok((row.get(0)?, row.get(1)?)))
1158 .await
1159 .map_err(|e| RepoError::Database { message: e })?;
1160
1161 let container_query = Query::with_params(
1163 "SELECT c.id, COALESCE(SUM(d.file_size), 0)
1164 FROM items c
1165 INNER JOIN items children
1166 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1167 INNER JOIN downloads d ON children.id = d.item_id
1168 WHERE d.status = 'completed'
1169 AND c.server_id = ?
1170 AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1171 GROUP BY c.id",
1172 vec![QueryParam::String(self.server_id.clone())],
1173 );
1174 let containers: Vec<(String, i64)> = self
1175 .db_service
1176 .query_many(container_query, |row| Ok((row.get(0)?, row.get(1)?)))
1177 .await
1178 .map_err(|e| RepoError::Database { message: e })?;
1179
1180 let partial_query = Query::with_params(
1191 "WITH downloaded_containers AS (
1192 SELECT DISTINCT c.id
1193 FROM items c
1194 INNER JOIN items children
1195 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1196 INNER JOIN downloads d ON children.id = d.item_id
1197 WHERE d.status = 'completed'
1198 AND c.server_id = ?
1199 AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1200 )
1201 SELECT c.id,
1202 COUNT(children.id) AS total_children,
1203 SUM(CASE WHEN d.status = 'completed' THEN 1 ELSE 0 END) AS downloaded_children
1204 FROM items c
1205 INNER JOIN downloaded_containers dc ON dc.id = c.id
1206 INNER JOIN items children
1207 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1208 LEFT JOIN downloads d ON children.id = d.item_id AND d.status = 'completed'
1209 WHERE children.item_type IN ('Audio', 'Movie', 'Episode', 'Season')
1210 GROUP BY c.id",
1211 vec![QueryParam::String(self.server_id.clone())],
1212 );
1213 let partial_rows: Vec<(String, i64, i64)> = self
1214 .db_service
1215 .query_many(partial_query, |row| {
1216 Ok((
1217 row.get(0)?,
1218 row.get(1)?,
1219 row.get::<_, Option<i64>>(2)?.unwrap_or(0),
1220 ))
1221 })
1222 .await
1223 .map_err(|e| RepoError::Database { message: e })?;
1224
1225 let mut partial_containers = std::collections::HashMap::new();
1226 for (id, total, downloaded) in partial_rows {
1227 if downloaded > 0 && downloaded < total {
1230 partial_containers.insert(id, true);
1231 }
1232 }
1233
1234 let item_count = leaves.len() as u32;
1235 let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum();
1236
1237 let mut sizes = std::collections::HashMap::new();
1238 for (id, bytes) in leaves.into_iter().chain(containers) {
1239 *sizes.entry(id).or_insert(0) += bytes;
1242 }
1243
1244 Ok(DownloadDiskUsage {
1245 sizes,
1246 partial_containers,
1247 device_total_bytes,
1248 item_count,
1249 })
1250 }
1251
1252 pub async fn save_playlist_items_to_cache(
1255 &self,
1256 playlist_id: &str,
1257 entries: &[PlaylistEntry],
1258 ) -> Result<(), RepoError> {
1259 let playlist_id = playlist_id.to_string();
1260 let user_id = self.user_id.clone();
1261 let entries: Vec<(String, String, usize)> = entries
1262 .iter()
1263 .enumerate()
1264 .map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i))
1265 .collect();
1266
1267 self.db_service
1268 .transaction(move |tx| {
1269 use crate::storage::db_service::{Query, QueryParam};
1270
1271 tx.execute(Query::with_params(
1273 "INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)",
1274 vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)],
1275 ))?;
1276
1277 tx.execute(Query::with_params(
1279 "DELETE FROM playlist_items WHERE playlist_id = ?",
1280 vec![QueryParam::String(playlist_id.clone())],
1281 ))?;
1282
1283 for (_, item_id, sort_order) in &entries {
1284 tx.execute(Query::with_params(
1285 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
1286 vec![
1287 QueryParam::String(playlist_id.clone()),
1288 QueryParam::String(item_id.clone()),
1289 QueryParam::Int(*sort_order as i32),
1290 ],
1291 ))?;
1292 }
1293
1294 Ok(())
1295 })
1296 .await
1297 .map_err(|e| RepoError::Database {
1298 message: format!("Failed to cache playlist items: {}", e),
1299 })
1300 }
1301}
1302
1303#[async_trait]
1304impl MediaRepository for OfflineRepository {
1305 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1306 let query = Query::with_params(
1314 "SELECT l.id, l.name, l.collection_type, l.image_tag
1315 FROM libraries l
1316 WHERE l.server_id = ?
1317 ORDER BY l.sort_order ASC, l.name ASC",
1318 vec![QueryParam::String(self.server_id.clone())],
1319 );
1320
1321 self.db_service
1322 .query_many(query, |row| {
1323 Ok(Library::new(
1324 row.get(0)?,
1325 row.get(1)?,
1326 row.get::<_, Option<String>>(2)?
1327 .unwrap_or_else(|| "unknown".to_string()),
1328 row.get(3)?,
1329 ))
1330 })
1331 .await
1332 .map_err(|e| RepoError::Database { message: e })
1333 }
1334
1335 async fn get_items(
1336 &self,
1337 parent_id: &str,
1338 options: Option<GetItemsOptions>,
1339 ) -> Result<SearchResult, RepoError> {
1340 debug!(
1341 "[OfflineRepo] get_items called for parent_id: {}",
1342 &parent_id[..8.min(parent_id.len())]
1343 );
1344 let opts = options.unwrap_or_default();
1345 let limit = opts.limit.unwrap_or(10000); let start_index = opts.start_index.unwrap_or(0);
1347
1348 let default_sort = default_listing_sort(opts.parent_kind);
1356 let sort_field = opts
1357 .sort_by
1358 .as_deref()
1359 .or(default_sort.map(|(field, _)| field));
1360 let descending = opts
1361 .sort_order
1362 .as_deref()
1363 .or(default_sort.map(|(_, order)| order))
1364 == Some("Descending");
1365 let order_by = match sort_field {
1366 Some("Random") => "RANDOM()".to_string(),
1367 Some("PremiereDate") => format!(
1368 "i.premiere_date IS NULL, i.premiere_date {}, i.sort_name ASC",
1369 if descending { "DESC" } else { "ASC" }
1370 ),
1371 _ => "i.sort_name ASC, i.name ASC".to_string(),
1372 };
1373
1374 let type_values: &[String] = opts
1381 .include_item_types
1382 .as_deref()
1383 .filter(|types| !types.is_empty())
1384 .unwrap_or(&[]);
1385 let type_filter = if type_values.is_empty() {
1386 String::new()
1387 } else {
1388 let placeholders = vec!["?"; type_values.len()].join(",");
1389 format!(" AND i.item_type IN ({})", placeholders)
1390 };
1391
1392 let favorites_filter = if opts.favorites_only == Some(true) {
1397 " AND EXISTS (
1398 SELECT 1 FROM user_data ud
1399 WHERE ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1
1400 )"
1401 } else {
1402 ""
1403 };
1404
1405 let catalog_branch = if include_catalog_browse() {
1413 "UNION
1414
1415 -- Cached items for fast browsing (online) or the offline catalog view
1416 SELECT DISTINCT i.id
1417 FROM items i
1418 WHERE i.synced_at IS NOT NULL"
1419 } else {
1420 ""
1421 };
1422 let sql = format!(
1423 "WITH available_items AS (
1424 -- Playable items with completed downloads
1425 SELECT DISTINCT i.id
1426 FROM items i
1427 INNER JOIN downloads d ON i.id = d.item_id
1428 WHERE d.status = 'completed'
1429 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1430
1431 UNION
1432
1433 -- Containers with downloaded children
1434 SELECT DISTINCT i.id
1435 FROM items i
1436 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)
1437 INNER JOIN downloads d ON children.id = d.item_id
1438 WHERE d.status = 'completed'
1439 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1440
1441 {catalog_branch}
1442 )
1443 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1444 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1445 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1446 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1447 i.parent_index_number, i.is_folder, i.premiere_date
1448 FROM items i
1449 INNER JOIN available_items ai ON i.id = ai.id
1450 WHERE i.server_id = ?
1451 AND (
1452 i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
1453 -- When the requested parent is a LIBRARY, there is no per-item
1454 -- link back to it (library_id/parent_id are NULL in the cache),
1455 -- so match every item on the server and let the type filter
1456 -- (e.g. MusicAlbum / Movie / Series) narrow it. This is what
1457 -- makes library landing pages show albums/movies/shows offline.
1458 --
1459 -- The type correlation is NOT optional. Without it this
1460 -- EXISTS never mentions the item, so it is true for every
1461 -- cached row as soon as the requested parent is any library.
1462 -- Music/Movies/TV got away with that because their landing
1463 -- pages pass `include_item_types`, which narrowed the result;
1464 -- the generic library page passes none, so a Books or Photos
1465 -- library served the entire cached server (DR-277).
1466 --
1467 -- `library_id` wins wherever it survived the cache write:
1468 -- it is the server's own answer, and it is the only thing
1469 -- that can scope a library whose type has no mapping (Books,
1470 -- Photos, Collections) or none at all (a mixed library, where
1471 -- Jellyfin sends CollectionType null). The taxonomy is the
1472 -- fallback for rows that predate it being stored.
1473 --
1474 -- A library with neither a stored link nor a mapped type now
1475 -- matches nothing here and falls through to the server, which
1476 -- does know what is in it. Showing nothing briefly beats
1477 -- showing somebody else's films with confidence.
1478 OR EXISTS (
1479 SELECT 1 FROM libraries l
1480 WHERE l.id = ? AND l.server_id = i.server_id
1481 AND (
1482 i.library_id = l.id
1483 OR (i.library_id IS NULL AND {})
1484 )
1485 )
1486 ){}{}
1487 ORDER BY {}
1488 LIMIT {} OFFSET {}",
1489 library_type_matches_item!(),
1490 type_filter,
1491 favorites_filter,
1492 order_by,
1493 limit,
1494 start_index
1495 );
1496
1497 let mut params = vec![
1503 QueryParam::String(self.server_id.clone()),
1504 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()), ];
1510 params.extend(type_values.iter().cloned().map(QueryParam::String));
1515 if !favorites_filter.is_empty() {
1516 params.push(QueryParam::String(self.user_id.clone())); }
1518 let query = Query::with_params(sql, params);
1519
1520 let cached_items: Vec<CachedItem> = self
1521 .db_service
1522 .query_many(query, row_to_cached_item)
1523 .await
1524 .map_err(|e| RepoError::Database { message: e })?;
1525
1526 debug!(
1527 "[OfflineRepo] Found {} cached items for parent {}",
1528 cached_items.len(),
1529 &parent_id[..8.min(parent_id.len())]
1530 );
1531
1532 let mut items = Vec::new();
1534 for cached in cached_items {
1535 let user_data = self.get_user_data(&cached.id).await;
1536 items.push(Self::cached_item_to_media_item(cached, user_data));
1537 }
1538
1539 let total_record_count = items.len();
1540
1541 debug!(
1542 "[OfflineRepo] Returning {} items for parent {}",
1543 total_record_count,
1544 &parent_id[..8.min(parent_id.len())]
1545 );
1546
1547 Ok(SearchResult {
1548 items,
1549 total_record_count,
1550 })
1551 }
1552
1553 async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
1554 let query = Query::with_params(
1556 "WITH downloaded_items AS (
1557 -- Playable items with completed downloads
1558 SELECT DISTINCT i.id
1559 FROM items i
1560 INNER JOIN downloads d ON i.id = d.item_id
1561 WHERE d.status = 'completed'
1562 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1563
1564 UNION
1565
1566 -- Containers with downloaded children
1567 SELECT DISTINCT i.id
1568 FROM items i
1569 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)
1570 INNER JOIN downloads d ON children.id = d.item_id
1571 WHERE d.status = 'completed'
1572 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1573 )
1574 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1575 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1576 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1577 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1578 i.parent_index_number, i.is_folder, i.premiere_date
1579 FROM items i
1580 INNER JOIN downloaded_items di ON i.id = di.id
1581 WHERE i.id = ?",
1582 vec![QueryParam::String(item_id.to_string())],
1583 );
1584
1585 let cached = self
1586 .db_service
1587 .query_optional(query, row_to_cached_item)
1588 .await
1589 .map_err(|e| RepoError::Database { message: e })?
1590 .ok_or_else(|| RepoError::NotFound {
1591 message: format!(
1592 "Item {} not found in offline cache or not downloaded",
1593 item_id
1594 ),
1595 })?;
1596
1597 let user_data = self.get_user_data(item_id).await;
1598 Ok(Self::cached_item_to_media_item(cached, user_data))
1599 }
1600
1601 async fn get_latest_items(
1602 &self,
1603 parent_id: &str,
1604 limit: Option<usize>,
1605 ) -> Result<Vec<MediaItem>, RepoError> {
1606 let limit_val = limit.unwrap_or(16);
1607
1608 let query = Query::with_params(
1609 format!(
1610 "WITH downloaded_items AS (
1611 -- Playable items with completed downloads
1612 SELECT DISTINCT i.id
1613 FROM items i
1614 INNER JOIN downloads d ON i.id = d.item_id
1615 WHERE d.status = 'completed'
1616 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1617
1618 UNION
1619
1620 -- Containers with downloaded children
1621 SELECT DISTINCT i.id
1622 FROM items i
1623 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)
1624 INNER JOIN downloads d ON children.id = d.item_id
1625 WHERE d.status = 'completed'
1626 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1627 )
1628 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1629 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1630 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1631 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1632 i.parent_index_number, i.is_folder, i.premiere_date
1633 FROM items i
1634 INNER JOIN downloaded_items di ON i.id = di.id
1635 WHERE i.server_id = ? AND i.library_id = ?
1636 -- Collapse leaves into the container that was added: a new
1637 -- 14-track album should read as one album, not 14 songs. Only
1638 -- drops a leaf when its own container is present in the same
1639 -- result, so a standalone track or movie still appears.
1640 AND NOT EXISTS (
1641 SELECT 1 FROM downloaded_items parent
1642 WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
1643 )
1644 ORDER BY i.synced_at DESC
1645 LIMIT {}", limit_val
1646 ),
1647 vec![
1648 QueryParam::String(self.server_id.clone()),
1649 QueryParam::String(parent_id.to_string()),
1650 ],
1651 );
1652
1653 let cached_items: Vec<CachedItem> = self
1654 .db_service
1655 .query_many(query, row_to_cached_item)
1656 .await
1657 .map_err(|e| RepoError::Database { message: e })?;
1658
1659 let mut items = Vec::new();
1660 for cached in cached_items {
1661 let user_data = self.get_user_data(&cached.id).await;
1662 items.push(Self::cached_item_to_media_item(cached, user_data));
1663 }
1664
1665 Ok(items)
1666 }
1667
1668 async fn get_resume_items(
1669 &self,
1670 parent_id: Option<&str>,
1671 limit: Option<usize>,
1672 ) -> Result<Vec<MediaItem>, RepoError> {
1673 let limit_val = limit.unwrap_or(12);
1674
1675 let (sql, params) = if let Some(pid) = parent_id {
1677 (
1678 format!(
1679 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1680 i.overview, i.genres, i.runtime_ticks, i.production_year,
1681 i.community_rating, i.official_rating, i.primary_image_tag,
1682 i.album_id, i.album_name, i.album_artist, i.artists,
1683 i.index_number, i.series_id, i.series_name, i.season_id,
1684 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1685 FROM items i
1686 JOIN user_data ud ON i.id = ud.item_id
1687 INNER JOIN downloads d ON i.id = d.item_id
1688 WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
1689 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1690 AND d.status = 'completed'
1691 AND i.item_type IN ('Movie', 'Episode')
1692 ORDER BY ud.last_played_at DESC
1693 LIMIT {}",
1694 limit_val
1695 ),
1696 vec![
1697 QueryParam::String(self.server_id.clone()),
1698 QueryParam::String(self.user_id.clone()),
1699 QueryParam::String(pid.to_string()),
1700 ],
1701 )
1702 } else {
1703 (
1704 format!(
1705 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1706 i.overview, i.genres, i.runtime_ticks, i.production_year,
1707 i.community_rating, i.official_rating, i.primary_image_tag,
1708 i.album_id, i.album_name, i.album_artist, i.artists,
1709 i.index_number, i.series_id, i.series_name, i.season_id,
1710 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1711 FROM items i
1712 JOIN user_data ud ON i.id = ud.item_id
1713 INNER JOIN downloads d ON i.id = d.item_id
1714 WHERE i.server_id = ? AND ud.user_id = ?
1715 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1716 AND d.status = 'completed'
1717 AND i.item_type IN ('Movie', 'Episode')
1718 ORDER BY ud.last_played_at DESC
1719 LIMIT {}",
1720 limit_val
1721 ),
1722 vec![
1723 QueryParam::String(self.server_id.clone()),
1724 QueryParam::String(self.user_id.clone()),
1725 ],
1726 )
1727 };
1728
1729 let query = Query::with_params(sql, params);
1730
1731 let cached_items: Vec<CachedItem> = self
1732 .db_service
1733 .query_many(query, row_to_cached_item)
1734 .await
1735 .map_err(|e| RepoError::Database { message: e })?;
1736
1737 let mut items = Vec::new();
1738 for cached in cached_items {
1739 let user_data = self.get_user_data(&cached.id).await;
1740 items.push(Self::cached_item_to_media_item(cached, user_data));
1741 }
1742
1743 Ok(items)
1744 }
1745
1746 async fn get_next_up_episodes(
1747 &self,
1748 _series_id: Option<&str>,
1749 _limit: Option<usize>,
1750 ) -> Result<Vec<MediaItem>, RepoError> {
1751 Ok(Vec::new())
1754 }
1755
1756 async fn get_recently_played_audio(
1757 &self,
1758 limit: Option<usize>,
1759 ) -> Result<Vec<MediaItem>, RepoError> {
1760 let limit_val = limit.unwrap_or(12);
1761
1762 let query = Query::with_params(
1767 format!(
1768 "WITH downloaded_items AS (
1769 -- Playable items with completed downloads (Audio tracks)
1770 SELECT DISTINCT i.id
1771 FROM items i
1772 INNER JOIN downloads d ON i.id = d.item_id
1773 WHERE d.status = 'completed'
1774 AND i.item_type = 'Audio'
1775
1776 UNION
1777
1778 -- Containers with downloaded children (Albums)
1779 SELECT DISTINCT i.id
1780 FROM items i
1781 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)
1782 INNER JOIN downloads d ON children.id = d.item_id
1783 WHERE d.status = 'completed'
1784 AND i.item_type = 'MusicAlbum'
1785 ),
1786 ranked_plays AS (
1787 SELECT
1788 CASE
1789 WHEN ud.playback_context_type = 'container' THEN ud.playback_context_id
1790 WHEN ud.playback_context_type = 'single' THEN ud.item_id
1791 ELSE COALESCE(i.album_id, ud.item_id)
1792 END AS display_id,
1793 MAX(ud.last_played_at) AS most_recent_play
1794 FROM user_data ud
1795 JOIN items i ON ud.item_id = i.id
1796 WHERE ud.user_id = ? AND i.server_id = ?
1797 AND i.item_type = 'Audio'
1798 AND ud.last_played_at IS NOT NULL
1799 GROUP BY display_id
1800 ORDER BY most_recent_play DESC
1801 LIMIT {}
1802 )
1803 SELECT DISTINCT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1804 i.overview, i.genres, i.runtime_ticks, i.production_year,
1805 i.community_rating, i.official_rating, i.primary_image_tag,
1806 i.album_id, i.album_name, i.album_artist, i.artists,
1807 i.index_number, i.series_id, i.series_name, i.season_id,
1808 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1809 FROM ranked_plays rp
1810 JOIN items i ON rp.display_id = i.id
1811 INNER JOIN downloaded_items di ON i.id = di.id
1812 ORDER BY rp.most_recent_play DESC",
1813 limit_val
1814 ),
1815 vec![
1816 QueryParam::String(self.user_id.clone()),
1817 QueryParam::String(self.server_id.clone()),
1818 ],
1819 );
1820
1821 let cached_items: Vec<CachedItem> = self
1822 .db_service
1823 .query_many(query, row_to_cached_item)
1824 .await
1825 .map_err(|e| RepoError::Database { message: e })?;
1826
1827 let mut items = Vec::new();
1828 for cached in cached_items {
1829 let user_data = self.get_user_data(&cached.id).await;
1830 items.push(Self::cached_item_to_media_item(cached, user_data));
1831 }
1832
1833 Ok(items)
1834 }
1835
1836 async fn get_rediscover_albums(
1837 &self,
1838 _parent_id: Option<&str>,
1839 _limit: Option<usize>,
1840 ) -> Result<Vec<MediaItem>, RepoError> {
1841 Ok(Vec::new())
1845 }
1846
1847 async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
1848 let limit_val = limit.unwrap_or(12);
1849
1850 let query = Query::with_params(
1852 format!(
1853 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1854 i.overview, i.genres, i.runtime_ticks, i.production_year,
1855 i.community_rating, i.official_rating, i.primary_image_tag,
1856 i.album_id, i.album_name, i.album_artist, i.artists,
1857 i.index_number, i.series_id, i.series_name, i.season_id,
1858 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1859 FROM items i
1860 JOIN user_data ud ON i.id = ud.item_id
1861 INNER JOIN downloads d ON i.id = d.item_id
1862 WHERE i.server_id = ? AND ud.user_id = ? AND i.item_type = 'Movie'
1863 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1864 AND d.status = 'completed'
1865 ORDER BY ud.last_played_at DESC
1866 LIMIT {}",
1867 limit_val
1868 ),
1869 vec![
1870 QueryParam::String(self.server_id.clone()),
1871 QueryParam::String(self.user_id.clone()),
1872 ],
1873 );
1874
1875 let cached_items: Vec<CachedItem> = self
1876 .db_service
1877 .query_many(query, row_to_cached_item)
1878 .await
1879 .map_err(|e| RepoError::Database { message: e })?;
1880
1881 let mut items = Vec::new();
1882 for cached in cached_items {
1883 let user_data = self.get_user_data(&cached.id).await;
1884 items.push(Self::cached_item_to_media_item(cached, user_data));
1885 }
1886
1887 Ok(items)
1888 }
1889
1890 async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1891 let library_id = parent_id.unwrap_or("").to_string();
1896
1897 let query = Query::with_params(
1898 "SELECT id, name, album_count FROM genres WHERE server_id = ? AND library_id = ?",
1899 vec![
1900 QueryParam::String(self.server_id.clone()),
1901 QueryParam::String(library_id),
1902 ],
1903 );
1904
1905 let genres: Vec<Genre> = self
1906 .db_service
1907 .query_many(query, |row| {
1908 Ok(Genre {
1909 id: row.get(0)?,
1910 name: row.get(1)?,
1911 album_count: row.get::<_, Option<i64>>(2)?.map(|c| c as u32),
1912 })
1913 })
1914 .await
1915 .map_err(|e| RepoError::Database { message: e })?;
1916
1917 Ok(genres)
1918 }
1919
1920 async fn search(
1921 &self,
1922 query: &str,
1923 options: Option<SearchOptions>,
1924 ) -> Result<SearchResult, RepoError> {
1925 let opts = options.unwrap_or_default();
1926 let limit = opts.limit.unwrap_or(20);
1927
1928 let Some(fts_query) = build_fts_prefix_query(query) else {
1931 return Ok(SearchResult {
1932 items: Vec::new(),
1933 total_record_count: 0,
1934 });
1935 };
1936
1937 let type_values: &[String] = opts
1941 .include_item_types
1942 .as_deref()
1943 .filter(|types| !types.is_empty())
1944 .unwrap_or(&[]);
1945 let type_filter = if type_values.is_empty() {
1946 String::new()
1947 } else {
1948 let placeholders = vec!["?"; type_values.len()].join(",");
1949 format!(" AND i.item_type IN ({})", placeholders)
1950 };
1951
1952 let catalog_branch = if include_catalog_browse() {
1958 "UNION
1959
1960 -- Synced catalog: fast online search, or the offline
1961 -- 'Show all server media' view. See set_include_catalog_browse.
1962 SELECT DISTINCT i.id
1963 FROM items i
1964 WHERE i.synced_at IS NOT NULL"
1965 } else {
1966 ""
1967 };
1968
1969 let sql = format!(
1970 "WITH available_items AS (
1971 -- Playable items with completed downloads
1972 SELECT DISTINCT i.id
1973 FROM items i
1974 INNER JOIN downloads d ON i.id = d.item_id
1975 WHERE d.status = 'completed'
1976 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1977
1978 UNION
1979
1980 -- Containers with downloaded children
1981 SELECT DISTINCT i.id
1982 FROM items i
1983 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)
1984 INNER JOIN downloads d ON children.id = d.item_id
1985 WHERE d.status = 'completed'
1986 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1987
1988 {}
1989 )
1990 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1991 i.overview, i.genres, i.runtime_ticks, i.production_year,
1992 i.community_rating, i.official_rating, i.primary_image_tag,
1993 i.album_id, i.album_name, i.album_artist, i.artists,
1994 i.index_number, i.series_id, i.series_name, i.season_id,
1995 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1996 FROM items i
1997 JOIN items_fts fts ON fts.rowid = i.rowid
1998 INNER JOIN available_items ai ON i.id = ai.id
1999 WHERE i.server_id = ? AND items_fts MATCH ?{}
2000 ORDER BY rank
2001 LIMIT {}",
2002 catalog_branch, type_filter, limit
2003 );
2004
2005 let mut params = vec![
2006 QueryParam::String(self.server_id.clone()),
2007 QueryParam::String(fts_query.clone()),
2008 ];
2009 params.extend(type_values.iter().cloned().map(QueryParam::String));
2010
2011 let db_query = Query::with_params(sql, params);
2012
2013 let cached_items: Vec<CachedItem> = self
2014 .db_service
2015 .query_many(db_query, row_to_cached_item)
2016 .await
2017 .map_err(|e| RepoError::Database { message: e })?;
2018
2019 let mut items = Vec::new();
2020 for cached in cached_items {
2021 let user_data = self.get_user_data(&cached.id).await;
2022 items.push(Self::cached_item_to_media_item(cached, user_data));
2023 }
2024
2025 if type_values.is_empty() {
2031 items.extend(self.search_people(&fts_query, limit).await?);
2032 }
2033
2034 let total_record_count = items.len();
2035
2036 Ok(SearchResult {
2037 items,
2038 total_record_count,
2039 })
2040 }
2041
2042 async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
2043 Err(RepoError::Offline)
2045 }
2046
2047 async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
2048 Err(RepoError::Offline)
2050 }
2051
2052 async fn get_audio_only_stream_url_for_video(
2053 &self,
2054 _item_id: &str,
2055 _media_source_id: Option<&str>,
2056 _start_time_seconds: Option<f64>,
2057 _audio_stream_index: Option<i32>,
2058 ) -> Result<String, RepoError> {
2059 Err(RepoError::Offline)
2061 }
2062
2063 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
2064 Err(RepoError::Offline)
2066 }
2067
2068 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
2069 Err(RepoError::Offline)
2071 }
2072
2073 async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
2074 Err(RepoError::Offline)
2076 }
2077
2078 async fn report_playback_start(
2079 &self,
2080 _item_id: &str,
2081 _position_ticks: i64,
2082 ) -> Result<(), RepoError> {
2083 Err(RepoError::Offline)
2085 }
2086
2087 async fn report_playback_progress(
2088 &self,
2089 _item_id: &str,
2090 _position_ticks: i64,
2091 ) -> Result<(), RepoError> {
2092 Err(RepoError::Offline)
2094 }
2095
2096 async fn report_playback_stopped(
2097 &self,
2098 _item_id: &str,
2099 _position_ticks: i64,
2100 ) -> Result<(), RepoError> {
2101 Err(RepoError::Offline)
2103 }
2104
2105 fn get_image_url(
2106 &self,
2107 item_id: &str,
2108 image_type: ImageType,
2109 options: Option<ImageOptions>,
2110 ) -> String {
2111 let type_str = match image_type {
2114 ImageType::Primary => "Primary",
2115 ImageType::Backdrop => "Backdrop",
2116 ImageType::Logo => "Logo",
2117 ImageType::Thumb => "Thumb",
2118 ImageType::Banner => "Banner",
2119 };
2120
2121 if let Some(opts) = options {
2122 if let Some(tag) = opts.tag {
2123 return format!("offline://{}/{}/{}", item_id, type_str, tag);
2124 }
2125 }
2126
2127 format!("offline://{}/{}", item_id, type_str)
2128 }
2129
2130 fn get_subtitle_url(
2131 &self,
2132 _item_id: &str,
2133 _media_source_id: &str,
2134 _stream_index: i32,
2135 _format: &str,
2136 ) -> String {
2137 String::new()
2139 }
2140
2141 fn get_video_download_url(
2142 &self,
2143 _item_id: &str,
2144 _quality: &str,
2145 _media_source_id: Option<&str>,
2146 _source_audio_codec: Option<&str>,
2147 ) -> String {
2148 String::new()
2150 }
2151
2152 async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
2153 Err(RepoError::Offline)
2155 }
2156
2157 async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
2158 Err(RepoError::Offline)
2160 }
2161
2162 async fn get_favorites(
2171 &self,
2172 scope: SearchScope,
2173 options: Option<GetItemsOptions>,
2174 ) -> Result<SearchResult, RepoError> {
2175 let opts = options.unwrap_or_default();
2176 let limit = opts.limit.unwrap_or(10000);
2177 let start_index = opts.start_index.unwrap_or(0);
2178
2179 let type_filter = match scope.item_types() {
2182 Some(types) if !types.is_empty() => {
2183 let placeholders = vec!["?"; types.len()].join(",");
2184 format!(" AND i.item_type IN ({})", placeholders)
2185 }
2186 _ => String::new(),
2187 };
2188
2189 let catalog_branch = if include_catalog_browse() {
2190 "UNION
2191
2192 SELECT DISTINCT i.id
2193 FROM items i
2194 WHERE i.synced_at IS NOT NULL"
2195 } else {
2196 ""
2197 };
2198
2199 let sql = format!(
2200 "WITH available_items AS (
2201 SELECT DISTINCT i.id
2202 FROM items i
2203 INNER JOIN downloads d ON i.id = d.item_id
2204 WHERE d.status = 'completed'
2205 AND i.item_type IN ('Audio', 'Movie', 'Episode')
2206
2207 UNION
2208
2209 SELECT DISTINCT i.id
2210 FROM items i
2211 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)
2212 INNER JOIN downloads d ON children.id = d.item_id
2213 WHERE d.status = 'completed'
2214 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
2215
2216 {catalog_branch}
2217 )
2218 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
2219 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
2220 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
2221 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
2222 i.parent_index_number, i.is_folder, i.premiere_date
2223 FROM items i
2224 INNER JOIN available_items ai ON i.id = ai.id
2225 INNER JOIN user_data ud ON ud.item_id = i.id
2226 WHERE i.server_id = ?
2227 AND ud.user_id = ?
2228 AND ud.is_favorite = 1{}
2229 ORDER BY i.sort_name ASC, i.name ASC
2230 LIMIT {} OFFSET {}",
2231 type_filter, limit, start_index
2232 );
2233
2234 let mut params = vec![
2235 QueryParam::String(self.server_id.clone()),
2236 QueryParam::String(self.user_id.clone()),
2237 ];
2238 if let Some(types) = scope.item_types() {
2239 params.extend(types.into_iter().map(QueryParam::String));
2240 }
2241
2242 let cached_items: Vec<CachedItem> = self
2243 .db_service
2244 .query_many(Query::with_params(sql, params), row_to_cached_item)
2245 .await
2246 .map_err(|e| RepoError::Database { message: e })?;
2247
2248 let mut items = Vec::new();
2249 for cached in cached_items {
2250 let user_data = self.get_user_data(&cached.id).await;
2251 items.push(Self::cached_item_to_media_item(cached, user_data));
2252 }
2253
2254 let total_record_count = items.len();
2255 debug!("[OfflineRepo] Returning {} favourites", total_record_count);
2256
2257 Ok(SearchResult {
2258 items,
2259 total_record_count,
2260 })
2261 }
2262
2263 async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
2264 Err(RepoError::Offline)
2267 }
2268
2269 async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
2270 Err(RepoError::Offline)
2273 }
2274
2275 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
2276 let query = Query::with_params(
2277 "SELECT id, name, overview, primary_image_tag
2278 FROM people WHERE id = ?",
2279 vec![QueryParam::String(person_id.to_string())],
2280 );
2281
2282 let person_data = self
2283 .db_service
2284 .query_optional(query, |row| {
2285 Ok((
2286 row.get::<_, String>(0)?,
2287 row.get::<_, String>(1)?,
2288 row.get::<_, Option<String>>(2)?,
2289 row.get::<_, Option<String>>(3)?,
2290 ))
2291 })
2292 .await
2293 .map_err(|e| RepoError::Database { message: e })?
2294 .ok_or_else(|| RepoError::NotFound {
2295 message: format!("Person {} not found in cache", person_id),
2296 })?;
2297
2298 Ok(MediaItem {
2299 id: person_data.0,
2300 name: person_data.1,
2301 item_type: "Person".to_string(),
2302 kind: crate::domain::MediaKind::Person,
2303 is_folder: false,
2304 server_id: self.server_id.clone(),
2305 parent_id: None,
2306 library_id: None,
2307 overview: person_data.2,
2308 genres: None,
2309 runtime_ticks: None,
2310 duration_ms: None,
2311 production_year: None,
2312 premiere_date: None,
2313 community_rating: None,
2314 official_rating: None,
2315 primary_image_tag: person_data.3.clone(),
2316 image_id: person_data.3,
2317 backdrop_image_tags: None,
2318 parent_backdrop_image_tags: None,
2319 album_id: None,
2320 album_name: None,
2321 album_artist: None,
2322 artists: None,
2323 artist_items: None,
2324 index_number: None,
2325 series_id: None,
2326 series_name: None,
2327 season_id: None,
2328 season_name: None,
2329 parent_index_number: None,
2330 user_data: None,
2331 media_streams: None,
2332 media_sources: None,
2333 people: None,
2334 })
2335 }
2336
2337 async fn get_items_by_person(
2338 &self,
2339 person_id: &str,
2340 options: Option<GetItemsOptions>,
2341 ) -> Result<SearchResult, RepoError> {
2342 let opts = options.unwrap_or_default();
2343 let limit = opts.limit.unwrap_or(10000); let query = Query::with_params(
2347 format!(
2348 "WITH downloaded_items AS (
2349 -- Playable items with completed downloads
2350 SELECT DISTINCT i.id
2351 FROM items i
2352 INNER JOIN downloads d ON i.id = d.item_id
2353 WHERE d.status = 'completed'
2354 AND i.item_type IN ('Audio', 'Movie', 'Episode')
2355
2356 UNION
2357
2358 -- Containers with downloaded children
2359 SELECT DISTINCT i.id
2360 FROM items i
2361 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)
2362 INNER JOIN downloads d ON children.id = d.item_id
2363 WHERE d.status = 'completed'
2364 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
2365 )
2366 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
2367 i.overview, i.genres, i.runtime_ticks, i.production_year,
2368 i.community_rating, i.official_rating, i.primary_image_tag,
2369 i.album_id, i.album_name, i.album_artist, i.artists,
2370 i.index_number, i.series_id, i.series_name, i.season_id,
2371 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
2372 FROM items i
2373 JOIN item_people ip ON i.id = ip.item_id
2374 INNER JOIN downloaded_items di ON i.id = di.id
2375 WHERE i.server_id = ? AND ip.person_id = ?
2376 ORDER BY i.production_year DESC, i.sort_name ASC
2377 LIMIT {}", limit
2378 ),
2379 vec![
2380 QueryParam::String(self.server_id.clone()),
2381 QueryParam::String(person_id.to_string()),
2382 ],
2383 );
2384
2385 let cached_items: Vec<CachedItem> = self
2386 .db_service
2387 .query_many(query, row_to_cached_item)
2388 .await
2389 .map_err(|e| RepoError::Database { message: e })?;
2390
2391 let mut items = Vec::new();
2392 for cached in cached_items {
2393 let user_data = self.get_user_data(&cached.id).await;
2394 items.push(Self::cached_item_to_media_item(cached, user_data));
2395 }
2396
2397 let total_record_count = items.len();
2398
2399 Ok(SearchResult {
2400 items,
2401 total_record_count,
2402 })
2403 }
2404
2405 async fn get_similar_items(
2406 &self,
2407 _item_id: &str,
2408 _limit: Option<usize>,
2409 ) -> Result<SearchResult, RepoError> {
2410 Err(RepoError::Offline)
2412 }
2413
2414 async fn create_playlist(
2417 &self,
2418 name: &str,
2419 item_ids: &[String],
2420 ) -> Result<PlaylistCreatedResult, RepoError> {
2421 let playlist_id = uuid::Uuid::new_v4().to_string();
2422 let user_id = self.user_id.clone();
2423 let name = name.to_string();
2424 let item_ids = item_ids.to_vec();
2425 let pid = playlist_id.clone();
2426
2427 self.db_service
2428 .transaction(move |tx| {
2429 use crate::storage::db_service::{Query, QueryParam};
2430
2431 tx.execute(Query::with_params(
2432 "INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)",
2433 vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)],
2434 ))?;
2435
2436 for (i, item_id) in item_ids.iter().enumerate() {
2437 tx.execute(Query::with_params(
2438 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
2439 vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)],
2440 ))?;
2441 }
2442
2443 Ok(())
2444 })
2445 .await
2446 .map_err(|e| RepoError::Database {
2447 message: format!("Failed to create playlist: {}", e),
2448 })?;
2449
2450 Ok(PlaylistCreatedResult { id: playlist_id })
2451 }
2452
2453 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
2454 let query = Query::with_params(
2455 "DELETE FROM playlists WHERE id = ?",
2456 vec![QueryParam::String(playlist_id.to_string())],
2457 );
2458 self.db_service
2459 .execute(query)
2460 .await
2461 .map_err(|e| RepoError::Database {
2462 message: format!("Failed to delete playlist: {}", e),
2463 })?;
2464 Ok(())
2465 }
2466
2467 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
2468 let query = Query::with_params(
2469 "UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
2470 vec![
2471 QueryParam::String(name.to_string()),
2472 QueryParam::String(playlist_id.to_string()),
2473 ],
2474 );
2475 self.db_service
2476 .execute(query)
2477 .await
2478 .map_err(|e| RepoError::Database {
2479 message: format!("Failed to rename playlist: {}", e),
2480 })?;
2481 Ok(())
2482 }
2483
2484 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
2485 let query = Query::with_params(
2486 "SELECT pi.id, \
2487 i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \
2488 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
2489 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
2490 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
2491 i.parent_index_number, i.is_folder, i.premiere_date \
2492 FROM playlist_items pi \
2493 JOIN items i ON pi.item_id = i.id \
2494 WHERE pi.playlist_id = ? \
2495 ORDER BY pi.sort_order ASC",
2496 vec![QueryParam::String(playlist_id.to_string())],
2497 );
2498
2499 let items = self
2500 .db_service
2501 .query_many(query, |row| {
2502 let entry_id: i64 = row.get(0)?;
2503 let cached = CachedItem {
2505 id: row.get(1)?,
2506 name: row.get(2)?,
2507 item_type: row.get(3)?,
2508 server_id: row.get(4)?,
2509 parent_id: row.get(5)?,
2510 library_id: row.get(6)?,
2511 overview: row.get(7)?,
2512 genres: row.get(8)?,
2513 runtime_ticks: row.get(9)?,
2514 production_year: row.get(10)?,
2515 community_rating: row.get(11)?,
2516 official_rating: row.get(12)?,
2517 primary_image_tag: row.get(13)?,
2518 backdrop_image_tags: None,
2519 parent_backdrop_image_tags: None,
2520 album_id: row.get(14)?,
2521 album_name: row.get(15)?,
2522 album_artist: row.get(16)?,
2523 artists: row.get(17)?,
2524 index_number: row.get(18)?,
2525 series_id: row.get(19)?,
2526 series_name: row.get(20)?,
2527 season_id: row.get(21)?,
2528 season_name: row.get(22)?,
2529 parent_index_number: row.get(23)?,
2530 is_folder: row.get::<_, Option<i64>>(24)?.unwrap_or(0) != 0,
2531 premiere_date: row.get(25)?,
2532 };
2533 Ok((entry_id.to_string(), cached))
2534 })
2535 .await
2536 .map_err(|e| RepoError::Database {
2537 message: format!("Failed to get playlist items: {}", e),
2538 })?;
2539
2540 Ok(items
2541 .into_iter()
2542 .map(|(entry_id, cached)| PlaylistEntry {
2543 playlist_item_id: entry_id,
2544 item: Self::cached_item_to_media_item(cached, None),
2545 })
2546 .collect())
2547 }
2548
2549 async fn add_to_playlist(
2550 &self,
2551 playlist_id: &str,
2552 item_ids: &[String],
2553 ) -> Result<(), RepoError> {
2554 let max_query = Query::with_params(
2556 "SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?",
2557 vec![QueryParam::String(playlist_id.to_string())],
2558 );
2559 let max_order: i32 = self
2560 .db_service
2561 .query_one(max_query, |row| row.get(0))
2562 .await
2563 .unwrap_or(-1);
2564
2565 let playlist_id = playlist_id.to_string();
2566 let item_ids = item_ids.to_vec();
2567
2568 self.db_service
2569 .transaction(move |tx| {
2570 use crate::storage::db_service::{Query, QueryParam};
2571
2572 for (i, item_id) in item_ids.iter().enumerate() {
2573 tx.execute(Query::with_params(
2574 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
2575 vec![
2576 QueryParam::String(playlist_id.clone()),
2577 QueryParam::String(item_id.clone()),
2578 QueryParam::Int(max_order + 1 + i as i32),
2579 ],
2580 ))?;
2581 }
2582 Ok(())
2583 })
2584 .await
2585 .map_err(|e| RepoError::Database {
2586 message: format!("Failed to add items to playlist: {}", e),
2587 })?;
2588
2589 Ok(())
2590 }
2591
2592 async fn remove_from_playlist(
2593 &self,
2594 playlist_id: &str,
2595 entry_ids: &[String],
2596 ) -> Result<(), RepoError> {
2597 let playlist_id = playlist_id.to_string();
2598 let entry_ids = entry_ids.to_vec();
2599
2600 self.db_service
2601 .transaction(move |tx| {
2602 use crate::storage::db_service::{Query, QueryParam};
2603
2604 for entry_id in &entry_ids {
2605 tx.execute(Query::with_params(
2606 "DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?",
2607 vec![
2608 QueryParam::String(playlist_id.clone()),
2609 QueryParam::String(entry_id.clone()),
2610 ],
2611 ))?;
2612 }
2613 Ok(())
2614 })
2615 .await
2616 .map_err(|e| RepoError::Database {
2617 message: format!("Failed to remove items from playlist: {}", e),
2618 })?;
2619
2620 Ok(())
2621 }
2622
2623 async fn move_playlist_item(
2624 &self,
2625 playlist_id: &str,
2626 item_id: &str,
2627 new_index: u32,
2628 ) -> Result<(), RepoError> {
2629 let playlist_id = playlist_id.to_string();
2630 let item_id = item_id.to_string();
2631
2632 self.db_service
2633 .transaction(move |tx| {
2634 use crate::storage::db_service::{Query, QueryParam};
2635
2636 let items: Vec<(i64, String)> = tx.query_many(
2638 Query::with_params(
2639 "SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order",
2640 vec![QueryParam::String(playlist_id)],
2641 ),
2642 |row| Ok((row.get(0)?, row.get(1)?)),
2643 )?;
2644
2645 let old_idx = items.iter().position(|(_, iid)| iid == &item_id);
2647 if let Some(old_pos) = old_idx {
2648 let mut ids = items;
2649 let entry = ids.remove(old_pos);
2650 let insert_at = (new_index as usize).min(ids.len());
2651 ids.insert(insert_at, entry);
2652
2653 for (i, (entry_id, _)) in ids.iter().enumerate() {
2655 tx.execute(Query::with_params(
2656 "UPDATE playlist_items SET sort_order = ? WHERE id = ?",
2657 vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)],
2658 ))?;
2659 }
2660 }
2661
2662 Ok(())
2663 })
2664 .await
2665 .map_err(|e| RepoError::Database {
2666 message: format!("Failed to move playlist item: {}", e),
2667 })?;
2668
2669 Ok(())
2670 }
2671}
2672
2673#[cfg(test)]
2674mod tests {
2675 #![allow(clippy::await_holding_lock)]
2684
2685 use super::*;
2686 use crate::storage::db_service::RusqliteService;
2687 use rusqlite::Connection;
2688 use std::sync::{Arc, Mutex};
2689
2690 static CATALOG_BROWSE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2697
2698 fn lock_catalog_browse() -> std::sync::MutexGuard<'static, ()> {
2699 use crate::utils::lock::MutexSafe;
2700 CATALOG_BROWSE_LOCK.lock_safe()
2701 }
2702
2703 #[test]
2705 fn test_build_fts_prefix_query() {
2706 assert_eq!(build_fts_prefix_query("Arr").as_deref(), Some("\"Arr\"*"));
2708
2709 assert_eq!(
2712 build_fts_prefix_query("parks rec").as_deref(),
2713 Some("\"parks\" \"rec\"*")
2714 );
2715
2716 for query in ["Bob's Burgers", "Spider-Man", "AC/DC", "Wall-E", "9-1-1"] {
2719 let built = build_fts_prefix_query(query).expect("should build");
2720 assert!(
2721 built.starts_with('"') && built.ends_with("*"),
2722 "{query:?} produced {built:?}"
2723 );
2724 }
2725
2726 assert_eq!(
2729 build_fts_prefix_query("say \"hi\"").as_deref(),
2730 Some("\"say\" \"\"\"hi\"\"\"*")
2731 );
2732
2733 assert_eq!(build_fts_prefix_query(""), None);
2736 assert_eq!(build_fts_prefix_query(" "), None);
2737 assert_eq!(build_fts_prefix_query("-"), None);
2738 }
2739
2740 #[tokio::test]
2745 async fn test_search_empty_query_returns_empty_not_error() {
2746 let db_service = create_test_db();
2747 let repo = OfflineRepository::new(
2748 db_service,
2749 "test-server".to_string(),
2750 "test-user".to_string(),
2751 );
2752
2753 let result = repo.search("", None).await;
2754 assert!(
2755 result.is_ok(),
2756 "empty query must not error: {:?}",
2757 result.err()
2758 );
2759 assert!(result.unwrap().items.is_empty());
2760 }
2761
2762 fn create_test_db() -> Arc<RusqliteService> {
2763 let conn = Connection::open_in_memory().unwrap();
2764
2765 conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
2767
2768 conn.execute_batch(
2770 r#"
2771 CREATE TABLE servers (
2772 id TEXT PRIMARY KEY,
2773 name TEXT NOT NULL,
2774 url TEXT NOT NULL UNIQUE
2775 );
2776
2777 CREATE TABLE items (
2778 id TEXT PRIMARY KEY,
2779 server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
2780 library_id TEXT,
2781 parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
2782 name TEXT NOT NULL,
2783 item_type TEXT NOT NULL,
2784 is_folder INTEGER DEFAULT 0,
2785 overview TEXT,
2786 genres TEXT,
2787 runtime_ticks INTEGER,
2788 production_year INTEGER,
2789 premiere_date TEXT,
2790 community_rating REAL,
2791 official_rating TEXT,
2792 primary_image_tag TEXT,
2793 backdrop_image_tags TEXT,
2794 album_id TEXT,
2795 album_name TEXT,
2796 album_artist TEXT,
2797 artists TEXT,
2798 index_number INTEGER,
2799 series_id TEXT,
2800 series_name TEXT,
2801 season_id TEXT,
2802 season_name TEXT,
2803 parent_index_number INTEGER,
2804 synced_at TEXT,
2805 sort_name TEXT
2806 );
2807
2808 -- Mirrors the real FTS5 index and its triggers (schema.rs migration
2809 -- 001) so search can be exercised in tests at all.
2810 CREATE VIRTUAL TABLE items_fts USING fts5(
2811 name, overview, album_name, album_artist, artists, series_name,
2812 content='items', content_rowid='rowid'
2813 );
2814
2815 CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
2816 INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
2817 VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
2818 END;
2819
2820 CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
2821 INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
2822 VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
2823 END;
2824
2825 CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
2826 INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
2827 VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
2828 INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
2829 VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
2830 END;
2831
2832 CREATE TABLE user_data (
2833 user_id TEXT NOT NULL,
2834 item_id TEXT NOT NULL,
2835 playback_position_ticks INTEGER,
2836 is_played INTEGER,
2837 is_favorite INTEGER,
2838 play_count INTEGER,
2839 last_played_at TEXT,
2840 playback_context_type TEXT,
2841 playback_context_id TEXT,
2842 synced_at TEXT,
2843 pending_sync INTEGER DEFAULT 0,
2844 PRIMARY KEY (user_id, item_id)
2845 );
2846
2847 CREATE TABLE playlists (
2848 id TEXT PRIMARY KEY,
2849 user_id TEXT NOT NULL,
2850 name TEXT NOT NULL,
2851 is_local INTEGER DEFAULT 0,
2852 jellyfin_id TEXT,
2853 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
2854 updated_at TEXT
2855 );
2856
2857 CREATE TABLE playlist_items (
2858 id INTEGER PRIMARY KEY AUTOINCREMENT,
2859 playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
2860 item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
2861 sort_order INTEGER NOT NULL,
2862 added_at TEXT DEFAULT CURRENT_TIMESTAMP,
2863 UNIQUE(playlist_id, item_id)
2864 );
2865
2866 CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
2867
2868 CREATE TABLE downloads (
2869 id INTEGER PRIMARY KEY AUTOINCREMENT,
2870 item_id TEXT NOT NULL,
2871 status TEXT NOT NULL,
2872 file_size INTEGER
2873 );
2874
2875 CREATE TABLE libraries (
2876 id TEXT PRIMARY KEY,
2877 server_id TEXT NOT NULL,
2878 name TEXT NOT NULL,
2879 collection_type TEXT,
2880 image_tag TEXT,
2881 sort_order INTEGER DEFAULT 0,
2882 synced_at TEXT
2883 );
2884
2885 -- Mirrors migration 009 + the migration 022 FTS index.
2886 CREATE TABLE people (
2887 id TEXT PRIMARY KEY,
2888 server_id TEXT NOT NULL,
2889 name TEXT NOT NULL,
2890 overview TEXT,
2891 primary_image_tag TEXT,
2892 premiere_date TEXT,
2893 end_date TEXT,
2894 synced_at TEXT DEFAULT CURRENT_TIMESTAMP
2895 );
2896
2897 CREATE VIRTUAL TABLE people_fts USING fts5(
2898 name, overview, content='people', content_rowid='rowid'
2899 );
2900
2901 CREATE TRIGGER people_ai AFTER INSERT ON people BEGIN
2902 INSERT INTO people_fts(rowid, name, overview)
2903 VALUES (new.rowid, new.name, new.overview);
2904 END;
2905
2906 CREATE TRIGGER people_ad AFTER DELETE ON people BEGIN
2907 INSERT INTO people_fts(people_fts, rowid, name, overview)
2908 VALUES('delete', old.rowid, old.name, old.overview);
2909 END;
2910
2911 CREATE TRIGGER people_au AFTER UPDATE ON people BEGIN
2912 INSERT INTO people_fts(people_fts, rowid, name, overview)
2913 VALUES('delete', old.rowid, old.name, old.overview);
2914 INSERT INTO people_fts(rowid, name, overview)
2915 VALUES (new.rowid, new.name, new.overview);
2916 END;
2917
2918 CREATE TABLE genres (
2919 id TEXT NOT NULL,
2920 server_id TEXT NOT NULL,
2921 library_id TEXT,
2922 name TEXT NOT NULL,
2923 album_count INTEGER,
2924 synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
2925 PRIMARY KEY (server_id, library_id, name)
2926 );
2927 "#,
2928 )
2929 .unwrap();
2930
2931 conn.execute(
2933 "INSERT INTO servers (id, name, url) VALUES ('test-server', 'Test Server', 'http://test')",
2934 [],
2935 ).unwrap();
2936
2937 Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
2938 }
2939
2940 fn create_test_item(id: &str, name: &str, parent_id: Option<&str>) -> MediaItem {
2942 MediaItem {
2943 id: id.to_string(),
2944 name: name.to_string(),
2945 item_type: "Audio".to_string(),
2946 kind: crate::domain::MediaKind::Track,
2947 is_folder: false,
2948 server_id: "test-server".to_string(),
2949 parent_id: parent_id.map(|s| s.to_string()),
2950 library_id: None,
2951 overview: None,
2952 genres: None,
2953 runtime_ticks: None,
2954 duration_ms: None,
2955 production_year: None,
2956 premiere_date: None,
2957 community_rating: None,
2958 official_rating: None,
2959 primary_image_tag: None,
2960 image_id: None,
2961 backdrop_image_tags: None,
2962 parent_backdrop_image_tags: None,
2963 album_id: None,
2964 album_name: None,
2965 album_artist: None,
2966 artists: None,
2967 artist_items: None,
2968 index_number: None,
2969 series_id: None,
2970 series_name: None,
2971 season_id: None,
2972 season_name: None,
2973 parent_index_number: None,
2974 user_data: None,
2975 media_streams: None,
2976 media_sources: None,
2977 people: None,
2978 }
2979 }
2980
2981 #[tokio::test]
2982 async fn test_save_to_cache_with_missing_parent_fk() {
2983 let db_service = create_test_db();
2984
2985 let fk_enabled: i32 = db_service
2987 .query_one(Query::new("PRAGMA foreign_keys"), |row| row.get(0))
2988 .await
2989 .unwrap();
2990 println!("Foreign keys enabled: {}", fk_enabled);
2991 assert_eq!(fk_enabled, 1, "Foreign keys should be enabled");
2992
2993 let repo = OfflineRepository::new(
2994 db_service.clone(),
2995 "test-server".to_string(),
2996 "test-user".to_string(),
2997 );
2998
2999 let items = vec![
3003 create_test_item("track-1", "Track 1", Some("album-1")),
3005 create_test_item("track-2", "Track 2", Some("album-1")),
3006 create_test_item("track-3", "Track 3", Some("album-2")),
3008 create_test_item("track-4", "Track 4", Some("album-2")),
3009 create_test_item("album-1", "Album One", Some("library-123")),
3011 create_test_item("album-2", "Album Two", Some("library-123")),
3012 ];
3013
3014 println!("Attempting to save {} items...", items.len());
3015 for (i, item) in items.iter().enumerate() {
3016 println!(" Item {}: {} (parent: {:?})", i, item.id, item.parent_id);
3017 }
3018
3019 let result = repo.save_to_cache("library-123", &items).await;
3024
3025 match &result {
3027 Ok(count) => {
3028 println!("✓ Saved {} items", count);
3029 assert_eq!(*count, 6);
3030
3031 let all_items: Vec<(String, Option<String>)> = db_service
3033 .query_many(
3034 Query::new("SELECT id, parent_id FROM items ORDER BY id"),
3035 |row| Ok((row.get(0)?, row.get(1)?)),
3036 )
3037 .await
3038 .unwrap();
3039
3040 println!("\nAll items in database:");
3041 for (id, parent) in &all_items {
3042 println!(" {} -> parent: {:?}", id, parent);
3043 }
3044
3045 let track1_parent: Option<String> = db_service
3047 .query_optional(
3048 Query::with_params(
3049 "SELECT parent_id FROM items WHERE id = ?",
3050 vec![QueryParam::String("track-1".to_string())],
3051 ),
3052 |row| row.get(0),
3053 )
3054 .await
3055 .unwrap()
3056 .flatten();
3057
3058 println!("\ntrack-1 parent_id in DB: {:?}", track1_parent);
3059 println!("track-1 expected parent_id: Some(\"album-1\")");
3060
3061 assert_eq!(
3063 track1_parent,
3064 Some("album-1".to_string()),
3065 "track-1 should have parent_id='album-1'"
3066 );
3067
3068 let album1_parent: Option<String> = db_service
3070 .query_optional(
3071 Query::with_params(
3072 "SELECT parent_id FROM items WHERE id = ?",
3073 vec![QueryParam::String("album-1".to_string())],
3074 ),
3075 |row| row.get(0),
3076 )
3077 .await
3078 .unwrap()
3079 .flatten();
3080
3081 assert_eq!(
3082 album1_parent,
3083 Some("library-123".to_string()),
3084 "album-1 should have parent_id='library-123'"
3085 );
3086 }
3087 Err(e) => panic!("Unexpected error: {:?}", e),
3088 }
3089 }
3090
3091 #[tokio::test]
3092 async fn test_save_to_cache_simple_case() {
3093 let db_service = create_test_db();
3094 let repo = OfflineRepository::new(
3095 db_service.clone(),
3096 "test-server".to_string(),
3097 "test-user".to_string(),
3098 );
3099
3100 let items = vec![
3102 create_test_item("item-1", "Item 1", Some("parent-123")),
3103 create_test_item("item-2", "Item 2", Some("parent-123")),
3104 create_test_item("item-3", "Item 3", Some("parent-123")),
3105 ];
3106
3107 let result = repo.save_to_cache("parent-123", &items).await;
3108 assert!(result.is_ok(), "Simple case should work: {:?}", result);
3109 assert_eq!(result.unwrap(), 3);
3110 }
3111
3112 #[tokio::test]
3120 async fn test_get_item_album_available_via_album_id_link() {
3121 use crate::storage::db_service::DatabaseService;
3122 let db_service = create_test_db();
3123
3124 for sql in [
3125 "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
3127 VALUES ('album-1', 'test-server', 'Hadestown', 'MusicAlbum', NULL, NULL)",
3128 "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
3130 VALUES ('track-1', 'test-server', 'Wait For Me', 'Audio', 'album-1', NULL)",
3131 "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
3132 ] {
3133 db_service.execute(Query::new(sql)).await.unwrap();
3134 }
3135
3136 let repo = OfflineRepository::new(
3137 db_service.clone(),
3138 "test-server".to_string(),
3139 "test-user".to_string(),
3140 );
3141
3142 assert!(
3144 repo.get_item("track-1").await.is_ok(),
3145 "downloaded track should be available offline"
3146 );
3147
3148 let album = repo.get_item("album-1").await;
3151 assert!(
3152 album.is_ok(),
3153 "album with an album_id-linked downloaded track should be available offline, got {:?}",
3154 album.err()
3155 );
3156 assert_eq!(album.unwrap().id, "album-1");
3157
3158 let tracks = repo.get_items("album-1", None).await.unwrap();
3162 assert_eq!(
3163 tracks.items.len(),
3164 1,
3165 "get_items(album_id) should return the track"
3166 );
3167 assert_eq!(tracks.items[0].id, "track-1");
3168 }
3169
3170 #[tokio::test]
3183 async fn test_get_items_toggle_gates_synced_catalog() {
3184 use crate::storage::db_service::DatabaseService;
3185 let _guard = lock_catalog_browse();
3186 let db_service = create_test_db();
3187
3188 for sql in [
3189 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3191 VALUES ('movie-dl', 'test-server', 'Downloaded', 'Movie', 'lib-1', '2026-01-01')",
3192 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3193 VALUES ('movie-cat', 'test-server', 'CatalogOnly', 'Movie', 'lib-1', '2026-01-01')",
3194 "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
3196 "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
3198 ] {
3199 db_service.execute(Query::new(sql)).await.unwrap();
3200 }
3201
3202 let repo = OfflineRepository::new(
3203 db_service.clone(),
3204 "test-server".to_string(),
3205 "test-user".to_string(),
3206 );
3207 let opts = Some(GetItemsOptions {
3208 include_item_types: Some(vec!["Movie".to_string()]),
3209 ..Default::default()
3210 });
3211
3212 set_include_catalog_browse(false);
3214 let local_only = repo.get_items("lib-1", opts.clone()).await.unwrap();
3215 let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
3216 assert_eq!(
3217 ids,
3218 vec!["movie-dl"],
3219 "toggle off should show downloaded media only"
3220 );
3221
3222 set_include_catalog_browse(true);
3224 let full_catalog = repo.get_items("lib-1", opts).await.unwrap();
3225 let mut ids: Vec<&str> = full_catalog.items.iter().map(|i| i.id.as_str()).collect();
3226 ids.sort();
3227 assert_eq!(
3228 ids,
3229 vec!["movie-cat", "movie-dl"],
3230 "toggle on should reveal the full catalog"
3231 );
3232
3233 set_include_catalog_browse(true);
3235 }
3236
3237 #[tokio::test]
3244 async fn test_search_includes_cached_people() {
3245 use crate::storage::db_service::DatabaseService;
3246 let _guard = lock_catalog_browse();
3247 let db_service = create_test_db();
3248
3249 for sql in [
3250 "INSERT INTO people (id, server_id, name, overview) \
3251 VALUES ('p1', 'test-server', 'Tilda Swinton', 'Actor')",
3252 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3255 VALUES ('m1', 'test-server', 'Tilda the Movie', 'Movie', '2026-01-01')",
3256 ] {
3257 db_service.execute(Query::new(sql)).await.unwrap();
3258 }
3259
3260 let repo = OfflineRepository::new(
3261 db_service.clone(),
3262 "test-server".to_string(),
3263 "test-user".to_string(),
3264 );
3265 set_include_catalog_browse(true);
3266
3267 let all = repo.search("Tilda", None).await.unwrap();
3269 let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
3270 ids.sort();
3271 assert_eq!(ids, vec!["m1", "p1"], "unscoped search must include people");
3272
3273 let person = all.items.iter().find(|i| i.id == "p1").unwrap();
3274 assert_eq!(person.item_type, "Person");
3275 assert_eq!(person.kind, crate::domain::MediaKind::Person);
3276
3277 let scoped = repo
3279 .search(
3280 "Tilda",
3281 Some(SearchOptions {
3282 include_item_types: Some(vec!["Movie".to_string()]),
3283 ..Default::default()
3284 }),
3285 )
3286 .await
3287 .unwrap();
3288 let ids: Vec<&str> = scoped.items.iter().map(|i| i.id.as_str()).collect();
3289 assert_eq!(ids, vec!["m1"], "a scoped search must not leak people in");
3290
3291 set_include_catalog_browse(true);
3292 }
3293
3294 #[tokio::test]
3301 async fn test_prune_stale_catalog() {
3302 use crate::storage::db_service::DatabaseService;
3303 let db_service = create_test_db();
3304
3305 let old = "2026-01-01T00:00:00+00:00";
3307 let new = "2026-06-01T00:00:00+00:00";
3308 let cutoff = "2026-03-01T00:00:00+00:00";
3309
3310 for sql in [
3311 "INSERT INTO servers (id, name, url) \
3313 VALUES ('other-server', 'Other', 'http://other')"
3314 .to_string(),
3315 format!(
3317 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3318 VALUES ('keep-fresh', 'test-server', 'Fresh', 'Movie', '{new}')"
3319 ),
3320 format!(
3322 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3323 VALUES ('gone', 'test-server', 'Vanished', 'Movie', '{old}')"
3324 ),
3325 format!(
3327 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3328 VALUES ('keep-dl', 'test-server', 'Downloaded', 'Movie', '{old}')"
3329 ),
3330 "INSERT INTO downloads (item_id, status) VALUES ('keep-dl', 'completed')".to_string(),
3331 format!(
3333 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3334 VALUES ('keep-album', 'test-server', 'Album', 'MusicAlbum', '{old}')"
3335 ),
3336 format!(
3337 "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
3338 VALUES ('keep-track', 'test-server', 'Track', 'Audio', 'keep-album', '{old}')"
3339 ),
3340 "INSERT INTO downloads (item_id, status) VALUES ('keep-track', 'completed')"
3341 .to_string(),
3342 format!(
3345 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3346 VALUES ('keep-artist', 'test-server', 'Artist', 'MusicArtist', '{old}')"
3347 ),
3348 format!(
3350 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3351 VALUES ('keep-other', 'other-server', 'Elsewhere', 'Movie', '{old}')"
3352 ),
3353 ] {
3354 db_service.execute(Query::new(&sql)).await.unwrap();
3355 }
3356
3357 let repo = OfflineRepository::new(
3358 db_service.clone(),
3359 "test-server".to_string(),
3360 "test-user".to_string(),
3361 );
3362
3363 let crawled_types = vec![
3364 "Movie".to_string(),
3365 "MusicAlbum".to_string(),
3366 "Audio".to_string(),
3367 ];
3368 let removed = repo
3369 .prune_stale_catalog(cutoff, &crawled_types)
3370 .await
3371 .unwrap();
3372 assert_eq!(removed, 1, "only the vanished movie should be swept");
3373
3374 let mut surviving: Vec<String> = db_service
3375 .query_many(Query::new("SELECT id FROM items"), |row| row.get(0))
3376 .await
3377 .unwrap();
3378 surviving.sort();
3379 assert_eq!(
3380 surviving,
3381 vec![
3382 "keep-album",
3383 "keep-artist",
3384 "keep-dl",
3385 "keep-fresh",
3386 "keep-other",
3387 "keep-track",
3388 ]
3389 );
3390
3391 assert_eq!(repo.prune_stale_catalog(cutoff, &[]).await.unwrap(), 0);
3393 }
3394
3395 #[tokio::test]
3410 async fn test_repeated_cache_does_not_duplicate_fts_entries() {
3411 use crate::storage::db_service::DatabaseService;
3412 let db_service = create_test_db();
3413 let repo = OfflineRepository::new(
3414 db_service.clone(),
3415 "test-server".to_string(),
3416 "test-user".to_string(),
3417 );
3418
3419 let items = vec![create_test_item("track-1", "Wait For Me", None)];
3420
3421 for _ in 0..3 {
3424 repo.save_to_cache("parent-1", &items).await.unwrap();
3425 }
3426
3427 let item_rows: i64 = db_service
3430 .query_one(
3431 Query::new("SELECT COUNT(*) FROM items WHERE id = 'track-1'"),
3432 |row| row.get(0),
3433 )
3434 .await
3435 .unwrap();
3436 assert_eq!(item_rows, 1, "three passes must leave one item row");
3437
3438 let fts_hits: i64 = db_service
3439 .query_one(
3440 Query::with_params(
3441 "SELECT COUNT(*) FROM items_fts WHERE items_fts MATCH ?",
3442 vec![QueryParam::String("\"Wait\"*".to_string())],
3443 ),
3444 |row| row.get(0),
3445 )
3446 .await
3447 .unwrap();
3448 assert_eq!(
3449 fts_hits, 1,
3450 "the FTS index must hold one entry per item, not one per sync pass"
3451 );
3452 }
3453
3454 #[tokio::test]
3463 async fn test_search_toggle_gates_synced_catalog() {
3464 use crate::storage::db_service::DatabaseService;
3465 let _guard = lock_catalog_browse();
3466 let db_service = create_test_db();
3467
3468 for sql in [
3469 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3471 VALUES ('movie-dl', 'test-server', 'Arrival', 'Movie', 'lib-1', '2026-01-01')",
3472 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3473 VALUES ('movie-cat', 'test-server', 'Arrakis', 'Movie', 'lib-1', '2026-01-01')",
3474 "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
3476 ] {
3477 db_service.execute(Query::new(sql)).await.unwrap();
3478 }
3479
3480 let repo = OfflineRepository::new(
3481 db_service.clone(),
3482 "test-server".to_string(),
3483 "test-user".to_string(),
3484 );
3485
3486 set_include_catalog_browse(true);
3489 let full = repo.search("Arr", None).await.unwrap();
3490 let mut ids: Vec<&str> = full.items.iter().map(|i| i.id.as_str()).collect();
3491 ids.sort();
3492 assert_eq!(
3493 ids,
3494 vec!["movie-cat", "movie-dl"],
3495 "with catalog browse on, search must cover synced-but-not-downloaded items"
3496 );
3497
3498 set_include_catalog_browse(false);
3500 let local_only = repo.search("Arr", None).await.unwrap();
3501 let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
3502 assert_eq!(
3503 ids,
3504 vec!["movie-dl"],
3505 "with catalog browse off, search stays downloads-only"
3506 );
3507
3508 set_include_catalog_browse(true);
3510 }
3511
3512 #[tokio::test]
3519 async fn test_search_type_filter_is_parameterised() {
3520 use crate::storage::db_service::DatabaseService;
3521 let _guard = lock_catalog_browse();
3522 let db_service = create_test_db();
3523
3524 db_service
3525 .execute(Query::new(
3526 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3527 VALUES ('m1', 'test-server', 'Arrival', 'Movie', '2026-01-01')",
3528 ))
3529 .await
3530 .unwrap();
3531
3532 let repo = OfflineRepository::new(
3533 db_service.clone(),
3534 "test-server".to_string(),
3535 "test-user".to_string(),
3536 );
3537 set_include_catalog_browse(true);
3538
3539 let opts = SearchOptions {
3541 include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
3542 ..Default::default()
3543 };
3544 let result = repo.search("Arr", Some(opts)).await;
3545 assert!(
3546 result.is_ok(),
3547 "a quote in an item type must not break the query: {:?}",
3548 result.err()
3549 );
3550 assert!(
3551 result.unwrap().items.is_empty(),
3552 "an injected type filter must not widen the result set"
3553 );
3554
3555 let opts = SearchOptions {
3557 include_item_types: Some(vec!["Movie".to_string()]),
3558 ..Default::default()
3559 };
3560 assert_eq!(repo.search("Arr", Some(opts)).await.unwrap().items.len(), 1);
3561 }
3562
3563 #[tokio::test]
3568 async fn test_get_item_tv_available_via_season_series_link() {
3569 use crate::storage::db_service::DatabaseService;
3570 let db_service = create_test_db();
3571
3572 for sql in [
3573 "INSERT INTO items (id, server_id, name, item_type, parent_id) \
3574 VALUES ('series-1', 'test-server', 'Gilmore Girls', 'Series', NULL)",
3575 "INSERT INTO items (id, server_id, name, item_type, series_id, parent_id) \
3576 VALUES ('season-1', 'test-server', 'Season 1', 'Season', 'series-1', NULL)",
3577 "INSERT INTO items (id, server_id, name, item_type, season_id, series_id, parent_id) \
3579 VALUES ('ep-1', 'test-server', 'Pilot', 'Episode', 'season-1', 'series-1', NULL)",
3580 "INSERT INTO downloads (item_id, status) VALUES ('ep-1', 'completed')",
3581 ] {
3582 db_service.execute(Query::new(sql)).await.unwrap();
3583 }
3584
3585 let repo = OfflineRepository::new(
3586 db_service.clone(),
3587 "test-server".to_string(),
3588 "test-user".to_string(),
3589 );
3590
3591 assert!(
3592 repo.get_item("ep-1").await.is_ok(),
3593 "downloaded episode available offline"
3594 );
3595 assert!(
3596 repo.get_item("season-1").await.is_ok(),
3597 "season with a season_id-linked downloaded episode should be available offline"
3598 );
3599 assert!(
3600 repo.get_item("series-1").await.is_ok(),
3601 "series with a series_id-linked downloaded episode should be available offline"
3602 );
3603
3604 let season_items = repo.get_items("season-1", None).await.unwrap();
3606 assert!(
3607 season_items.items.iter().any(|i| i.id == "ep-1"),
3608 "get_items(season_id) should return the episode"
3609 );
3610
3611 let series_items = repo.get_items("series-1", None).await.unwrap();
3613 assert!(
3614 series_items.items.iter().any(|i| i.id == "ep-1"),
3615 "get_items(series_id) should surface the downloaded episode"
3616 );
3617 }
3618
3619 #[tokio::test]
3624 async fn test_libraries_cache_roundtrip_available_offline() {
3625 let db_service = create_test_db();
3626 let repo = OfflineRepository::new(
3627 db_service.clone(),
3628 "test-server".to_string(),
3629 "test-user".to_string(),
3630 );
3631
3632 assert!(repo.get_libraries().await.unwrap().is_empty());
3635
3636 let server_libs = vec![
3638 Library::new("music".into(), "Music".into(), "music".into(), None),
3639 Library::new(
3640 "movies".into(),
3641 "Movies".into(),
3642 "movies".into(),
3643 Some("tag".into()),
3644 ),
3645 ];
3646 let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
3647 assert_eq!(saved, 2);
3648
3649 let offline_libs = repo.get_libraries().await.unwrap();
3651 let names: Vec<&str> = offline_libs.iter().map(|l| l.name.as_str()).collect();
3652 assert_eq!(
3653 names,
3654 vec!["Music", "Movies"],
3655 "cached libraries available offline in sort order"
3656 );
3657
3658 repo.save_libraries_to_cache(&server_libs).await.unwrap();
3660 assert_eq!(repo.get_libraries().await.unwrap().len(), 2);
3661 }
3662
3663 #[tokio::test]
3670 async fn test_genres_cache_roundtrip_scoped_by_library() {
3671 let db_service = create_test_db();
3672 let repo = OfflineRepository::new(
3673 db_service.clone(),
3674 "test-server".to_string(),
3675 "test-user".to_string(),
3676 );
3677
3678 assert!(repo.get_genres(Some("music-lib")).await.unwrap().is_empty());
3680
3681 let server_genres = vec![
3683 Genre {
3684 id: "g1".into(),
3685 name: "Rock".into(),
3686 album_count: Some(42),
3687 },
3688 Genre {
3689 id: "g2".into(),
3690 name: "Jazz".into(),
3691 album_count: Some(17),
3692 },
3693 Genre {
3694 id: "g3".into(),
3695 name: "Ambient".into(),
3696 album_count: None,
3697 },
3698 ];
3699 let saved = repo
3700 .save_genres_to_cache(Some("music-lib"), &server_genres)
3701 .await
3702 .unwrap();
3703 assert_eq!(saved, 3);
3704
3705 let mut offline_genres = repo.get_genres(Some("music-lib")).await.unwrap();
3707 offline_genres.sort_by(|a, b| a.name.cmp(&b.name));
3708 let names: Vec<&str> = offline_genres.iter().map(|g| g.name.as_str()).collect();
3709 assert_eq!(names, vec!["Ambient", "Jazz", "Rock"]);
3710 let rock = offline_genres.iter().find(|g| g.name == "Rock").unwrap();
3711 assert_eq!(rock.album_count, Some(42));
3712
3713 assert!(repo.get_genres(Some("other-lib")).await.unwrap().is_empty());
3715
3716 let updated = vec![Genre {
3718 id: "g1".into(),
3719 name: "Rock".into(),
3720 album_count: Some(50),
3721 }];
3722 repo.save_genres_to_cache(Some("music-lib"), &updated)
3723 .await
3724 .unwrap();
3725 let after = repo.get_genres(Some("music-lib")).await.unwrap();
3726 assert_eq!(after.len(), 1, "stale genres removed on refresh");
3727 assert_eq!(after[0].album_count, Some(50), "counts updated on refresh");
3728 }
3729
3730 async fn seed_items(repo: &OfflineRepository, ids: &[&str]) {
3734 let items: Vec<MediaItem> = ids
3735 .iter()
3736 .map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1")))
3737 .collect();
3738 repo.save_to_cache("library-1", &items).await.unwrap();
3739 }
3740
3741 async fn insert_item(
3744 db: &Arc<RusqliteService>,
3745 id: &str,
3746 item_type: &str,
3747 album_id: Option<&str>,
3748 series_id: Option<&str>,
3749 season_id: Option<&str>,
3750 ) {
3751 db.execute(Query::with_params(
3752 "INSERT INTO items (id, server_id, name, item_type, album_id, series_id, season_id, synced_at)
3753 VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, ?6, '2024-01-01')",
3754 vec![
3755 QueryParam::String(id.to_string()),
3756 QueryParam::String(format!("Name {id}")),
3757 QueryParam::String(item_type.to_string()),
3758 album_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
3759 series_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
3760 season_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
3761 ],
3762 ))
3763 .await
3764 .unwrap();
3765 }
3766
3767 async fn insert_library_item(
3770 db: &Arc<RusqliteService>,
3771 id: &str,
3772 item_type: &str,
3773 library_id: &str,
3774 album_id: Option<&str>,
3775 ) {
3776 db.execute(Query::with_params(
3777 "INSERT INTO items (id, server_id, library_id, name, item_type, album_id, synced_at)
3778 VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, '2024-01-01')",
3779 vec![
3780 QueryParam::String(id.to_string()),
3781 QueryParam::String(library_id.to_string()),
3782 QueryParam::String(format!("Name {id}")),
3783 QueryParam::String(item_type.to_string()),
3784 album_id
3785 .map(|s| QueryParam::String(s.to_string()))
3786 .unwrap_or(QueryParam::Null),
3787 ],
3788 ))
3789 .await
3790 .unwrap();
3791 }
3792
3793 async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
3794 db.execute(Query::with_params(
3795 "INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
3796 vec![
3797 QueryParam::String(item_id.to_string()),
3798 QueryParam::Int64(file_size),
3799 ],
3800 ))
3801 .await
3802 .unwrap();
3803 }
3804
3805 async fn seed_library(db: &Arc<RusqliteService>, id: &str, collection_type: &str) {
3806 db.execute(Query::with_params(
3807 "INSERT INTO libraries (id, server_id, name, collection_type, sort_order)
3808 VALUES (?1, 'test-server', ?2, ?3, 0)",
3809 vec![
3810 QueryParam::String(id.to_string()),
3811 QueryParam::String(format!("Lib {id}")),
3812 QueryParam::String(collection_type.to_string()),
3813 ],
3814 ))
3815 .await
3816 .unwrap();
3817 }
3818
3819 fn make_repo(db: &Arc<RusqliteService>) -> OfflineRepository {
3820 OfflineRepository::new(
3821 db.clone(),
3822 "test-server".to_string(),
3823 "test-user".to_string(),
3824 )
3825 }
3826
3827 #[tokio::test]
3834 async fn test_get_latest_items_collapses_tracks_into_their_album() {
3835 let db = create_test_db();
3836 insert_library_item(&db, "album-1", "MusicAlbum", "lib-1", None).await;
3837 for track in ["track-1", "track-2", "track-3"] {
3838 insert_library_item(&db, track, "Audio", "lib-1", Some("album-1")).await;
3839 seed_completed_download(&db, track, 1000).await;
3840 }
3841 insert_library_item(&db, "movie-1", "Movie", "lib-1", None).await;
3843 seed_completed_download(&db, "movie-1", 2000).await;
3844
3845 let repo = make_repo(&db);
3846 let latest = repo.get_latest_items("lib-1", Some(16)).await.unwrap();
3847 let ids: Vec<&str> = latest.iter().map(|i| i.id.as_str()).collect();
3848
3849 assert!(
3850 !ids.iter().any(|id| id.starts_with("track-")),
3851 "individual tracks must collapse into their album, got: {ids:?}"
3852 );
3853 assert!(ids.contains(&"album-1"), "the album itself is listed");
3854 assert!(ids.contains(&"movie-1"), "containerless items still listed");
3855 }
3856
3857 #[tokio::test]
3862 async fn test_get_downloaded_items_returns_leaf_and_container() {
3863 let db = create_test_db();
3864 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
3865 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
3866 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
3867 seed_completed_download(&db, "track-1", 1000).await;
3869
3870 let repo = make_repo(&db);
3871
3872 let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
3874 let ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
3875 assert_eq!(ids, vec!["track-1"], "only the downloaded track is listed");
3876 }
3877
3878 #[tokio::test]
3884 async fn test_get_downloaded_items_library_lists_albums_not_tracks() {
3885 let db = create_test_db();
3886 seed_library(&db, "music-lib", "music").await;
3887 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
3888 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
3890 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
3891 seed_completed_download(&db, "track-1", 1000).await;
3892 seed_completed_download(&db, "track-2", 1000).await;
3893
3894 let repo = make_repo(&db);
3895
3896 let at_library = repo.get_downloaded_items("music-lib", None).await.unwrap();
3898 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
3899 assert_eq!(
3900 ids,
3901 vec!["album-1"],
3902 "library browse lists the album container, not its tracks"
3903 );
3904
3905 let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
3907 let mut track_ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
3908 track_ids.sort();
3909 assert_eq!(track_ids, vec!["track-1", "track-2"]);
3910 }
3911
3912 #[tokio::test]
3924 async fn test_get_downloaded_items_library_does_not_mix_media_types() {
3925 let db = create_test_db();
3926 seed_library(&db, "music-lib", "music").await;
3927 seed_library(&db, "movie-lib", "movies").await;
3928 seed_library(&db, "tv-lib", "tvshows").await;
3929
3930 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
3931 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
3932 insert_item(&db, "movie-1", "Movie", None, None, None).await;
3933 insert_item(&db, "series-1", "Series", None, None, None).await;
3934 insert_item(&db, "episode-1", "Episode", None, Some("series-1"), None).await;
3935
3936 seed_completed_download(&db, "track-1", 1000).await;
3937 seed_completed_download(&db, "movie-1", 2000).await;
3938 seed_completed_download(&db, "episode-1", 3000).await;
3939
3940 let repo = make_repo(&db);
3941
3942 let music: Vec<String> = repo
3943 .get_downloaded_items("music-lib", None)
3944 .await
3945 .unwrap()
3946 .items
3947 .iter()
3948 .map(|i| i.id.clone())
3949 .collect();
3950 assert_eq!(
3951 music,
3952 vec!["album-1"],
3953 "the music library must not list films or series; got {:?}",
3954 music
3955 );
3956
3957 let movies: Vec<String> = repo
3958 .get_downloaded_items("movie-lib", None)
3959 .await
3960 .unwrap()
3961 .items
3962 .iter()
3963 .map(|i| i.id.clone())
3964 .collect();
3965 assert_eq!(
3966 movies,
3967 vec!["movie-1"],
3968 "the movie library must not list albums or series; got {:?}",
3969 movies
3970 );
3971
3972 let tv: Vec<String> = repo
3973 .get_downloaded_items("tv-lib", None)
3974 .await
3975 .unwrap()
3976 .items
3977 .iter()
3978 .map(|i| i.id.clone())
3979 .collect();
3980 assert_eq!(
3981 tv,
3982 vec!["series-1"],
3983 "the TV library must not list albums or films; got {:?}",
3984 tv
3985 );
3986 }
3987
3988 #[tokio::test]
3994 async fn test_get_downloaded_items_library_lists_series_not_episodes() {
3995 let db = create_test_db();
3996 seed_library(&db, "tv-lib", "tvshows").await;
3997 insert_item(&db, "series-1", "Series", None, None, None).await;
3998 insert_item(&db, "season-1", "Season", None, Some("series-1"), None).await;
4000 insert_item(
4001 &db,
4002 "ep-1",
4003 "Episode",
4004 None,
4005 Some("series-1"),
4006 Some("season-1"),
4007 )
4008 .await;
4009 seed_completed_download(&db, "ep-1", 4000).await;
4010
4011 let repo = make_repo(&db);
4012
4013 let at_library = repo.get_downloaded_items("tv-lib", None).await.unwrap();
4015 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
4016 assert_eq!(
4017 ids,
4018 vec!["series-1"],
4019 "TV library browse lists the series, not seasons/episodes"
4020 );
4021
4022 let in_series = repo.get_downloaded_items("series-1", None).await.unwrap();
4024 assert!(
4025 in_series.items.iter().any(|i| i.id == "season-1"),
4026 "series drill returns the season"
4027 );
4028 let in_season = repo.get_downloaded_items("season-1", None).await.unwrap();
4029 assert!(
4030 in_season.items.iter().any(|i| i.id == "ep-1"),
4031 "season drill returns the episode"
4032 );
4033 }
4034
4035 #[tokio::test]
4040 async fn test_get_downloaded_items_library_keeps_orphan_leaves() {
4041 let db = create_test_db();
4042 seed_library(&db, "movie-lib", "movies").await;
4043 insert_item(&db, "movie-1", "Movie", None, None, None).await;
4044 seed_completed_download(&db, "movie-1", 5000).await;
4045
4046 let repo = make_repo(&db);
4047 let at_library = repo.get_downloaded_items("movie-lib", None).await.unwrap();
4048 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
4049 assert_eq!(
4050 ids,
4051 vec!["movie-1"],
4052 "a downloaded movie with no container shows"
4053 );
4054 }
4055
4056 #[tokio::test]
4061 async fn test_get_downloaded_items_empty_is_authoritative() {
4062 let db = create_test_db();
4063 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4064 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4065 set_include_catalog_browse(true);
4067 let repo = make_repo(&db);
4068
4069 let result = repo.get_downloaded_items("album-1", None).await.unwrap();
4070 assert!(
4071 result.items.is_empty(),
4072 "empty downloaded browse returns no items even with catalog-browse on"
4073 );
4074 }
4075
4076 #[tokio::test]
4080 async fn test_get_downloaded_libraries_omits_empty() {
4081 let db = create_test_db();
4082 seed_library(&db, "music-lib", "music").await;
4083 seed_library(&db, "movie-lib", "movies").await;
4084 insert_item(&db, "track-1", "Audio", None, None, None).await;
4085 seed_completed_download(&db, "track-1", 500).await;
4086
4087 let repo = make_repo(&db);
4088 let libs = repo.get_downloaded_libraries().await.unwrap();
4089 let ids: Vec<&str> = libs.iter().map(|l| l.id.as_str()).collect();
4090 assert_eq!(
4091 ids,
4092 vec!["music-lib"],
4093 "movie library with no downloads omitted"
4094 );
4095 }
4096
4097 #[tokio::test]
4102 async fn test_download_disk_usage_aggregates_containers() {
4103 let db = create_test_db();
4104 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4105 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4106 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
4107 seed_completed_download(&db, "track-1", 1000).await;
4108 seed_completed_download(&db, "track-2", 2000).await;
4109
4110 let repo = make_repo(&db);
4111 let usage = repo.get_download_disk_usage().await.unwrap();
4112
4113 assert_eq!(usage.item_count, 2, "two leaf downloads");
4114 assert_eq!(
4115 usage.device_total_bytes, 3000,
4116 "device total is the leaf sum"
4117 );
4118 assert_eq!(usage.sizes.get("track-1"), Some(&1000));
4119 assert_eq!(
4120 usage.sizes.get("album-1"),
4121 Some(&3000),
4122 "container = sum of children"
4123 );
4124 assert_eq!(
4126 usage.partial_containers.get("album-1"),
4127 None,
4128 "fully downloaded album is not partial"
4129 );
4130 let leaf_sum: i64 = ["track-1", "track-2"]
4132 .iter()
4133 .map(|id| usage.sizes[*id])
4134 .sum();
4135 assert_eq!(leaf_sum, usage.device_total_bytes);
4136 }
4137
4138 #[tokio::test]
4141 async fn test_download_disk_usage_flags_partial_container() {
4142 let db = create_test_db();
4143 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4144 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4145 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
4146 seed_completed_download(&db, "track-1", 1000).await;
4148
4149 let repo = make_repo(&db);
4150 let usage = repo.get_download_disk_usage().await.unwrap();
4151 assert_eq!(
4152 usage.partial_containers.get("album-1"),
4153 Some(&true),
4154 "album with a missing child is partial"
4155 );
4156 }
4157
4158 #[tokio::test]
4159 async fn test_playlist_create_empty() {
4160 let db_service = create_test_db();
4161 let repo = OfflineRepository::new(
4162 db_service.clone(),
4163 "test-server".to_string(),
4164 "test-user".to_string(),
4165 );
4166
4167 let result = repo.create_playlist("My Playlist", &[]).await;
4168 assert!(result.is_ok());
4169 let created = result.unwrap();
4170 assert!(
4171 !created.id.is_empty(),
4172 "Should return a non-empty playlist ID"
4173 );
4174
4175 let name: String = db_service
4177 .query_one(
4178 Query::with_params(
4179 "SELECT name FROM playlists WHERE id = ?",
4180 vec![QueryParam::String(created.id.clone())],
4181 ),
4182 |row| row.get(0),
4183 )
4184 .await
4185 .unwrap();
4186 assert_eq!(name, "My Playlist");
4187 }
4188
4189 #[tokio::test]
4190 async fn test_playlist_create_with_items() {
4191 let db_service = create_test_db();
4192 let repo = OfflineRepository::new(
4193 db_service.clone(),
4194 "test-server".to_string(),
4195 "test-user".to_string(),
4196 );
4197 seed_items(&repo, &["t1", "t2", "t3"]).await;
4198
4199 let created = repo
4200 .create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()])
4201 .await
4202 .unwrap();
4203
4204 let items = repo.get_playlist_items(&created.id).await.unwrap();
4205 assert_eq!(items.len(), 3);
4206 assert_eq!(items[0].item.id, "t1");
4207 assert_eq!(items[1].item.id, "t2");
4208 assert_eq!(items[2].item.id, "t3");
4209 }
4210
4211 #[tokio::test]
4212 async fn test_playlist_delete() {
4213 let db_service = create_test_db();
4214 let repo = OfflineRepository::new(
4215 db_service.clone(),
4216 "test-server".to_string(),
4217 "test-user".to_string(),
4218 );
4219 seed_items(&repo, &["t1"]).await;
4220
4221 let created = repo
4222 .create_playlist("To Delete", &["t1".into()])
4223 .await
4224 .unwrap();
4225
4226 repo.delete_playlist(&created.id).await.unwrap();
4228
4229 let count: i32 = db_service
4231 .query_one(
4232 Query::with_params(
4233 "SELECT COUNT(*) FROM playlists WHERE id = ?",
4234 vec![QueryParam::String(created.id.clone())],
4235 ),
4236 |row| row.get(0),
4237 )
4238 .await
4239 .unwrap();
4240 assert_eq!(count, 0);
4241
4242 let item_count: i32 = db_service
4244 .query_one(
4245 Query::with_params(
4246 "SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?",
4247 vec![QueryParam::String(created.id)],
4248 ),
4249 |row| row.get(0),
4250 )
4251 .await
4252 .unwrap();
4253 assert_eq!(item_count, 0);
4254 }
4255
4256 #[tokio::test]
4257 async fn test_playlist_rename() {
4258 let db_service = create_test_db();
4259 let repo = OfflineRepository::new(
4260 db_service.clone(),
4261 "test-server".to_string(),
4262 "test-user".to_string(),
4263 );
4264
4265 let created = repo.create_playlist("Original Name", &[]).await.unwrap();
4266 repo.rename_playlist(&created.id, "New Name").await.unwrap();
4267
4268 let name: String = db_service
4269 .query_one(
4270 Query::with_params(
4271 "SELECT name FROM playlists WHERE id = ?",
4272 vec![QueryParam::String(created.id)],
4273 ),
4274 |row| row.get(0),
4275 )
4276 .await
4277 .unwrap();
4278 assert_eq!(name, "New Name");
4279 }
4280
4281 #[tokio::test]
4282 async fn test_playlist_get_items_preserves_order() {
4283 let db_service = create_test_db();
4284 let repo = OfflineRepository::new(
4285 db_service.clone(),
4286 "test-server".to_string(),
4287 "test-user".to_string(),
4288 );
4289 seed_items(&repo, &["a", "b", "c"]).await;
4290
4291 let created = repo
4292 .create_playlist("Ordered", &["c".into(), "a".into(), "b".into()])
4293 .await
4294 .unwrap();
4295 let items = repo.get_playlist_items(&created.id).await.unwrap();
4296
4297 assert_eq!(items.len(), 3);
4298 assert_eq!(items[0].item.id, "c");
4300 assert_eq!(items[1].item.id, "a");
4301 assert_eq!(items[2].item.id, "b");
4302 assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id);
4304 assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id);
4305 }
4306
4307 #[tokio::test]
4308 async fn test_playlist_get_items_empty_playlist() {
4309 let db_service = create_test_db();
4310 let repo = OfflineRepository::new(
4311 db_service.clone(),
4312 "test-server".to_string(),
4313 "test-user".to_string(),
4314 );
4315
4316 let created = repo.create_playlist("Empty", &[]).await.unwrap();
4317 let items = repo.get_playlist_items(&created.id).await.unwrap();
4318 assert!(items.is_empty());
4319 }
4320
4321 #[tokio::test]
4322 async fn test_playlist_add_items() {
4323 let db_service = create_test_db();
4324 let repo = OfflineRepository::new(
4325 db_service.clone(),
4326 "test-server".to_string(),
4327 "test-user".to_string(),
4328 );
4329 seed_items(&repo, &["t1", "t2", "t3"]).await;
4330
4331 let created = repo
4332 .create_playlist("Addable", &["t1".into()])
4333 .await
4334 .unwrap();
4335
4336 repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()])
4338 .await
4339 .unwrap();
4340
4341 let items = repo.get_playlist_items(&created.id).await.unwrap();
4342 assert_eq!(items.len(), 3);
4343 assert_eq!(items[0].item.id, "t1");
4344 assert_eq!(items[1].item.id, "t2");
4345 assert_eq!(items[2].item.id, "t3");
4346 }
4347
4348 #[tokio::test]
4349 async fn test_playlist_add_duplicate_items_ignored() {
4350 let db_service = create_test_db();
4351 let repo = OfflineRepository::new(
4352 db_service.clone(),
4353 "test-server".to_string(),
4354 "test-user".to_string(),
4355 );
4356 seed_items(&repo, &["t1"]).await;
4357
4358 let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap();
4359
4360 repo.add_to_playlist(&created.id, &["t1".into()])
4362 .await
4363 .unwrap();
4364
4365 let items = repo.get_playlist_items(&created.id).await.unwrap();
4366 assert_eq!(
4367 items.len(),
4368 1,
4369 "Duplicate should be ignored (UNIQUE constraint)"
4370 );
4371 }
4372
4373 #[tokio::test]
4374 async fn test_playlist_remove_items() {
4375 let db_service = create_test_db();
4376 let repo = OfflineRepository::new(
4377 db_service.clone(),
4378 "test-server".to_string(),
4379 "test-user".to_string(),
4380 );
4381 seed_items(&repo, &["t1", "t2", "t3"]).await;
4382
4383 let created = repo
4384 .create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()])
4385 .await
4386 .unwrap();
4387 let items = repo.get_playlist_items(&created.id).await.unwrap();
4388 assert_eq!(items.len(), 3);
4389
4390 let entry_id_to_remove = items[1].playlist_item_id.clone();
4392 repo.remove_from_playlist(&created.id, &[entry_id_to_remove])
4393 .await
4394 .unwrap();
4395
4396 let items_after = repo.get_playlist_items(&created.id).await.unwrap();
4397 assert_eq!(items_after.len(), 2);
4398 assert_eq!(items_after[0].item.id, "t1");
4399 assert_eq!(items_after[1].item.id, "t3");
4400 }
4401
4402 #[tokio::test]
4403 async fn test_playlist_move_item_forward() {
4404 let db_service = create_test_db();
4405 let repo = OfflineRepository::new(
4406 db_service.clone(),
4407 "test-server".to_string(),
4408 "test-user".to_string(),
4409 );
4410 seed_items(&repo, &["a", "b", "c", "d"]).await;
4411
4412 let created = repo
4413 .create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()])
4414 .await
4415 .unwrap();
4416
4417 repo.move_playlist_item(&created.id, "a", 2).await.unwrap();
4419
4420 let items = repo.get_playlist_items(&created.id).await.unwrap();
4421 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4422 assert_eq!(ids, vec!["b", "c", "a", "d"]);
4423 }
4424
4425 #[tokio::test]
4426 async fn test_playlist_move_item_backward() {
4427 let db_service = create_test_db();
4428 let repo = OfflineRepository::new(
4429 db_service.clone(),
4430 "test-server".to_string(),
4431 "test-user".to_string(),
4432 );
4433 seed_items(&repo, &["a", "b", "c", "d"]).await;
4434
4435 let created = repo
4436 .create_playlist(
4437 "Reorder2",
4438 &["a".into(), "b".into(), "c".into(), "d".into()],
4439 )
4440 .await
4441 .unwrap();
4442
4443 repo.move_playlist_item(&created.id, "d", 0).await.unwrap();
4445
4446 let items = repo.get_playlist_items(&created.id).await.unwrap();
4447 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4448 assert_eq!(ids, vec!["d", "a", "b", "c"]);
4449 }
4450
4451 #[tokio::test]
4452 async fn test_playlist_move_item_to_end() {
4453 let db_service = create_test_db();
4454 let repo = OfflineRepository::new(
4455 db_service.clone(),
4456 "test-server".to_string(),
4457 "test-user".to_string(),
4458 );
4459 seed_items(&repo, &["a", "b", "c"]).await;
4460
4461 let created = repo
4462 .create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()])
4463 .await
4464 .unwrap();
4465
4466 repo.move_playlist_item(&created.id, "a", 99).await.unwrap();
4468
4469 let items = repo.get_playlist_items(&created.id).await.unwrap();
4470 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4471 assert_eq!(ids, vec!["b", "c", "a"]);
4472 }
4473
4474 #[tokio::test]
4475 async fn test_playlist_move_nonexistent_item_is_noop() {
4476 let db_service = create_test_db();
4477 let repo = OfflineRepository::new(
4478 db_service.clone(),
4479 "test-server".to_string(),
4480 "test-user".to_string(),
4481 );
4482 seed_items(&repo, &["a", "b"]).await;
4483
4484 let created = repo
4485 .create_playlist("NoOp", &["a".into(), "b".into()])
4486 .await
4487 .unwrap();
4488
4489 repo.move_playlist_item(&created.id, "nonexistent", 0)
4491 .await
4492 .unwrap();
4493
4494 let items = repo.get_playlist_items(&created.id).await.unwrap();
4495 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4496 assert_eq!(ids, vec!["a", "b"]);
4497 }
4498
4499 async fn seed_favorites(db_service: &Arc<RusqliteService>) {
4502 use crate::storage::db_service::DatabaseService;
4503 for sql in [
4504 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4505 VALUES ('movie-fav', 'test-server', 'Favourite Movie', 'Movie', 'lib-1', '2026-01-01', 'Favourite Movie')",
4506 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4507 VALUES ('movie-plain', 'test-server', 'Ordinary Movie', 'Movie', 'lib-1', '2026-01-01', 'Ordinary Movie')",
4508 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4509 VALUES ('album-fav', 'test-server', 'Favourite Album', 'MusicAlbum', 'lib-2', '2026-01-01', 'Favourite Album')",
4510 "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
4511 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-fav', 1)",
4512 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-fav', 1)",
4513 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-plain', 0)",
4515 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('other-user', 'movie-plain', 1)",
4517 ] {
4518 db_service.execute(Query::new(sql)).await.unwrap();
4519 }
4520 }
4521
4522 #[tokio::test]
4527 async fn test_get_favorites_returns_only_scoped_favorites() {
4528 let _guard = lock_catalog_browse();
4529 set_include_catalog_browse(true);
4530
4531 let db_service = create_test_db();
4532 seed_favorites(&db_service).await;
4533 let repo = OfflineRepository::new(
4534 db_service,
4535 "test-server".to_string(),
4536 "test-user".to_string(),
4537 );
4538
4539 let all = repo.get_favorites(SearchScope::All, None).await.unwrap();
4540 let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
4541 ids.sort();
4542 assert_eq!(
4543 ids,
4544 vec!["album-fav", "movie-fav"],
4545 "All scope should return every favourite and nothing else"
4546 );
4547
4548 let movies = repo.get_favorites(SearchScope::Movies, None).await.unwrap();
4549 let ids: Vec<&str> = movies.items.iter().map(|i| i.id.as_str()).collect();
4550 assert_eq!(ids, vec!["movie-fav"]);
4551
4552 let music = repo.get_favorites(SearchScope::Music, None).await.unwrap();
4553 let ids: Vec<&str> = music.items.iter().map(|i| i.id.as_str()).collect();
4554 assert_eq!(ids, vec!["album-fav"]);
4555 }
4556
4557 #[tokio::test]
4562 async fn test_get_favorites_respects_catalog_browse_gate() {
4563 use crate::storage::db_service::DatabaseService;
4564 let _guard = lock_catalog_browse();
4565
4566 let db_service = create_test_db();
4567 seed_favorites(&db_service).await;
4568 db_service
4570 .execute(Query::new(
4571 "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
4572 VALUES ('track-1', 'test-server', 'Track', 'Audio', 'album-fav', '2026-01-01')",
4573 ))
4574 .await
4575 .unwrap();
4576 db_service
4577 .execute(Query::new(
4578 "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
4579 ))
4580 .await
4581 .unwrap();
4582
4583 let repo = OfflineRepository::new(
4584 db_service,
4585 "test-server".to_string(),
4586 "test-user".to_string(),
4587 );
4588
4589 set_include_catalog_browse(false);
4590 let offline_only = repo.get_favorites(SearchScope::All, None).await.unwrap();
4591 let ids: Vec<&str> = offline_only.items.iter().map(|i| i.id.as_str()).collect();
4592 assert_eq!(
4593 ids,
4594 vec!["album-fav"],
4595 "with the gate off, only favourites on the device are listed"
4596 );
4597
4598 set_include_catalog_browse(true);
4599 let with_catalog = repo.get_favorites(SearchScope::All, None).await.unwrap();
4600 assert_eq!(with_catalog.items.len(), 2);
4601 }
4602
4603 #[tokio::test]
4607 async fn test_get_items_favorites_only_filters_listing() {
4608 let _guard = lock_catalog_browse();
4609 set_include_catalog_browse(true);
4610
4611 let db_service = create_test_db();
4612 seed_favorites(&db_service).await;
4613 let repo = OfflineRepository::new(
4614 db_service,
4615 "test-server".to_string(),
4616 "test-user".to_string(),
4617 );
4618
4619 let unfiltered = repo
4620 .get_items(
4621 "lib-1",
4622 Some(GetItemsOptions {
4623 include_item_types: Some(vec!["Movie".to_string()]),
4624 ..Default::default()
4625 }),
4626 )
4627 .await
4628 .unwrap();
4629 assert_eq!(unfiltered.items.len(), 2, "both movies without the filter");
4630
4631 let favourites = repo
4632 .get_items(
4633 "lib-1",
4634 Some(GetItemsOptions {
4635 include_item_types: Some(vec!["Movie".to_string()]),
4636 favorites_only: Some(true),
4637 ..Default::default()
4638 }),
4639 )
4640 .await
4641 .unwrap();
4642 let ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
4643 assert_eq!(ids, vec!["movie-fav"]);
4644 }
4645
4646 #[tokio::test]
4657 async fn test_get_items_type_filter_is_bound_not_interpolated() {
4658 let _guard = lock_catalog_browse();
4659 set_include_catalog_browse(true);
4660
4661 let db_service = create_test_db();
4662 seed_favorites(&db_service).await;
4663 let repo = OfflineRepository::new(
4664 db_service,
4665 "test-server".to_string(),
4666 "test-user".to_string(),
4667 );
4668
4669 let injected = repo
4670 .get_items(
4671 "lib-1",
4672 Some(GetItemsOptions {
4673 include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
4674 ..Default::default()
4675 }),
4676 )
4677 .await
4678 .expect("a hostile type name must be data, not a broken query");
4679 assert!(
4680 injected.items.is_empty(),
4681 "no cached item has that type, so nothing may come back; got {:?}",
4682 injected
4683 .items
4684 .iter()
4685 .map(|i| i.id.as_str())
4686 .collect::<Vec<_>>()
4687 );
4688
4689 let quoted = repo
4691 .get_items(
4692 "lib-1",
4693 Some(GetItemsOptions {
4694 include_item_types: Some(vec!["Mo'vie".to_string()]),
4695 ..Default::default()
4696 }),
4697 )
4698 .await
4699 .expect("an embedded quote must not break the query");
4700 assert!(quoted.items.is_empty());
4701 }
4702
4703 #[tokio::test]
4710 async fn test_get_items_binds_multiple_types_in_parameter_order() {
4711 let _guard = lock_catalog_browse();
4712 set_include_catalog_browse(true);
4713
4714 let db_service = create_test_db();
4715 seed_favorites(&db_service).await;
4716
4717 db_service
4724 .execute(Query::new(
4725 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4726 VALUES ('album-lib1', 'test-server', 'Album In Lib One', 'MusicAlbum', 'lib-1', '2026-01-01', 'Album In Lib One')",
4727 ))
4728 .await
4729 .unwrap();
4730 db_service
4731 .execute(Query::new(
4732 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-lib1', 1)",
4733 ))
4734 .await
4735 .unwrap();
4736
4737 let repo = OfflineRepository::new(
4738 db_service,
4739 "test-server".to_string(),
4740 "test-user".to_string(),
4741 );
4742
4743 let both = repo
4744 .get_items(
4745 "lib-1",
4746 Some(GetItemsOptions {
4747 include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
4748 ..Default::default()
4749 }),
4750 )
4751 .await
4752 .unwrap();
4753 let mut ids: Vec<&str> = both.items.iter().map(|i| i.id.as_str()).collect();
4754 ids.sort();
4755 assert_eq!(ids, vec!["album-lib1", "movie-fav", "movie-plain"]);
4756 assert!(
4757 !ids.contains(&"album-fav"),
4758 "album-fav belongs to lib-2 and must not appear in a lib-1 listing"
4759 );
4760
4761 let favourites = repo
4763 .get_items(
4764 "lib-1",
4765 Some(GetItemsOptions {
4766 include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
4767 favorites_only: Some(true),
4768 ..Default::default()
4769 }),
4770 )
4771 .await
4772 .unwrap();
4773 let mut ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
4774 ids.sort();
4775 assert_eq!(ids, vec!["album-lib1", "movie-fav"]);
4776 }
4777
4778 #[tokio::test]
4787 async fn test_save_to_cache_mirrors_favorites_without_clobbering_pending() {
4788 use crate::storage::db_service::DatabaseService;
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
4796 let favourite_flag = |id: &'static str| {
4797 let db = db_service.clone();
4798 async move {
4799 db.query_optional(
4800 Query::with_params(
4801 "SELECT is_favorite, pending_sync FROM user_data \
4802 WHERE user_id = ? AND item_id = ?",
4803 vec![
4804 QueryParam::String("test-user".to_string()),
4805 QueryParam::String(id.to_string()),
4806 ],
4807 ),
4808 |row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
4809 )
4810 .await
4811 .unwrap()
4812 }
4813 };
4814
4815 let mut favourited = create_test_item("fav-1", "Favourited Elsewhere", None);
4817 favourited.user_data = Some(UserData {
4818 is_favorite: Some(true),
4819 ..Default::default()
4820 });
4821 let untouched = create_test_item("plain-1", "No User Data", None);
4823
4824 repo.save_to_cache("parent-1", &[favourited.clone(), untouched])
4825 .await
4826 .unwrap();
4827
4828 assert_eq!(
4829 favourite_flag("fav-1").await,
4830 Some((Some(1), Some(0))),
4831 "server favourite should be mirrored as synced"
4832 );
4833 assert_eq!(
4834 favourite_flag("plain-1").await,
4835 None,
4836 "an item without UserData should not get an invented user_data row"
4837 );
4838
4839 db_service
4841 .execute(Query::with_params(
4842 "UPDATE user_data SET is_favorite = 0, pending_sync = 1 \
4843 WHERE user_id = ? AND item_id = ?",
4844 vec![
4845 QueryParam::String("test-user".to_string()),
4846 QueryParam::String("fav-1".to_string()),
4847 ],
4848 ))
4849 .await
4850 .unwrap();
4851
4852 repo.save_to_cache("parent-1", &[favourited]).await.unwrap();
4854
4855 assert_eq!(
4856 favourite_flag("fav-1").await,
4857 Some((Some(0), Some(1))),
4858 "an unsynced local toggle must survive a cache write"
4859 );
4860 }
4861
4862 #[tokio::test]
4874 async fn test_save_to_cache_mirrors_playback_position_without_clobbering_pending() {
4875 use crate::storage::db_service::DatabaseService;
4876 let db_service = create_test_db();
4877 let repo = OfflineRepository::new(
4878 db_service.clone(),
4879 "test-server".to_string(),
4880 "test-user".to_string(),
4881 );
4882
4883 let position = |id: &'static str| {
4884 let db = db_service.clone();
4885 async move {
4886 db.query_optional(
4887 Query::with_params(
4888 "SELECT playback_position_ticks, pending_sync FROM user_data \
4889 WHERE user_id = ? AND item_id = ?",
4890 vec![
4891 QueryParam::String("test-user".to_string()),
4892 QueryParam::String(id.to_string()),
4893 ],
4894 ),
4895 |row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i32>>(1)?)),
4896 )
4897 .await
4898 .unwrap()
4899 }
4900 };
4901
4902 let mut watched = create_test_item("ep-1", "Watched Elsewhere", None);
4904 watched.user_data = Some(UserData {
4905 playback_position_ticks: Some(12_000_000_000),
4906 ..Default::default()
4907 });
4908 let untouched = create_test_item("ep-2", "No User Data", None);
4910
4911 repo.save_to_cache("parent-1", &[watched.clone(), untouched])
4912 .await
4913 .unwrap();
4914
4915 assert_eq!(
4916 position("ep-1").await,
4917 Some((Some(12_000_000_000), Some(0))),
4918 "the server's position should be mirrored as synced"
4919 );
4920 assert_eq!(
4921 position("ep-2").await,
4922 None,
4923 "an item without UserData should not get an invented position"
4924 );
4925
4926 db_service
4928 .execute(Query::with_params(
4929 "UPDATE user_data SET playback_position_ticks = ?, pending_sync = 1 \
4930 WHERE user_id = ? AND item_id = ?",
4931 vec![
4932 QueryParam::Int64(30_000_000_000),
4933 QueryParam::String("test-user".to_string()),
4934 QueryParam::String("ep-1".to_string()),
4935 ],
4936 ))
4937 .await
4938 .unwrap();
4939
4940 repo.save_to_cache("parent-1", &[watched]).await.unwrap();
4942
4943 assert_eq!(
4944 position("ep-1").await,
4945 Some((Some(30_000_000_000), Some(1))),
4946 "an unsynced local position must not be pulled backwards"
4947 );
4948 }
4949
4950 #[tokio::test]
4961 async fn test_save_to_cache_mirrors_played_flag_without_clobbering_pending() {
4962 use crate::storage::db_service::DatabaseService;
4963 let db_service = create_test_db();
4964 let repo = OfflineRepository::new(
4965 db_service.clone(),
4966 "test-server".to_string(),
4967 "test-user".to_string(),
4968 );
4969
4970 let played_flag = |id: &'static str| {
4971 let db = db_service.clone();
4972 async move {
4973 db.query_optional(
4974 Query::with_params(
4975 "SELECT is_played, pending_sync FROM user_data \
4976 WHERE user_id = ? AND item_id = ?",
4977 vec![
4978 QueryParam::String("test-user".to_string()),
4979 QueryParam::String(id.to_string()),
4980 ],
4981 ),
4982 |row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
4983 )
4984 .await
4985 .unwrap()
4986 }
4987 };
4988
4989 let mut watched = create_test_item("ep-4", "Watched Elsewhere", None);
4991 watched.user_data = Some(UserData {
4992 is_played: Some(true),
4993 ..Default::default()
4994 });
4995 let untouched = create_test_item("ep-5", "No User Data", None);
4997
4998 repo.save_to_cache("parent-1", &[watched, untouched])
4999 .await
5000 .unwrap();
5001
5002 assert_eq!(
5003 played_flag("ep-4").await,
5004 Some((Some(1), Some(0))),
5005 "the server's played flag should be mirrored as synced"
5006 );
5007 assert_eq!(
5008 played_flag("ep-5").await,
5009 None,
5010 "an item without UserData should not get an invented played flag"
5011 );
5012
5013 db_service
5015 .execute(Query::with_params(
5016 "UPDATE user_data SET is_played = 0, pending_sync = 1 \
5017 WHERE user_id = ? AND item_id = ?",
5018 vec![
5019 QueryParam::String("test-user".to_string()),
5020 QueryParam::String("ep-4".to_string()),
5021 ],
5022 ))
5023 .await
5024 .unwrap();
5025
5026 let mut still_played = create_test_item("ep-4", "Watched Elsewhere", None);
5027 still_played.user_data = Some(UserData {
5028 is_played: Some(true),
5029 ..Default::default()
5030 });
5031 repo.save_to_cache("parent-1", &[still_played])
5032 .await
5033 .unwrap();
5034
5035 assert_eq!(
5036 played_flag("ep-4").await,
5037 Some((Some(0), Some(1))),
5038 "an unsynced local toggle must survive a cache write"
5039 );
5040 }
5041
5042 #[tokio::test]
5052 async fn test_position_is_mirrored_even_when_no_favourite_flag_is_present() {
5053 use crate::storage::db_service::DatabaseService;
5054 let db_service = create_test_db();
5055 let repo = OfflineRepository::new(
5056 db_service.clone(),
5057 "test-server".to_string(),
5058 "test-user".to_string(),
5059 );
5060
5061 let mut watched = create_test_item("ep-3", "Position Only", None);
5062 watched.user_data = Some(UserData {
5063 is_favorite: None,
5064 playback_position_ticks: Some(9_000_000_000),
5065 ..Default::default()
5066 });
5067
5068 repo.save_to_cache("parent-1", &[watched]).await.unwrap();
5069
5070 let stored = db_service
5071 .query_optional(
5072 Query::with_params(
5073 "SELECT playback_position_ticks FROM user_data \
5074 WHERE user_id = ? AND item_id = ?",
5075 vec![
5076 QueryParam::String("test-user".to_string()),
5077 QueryParam::String("ep-3".to_string()),
5078 ],
5079 ),
5080 |row| row.get::<_, Option<i64>>(0),
5081 )
5082 .await
5083 .unwrap();
5084
5085 assert_eq!(
5086 stored,
5087 Some(Some(9_000_000_000)),
5088 "a position with no favourite flag must still be mirrored"
5089 );
5090 }
5091
5092 #[tokio::test]
5111 async fn test_get_items_unknown_library_type_does_not_return_whole_server() {
5112 let _guard = lock_catalog_browse();
5115 set_include_catalog_browse(true);
5116
5117 let db = create_test_db();
5118
5119 insert_item(&db, "movie-1", "Movie", None, None, None).await;
5120 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
5121 insert_item(&db, "series-1", "Series", None, None, None).await;
5122
5123 for collection_type in ["books", "boxsets", "photos", "homevideos", ""] {
5126 let lib = format!("lib-{collection_type}");
5127 seed_library(&db, &lib, collection_type).await;
5128
5129 let repo = make_repo(&db);
5130 let ids: Vec<String> = repo
5131 .get_items(&lib, None)
5132 .await
5133 .unwrap()
5134 .items
5135 .iter()
5136 .map(|i| i.id.clone())
5137 .collect();
5138
5139 assert!(
5140 ids.is_empty(),
5141 "a '{collection_type}' library must not serve the server's films, \
5142 albums and shows; got {:?}",
5143 ids
5144 );
5145 }
5146 }
5147
5148 #[tokio::test]
5159 async fn test_get_items_two_libraries_of_one_type_are_not_interchangeable() {
5160 let _guard = lock_catalog_browse();
5163 set_include_catalog_browse(true);
5164
5165 let db = create_test_db();
5166 seed_library(&db, "tv-lib", "tvshows").await;
5167 seed_library(&db, "shows-lib", "tvshows").await;
5168
5169 let repo = make_repo(&db);
5170
5171 for (id, lib) in [("series-a", "tv-lib"), ("series-b", "shows-lib")] {
5174 let mut item = create_test_item(id, id, None);
5175 item.item_type = "Series".to_string();
5176 item.kind = crate::domain::MediaKind::Series;
5177 repo.save_to_cache(lib, &[item]).await.unwrap();
5178 }
5179 for (lib, own, other) in [
5180 ("tv-lib", "series-a", "series-b"),
5181 ("shows-lib", "series-b", "series-a"),
5182 ] {
5183 let ids: Vec<String> = repo
5184 .get_items(lib, None)
5185 .await
5186 .unwrap()
5187 .items
5188 .iter()
5189 .map(|i| i.id.clone())
5190 .collect();
5191 assert!(
5192 ids.contains(&own.to_string()),
5193 "{lib} should list {own}; got {:?}",
5194 ids
5195 );
5196 assert!(
5197 !ids.contains(&other.to_string()),
5198 "{lib} must not list {other}, which lives in the other library; got {:?}",
5199 ids
5200 );
5201 }
5202 }
5203
5204 #[tokio::test]
5215 async fn test_get_items_collection_lists_its_own_children() {
5216 let _guard = lock_catalog_browse();
5219 set_include_catalog_browse(true);
5220
5221 let db = create_test_db();
5222 seed_library(&db, "boxset-lib", "boxsets").await;
5223
5224 insert_item(&db, "boxset-1", "BoxSet", None, None, None).await;
5225 insert_item(&db, "outsider", "Movie", None, None, None).await;
5226
5227 db.execute(Query::with_params(
5230 "INSERT INTO items (id, server_id, name, item_type, parent_id, synced_at) \
5231 VALUES ('in-set', 'test-server', 'In The Set', 'Movie', ?1, '2024-01-01')",
5232 vec![QueryParam::String("boxset-1".to_string())],
5233 ))
5234 .await
5235 .unwrap();
5236
5237 let repo = make_repo(&db);
5238 let ids: Vec<String> = repo
5239 .get_items("boxset-1", None)
5240 .await
5241 .unwrap()
5242 .items
5243 .iter()
5244 .map(|i| i.id.clone())
5245 .collect();
5246
5247 assert_eq!(
5248 ids,
5249 vec!["in-set".to_string()],
5250 "a collection lists its own children and nothing else; got {:?}",
5251 ids
5252 );
5253 }
5254
5255 #[tokio::test]
5260 async fn test_get_items_typed_libraries_still_return_their_own_media() {
5261 let _guard = lock_catalog_browse();
5264 set_include_catalog_browse(true);
5265
5266 let db = create_test_db();
5267 seed_library(&db, "music-lib", "music").await;
5268 seed_library(&db, "movie-lib", "movies").await;
5269 seed_library(&db, "tv-lib", "tvshows").await;
5270
5271 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
5272 insert_item(&db, "movie-1", "Movie", None, None, None).await;
5273 insert_item(&db, "series-1", "Series", None, None, None).await;
5274
5275 let repo = make_repo(&db);
5276
5277 for (lib, expected, forbidden) in [
5278 ("music-lib", "album-1", "movie-1"),
5279 ("movie-lib", "movie-1", "album-1"),
5280 ("tv-lib", "series-1", "album-1"),
5281 ] {
5282 let ids: Vec<String> = repo
5283 .get_items(lib, None)
5284 .await
5285 .unwrap()
5286 .items
5287 .iter()
5288 .map(|i| i.id.clone())
5289 .collect();
5290 assert!(
5291 ids.contains(&expected.to_string()),
5292 "{lib} should list {expected}; got {:?}",
5293 ids
5294 );
5295 assert!(
5296 !ids.contains(&forbidden.to_string()),
5297 "{lib} must not list {forbidden}; got {:?}",
5298 ids
5299 );
5300 }
5301 }
5302}