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 save_to_cache_impl(
450 &self,
451 parent_id: &str,
452 items: &[MediaItem],
453 now: &str,
454 ) -> Result<usize, RepoError> {
455 let mut parent_ids = std::collections::HashSet::new();
457 parent_ids.insert(parent_id.to_string());
458
459 for item in items {
460 if let Some(pid) = &item.parent_id {
461 parent_ids.insert(pid.clone());
462 }
463 }
464
465 #[cfg(test)]
467 println!("Creating stub parents for: {:?}", parent_ids);
468
469 for pid in parent_ids {
470 let parent_query = Query::with_params(
471 "INSERT OR IGNORE INTO items (id, server_id, name, item_type, synced_at)
472 VALUES (?1, ?2, ?3, ?4, ?5)",
473 vec![
474 QueryParam::String(pid.clone()),
475 QueryParam::String(self.server_id.clone()),
476 QueryParam::String("Parent".to_string()),
477 QueryParam::String("Folder".to_string()),
478 QueryParam::String(now.to_string()),
479 ],
480 );
481
482 let _stub_rows = self
483 .db_service
484 .execute(parent_query)
485 .await
486 .map_err(|e| RepoError::Database { message: e })?;
487 #[cfg(test)]
488 println!(
489 " Created stub parent {} (rows affected: {})",
490 pid, _stub_rows
491 );
492 }
493
494 let mut count = 0;
495
496 for item in items {
497 let genres_json = item
499 .genres
500 .as_ref()
501 .map(|g| serde_json::to_string(g).unwrap_or_else(|_| "[]".to_string()));
502 let artists_json = item
503 .artists
504 .as_ref()
505 .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
506 let backdrop_tags_json = item
507 .backdrop_image_tags
508 .as_ref()
509 .map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".to_string()));
510
511 let query = Query::with_params(
527 "INSERT INTO items (
528 id, server_id, library_id, parent_id,
529 name, item_type, is_folder, overview,
530 genres, series_id, series_name,
531 season_id, season_name, index_number, parent_index_number,
532 album_id, album_name, album_artist, artists,
533 production_year, premiere_date, runtime_ticks,
534 primary_image_tag, backdrop_image_tags,
535 community_rating, official_rating,
536 synced_at
537 ) VALUES (
538 ?1, ?2, ?3, ?4,
539 ?5, ?6, ?7, ?8,
540 ?9, ?10, ?11,
541 ?12, ?13, ?14, ?15,
542 ?16, ?17, ?18, ?19,
543 ?20, ?21, ?22,
544 ?23, ?24,
545 ?25, ?26,
546 ?27
547 )
548 ON CONFLICT(id) DO UPDATE SET
549 server_id = excluded.server_id,
550 -- This call site never supplies library_id (it is always
551 -- bound NULL), so keep whatever another path recorded rather
552 -- than clearing it the way REPLACE did.
553 library_id = COALESCE(excluded.library_id, items.library_id),
554 parent_id = excluded.parent_id,
555 name = excluded.name,
556 item_type = excluded.item_type,
557 is_folder = excluded.is_folder,
558 overview = excluded.overview,
559 genres = excluded.genres,
560 series_id = excluded.series_id,
561 series_name = excluded.series_name,
562 season_id = excluded.season_id,
563 season_name = excluded.season_name,
564 index_number = excluded.index_number,
565 parent_index_number = excluded.parent_index_number,
566 album_id = excluded.album_id,
567 album_name = excluded.album_name,
568 album_artist = excluded.album_artist,
569 artists = excluded.artists,
570 production_year = excluded.production_year,
571 premiere_date = excluded.premiere_date,
572 runtime_ticks = excluded.runtime_ticks,
573 primary_image_tag = excluded.primary_image_tag,
574 backdrop_image_tags = excluded.backdrop_image_tags,
575 community_rating = excluded.community_rating,
576 official_rating = excluded.official_rating,
577 synced_at = excluded.synced_at",
578 vec![
579 QueryParam::String(item.id.clone()),
580 QueryParam::String(self.server_id.clone()),
581 QueryParam::Null, match &item.parent_id {
585 Some(pid) => QueryParam::String(pid.clone()),
586 None => QueryParam::Null,
587 },
588 QueryParam::String(item.name.clone()),
589 QueryParam::String(item.item_type.clone()),
590 QueryParam::Int(if item.is_folder { 1 } else { 0 }),
591 match &item.overview {
592 Some(o) => QueryParam::String(o.clone()),
593 None => QueryParam::Null,
594 },
595 match genres_json {
596 Some(g) => QueryParam::String(g),
597 None => QueryParam::Null,
598 },
599 match &item.series_id {
600 Some(s) => QueryParam::String(s.clone()),
601 None => QueryParam::Null,
602 },
603 match &item.series_name {
604 Some(s) => QueryParam::String(s.clone()),
605 None => QueryParam::Null,
606 },
607 match &item.season_id {
608 Some(s) => QueryParam::String(s.clone()),
609 None => QueryParam::Null,
610 },
611 match &item.season_name {
612 Some(s) => QueryParam::String(s.clone()),
613 None => QueryParam::Null,
614 },
615 match item.index_number {
616 Some(i) => QueryParam::Int(i),
617 None => QueryParam::Null,
618 },
619 match item.parent_index_number {
620 Some(i) => QueryParam::Int(i),
621 None => QueryParam::Null,
622 },
623 match &item.album_id {
624 Some(a) => QueryParam::String(a.clone()),
625 None => QueryParam::Null,
626 },
627 match &item.album_name {
628 Some(a) => QueryParam::String(a.clone()),
629 None => QueryParam::Null,
630 },
631 match &item.album_artist {
632 Some(a) => QueryParam::String(a.clone()),
633 None => QueryParam::Null,
634 },
635 match artists_json {
636 Some(a) => QueryParam::String(a),
637 None => QueryParam::Null,
638 },
639 match item.production_year {
640 Some(y) => QueryParam::Int(y),
641 None => QueryParam::Null,
642 },
643 match &item.premiere_date {
644 Some(d) => QueryParam::String(d.clone()),
645 None => QueryParam::Null,
646 },
647 match item.runtime_ticks {
648 Some(r) => QueryParam::Int64(r),
649 None => QueryParam::Null,
650 },
651 match &item.primary_image_tag {
652 Some(t) => QueryParam::String(t.clone()),
653 None => QueryParam::Null,
654 },
655 match backdrop_tags_json {
656 Some(b) => QueryParam::String(b),
657 None => QueryParam::Null,
658 },
659 match item.community_rating {
660 Some(r) => QueryParam::Float(r),
661 None => QueryParam::Null,
662 },
663 match &item.official_rating {
664 Some(r) => QueryParam::String(r.clone()),
665 None => QueryParam::Null,
666 },
667 QueryParam::String(now.to_string()),
668 ],
669 );
670
671 let _rows_affected =
672 self.db_service
673 .execute(query)
674 .await
675 .map_err(|e| RepoError::Database {
676 message: format!("Failed to insert item {}: {}", item.id, e),
677 })?;
678 #[cfg(test)]
679 println!(
680 " [save_to_cache] Saved item {} (rows affected: {})",
681 item.id, _rows_affected
682 );
683
684 self.mirror_user_data(item, now).await?;
685 count += 1;
686 }
687
688 Ok(count)
689 }
690
691 async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
718 let user_data = item.user_data.as_ref();
719 let is_favorite = user_data.and_then(|ud| ud.is_favorite);
720 let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
721 let is_played = user_data.and_then(|ud| ud.is_played);
722
723 if is_favorite.is_none() && position_ticks.is_none() && is_played.is_none() {
725 return Ok(());
726 }
727
728 let query = Query::with_params(
729 "INSERT INTO user_data
730 (user_id, item_id, is_favorite, playback_position_ticks, is_played,
731 synced_at, pending_sync)
732 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)
733 ON CONFLICT(user_id, item_id) DO UPDATE SET
734 is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
735 playback_position_ticks = COALESCE(
736 excluded.playback_position_ticks, user_data.playback_position_ticks),
737 is_played = COALESCE(excluded.is_played, user_data.is_played),
738 synced_at = excluded.synced_at
739 WHERE user_data.pending_sync = 0",
740 vec![
741 QueryParam::String(self.user_id.clone()),
742 QueryParam::String(item.id.clone()),
743 is_favorite
744 .map(|f| QueryParam::Int(if f { 1 } else { 0 }))
745 .unwrap_or(QueryParam::Null),
746 position_ticks
747 .map(QueryParam::Int64)
748 .unwrap_or(QueryParam::Null),
749 is_played
750 .map(|p| QueryParam::Int(if p { 1 } else { 0 }))
751 .unwrap_or(QueryParam::Null),
752 QueryParam::String(now.to_string()),
753 ],
754 );
755
756 if let Err(e) = self.db_service.execute(query).await {
760 debug!(
761 "[OfflineRepo] user_data mirror skipped for {}: {}",
762 item.id, e
763 );
764 }
765
766 Ok(())
767 }
768
769 pub async fn save_libraries_to_cache(&self, libraries: &[Library]) -> Result<usize, RepoError> {
774 if libraries.is_empty() {
775 return Ok(0);
776 }
777
778 let mut count = 0;
779 for (idx, lib) in libraries.iter().enumerate() {
780 let query = Query::with_params(
781 "INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
782 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
783 vec![
784 QueryParam::String(lib.id.clone()),
785 QueryParam::String(self.server_id.clone()),
786 QueryParam::String(lib.name.clone()),
787 QueryParam::String(lib.collection_type.clone()),
788 lib.image_tag.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
789 QueryParam::Int(idx as i32),
790 ],
791 );
792 self.db_service
793 .execute(query)
794 .await
795 .map_err(|e| RepoError::Database { message: e })?;
796 count += 1;
797 }
798 Ok(count)
799 }
800
801 pub async fn save_genres_to_cache(
806 &self,
807 parent_id: Option<&str>,
808 genres: &[Genre],
809 ) -> Result<usize, RepoError> {
810 if genres.is_empty() {
811 return Ok(0);
812 }
813
814 let library_id = parent_id.unwrap_or("").to_string();
817 let server_id = self.server_id.clone();
818 let genres: Vec<(String, String, Option<u32>)> = genres
819 .iter()
820 .map(|g| (g.id.clone(), g.name.clone(), g.album_count))
821 .collect();
822 let saved = genres.len();
823
824 self.db_service
825 .transaction(move |tx| {
826 use crate::storage::db_service::{Query, QueryParam};
827
828 tx.execute(Query::with_params(
830 "DELETE FROM genres WHERE server_id = ? AND library_id = ?",
831 vec![
832 QueryParam::String(server_id.clone()),
833 QueryParam::String(library_id.clone()),
834 ],
835 ))?;
836
837 for (id, name, album_count) in &genres {
838 tx.execute(Query::with_params(
839 "INSERT OR REPLACE INTO genres (id, server_id, library_id, name, album_count, synced_at)
840 VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
841 vec![
842 QueryParam::String(id.clone()),
843 QueryParam::String(server_id.clone()),
844 QueryParam::String(library_id.clone()),
845 QueryParam::String(name.clone()),
846 album_count.map(|c| QueryParam::Int(c as i32)).unwrap_or(QueryParam::Null),
847 ],
848 ))?;
849 }
850
851 Ok(())
852 })
853 .await
854 .map_err(|e| RepoError::Database { message: e })?;
855
856 Ok(saved)
857 }
858
859 const LIBRARY_HOLDS_ITEM: &'static str = concat!(
884 "(",
885 library_type_matches_item!(),
886 " OR l.collection_type IS NULL
887 OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
888 )"
889 );
890
891 const DOWNLOADED_ITEMS_CTE: &'static str = "
893 WITH downloaded_items AS (
894 SELECT DISTINCT i.id
895 FROM items i
896 INNER JOIN downloads d ON i.id = d.item_id
897 WHERE d.status = 'completed'
898 AND i.item_type IN ('Audio', 'Movie', 'Episode')
899
900 UNION
901
902 SELECT DISTINCT i.id
903 FROM items i
904 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)
905 INNER JOIN downloads d ON children.id = d.item_id
906 WHERE d.status = 'completed'
907 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
908 )";
909
910 pub async fn get_downloaded_items(
920 &self,
921 parent_id: &str,
922 options: Option<GetItemsOptions>,
923 ) -> Result<SearchResult, RepoError> {
924 let opts = options.unwrap_or_default();
925 let limit = opts.limit.unwrap_or(10000);
926 let start_index = opts.start_index.unwrap_or(0);
927
928 let type_filter = if let Some(include_item_types) = &opts.include_item_types {
929 if !include_item_types.is_empty() {
930 let types = include_item_types
931 .iter()
932 .map(|t| format!("'{}'", t.replace('\'', "''")))
933 .collect::<Vec<_>>()
934 .join(",");
935 format!(" AND i.item_type IN ({})", types)
936 } else {
937 String::new()
938 }
939 } else {
940 String::new()
941 };
942
943 let sql = format!(
956 "{cte}
957 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
958 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
959 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
960 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
961 i.parent_index_number, i.is_folder, i.premiere_date
962 FROM items i
963 INNER JOIN downloaded_items di ON i.id = di.id
964 WHERE i.server_id = ?
965 AND (
966 i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
967 OR (
968 EXISTS (
969 SELECT 1 FROM libraries l
970 WHERE l.id = ? AND l.server_id = i.server_id
971 AND {membership}
972 )
973 -- Top-level only: hide leaves whose container is downloaded.
974 AND NOT EXISTS (
975 SELECT 1 FROM downloaded_items parent
976 WHERE parent.id = i.album_id
977 OR parent.id = i.season_id
978 OR parent.id = i.series_id
979 OR parent.id = i.parent_id
980 )
981 )
982 ){type_filter}
983 ORDER BY i.sort_name ASC, i.name ASC
984 LIMIT {limit} OFFSET {start_index}",
985 cte = Self::DOWNLOADED_ITEMS_CTE,
986 membership = Self::LIBRARY_HOLDS_ITEM,
987 );
988
989 let query = Query::with_params(
990 sql,
991 vec![
992 QueryParam::String(self.server_id.clone()),
993 QueryParam::String(parent_id.to_string()),
994 QueryParam::String(parent_id.to_string()),
995 QueryParam::String(parent_id.to_string()),
996 QueryParam::String(parent_id.to_string()),
997 QueryParam::String(parent_id.to_string()),
998 ],
999 );
1000
1001 let cached_items: Vec<CachedItem> = self
1002 .db_service
1003 .query_many(query, row_to_cached_item)
1004 .await
1005 .map_err(|e| RepoError::Database { message: e })?;
1006
1007 let mut items = Vec::new();
1008 for cached in cached_items {
1009 let user_data = self.get_user_data(&cached.id).await;
1010 items.push(Self::cached_item_to_media_item(cached, user_data));
1011 }
1012
1013 let total_record_count = items.len();
1014 Ok(SearchResult {
1015 items,
1016 total_record_count,
1017 })
1018 }
1019
1020 pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
1026 let query = Query::with_params(
1031 format!(
1032 "{cte}
1033 SELECT l.id, l.name, l.collection_type, l.image_tag
1034 FROM libraries l
1035 WHERE l.server_id = ?
1036 AND EXISTS (
1037 SELECT 1 FROM items i
1038 INNER JOIN downloaded_items di ON i.id = di.id
1039 WHERE i.server_id = l.server_id
1040 AND {membership}
1041 )
1042 ORDER BY l.sort_order ASC, l.name ASC",
1043 cte = Self::DOWNLOADED_ITEMS_CTE,
1044 membership = Self::LIBRARY_HOLDS_ITEM,
1045 ),
1046 vec![QueryParam::String(self.server_id.clone())],
1047 );
1048
1049 self.db_service
1050 .query_many(query, |row| {
1051 Ok(Library::new(
1052 row.get(0)?,
1053 row.get(1)?,
1054 row.get::<_, Option<String>>(2)?
1055 .unwrap_or_else(|| "unknown".to_string()),
1056 row.get(3)?,
1057 ))
1058 })
1059 .await
1060 .map_err(|e| RepoError::Database { message: e })
1061 }
1062
1063 pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
1072 let leaf_query = Query::with_params(
1074 "SELECT d.item_id, COALESCE(d.file_size, 0)
1075 FROM downloads d
1076 INNER JOIN items i ON i.id = d.item_id
1077 WHERE d.status = 'completed'
1078 AND i.server_id = ?
1079 AND i.item_type IN ('Audio', 'Movie', 'Episode')",
1080 vec![QueryParam::String(self.server_id.clone())],
1081 );
1082 let leaves: Vec<(String, i64)> = self
1083 .db_service
1084 .query_many(leaf_query, |row| Ok((row.get(0)?, row.get(1)?)))
1085 .await
1086 .map_err(|e| RepoError::Database { message: e })?;
1087
1088 let container_query = Query::with_params(
1090 "SELECT c.id, COALESCE(SUM(d.file_size), 0)
1091 FROM items c
1092 INNER JOIN items children
1093 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1094 INNER JOIN downloads d ON children.id = d.item_id
1095 WHERE d.status = 'completed'
1096 AND c.server_id = ?
1097 AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1098 GROUP BY c.id",
1099 vec![QueryParam::String(self.server_id.clone())],
1100 );
1101 let containers: Vec<(String, i64)> = self
1102 .db_service
1103 .query_many(container_query, |row| Ok((row.get(0)?, row.get(1)?)))
1104 .await
1105 .map_err(|e| RepoError::Database { message: e })?;
1106
1107 let partial_query = Query::with_params(
1118 "WITH downloaded_containers AS (
1119 SELECT DISTINCT c.id
1120 FROM items c
1121 INNER JOIN items children
1122 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1123 INNER JOIN downloads d ON children.id = d.item_id
1124 WHERE d.status = 'completed'
1125 AND c.server_id = ?
1126 AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1127 )
1128 SELECT c.id,
1129 COUNT(children.id) AS total_children,
1130 SUM(CASE WHEN d.status = 'completed' THEN 1 ELSE 0 END) AS downloaded_children
1131 FROM items c
1132 INNER JOIN downloaded_containers dc ON dc.id = c.id
1133 INNER JOIN items children
1134 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1135 LEFT JOIN downloads d ON children.id = d.item_id AND d.status = 'completed'
1136 WHERE children.item_type IN ('Audio', 'Movie', 'Episode', 'Season')
1137 GROUP BY c.id",
1138 vec![QueryParam::String(self.server_id.clone())],
1139 );
1140 let partial_rows: Vec<(String, i64, i64)> = self
1141 .db_service
1142 .query_many(partial_query, |row| {
1143 Ok((
1144 row.get(0)?,
1145 row.get(1)?,
1146 row.get::<_, Option<i64>>(2)?.unwrap_or(0),
1147 ))
1148 })
1149 .await
1150 .map_err(|e| RepoError::Database { message: e })?;
1151
1152 let mut partial_containers = std::collections::HashMap::new();
1153 for (id, total, downloaded) in partial_rows {
1154 if downloaded > 0 && downloaded < total {
1157 partial_containers.insert(id, true);
1158 }
1159 }
1160
1161 let item_count = leaves.len() as u32;
1162 let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum();
1163
1164 let mut sizes = std::collections::HashMap::new();
1165 for (id, bytes) in leaves.into_iter().chain(containers) {
1166 *sizes.entry(id).or_insert(0) += bytes;
1169 }
1170
1171 Ok(DownloadDiskUsage {
1172 sizes,
1173 partial_containers,
1174 device_total_bytes,
1175 item_count,
1176 })
1177 }
1178
1179 pub async fn save_playlist_items_to_cache(
1182 &self,
1183 playlist_id: &str,
1184 entries: &[PlaylistEntry],
1185 ) -> Result<(), RepoError> {
1186 let playlist_id = playlist_id.to_string();
1187 let user_id = self.user_id.clone();
1188 let entries: Vec<(String, String, usize)> = entries
1189 .iter()
1190 .enumerate()
1191 .map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i))
1192 .collect();
1193
1194 self.db_service
1195 .transaction(move |tx| {
1196 use crate::storage::db_service::{Query, QueryParam};
1197
1198 tx.execute(Query::with_params(
1200 "INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)",
1201 vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)],
1202 ))?;
1203
1204 tx.execute(Query::with_params(
1206 "DELETE FROM playlist_items WHERE playlist_id = ?",
1207 vec![QueryParam::String(playlist_id.clone())],
1208 ))?;
1209
1210 for (_, item_id, sort_order) in &entries {
1211 tx.execute(Query::with_params(
1212 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
1213 vec![
1214 QueryParam::String(playlist_id.clone()),
1215 QueryParam::String(item_id.clone()),
1216 QueryParam::Int(*sort_order as i32),
1217 ],
1218 ))?;
1219 }
1220
1221 Ok(())
1222 })
1223 .await
1224 .map_err(|e| RepoError::Database {
1225 message: format!("Failed to cache playlist items: {}", e),
1226 })
1227 }
1228}
1229
1230#[async_trait]
1231impl MediaRepository for OfflineRepository {
1232 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1233 let query = Query::with_params(
1241 "SELECT l.id, l.name, l.collection_type, l.image_tag
1242 FROM libraries l
1243 WHERE l.server_id = ?
1244 ORDER BY l.sort_order ASC, l.name ASC",
1245 vec![QueryParam::String(self.server_id.clone())],
1246 );
1247
1248 self.db_service
1249 .query_many(query, |row| {
1250 Ok(Library::new(
1251 row.get(0)?,
1252 row.get(1)?,
1253 row.get::<_, Option<String>>(2)?
1254 .unwrap_or_else(|| "unknown".to_string()),
1255 row.get(3)?,
1256 ))
1257 })
1258 .await
1259 .map_err(|e| RepoError::Database { message: e })
1260 }
1261
1262 async fn get_items(
1263 &self,
1264 parent_id: &str,
1265 options: Option<GetItemsOptions>,
1266 ) -> Result<SearchResult, RepoError> {
1267 debug!(
1268 "[OfflineRepo] get_items called for parent_id: {}",
1269 &parent_id[..8.min(parent_id.len())]
1270 );
1271 let opts = options.unwrap_or_default();
1272 let limit = opts.limit.unwrap_or(10000); let start_index = opts.start_index.unwrap_or(0);
1274
1275 let default_sort = default_listing_sort(opts.parent_kind);
1283 let sort_field = opts
1284 .sort_by
1285 .as_deref()
1286 .or(default_sort.map(|(field, _)| field));
1287 let descending = opts
1288 .sort_order
1289 .as_deref()
1290 .or(default_sort.map(|(_, order)| order))
1291 == Some("Descending");
1292 let order_by = match sort_field {
1293 Some("Random") => "RANDOM()".to_string(),
1294 Some("PremiereDate") => format!(
1295 "i.premiere_date IS NULL, i.premiere_date {}, i.sort_name ASC",
1296 if descending { "DESC" } else { "ASC" }
1297 ),
1298 _ => "i.sort_name ASC, i.name ASC".to_string(),
1299 };
1300
1301 let type_values: &[String] = opts
1308 .include_item_types
1309 .as_deref()
1310 .filter(|types| !types.is_empty())
1311 .unwrap_or(&[]);
1312 let type_filter = if type_values.is_empty() {
1313 String::new()
1314 } else {
1315 let placeholders = vec!["?"; type_values.len()].join(",");
1316 format!(" AND i.item_type IN ({})", placeholders)
1317 };
1318
1319 let favorites_filter = if opts.favorites_only == Some(true) {
1324 " AND EXISTS (
1325 SELECT 1 FROM user_data ud
1326 WHERE ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1
1327 )"
1328 } else {
1329 ""
1330 };
1331
1332 let catalog_branch = if include_catalog_browse() {
1340 "UNION
1341
1342 -- Cached items for fast browsing (online) or the offline catalog view
1343 SELECT DISTINCT i.id
1344 FROM items i
1345 WHERE i.synced_at IS NOT NULL"
1346 } else {
1347 ""
1348 };
1349 let sql = format!(
1350 "WITH available_items AS (
1351 -- Playable items with completed downloads
1352 SELECT DISTINCT i.id
1353 FROM items i
1354 INNER JOIN downloads d ON i.id = d.item_id
1355 WHERE d.status = 'completed'
1356 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1357
1358 UNION
1359
1360 -- Containers with downloaded children
1361 SELECT DISTINCT i.id
1362 FROM items i
1363 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)
1364 INNER JOIN downloads d ON children.id = d.item_id
1365 WHERE d.status = 'completed'
1366 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1367
1368 {catalog_branch}
1369 )
1370 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1371 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1372 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1373 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1374 i.parent_index_number, i.is_folder, i.premiere_date
1375 FROM items i
1376 INNER JOIN available_items ai ON i.id = ai.id
1377 WHERE i.server_id = ?
1378 AND (
1379 i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
1380 -- When the requested parent is a LIBRARY, there is no per-item
1381 -- link back to it (library_id/parent_id are NULL in the cache),
1382 -- so match every item on the server and let the type filter
1383 -- (e.g. MusicAlbum / Movie / Series) narrow it. This is what
1384 -- makes library landing pages show albums/movies/shows offline.
1385 --
1386 -- The type correlation is NOT optional. Without it this
1387 -- EXISTS never mentions the item, so it is true for every
1388 -- cached row as soon as the requested parent is any library.
1389 -- Music/Movies/TV got away with that because their landing
1390 -- pages pass `include_item_types`, which narrowed the result;
1391 -- the generic library page passes none, so a Books or Photos
1392 -- library served the entire cached server (DR-277).
1393 --
1394 -- `library_id` wins wherever it survived the cache write:
1395 -- it is the server's own answer, and it is the only thing
1396 -- that can scope a library whose type has no mapping (Books,
1397 -- Photos, Collections) or none at all (a mixed library, where
1398 -- Jellyfin sends CollectionType null). The taxonomy is the
1399 -- fallback for rows that predate it being stored.
1400 --
1401 -- A library with neither a stored link nor a mapped type now
1402 -- matches nothing here and falls through to the server, which
1403 -- does know what is in it. Showing nothing briefly beats
1404 -- showing somebody else's films with confidence.
1405 OR EXISTS (
1406 SELECT 1 FROM libraries l
1407 WHERE l.id = ? AND l.server_id = i.server_id
1408 AND (
1409 i.library_id = l.id
1410 OR (i.library_id IS NULL AND {})
1411 )
1412 )
1413 ){}{}
1414 ORDER BY {}
1415 LIMIT {} OFFSET {}",
1416 library_type_matches_item!(),
1417 type_filter,
1418 favorites_filter,
1419 order_by,
1420 limit,
1421 start_index
1422 );
1423
1424 let mut params = vec![
1430 QueryParam::String(self.server_id.clone()),
1431 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()), ];
1437 params.extend(type_values.iter().cloned().map(QueryParam::String));
1442 if !favorites_filter.is_empty() {
1443 params.push(QueryParam::String(self.user_id.clone())); }
1445 let query = Query::with_params(sql, params);
1446
1447 let cached_items: Vec<CachedItem> = self
1448 .db_service
1449 .query_many(query, row_to_cached_item)
1450 .await
1451 .map_err(|e| RepoError::Database { message: e })?;
1452
1453 debug!(
1454 "[OfflineRepo] Found {} cached items for parent {}",
1455 cached_items.len(),
1456 &parent_id[..8.min(parent_id.len())]
1457 );
1458
1459 let mut items = Vec::new();
1461 for cached in cached_items {
1462 let user_data = self.get_user_data(&cached.id).await;
1463 items.push(Self::cached_item_to_media_item(cached, user_data));
1464 }
1465
1466 let total_record_count = items.len();
1467
1468 debug!(
1469 "[OfflineRepo] Returning {} items for parent {}",
1470 total_record_count,
1471 &parent_id[..8.min(parent_id.len())]
1472 );
1473
1474 Ok(SearchResult {
1475 items,
1476 total_record_count,
1477 })
1478 }
1479
1480 async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
1481 let query = Query::with_params(
1483 "WITH downloaded_items AS (
1484 -- Playable items with completed downloads
1485 SELECT DISTINCT i.id
1486 FROM items i
1487 INNER JOIN downloads d ON i.id = d.item_id
1488 WHERE d.status = 'completed'
1489 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1490
1491 UNION
1492
1493 -- Containers with downloaded children
1494 SELECT DISTINCT i.id
1495 FROM items i
1496 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)
1497 INNER JOIN downloads d ON children.id = d.item_id
1498 WHERE d.status = 'completed'
1499 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1500 )
1501 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1502 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1503 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1504 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1505 i.parent_index_number, i.is_folder, i.premiere_date
1506 FROM items i
1507 INNER JOIN downloaded_items di ON i.id = di.id
1508 WHERE i.id = ?",
1509 vec![QueryParam::String(item_id.to_string())],
1510 );
1511
1512 let cached = self
1513 .db_service
1514 .query_optional(query, row_to_cached_item)
1515 .await
1516 .map_err(|e| RepoError::Database { message: e })?
1517 .ok_or_else(|| RepoError::NotFound {
1518 message: format!(
1519 "Item {} not found in offline cache or not downloaded",
1520 item_id
1521 ),
1522 })?;
1523
1524 let user_data = self.get_user_data(item_id).await;
1525 Ok(Self::cached_item_to_media_item(cached, user_data))
1526 }
1527
1528 async fn get_latest_items(
1529 &self,
1530 parent_id: &str,
1531 limit: Option<usize>,
1532 ) -> Result<Vec<MediaItem>, RepoError> {
1533 let limit_val = limit.unwrap_or(16);
1534
1535 let query = Query::with_params(
1536 format!(
1537 "WITH downloaded_items AS (
1538 -- Playable items with completed downloads
1539 SELECT DISTINCT i.id
1540 FROM items i
1541 INNER JOIN downloads d ON i.id = d.item_id
1542 WHERE d.status = 'completed'
1543 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1544
1545 UNION
1546
1547 -- Containers with downloaded children
1548 SELECT DISTINCT i.id
1549 FROM items i
1550 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)
1551 INNER JOIN downloads d ON children.id = d.item_id
1552 WHERE d.status = 'completed'
1553 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1554 )
1555 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1556 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1557 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1558 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1559 i.parent_index_number, i.is_folder, i.premiere_date
1560 FROM items i
1561 INNER JOIN downloaded_items di ON i.id = di.id
1562 WHERE i.server_id = ? AND i.library_id = ?
1563 -- Collapse leaves into the container that was added: a new
1564 -- 14-track album should read as one album, not 14 songs. Only
1565 -- drops a leaf when its own container is present in the same
1566 -- result, so a standalone track or movie still appears.
1567 AND NOT EXISTS (
1568 SELECT 1 FROM downloaded_items parent
1569 WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
1570 )
1571 ORDER BY i.synced_at DESC
1572 LIMIT {}", limit_val
1573 ),
1574 vec![
1575 QueryParam::String(self.server_id.clone()),
1576 QueryParam::String(parent_id.to_string()),
1577 ],
1578 );
1579
1580 let cached_items: Vec<CachedItem> = self
1581 .db_service
1582 .query_many(query, row_to_cached_item)
1583 .await
1584 .map_err(|e| RepoError::Database { message: e })?;
1585
1586 let mut items = Vec::new();
1587 for cached in cached_items {
1588 let user_data = self.get_user_data(&cached.id).await;
1589 items.push(Self::cached_item_to_media_item(cached, user_data));
1590 }
1591
1592 Ok(items)
1593 }
1594
1595 async fn get_resume_items(
1596 &self,
1597 parent_id: Option<&str>,
1598 limit: Option<usize>,
1599 ) -> Result<Vec<MediaItem>, RepoError> {
1600 let limit_val = limit.unwrap_or(12);
1601
1602 let (sql, params) = if let Some(pid) = parent_id {
1604 (
1605 format!(
1606 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1607 i.overview, i.genres, i.runtime_ticks, i.production_year,
1608 i.community_rating, i.official_rating, i.primary_image_tag,
1609 i.album_id, i.album_name, i.album_artist, i.artists,
1610 i.index_number, i.series_id, i.series_name, i.season_id,
1611 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1612 FROM items i
1613 JOIN user_data ud ON i.id = ud.item_id
1614 INNER JOIN downloads d ON i.id = d.item_id
1615 WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
1616 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1617 AND d.status = 'completed'
1618 AND i.item_type IN ('Movie', 'Episode')
1619 ORDER BY ud.last_played_at DESC
1620 LIMIT {}",
1621 limit_val
1622 ),
1623 vec![
1624 QueryParam::String(self.server_id.clone()),
1625 QueryParam::String(self.user_id.clone()),
1626 QueryParam::String(pid.to_string()),
1627 ],
1628 )
1629 } else {
1630 (
1631 format!(
1632 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1633 i.overview, i.genres, i.runtime_ticks, i.production_year,
1634 i.community_rating, i.official_rating, i.primary_image_tag,
1635 i.album_id, i.album_name, i.album_artist, i.artists,
1636 i.index_number, i.series_id, i.series_name, i.season_id,
1637 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1638 FROM items i
1639 JOIN user_data ud ON i.id = ud.item_id
1640 INNER JOIN downloads d ON i.id = d.item_id
1641 WHERE i.server_id = ? AND ud.user_id = ?
1642 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1643 AND d.status = 'completed'
1644 AND i.item_type IN ('Movie', 'Episode')
1645 ORDER BY ud.last_played_at DESC
1646 LIMIT {}",
1647 limit_val
1648 ),
1649 vec![
1650 QueryParam::String(self.server_id.clone()),
1651 QueryParam::String(self.user_id.clone()),
1652 ],
1653 )
1654 };
1655
1656 let query = Query::with_params(sql, params);
1657
1658 let cached_items: Vec<CachedItem> = self
1659 .db_service
1660 .query_many(query, row_to_cached_item)
1661 .await
1662 .map_err(|e| RepoError::Database { message: e })?;
1663
1664 let mut items = Vec::new();
1665 for cached in cached_items {
1666 let user_data = self.get_user_data(&cached.id).await;
1667 items.push(Self::cached_item_to_media_item(cached, user_data));
1668 }
1669
1670 Ok(items)
1671 }
1672
1673 async fn get_next_up_episodes(
1674 &self,
1675 _series_id: Option<&str>,
1676 _limit: Option<usize>,
1677 ) -> Result<Vec<MediaItem>, RepoError> {
1678 Ok(Vec::new())
1681 }
1682
1683 async fn get_recently_played_audio(
1684 &self,
1685 limit: Option<usize>,
1686 ) -> Result<Vec<MediaItem>, RepoError> {
1687 let limit_val = limit.unwrap_or(12);
1688
1689 let query = Query::with_params(
1694 format!(
1695 "WITH downloaded_items AS (
1696 -- Playable items with completed downloads (Audio tracks)
1697 SELECT DISTINCT i.id
1698 FROM items i
1699 INNER JOIN downloads d ON i.id = d.item_id
1700 WHERE d.status = 'completed'
1701 AND i.item_type = 'Audio'
1702
1703 UNION
1704
1705 -- Containers with downloaded children (Albums)
1706 SELECT DISTINCT i.id
1707 FROM items i
1708 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)
1709 INNER JOIN downloads d ON children.id = d.item_id
1710 WHERE d.status = 'completed'
1711 AND i.item_type = 'MusicAlbum'
1712 ),
1713 ranked_plays AS (
1714 SELECT
1715 CASE
1716 WHEN ud.playback_context_type = 'container' THEN ud.playback_context_id
1717 WHEN ud.playback_context_type = 'single' THEN ud.item_id
1718 ELSE COALESCE(i.album_id, ud.item_id)
1719 END AS display_id,
1720 MAX(ud.last_played_at) AS most_recent_play
1721 FROM user_data ud
1722 JOIN items i ON ud.item_id = i.id
1723 WHERE ud.user_id = ? AND i.server_id = ?
1724 AND i.item_type = 'Audio'
1725 AND ud.last_played_at IS NOT NULL
1726 GROUP BY display_id
1727 ORDER BY most_recent_play DESC
1728 LIMIT {}
1729 )
1730 SELECT DISTINCT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1731 i.overview, i.genres, i.runtime_ticks, i.production_year,
1732 i.community_rating, i.official_rating, i.primary_image_tag,
1733 i.album_id, i.album_name, i.album_artist, i.artists,
1734 i.index_number, i.series_id, i.series_name, i.season_id,
1735 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1736 FROM ranked_plays rp
1737 JOIN items i ON rp.display_id = i.id
1738 INNER JOIN downloaded_items di ON i.id = di.id
1739 ORDER BY rp.most_recent_play DESC",
1740 limit_val
1741 ),
1742 vec![
1743 QueryParam::String(self.user_id.clone()),
1744 QueryParam::String(self.server_id.clone()),
1745 ],
1746 );
1747
1748 let cached_items: Vec<CachedItem> = self
1749 .db_service
1750 .query_many(query, row_to_cached_item)
1751 .await
1752 .map_err(|e| RepoError::Database { message: e })?;
1753
1754 let mut items = Vec::new();
1755 for cached in cached_items {
1756 let user_data = self.get_user_data(&cached.id).await;
1757 items.push(Self::cached_item_to_media_item(cached, user_data));
1758 }
1759
1760 Ok(items)
1761 }
1762
1763 async fn get_rediscover_albums(
1764 &self,
1765 _parent_id: Option<&str>,
1766 _limit: Option<usize>,
1767 ) -> Result<Vec<MediaItem>, RepoError> {
1768 Ok(Vec::new())
1772 }
1773
1774 async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
1775 let limit_val = limit.unwrap_or(12);
1776
1777 let query = Query::with_params(
1779 format!(
1780 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1781 i.overview, i.genres, i.runtime_ticks, i.production_year,
1782 i.community_rating, i.official_rating, i.primary_image_tag,
1783 i.album_id, i.album_name, i.album_artist, i.artists,
1784 i.index_number, i.series_id, i.series_name, i.season_id,
1785 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1786 FROM items i
1787 JOIN user_data ud ON i.id = ud.item_id
1788 INNER JOIN downloads d ON i.id = d.item_id
1789 WHERE i.server_id = ? AND ud.user_id = ? AND i.item_type = 'Movie'
1790 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1791 AND d.status = 'completed'
1792 ORDER BY ud.last_played_at DESC
1793 LIMIT {}",
1794 limit_val
1795 ),
1796 vec![
1797 QueryParam::String(self.server_id.clone()),
1798 QueryParam::String(self.user_id.clone()),
1799 ],
1800 );
1801
1802 let cached_items: Vec<CachedItem> = self
1803 .db_service
1804 .query_many(query, row_to_cached_item)
1805 .await
1806 .map_err(|e| RepoError::Database { message: e })?;
1807
1808 let mut items = Vec::new();
1809 for cached in cached_items {
1810 let user_data = self.get_user_data(&cached.id).await;
1811 items.push(Self::cached_item_to_media_item(cached, user_data));
1812 }
1813
1814 Ok(items)
1815 }
1816
1817 async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1818 let library_id = parent_id.unwrap_or("").to_string();
1823
1824 let query = Query::with_params(
1825 "SELECT id, name, album_count FROM genres WHERE server_id = ? AND library_id = ?",
1826 vec![
1827 QueryParam::String(self.server_id.clone()),
1828 QueryParam::String(library_id),
1829 ],
1830 );
1831
1832 let genres: Vec<Genre> = self
1833 .db_service
1834 .query_many(query, |row| {
1835 Ok(Genre {
1836 id: row.get(0)?,
1837 name: row.get(1)?,
1838 album_count: row.get::<_, Option<i64>>(2)?.map(|c| c as u32),
1839 })
1840 })
1841 .await
1842 .map_err(|e| RepoError::Database { message: e })?;
1843
1844 Ok(genres)
1845 }
1846
1847 async fn search(
1848 &self,
1849 query: &str,
1850 options: Option<SearchOptions>,
1851 ) -> Result<SearchResult, RepoError> {
1852 let opts = options.unwrap_or_default();
1853 let limit = opts.limit.unwrap_or(20);
1854
1855 let Some(fts_query) = build_fts_prefix_query(query) else {
1858 return Ok(SearchResult {
1859 items: Vec::new(),
1860 total_record_count: 0,
1861 });
1862 };
1863
1864 let type_values: &[String] = opts
1868 .include_item_types
1869 .as_deref()
1870 .filter(|types| !types.is_empty())
1871 .unwrap_or(&[]);
1872 let type_filter = if type_values.is_empty() {
1873 String::new()
1874 } else {
1875 let placeholders = vec!["?"; type_values.len()].join(",");
1876 format!(" AND i.item_type IN ({})", placeholders)
1877 };
1878
1879 let catalog_branch = if include_catalog_browse() {
1885 "UNION
1886
1887 -- Synced catalog: fast online search, or the offline
1888 -- 'Show all server media' view. See set_include_catalog_browse.
1889 SELECT DISTINCT i.id
1890 FROM items i
1891 WHERE i.synced_at IS NOT NULL"
1892 } else {
1893 ""
1894 };
1895
1896 let sql = format!(
1897 "WITH available_items AS (
1898 -- Playable items with completed downloads
1899 SELECT DISTINCT i.id
1900 FROM items i
1901 INNER JOIN downloads d ON i.id = d.item_id
1902 WHERE d.status = 'completed'
1903 AND i.item_type IN ('Audio', 'Movie', 'Episode')
1904
1905 UNION
1906
1907 -- Containers with downloaded children
1908 SELECT DISTINCT i.id
1909 FROM items i
1910 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)
1911 INNER JOIN downloads d ON children.id = d.item_id
1912 WHERE d.status = 'completed'
1913 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1914
1915 {}
1916 )
1917 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1918 i.overview, i.genres, i.runtime_ticks, i.production_year,
1919 i.community_rating, i.official_rating, i.primary_image_tag,
1920 i.album_id, i.album_name, i.album_artist, i.artists,
1921 i.index_number, i.series_id, i.series_name, i.season_id,
1922 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1923 FROM items i
1924 JOIN items_fts fts ON fts.rowid = i.rowid
1925 INNER JOIN available_items ai ON i.id = ai.id
1926 WHERE i.server_id = ? AND items_fts MATCH ?{}
1927 ORDER BY rank
1928 LIMIT {}",
1929 catalog_branch, type_filter, limit
1930 );
1931
1932 let mut params = vec![
1933 QueryParam::String(self.server_id.clone()),
1934 QueryParam::String(fts_query.clone()),
1935 ];
1936 params.extend(type_values.iter().cloned().map(QueryParam::String));
1937
1938 let db_query = Query::with_params(sql, params);
1939
1940 let cached_items: Vec<CachedItem> = self
1941 .db_service
1942 .query_many(db_query, row_to_cached_item)
1943 .await
1944 .map_err(|e| RepoError::Database { message: e })?;
1945
1946 let mut items = Vec::new();
1947 for cached in cached_items {
1948 let user_data = self.get_user_data(&cached.id).await;
1949 items.push(Self::cached_item_to_media_item(cached, user_data));
1950 }
1951
1952 if type_values.is_empty() {
1958 items.extend(self.search_people(&fts_query, limit).await?);
1959 }
1960
1961 let total_record_count = items.len();
1962
1963 Ok(SearchResult {
1964 items,
1965 total_record_count,
1966 })
1967 }
1968
1969 async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
1970 Err(RepoError::Offline)
1972 }
1973
1974 async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
1975 Err(RepoError::Offline)
1977 }
1978
1979 async fn get_audio_only_stream_url_for_video(
1980 &self,
1981 _item_id: &str,
1982 _media_source_id: Option<&str>,
1983 _start_time_seconds: Option<f64>,
1984 _audio_stream_index: Option<i32>,
1985 ) -> Result<String, RepoError> {
1986 Err(RepoError::Offline)
1988 }
1989
1990 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
1991 Err(RepoError::Offline)
1993 }
1994
1995 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
1996 Err(RepoError::Offline)
1998 }
1999
2000 async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
2001 Err(RepoError::Offline)
2003 }
2004
2005 async fn report_playback_start(
2006 &self,
2007 _item_id: &str,
2008 _position_ticks: i64,
2009 ) -> Result<(), RepoError> {
2010 Err(RepoError::Offline)
2012 }
2013
2014 async fn report_playback_progress(
2015 &self,
2016 _item_id: &str,
2017 _position_ticks: i64,
2018 ) -> Result<(), RepoError> {
2019 Err(RepoError::Offline)
2021 }
2022
2023 async fn report_playback_stopped(
2024 &self,
2025 _item_id: &str,
2026 _position_ticks: i64,
2027 ) -> Result<(), RepoError> {
2028 Err(RepoError::Offline)
2030 }
2031
2032 fn get_image_url(
2033 &self,
2034 item_id: &str,
2035 image_type: ImageType,
2036 options: Option<ImageOptions>,
2037 ) -> String {
2038 let type_str = match image_type {
2041 ImageType::Primary => "Primary",
2042 ImageType::Backdrop => "Backdrop",
2043 ImageType::Logo => "Logo",
2044 ImageType::Thumb => "Thumb",
2045 ImageType::Banner => "Banner",
2046 };
2047
2048 if let Some(opts) = options {
2049 if let Some(tag) = opts.tag {
2050 return format!("offline://{}/{}/{}", item_id, type_str, tag);
2051 }
2052 }
2053
2054 format!("offline://{}/{}", item_id, type_str)
2055 }
2056
2057 fn get_subtitle_url(
2058 &self,
2059 _item_id: &str,
2060 _media_source_id: &str,
2061 _stream_index: i32,
2062 _format: &str,
2063 ) -> String {
2064 String::new()
2066 }
2067
2068 fn get_video_download_url(
2069 &self,
2070 _item_id: &str,
2071 _quality: &str,
2072 _media_source_id: Option<&str>,
2073 _source_audio_codec: Option<&str>,
2074 ) -> String {
2075 String::new()
2077 }
2078
2079 async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
2080 Err(RepoError::Offline)
2082 }
2083
2084 async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
2085 Err(RepoError::Offline)
2087 }
2088
2089 async fn get_favorites(
2098 &self,
2099 scope: SearchScope,
2100 options: Option<GetItemsOptions>,
2101 ) -> Result<SearchResult, RepoError> {
2102 let opts = options.unwrap_or_default();
2103 let limit = opts.limit.unwrap_or(10000);
2104 let start_index = opts.start_index.unwrap_or(0);
2105
2106 let type_filter = match scope.item_types() {
2109 Some(types) if !types.is_empty() => {
2110 let placeholders = vec!["?"; types.len()].join(",");
2111 format!(" AND i.item_type IN ({})", placeholders)
2112 }
2113 _ => String::new(),
2114 };
2115
2116 let catalog_branch = if include_catalog_browse() {
2117 "UNION
2118
2119 SELECT DISTINCT i.id
2120 FROM items i
2121 WHERE i.synced_at IS NOT NULL"
2122 } else {
2123 ""
2124 };
2125
2126 let sql = format!(
2127 "WITH available_items AS (
2128 SELECT DISTINCT i.id
2129 FROM items i
2130 INNER JOIN downloads d ON i.id = d.item_id
2131 WHERE d.status = 'completed'
2132 AND i.item_type IN ('Audio', 'Movie', 'Episode')
2133
2134 UNION
2135
2136 SELECT DISTINCT i.id
2137 FROM items i
2138 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)
2139 INNER JOIN downloads d ON children.id = d.item_id
2140 WHERE d.status = 'completed'
2141 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
2142
2143 {catalog_branch}
2144 )
2145 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
2146 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
2147 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
2148 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
2149 i.parent_index_number, i.is_folder, i.premiere_date
2150 FROM items i
2151 INNER JOIN available_items ai ON i.id = ai.id
2152 INNER JOIN user_data ud ON ud.item_id = i.id
2153 WHERE i.server_id = ?
2154 AND ud.user_id = ?
2155 AND ud.is_favorite = 1{}
2156 ORDER BY i.sort_name ASC, i.name ASC
2157 LIMIT {} OFFSET {}",
2158 type_filter, limit, start_index
2159 );
2160
2161 let mut params = vec![
2162 QueryParam::String(self.server_id.clone()),
2163 QueryParam::String(self.user_id.clone()),
2164 ];
2165 if let Some(types) = scope.item_types() {
2166 params.extend(types.into_iter().map(QueryParam::String));
2167 }
2168
2169 let cached_items: Vec<CachedItem> = self
2170 .db_service
2171 .query_many(Query::with_params(sql, params), row_to_cached_item)
2172 .await
2173 .map_err(|e| RepoError::Database { message: e })?;
2174
2175 let mut items = Vec::new();
2176 for cached in cached_items {
2177 let user_data = self.get_user_data(&cached.id).await;
2178 items.push(Self::cached_item_to_media_item(cached, user_data));
2179 }
2180
2181 let total_record_count = items.len();
2182 debug!("[OfflineRepo] Returning {} favourites", total_record_count);
2183
2184 Ok(SearchResult {
2185 items,
2186 total_record_count,
2187 })
2188 }
2189
2190 async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
2191 Err(RepoError::Offline)
2194 }
2195
2196 async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
2197 Err(RepoError::Offline)
2200 }
2201
2202 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
2203 let query = Query::with_params(
2204 "SELECT id, name, overview, primary_image_tag
2205 FROM people WHERE id = ?",
2206 vec![QueryParam::String(person_id.to_string())],
2207 );
2208
2209 let person_data = self
2210 .db_service
2211 .query_optional(query, |row| {
2212 Ok((
2213 row.get::<_, String>(0)?,
2214 row.get::<_, String>(1)?,
2215 row.get::<_, Option<String>>(2)?,
2216 row.get::<_, Option<String>>(3)?,
2217 ))
2218 })
2219 .await
2220 .map_err(|e| RepoError::Database { message: e })?
2221 .ok_or_else(|| RepoError::NotFound {
2222 message: format!("Person {} not found in cache", person_id),
2223 })?;
2224
2225 Ok(MediaItem {
2226 id: person_data.0,
2227 name: person_data.1,
2228 item_type: "Person".to_string(),
2229 kind: crate::domain::MediaKind::Person,
2230 is_folder: false,
2231 server_id: self.server_id.clone(),
2232 parent_id: None,
2233 library_id: None,
2234 overview: person_data.2,
2235 genres: None,
2236 runtime_ticks: None,
2237 duration_ms: None,
2238 production_year: None,
2239 premiere_date: None,
2240 community_rating: None,
2241 official_rating: None,
2242 primary_image_tag: person_data.3.clone(),
2243 image_id: person_data.3,
2244 backdrop_image_tags: None,
2245 parent_backdrop_image_tags: None,
2246 album_id: None,
2247 album_name: None,
2248 album_artist: None,
2249 artists: None,
2250 artist_items: None,
2251 index_number: None,
2252 series_id: None,
2253 series_name: None,
2254 season_id: None,
2255 season_name: None,
2256 parent_index_number: None,
2257 user_data: None,
2258 media_streams: None,
2259 media_sources: None,
2260 people: None,
2261 })
2262 }
2263
2264 async fn get_items_by_person(
2265 &self,
2266 person_id: &str,
2267 options: Option<GetItemsOptions>,
2268 ) -> Result<SearchResult, RepoError> {
2269 let opts = options.unwrap_or_default();
2270 let limit = opts.limit.unwrap_or(10000); let query = Query::with_params(
2274 format!(
2275 "WITH downloaded_items AS (
2276 -- Playable items with completed downloads
2277 SELECT DISTINCT i.id
2278 FROM items i
2279 INNER JOIN downloads d ON i.id = d.item_id
2280 WHERE d.status = 'completed'
2281 AND i.item_type IN ('Audio', 'Movie', 'Episode')
2282
2283 UNION
2284
2285 -- Containers with downloaded children
2286 SELECT DISTINCT i.id
2287 FROM items i
2288 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)
2289 INNER JOIN downloads d ON children.id = d.item_id
2290 WHERE d.status = 'completed'
2291 AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
2292 )
2293 SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
2294 i.overview, i.genres, i.runtime_ticks, i.production_year,
2295 i.community_rating, i.official_rating, i.primary_image_tag,
2296 i.album_id, i.album_name, i.album_artist, i.artists,
2297 i.index_number, i.series_id, i.series_name, i.season_id,
2298 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
2299 FROM items i
2300 JOIN item_people ip ON i.id = ip.item_id
2301 INNER JOIN downloaded_items di ON i.id = di.id
2302 WHERE i.server_id = ? AND ip.person_id = ?
2303 ORDER BY i.production_year DESC, i.sort_name ASC
2304 LIMIT {}", limit
2305 ),
2306 vec![
2307 QueryParam::String(self.server_id.clone()),
2308 QueryParam::String(person_id.to_string()),
2309 ],
2310 );
2311
2312 let cached_items: Vec<CachedItem> = self
2313 .db_service
2314 .query_many(query, row_to_cached_item)
2315 .await
2316 .map_err(|e| RepoError::Database { message: e })?;
2317
2318 let mut items = Vec::new();
2319 for cached in cached_items {
2320 let user_data = self.get_user_data(&cached.id).await;
2321 items.push(Self::cached_item_to_media_item(cached, user_data));
2322 }
2323
2324 let total_record_count = items.len();
2325
2326 Ok(SearchResult {
2327 items,
2328 total_record_count,
2329 })
2330 }
2331
2332 async fn get_similar_items(
2333 &self,
2334 _item_id: &str,
2335 _limit: Option<usize>,
2336 ) -> Result<SearchResult, RepoError> {
2337 Err(RepoError::Offline)
2339 }
2340
2341 async fn create_playlist(
2344 &self,
2345 name: &str,
2346 item_ids: &[String],
2347 ) -> Result<PlaylistCreatedResult, RepoError> {
2348 let playlist_id = uuid::Uuid::new_v4().to_string();
2349 let user_id = self.user_id.clone();
2350 let name = name.to_string();
2351 let item_ids = item_ids.to_vec();
2352 let pid = playlist_id.clone();
2353
2354 self.db_service
2355 .transaction(move |tx| {
2356 use crate::storage::db_service::{Query, QueryParam};
2357
2358 tx.execute(Query::with_params(
2359 "INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)",
2360 vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)],
2361 ))?;
2362
2363 for (i, item_id) in item_ids.iter().enumerate() {
2364 tx.execute(Query::with_params(
2365 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
2366 vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)],
2367 ))?;
2368 }
2369
2370 Ok(())
2371 })
2372 .await
2373 .map_err(|e| RepoError::Database {
2374 message: format!("Failed to create playlist: {}", e),
2375 })?;
2376
2377 Ok(PlaylistCreatedResult { id: playlist_id })
2378 }
2379
2380 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
2381 let query = Query::with_params(
2382 "DELETE FROM playlists WHERE id = ?",
2383 vec![QueryParam::String(playlist_id.to_string())],
2384 );
2385 self.db_service
2386 .execute(query)
2387 .await
2388 .map_err(|e| RepoError::Database {
2389 message: format!("Failed to delete playlist: {}", e),
2390 })?;
2391 Ok(())
2392 }
2393
2394 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
2395 let query = Query::with_params(
2396 "UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
2397 vec![
2398 QueryParam::String(name.to_string()),
2399 QueryParam::String(playlist_id.to_string()),
2400 ],
2401 );
2402 self.db_service
2403 .execute(query)
2404 .await
2405 .map_err(|e| RepoError::Database {
2406 message: format!("Failed to rename playlist: {}", e),
2407 })?;
2408 Ok(())
2409 }
2410
2411 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
2412 let query = Query::with_params(
2413 "SELECT pi.id, \
2414 i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \
2415 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
2416 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
2417 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
2418 i.parent_index_number, i.is_folder, i.premiere_date \
2419 FROM playlist_items pi \
2420 JOIN items i ON pi.item_id = i.id \
2421 WHERE pi.playlist_id = ? \
2422 ORDER BY pi.sort_order ASC",
2423 vec![QueryParam::String(playlist_id.to_string())],
2424 );
2425
2426 let items = self
2427 .db_service
2428 .query_many(query, |row| {
2429 let entry_id: i64 = row.get(0)?;
2430 let cached = CachedItem {
2432 id: row.get(1)?,
2433 name: row.get(2)?,
2434 item_type: row.get(3)?,
2435 server_id: row.get(4)?,
2436 parent_id: row.get(5)?,
2437 library_id: row.get(6)?,
2438 overview: row.get(7)?,
2439 genres: row.get(8)?,
2440 runtime_ticks: row.get(9)?,
2441 production_year: row.get(10)?,
2442 community_rating: row.get(11)?,
2443 official_rating: row.get(12)?,
2444 primary_image_tag: row.get(13)?,
2445 backdrop_image_tags: None,
2446 parent_backdrop_image_tags: None,
2447 album_id: row.get(14)?,
2448 album_name: row.get(15)?,
2449 album_artist: row.get(16)?,
2450 artists: row.get(17)?,
2451 index_number: row.get(18)?,
2452 series_id: row.get(19)?,
2453 series_name: row.get(20)?,
2454 season_id: row.get(21)?,
2455 season_name: row.get(22)?,
2456 parent_index_number: row.get(23)?,
2457 is_folder: row.get::<_, Option<i64>>(24)?.unwrap_or(0) != 0,
2458 premiere_date: row.get(25)?,
2459 };
2460 Ok((entry_id.to_string(), cached))
2461 })
2462 .await
2463 .map_err(|e| RepoError::Database {
2464 message: format!("Failed to get playlist items: {}", e),
2465 })?;
2466
2467 Ok(items
2468 .into_iter()
2469 .map(|(entry_id, cached)| PlaylistEntry {
2470 playlist_item_id: entry_id,
2471 item: Self::cached_item_to_media_item(cached, None),
2472 })
2473 .collect())
2474 }
2475
2476 async fn add_to_playlist(
2477 &self,
2478 playlist_id: &str,
2479 item_ids: &[String],
2480 ) -> Result<(), RepoError> {
2481 let max_query = Query::with_params(
2483 "SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?",
2484 vec![QueryParam::String(playlist_id.to_string())],
2485 );
2486 let max_order: i32 = self
2487 .db_service
2488 .query_one(max_query, |row| row.get(0))
2489 .await
2490 .unwrap_or(-1);
2491
2492 let playlist_id = playlist_id.to_string();
2493 let item_ids = item_ids.to_vec();
2494
2495 self.db_service
2496 .transaction(move |tx| {
2497 use crate::storage::db_service::{Query, QueryParam};
2498
2499 for (i, item_id) in item_ids.iter().enumerate() {
2500 tx.execute(Query::with_params(
2501 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
2502 vec![
2503 QueryParam::String(playlist_id.clone()),
2504 QueryParam::String(item_id.clone()),
2505 QueryParam::Int(max_order + 1 + i as i32),
2506 ],
2507 ))?;
2508 }
2509 Ok(())
2510 })
2511 .await
2512 .map_err(|e| RepoError::Database {
2513 message: format!("Failed to add items to playlist: {}", e),
2514 })?;
2515
2516 Ok(())
2517 }
2518
2519 async fn remove_from_playlist(
2520 &self,
2521 playlist_id: &str,
2522 entry_ids: &[String],
2523 ) -> Result<(), RepoError> {
2524 let playlist_id = playlist_id.to_string();
2525 let entry_ids = entry_ids.to_vec();
2526
2527 self.db_service
2528 .transaction(move |tx| {
2529 use crate::storage::db_service::{Query, QueryParam};
2530
2531 for entry_id in &entry_ids {
2532 tx.execute(Query::with_params(
2533 "DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?",
2534 vec![
2535 QueryParam::String(playlist_id.clone()),
2536 QueryParam::String(entry_id.clone()),
2537 ],
2538 ))?;
2539 }
2540 Ok(())
2541 })
2542 .await
2543 .map_err(|e| RepoError::Database {
2544 message: format!("Failed to remove items from playlist: {}", e),
2545 })?;
2546
2547 Ok(())
2548 }
2549
2550 async fn move_playlist_item(
2551 &self,
2552 playlist_id: &str,
2553 item_id: &str,
2554 new_index: u32,
2555 ) -> Result<(), RepoError> {
2556 let playlist_id = playlist_id.to_string();
2557 let item_id = item_id.to_string();
2558
2559 self.db_service
2560 .transaction(move |tx| {
2561 use crate::storage::db_service::{Query, QueryParam};
2562
2563 let items: Vec<(i64, String)> = tx.query_many(
2565 Query::with_params(
2566 "SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order",
2567 vec![QueryParam::String(playlist_id)],
2568 ),
2569 |row| Ok((row.get(0)?, row.get(1)?)),
2570 )?;
2571
2572 let old_idx = items.iter().position(|(_, iid)| iid == &item_id);
2574 if let Some(old_pos) = old_idx {
2575 let mut ids = items;
2576 let entry = ids.remove(old_pos);
2577 let insert_at = (new_index as usize).min(ids.len());
2578 ids.insert(insert_at, entry);
2579
2580 for (i, (entry_id, _)) in ids.iter().enumerate() {
2582 tx.execute(Query::with_params(
2583 "UPDATE playlist_items SET sort_order = ? WHERE id = ?",
2584 vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)],
2585 ))?;
2586 }
2587 }
2588
2589 Ok(())
2590 })
2591 .await
2592 .map_err(|e| RepoError::Database {
2593 message: format!("Failed to move playlist item: {}", e),
2594 })?;
2595
2596 Ok(())
2597 }
2598}
2599
2600#[cfg(test)]
2601mod tests {
2602 #![allow(clippy::await_holding_lock)]
2611
2612 use super::*;
2613 use crate::storage::db_service::RusqliteService;
2614 use rusqlite::Connection;
2615 use std::sync::{Arc, Mutex};
2616
2617 static CATALOG_BROWSE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2624
2625 fn lock_catalog_browse() -> std::sync::MutexGuard<'static, ()> {
2626 use crate::utils::lock::MutexSafe;
2627 CATALOG_BROWSE_LOCK.lock_safe()
2628 }
2629
2630 #[test]
2632 fn test_build_fts_prefix_query() {
2633 assert_eq!(build_fts_prefix_query("Arr").as_deref(), Some("\"Arr\"*"));
2635
2636 assert_eq!(
2639 build_fts_prefix_query("parks rec").as_deref(),
2640 Some("\"parks\" \"rec\"*")
2641 );
2642
2643 for query in ["Bob's Burgers", "Spider-Man", "AC/DC", "Wall-E", "9-1-1"] {
2646 let built = build_fts_prefix_query(query).expect("should build");
2647 assert!(
2648 built.starts_with('"') && built.ends_with("*"),
2649 "{query:?} produced {built:?}"
2650 );
2651 }
2652
2653 assert_eq!(
2656 build_fts_prefix_query("say \"hi\"").as_deref(),
2657 Some("\"say\" \"\"\"hi\"\"\"*")
2658 );
2659
2660 assert_eq!(build_fts_prefix_query(""), None);
2663 assert_eq!(build_fts_prefix_query(" "), None);
2664 assert_eq!(build_fts_prefix_query("-"), None);
2665 }
2666
2667 #[tokio::test]
2672 async fn test_search_empty_query_returns_empty_not_error() {
2673 let db_service = create_test_db();
2674 let repo = OfflineRepository::new(
2675 db_service,
2676 "test-server".to_string(),
2677 "test-user".to_string(),
2678 );
2679
2680 let result = repo.search("", None).await;
2681 assert!(
2682 result.is_ok(),
2683 "empty query must not error: {:?}",
2684 result.err()
2685 );
2686 assert!(result.unwrap().items.is_empty());
2687 }
2688
2689 fn create_test_db() -> Arc<RusqliteService> {
2690 let conn = Connection::open_in_memory().unwrap();
2691
2692 conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
2694
2695 conn.execute_batch(
2697 r#"
2698 CREATE TABLE servers (
2699 id TEXT PRIMARY KEY,
2700 name TEXT NOT NULL,
2701 url TEXT NOT NULL UNIQUE
2702 );
2703
2704 CREATE TABLE items (
2705 id TEXT PRIMARY KEY,
2706 server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
2707 library_id TEXT,
2708 parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
2709 name TEXT NOT NULL,
2710 item_type TEXT NOT NULL,
2711 is_folder INTEGER DEFAULT 0,
2712 overview TEXT,
2713 genres TEXT,
2714 runtime_ticks INTEGER,
2715 production_year INTEGER,
2716 premiere_date TEXT,
2717 community_rating REAL,
2718 official_rating TEXT,
2719 primary_image_tag TEXT,
2720 backdrop_image_tags TEXT,
2721 album_id TEXT,
2722 album_name TEXT,
2723 album_artist TEXT,
2724 artists TEXT,
2725 index_number INTEGER,
2726 series_id TEXT,
2727 series_name TEXT,
2728 season_id TEXT,
2729 season_name TEXT,
2730 parent_index_number INTEGER,
2731 synced_at TEXT,
2732 sort_name TEXT
2733 );
2734
2735 -- Mirrors the real FTS5 index and its triggers (schema.rs migration
2736 -- 001) so search can be exercised in tests at all.
2737 CREATE VIRTUAL TABLE items_fts USING fts5(
2738 name, overview, album_name, album_artist, artists, series_name,
2739 content='items', content_rowid='rowid'
2740 );
2741
2742 CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
2743 INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
2744 VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
2745 END;
2746
2747 CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
2748 INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
2749 VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
2750 END;
2751
2752 CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
2753 INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
2754 VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
2755 INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
2756 VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
2757 END;
2758
2759 CREATE TABLE user_data (
2760 user_id TEXT NOT NULL,
2761 item_id TEXT NOT NULL,
2762 playback_position_ticks INTEGER,
2763 is_played INTEGER,
2764 is_favorite INTEGER,
2765 play_count INTEGER,
2766 last_played_at TEXT,
2767 playback_context_type TEXT,
2768 playback_context_id TEXT,
2769 synced_at TEXT,
2770 pending_sync INTEGER DEFAULT 0,
2771 PRIMARY KEY (user_id, item_id)
2772 );
2773
2774 CREATE TABLE playlists (
2775 id TEXT PRIMARY KEY,
2776 user_id TEXT NOT NULL,
2777 name TEXT NOT NULL,
2778 is_local INTEGER DEFAULT 0,
2779 jellyfin_id TEXT,
2780 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
2781 updated_at TEXT
2782 );
2783
2784 CREATE TABLE playlist_items (
2785 id INTEGER PRIMARY KEY AUTOINCREMENT,
2786 playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
2787 item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
2788 sort_order INTEGER NOT NULL,
2789 added_at TEXT DEFAULT CURRENT_TIMESTAMP,
2790 UNIQUE(playlist_id, item_id)
2791 );
2792
2793 CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
2794
2795 CREATE TABLE downloads (
2796 id INTEGER PRIMARY KEY AUTOINCREMENT,
2797 item_id TEXT NOT NULL,
2798 status TEXT NOT NULL,
2799 file_size INTEGER
2800 );
2801
2802 CREATE TABLE libraries (
2803 id TEXT PRIMARY KEY,
2804 server_id TEXT NOT NULL,
2805 name TEXT NOT NULL,
2806 collection_type TEXT,
2807 image_tag TEXT,
2808 sort_order INTEGER DEFAULT 0,
2809 synced_at TEXT
2810 );
2811
2812 -- Mirrors migration 009 + the migration 022 FTS index.
2813 CREATE TABLE people (
2814 id TEXT PRIMARY KEY,
2815 server_id TEXT NOT NULL,
2816 name TEXT NOT NULL,
2817 overview TEXT,
2818 primary_image_tag TEXT,
2819 premiere_date TEXT,
2820 end_date TEXT,
2821 synced_at TEXT DEFAULT CURRENT_TIMESTAMP
2822 );
2823
2824 CREATE VIRTUAL TABLE people_fts USING fts5(
2825 name, overview, content='people', content_rowid='rowid'
2826 );
2827
2828 CREATE TRIGGER people_ai AFTER INSERT ON people BEGIN
2829 INSERT INTO people_fts(rowid, name, overview)
2830 VALUES (new.rowid, new.name, new.overview);
2831 END;
2832
2833 CREATE TRIGGER people_ad AFTER DELETE ON people BEGIN
2834 INSERT INTO people_fts(people_fts, rowid, name, overview)
2835 VALUES('delete', old.rowid, old.name, old.overview);
2836 END;
2837
2838 CREATE TRIGGER people_au AFTER UPDATE ON people BEGIN
2839 INSERT INTO people_fts(people_fts, rowid, name, overview)
2840 VALUES('delete', old.rowid, old.name, old.overview);
2841 INSERT INTO people_fts(rowid, name, overview)
2842 VALUES (new.rowid, new.name, new.overview);
2843 END;
2844
2845 CREATE TABLE genres (
2846 id TEXT NOT NULL,
2847 server_id TEXT NOT NULL,
2848 library_id TEXT,
2849 name TEXT NOT NULL,
2850 album_count INTEGER,
2851 synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
2852 PRIMARY KEY (server_id, library_id, name)
2853 );
2854 "#,
2855 )
2856 .unwrap();
2857
2858 conn.execute(
2860 "INSERT INTO servers (id, name, url) VALUES ('test-server', 'Test Server', 'http://test')",
2861 [],
2862 ).unwrap();
2863
2864 Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
2865 }
2866
2867 fn create_test_item(id: &str, name: &str, parent_id: Option<&str>) -> MediaItem {
2869 MediaItem {
2870 id: id.to_string(),
2871 name: name.to_string(),
2872 item_type: "Audio".to_string(),
2873 kind: crate::domain::MediaKind::Track,
2874 is_folder: false,
2875 server_id: "test-server".to_string(),
2876 parent_id: parent_id.map(|s| s.to_string()),
2877 library_id: None,
2878 overview: None,
2879 genres: None,
2880 runtime_ticks: None,
2881 duration_ms: None,
2882 production_year: None,
2883 premiere_date: None,
2884 community_rating: None,
2885 official_rating: None,
2886 primary_image_tag: None,
2887 image_id: None,
2888 backdrop_image_tags: None,
2889 parent_backdrop_image_tags: None,
2890 album_id: None,
2891 album_name: None,
2892 album_artist: None,
2893 artists: None,
2894 artist_items: None,
2895 index_number: None,
2896 series_id: None,
2897 series_name: None,
2898 season_id: None,
2899 season_name: None,
2900 parent_index_number: None,
2901 user_data: None,
2902 media_streams: None,
2903 media_sources: None,
2904 people: None,
2905 }
2906 }
2907
2908 #[tokio::test]
2909 async fn test_save_to_cache_with_missing_parent_fk() {
2910 let db_service = create_test_db();
2911
2912 let fk_enabled: i32 = db_service
2914 .query_one(Query::new("PRAGMA foreign_keys"), |row| row.get(0))
2915 .await
2916 .unwrap();
2917 println!("Foreign keys enabled: {}", fk_enabled);
2918 assert_eq!(fk_enabled, 1, "Foreign keys should be enabled");
2919
2920 let repo = OfflineRepository::new(
2921 db_service.clone(),
2922 "test-server".to_string(),
2923 "test-user".to_string(),
2924 );
2925
2926 let items = vec![
2930 create_test_item("track-1", "Track 1", Some("album-1")),
2932 create_test_item("track-2", "Track 2", Some("album-1")),
2933 create_test_item("track-3", "Track 3", Some("album-2")),
2935 create_test_item("track-4", "Track 4", Some("album-2")),
2936 create_test_item("album-1", "Album One", Some("library-123")),
2938 create_test_item("album-2", "Album Two", Some("library-123")),
2939 ];
2940
2941 println!("Attempting to save {} items...", items.len());
2942 for (i, item) in items.iter().enumerate() {
2943 println!(" Item {}: {} (parent: {:?})", i, item.id, item.parent_id);
2944 }
2945
2946 let result = repo.save_to_cache("library-123", &items).await;
2951
2952 match &result {
2954 Ok(count) => {
2955 println!("✓ Saved {} items", count);
2956 assert_eq!(*count, 6);
2957
2958 let all_items: Vec<(String, Option<String>)> = db_service
2960 .query_many(
2961 Query::new("SELECT id, parent_id FROM items ORDER BY id"),
2962 |row| Ok((row.get(0)?, row.get(1)?)),
2963 )
2964 .await
2965 .unwrap();
2966
2967 println!("\nAll items in database:");
2968 for (id, parent) in &all_items {
2969 println!(" {} -> parent: {:?}", id, parent);
2970 }
2971
2972 let track1_parent: Option<String> = db_service
2974 .query_optional(
2975 Query::with_params(
2976 "SELECT parent_id FROM items WHERE id = ?",
2977 vec![QueryParam::String("track-1".to_string())],
2978 ),
2979 |row| row.get(0),
2980 )
2981 .await
2982 .unwrap()
2983 .flatten();
2984
2985 println!("\ntrack-1 parent_id in DB: {:?}", track1_parent);
2986 println!("track-1 expected parent_id: Some(\"album-1\")");
2987
2988 assert_eq!(
2990 track1_parent,
2991 Some("album-1".to_string()),
2992 "track-1 should have parent_id='album-1'"
2993 );
2994
2995 let album1_parent: Option<String> = db_service
2997 .query_optional(
2998 Query::with_params(
2999 "SELECT parent_id FROM items WHERE id = ?",
3000 vec![QueryParam::String("album-1".to_string())],
3001 ),
3002 |row| row.get(0),
3003 )
3004 .await
3005 .unwrap()
3006 .flatten();
3007
3008 assert_eq!(
3009 album1_parent,
3010 Some("library-123".to_string()),
3011 "album-1 should have parent_id='library-123'"
3012 );
3013 }
3014 Err(e) => panic!("Unexpected error: {:?}", e),
3015 }
3016 }
3017
3018 #[tokio::test]
3019 async fn test_save_to_cache_simple_case() {
3020 let db_service = create_test_db();
3021 let repo = OfflineRepository::new(
3022 db_service.clone(),
3023 "test-server".to_string(),
3024 "test-user".to_string(),
3025 );
3026
3027 let items = vec![
3029 create_test_item("item-1", "Item 1", Some("parent-123")),
3030 create_test_item("item-2", "Item 2", Some("parent-123")),
3031 create_test_item("item-3", "Item 3", Some("parent-123")),
3032 ];
3033
3034 let result = repo.save_to_cache("parent-123", &items).await;
3035 assert!(result.is_ok(), "Simple case should work: {:?}", result);
3036 assert_eq!(result.unwrap(), 3);
3037 }
3038
3039 #[tokio::test]
3047 async fn test_get_item_album_available_via_album_id_link() {
3048 use crate::storage::db_service::DatabaseService;
3049 let db_service = create_test_db();
3050
3051 for sql in [
3052 "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
3054 VALUES ('album-1', 'test-server', 'Hadestown', 'MusicAlbum', NULL, NULL)",
3055 "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
3057 VALUES ('track-1', 'test-server', 'Wait For Me', 'Audio', 'album-1', NULL)",
3058 "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
3059 ] {
3060 db_service.execute(Query::new(sql)).await.unwrap();
3061 }
3062
3063 let repo = OfflineRepository::new(
3064 db_service.clone(),
3065 "test-server".to_string(),
3066 "test-user".to_string(),
3067 );
3068
3069 assert!(
3071 repo.get_item("track-1").await.is_ok(),
3072 "downloaded track should be available offline"
3073 );
3074
3075 let album = repo.get_item("album-1").await;
3078 assert!(
3079 album.is_ok(),
3080 "album with an album_id-linked downloaded track should be available offline, got {:?}",
3081 album.err()
3082 );
3083 assert_eq!(album.unwrap().id, "album-1");
3084
3085 let tracks = repo.get_items("album-1", None).await.unwrap();
3089 assert_eq!(
3090 tracks.items.len(),
3091 1,
3092 "get_items(album_id) should return the track"
3093 );
3094 assert_eq!(tracks.items[0].id, "track-1");
3095 }
3096
3097 #[tokio::test]
3110 async fn test_get_items_toggle_gates_synced_catalog() {
3111 use crate::storage::db_service::DatabaseService;
3112 let _guard = lock_catalog_browse();
3113 let db_service = create_test_db();
3114
3115 for sql in [
3116 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3118 VALUES ('movie-dl', 'test-server', 'Downloaded', 'Movie', 'lib-1', '2026-01-01')",
3119 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3120 VALUES ('movie-cat', 'test-server', 'CatalogOnly', 'Movie', 'lib-1', '2026-01-01')",
3121 "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
3123 "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
3125 ] {
3126 db_service.execute(Query::new(sql)).await.unwrap();
3127 }
3128
3129 let repo = OfflineRepository::new(
3130 db_service.clone(),
3131 "test-server".to_string(),
3132 "test-user".to_string(),
3133 );
3134 let opts = Some(GetItemsOptions {
3135 include_item_types: Some(vec!["Movie".to_string()]),
3136 ..Default::default()
3137 });
3138
3139 set_include_catalog_browse(false);
3141 let local_only = repo.get_items("lib-1", opts.clone()).await.unwrap();
3142 let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
3143 assert_eq!(
3144 ids,
3145 vec!["movie-dl"],
3146 "toggle off should show downloaded media only"
3147 );
3148
3149 set_include_catalog_browse(true);
3151 let full_catalog = repo.get_items("lib-1", opts).await.unwrap();
3152 let mut ids: Vec<&str> = full_catalog.items.iter().map(|i| i.id.as_str()).collect();
3153 ids.sort();
3154 assert_eq!(
3155 ids,
3156 vec!["movie-cat", "movie-dl"],
3157 "toggle on should reveal the full catalog"
3158 );
3159
3160 set_include_catalog_browse(true);
3162 }
3163
3164 #[tokio::test]
3171 async fn test_search_includes_cached_people() {
3172 use crate::storage::db_service::DatabaseService;
3173 let _guard = lock_catalog_browse();
3174 let db_service = create_test_db();
3175
3176 for sql in [
3177 "INSERT INTO people (id, server_id, name, overview) \
3178 VALUES ('p1', 'test-server', 'Tilda Swinton', 'Actor')",
3179 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3182 VALUES ('m1', 'test-server', 'Tilda the Movie', 'Movie', '2026-01-01')",
3183 ] {
3184 db_service.execute(Query::new(sql)).await.unwrap();
3185 }
3186
3187 let repo = OfflineRepository::new(
3188 db_service.clone(),
3189 "test-server".to_string(),
3190 "test-user".to_string(),
3191 );
3192 set_include_catalog_browse(true);
3193
3194 let all = repo.search("Tilda", None).await.unwrap();
3196 let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
3197 ids.sort();
3198 assert_eq!(ids, vec!["m1", "p1"], "unscoped search must include people");
3199
3200 let person = all.items.iter().find(|i| i.id == "p1").unwrap();
3201 assert_eq!(person.item_type, "Person");
3202 assert_eq!(person.kind, crate::domain::MediaKind::Person);
3203
3204 let scoped = repo
3206 .search(
3207 "Tilda",
3208 Some(SearchOptions {
3209 include_item_types: Some(vec!["Movie".to_string()]),
3210 ..Default::default()
3211 }),
3212 )
3213 .await
3214 .unwrap();
3215 let ids: Vec<&str> = scoped.items.iter().map(|i| i.id.as_str()).collect();
3216 assert_eq!(ids, vec!["m1"], "a scoped search must not leak people in");
3217
3218 set_include_catalog_browse(true);
3219 }
3220
3221 #[tokio::test]
3228 async fn test_prune_stale_catalog() {
3229 use crate::storage::db_service::DatabaseService;
3230 let db_service = create_test_db();
3231
3232 let old = "2026-01-01T00:00:00+00:00";
3234 let new = "2026-06-01T00:00:00+00:00";
3235 let cutoff = "2026-03-01T00:00:00+00:00";
3236
3237 for sql in [
3238 "INSERT INTO servers (id, name, url) \
3240 VALUES ('other-server', 'Other', 'http://other')"
3241 .to_string(),
3242 format!(
3244 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3245 VALUES ('keep-fresh', 'test-server', 'Fresh', 'Movie', '{new}')"
3246 ),
3247 format!(
3249 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3250 VALUES ('gone', 'test-server', 'Vanished', 'Movie', '{old}')"
3251 ),
3252 format!(
3254 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3255 VALUES ('keep-dl', 'test-server', 'Downloaded', 'Movie', '{old}')"
3256 ),
3257 "INSERT INTO downloads (item_id, status) VALUES ('keep-dl', 'completed')".to_string(),
3258 format!(
3260 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3261 VALUES ('keep-album', 'test-server', 'Album', 'MusicAlbum', '{old}')"
3262 ),
3263 format!(
3264 "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
3265 VALUES ('keep-track', 'test-server', 'Track', 'Audio', 'keep-album', '{old}')"
3266 ),
3267 "INSERT INTO downloads (item_id, status) VALUES ('keep-track', 'completed')"
3268 .to_string(),
3269 format!(
3272 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3273 VALUES ('keep-artist', 'test-server', 'Artist', 'MusicArtist', '{old}')"
3274 ),
3275 format!(
3277 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3278 VALUES ('keep-other', 'other-server', 'Elsewhere', 'Movie', '{old}')"
3279 ),
3280 ] {
3281 db_service.execute(Query::new(&sql)).await.unwrap();
3282 }
3283
3284 let repo = OfflineRepository::new(
3285 db_service.clone(),
3286 "test-server".to_string(),
3287 "test-user".to_string(),
3288 );
3289
3290 let crawled_types = vec![
3291 "Movie".to_string(),
3292 "MusicAlbum".to_string(),
3293 "Audio".to_string(),
3294 ];
3295 let removed = repo
3296 .prune_stale_catalog(cutoff, &crawled_types)
3297 .await
3298 .unwrap();
3299 assert_eq!(removed, 1, "only the vanished movie should be swept");
3300
3301 let mut surviving: Vec<String> = db_service
3302 .query_many(Query::new("SELECT id FROM items"), |row| row.get(0))
3303 .await
3304 .unwrap();
3305 surviving.sort();
3306 assert_eq!(
3307 surviving,
3308 vec![
3309 "keep-album",
3310 "keep-artist",
3311 "keep-dl",
3312 "keep-fresh",
3313 "keep-other",
3314 "keep-track",
3315 ]
3316 );
3317
3318 assert_eq!(repo.prune_stale_catalog(cutoff, &[]).await.unwrap(), 0);
3320 }
3321
3322 #[tokio::test]
3337 async fn test_repeated_cache_does_not_duplicate_fts_entries() {
3338 use crate::storage::db_service::DatabaseService;
3339 let db_service = create_test_db();
3340 let repo = OfflineRepository::new(
3341 db_service.clone(),
3342 "test-server".to_string(),
3343 "test-user".to_string(),
3344 );
3345
3346 let items = vec![create_test_item("track-1", "Wait For Me", None)];
3347
3348 for _ in 0..3 {
3351 repo.save_to_cache("parent-1", &items).await.unwrap();
3352 }
3353
3354 let item_rows: i64 = db_service
3357 .query_one(
3358 Query::new("SELECT COUNT(*) FROM items WHERE id = 'track-1'"),
3359 |row| row.get(0),
3360 )
3361 .await
3362 .unwrap();
3363 assert_eq!(item_rows, 1, "three passes must leave one item row");
3364
3365 let fts_hits: i64 = db_service
3366 .query_one(
3367 Query::with_params(
3368 "SELECT COUNT(*) FROM items_fts WHERE items_fts MATCH ?",
3369 vec![QueryParam::String("\"Wait\"*".to_string())],
3370 ),
3371 |row| row.get(0),
3372 )
3373 .await
3374 .unwrap();
3375 assert_eq!(
3376 fts_hits, 1,
3377 "the FTS index must hold one entry per item, not one per sync pass"
3378 );
3379 }
3380
3381 #[tokio::test]
3390 async fn test_search_toggle_gates_synced_catalog() {
3391 use crate::storage::db_service::DatabaseService;
3392 let _guard = lock_catalog_browse();
3393 let db_service = create_test_db();
3394
3395 for sql in [
3396 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3398 VALUES ('movie-dl', 'test-server', 'Arrival', 'Movie', 'lib-1', '2026-01-01')",
3399 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3400 VALUES ('movie-cat', 'test-server', 'Arrakis', 'Movie', 'lib-1', '2026-01-01')",
3401 "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
3403 ] {
3404 db_service.execute(Query::new(sql)).await.unwrap();
3405 }
3406
3407 let repo = OfflineRepository::new(
3408 db_service.clone(),
3409 "test-server".to_string(),
3410 "test-user".to_string(),
3411 );
3412
3413 set_include_catalog_browse(true);
3416 let full = repo.search("Arr", None).await.unwrap();
3417 let mut ids: Vec<&str> = full.items.iter().map(|i| i.id.as_str()).collect();
3418 ids.sort();
3419 assert_eq!(
3420 ids,
3421 vec!["movie-cat", "movie-dl"],
3422 "with catalog browse on, search must cover synced-but-not-downloaded items"
3423 );
3424
3425 set_include_catalog_browse(false);
3427 let local_only = repo.search("Arr", None).await.unwrap();
3428 let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
3429 assert_eq!(
3430 ids,
3431 vec!["movie-dl"],
3432 "with catalog browse off, search stays downloads-only"
3433 );
3434
3435 set_include_catalog_browse(true);
3437 }
3438
3439 #[tokio::test]
3446 async fn test_search_type_filter_is_parameterised() {
3447 use crate::storage::db_service::DatabaseService;
3448 let _guard = lock_catalog_browse();
3449 let db_service = create_test_db();
3450
3451 db_service
3452 .execute(Query::new(
3453 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3454 VALUES ('m1', 'test-server', 'Arrival', 'Movie', '2026-01-01')",
3455 ))
3456 .await
3457 .unwrap();
3458
3459 let repo = OfflineRepository::new(
3460 db_service.clone(),
3461 "test-server".to_string(),
3462 "test-user".to_string(),
3463 );
3464 set_include_catalog_browse(true);
3465
3466 let opts = SearchOptions {
3468 include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
3469 ..Default::default()
3470 };
3471 let result = repo.search("Arr", Some(opts)).await;
3472 assert!(
3473 result.is_ok(),
3474 "a quote in an item type must not break the query: {:?}",
3475 result.err()
3476 );
3477 assert!(
3478 result.unwrap().items.is_empty(),
3479 "an injected type filter must not widen the result set"
3480 );
3481
3482 let opts = SearchOptions {
3484 include_item_types: Some(vec!["Movie".to_string()]),
3485 ..Default::default()
3486 };
3487 assert_eq!(repo.search("Arr", Some(opts)).await.unwrap().items.len(), 1);
3488 }
3489
3490 #[tokio::test]
3495 async fn test_get_item_tv_available_via_season_series_link() {
3496 use crate::storage::db_service::DatabaseService;
3497 let db_service = create_test_db();
3498
3499 for sql in [
3500 "INSERT INTO items (id, server_id, name, item_type, parent_id) \
3501 VALUES ('series-1', 'test-server', 'Gilmore Girls', 'Series', NULL)",
3502 "INSERT INTO items (id, server_id, name, item_type, series_id, parent_id) \
3503 VALUES ('season-1', 'test-server', 'Season 1', 'Season', 'series-1', NULL)",
3504 "INSERT INTO items (id, server_id, name, item_type, season_id, series_id, parent_id) \
3506 VALUES ('ep-1', 'test-server', 'Pilot', 'Episode', 'season-1', 'series-1', NULL)",
3507 "INSERT INTO downloads (item_id, status) VALUES ('ep-1', 'completed')",
3508 ] {
3509 db_service.execute(Query::new(sql)).await.unwrap();
3510 }
3511
3512 let repo = OfflineRepository::new(
3513 db_service.clone(),
3514 "test-server".to_string(),
3515 "test-user".to_string(),
3516 );
3517
3518 assert!(
3519 repo.get_item("ep-1").await.is_ok(),
3520 "downloaded episode available offline"
3521 );
3522 assert!(
3523 repo.get_item("season-1").await.is_ok(),
3524 "season with a season_id-linked downloaded episode should be available offline"
3525 );
3526 assert!(
3527 repo.get_item("series-1").await.is_ok(),
3528 "series with a series_id-linked downloaded episode should be available offline"
3529 );
3530
3531 let season_items = repo.get_items("season-1", None).await.unwrap();
3533 assert!(
3534 season_items.items.iter().any(|i| i.id == "ep-1"),
3535 "get_items(season_id) should return the episode"
3536 );
3537
3538 let series_items = repo.get_items("series-1", None).await.unwrap();
3540 assert!(
3541 series_items.items.iter().any(|i| i.id == "ep-1"),
3542 "get_items(series_id) should surface the downloaded episode"
3543 );
3544 }
3545
3546 #[tokio::test]
3551 async fn test_libraries_cache_roundtrip_available_offline() {
3552 let db_service = create_test_db();
3553 let repo = OfflineRepository::new(
3554 db_service.clone(),
3555 "test-server".to_string(),
3556 "test-user".to_string(),
3557 );
3558
3559 assert!(repo.get_libraries().await.unwrap().is_empty());
3562
3563 let server_libs = vec![
3565 Library::new("music".into(), "Music".into(), "music".into(), None),
3566 Library::new(
3567 "movies".into(),
3568 "Movies".into(),
3569 "movies".into(),
3570 Some("tag".into()),
3571 ),
3572 ];
3573 let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
3574 assert_eq!(saved, 2);
3575
3576 let offline_libs = repo.get_libraries().await.unwrap();
3578 let names: Vec<&str> = offline_libs.iter().map(|l| l.name.as_str()).collect();
3579 assert_eq!(
3580 names,
3581 vec!["Music", "Movies"],
3582 "cached libraries available offline in sort order"
3583 );
3584
3585 repo.save_libraries_to_cache(&server_libs).await.unwrap();
3587 assert_eq!(repo.get_libraries().await.unwrap().len(), 2);
3588 }
3589
3590 #[tokio::test]
3597 async fn test_genres_cache_roundtrip_scoped_by_library() {
3598 let db_service = create_test_db();
3599 let repo = OfflineRepository::new(
3600 db_service.clone(),
3601 "test-server".to_string(),
3602 "test-user".to_string(),
3603 );
3604
3605 assert!(repo.get_genres(Some("music-lib")).await.unwrap().is_empty());
3607
3608 let server_genres = vec![
3610 Genre {
3611 id: "g1".into(),
3612 name: "Rock".into(),
3613 album_count: Some(42),
3614 },
3615 Genre {
3616 id: "g2".into(),
3617 name: "Jazz".into(),
3618 album_count: Some(17),
3619 },
3620 Genre {
3621 id: "g3".into(),
3622 name: "Ambient".into(),
3623 album_count: None,
3624 },
3625 ];
3626 let saved = repo
3627 .save_genres_to_cache(Some("music-lib"), &server_genres)
3628 .await
3629 .unwrap();
3630 assert_eq!(saved, 3);
3631
3632 let mut offline_genres = repo.get_genres(Some("music-lib")).await.unwrap();
3634 offline_genres.sort_by(|a, b| a.name.cmp(&b.name));
3635 let names: Vec<&str> = offline_genres.iter().map(|g| g.name.as_str()).collect();
3636 assert_eq!(names, vec!["Ambient", "Jazz", "Rock"]);
3637 let rock = offline_genres.iter().find(|g| g.name == "Rock").unwrap();
3638 assert_eq!(rock.album_count, Some(42));
3639
3640 assert!(repo.get_genres(Some("other-lib")).await.unwrap().is_empty());
3642
3643 let updated = vec![Genre {
3645 id: "g1".into(),
3646 name: "Rock".into(),
3647 album_count: Some(50),
3648 }];
3649 repo.save_genres_to_cache(Some("music-lib"), &updated)
3650 .await
3651 .unwrap();
3652 let after = repo.get_genres(Some("music-lib")).await.unwrap();
3653 assert_eq!(after.len(), 1, "stale genres removed on refresh");
3654 assert_eq!(after[0].album_count, Some(50), "counts updated on refresh");
3655 }
3656
3657 async fn seed_items(repo: &OfflineRepository, ids: &[&str]) {
3661 let items: Vec<MediaItem> = ids
3662 .iter()
3663 .map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1")))
3664 .collect();
3665 repo.save_to_cache("library-1", &items).await.unwrap();
3666 }
3667
3668 async fn insert_item(
3671 db: &Arc<RusqliteService>,
3672 id: &str,
3673 item_type: &str,
3674 album_id: Option<&str>,
3675 series_id: Option<&str>,
3676 season_id: Option<&str>,
3677 ) {
3678 db.execute(Query::with_params(
3679 "INSERT INTO items (id, server_id, name, item_type, album_id, series_id, season_id, synced_at)
3680 VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, ?6, '2024-01-01')",
3681 vec![
3682 QueryParam::String(id.to_string()),
3683 QueryParam::String(format!("Name {id}")),
3684 QueryParam::String(item_type.to_string()),
3685 album_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
3686 series_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
3687 season_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
3688 ],
3689 ))
3690 .await
3691 .unwrap();
3692 }
3693
3694 async fn insert_library_item(
3697 db: &Arc<RusqliteService>,
3698 id: &str,
3699 item_type: &str,
3700 library_id: &str,
3701 album_id: Option<&str>,
3702 ) {
3703 db.execute(Query::with_params(
3704 "INSERT INTO items (id, server_id, library_id, name, item_type, album_id, synced_at)
3705 VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, '2024-01-01')",
3706 vec![
3707 QueryParam::String(id.to_string()),
3708 QueryParam::String(library_id.to_string()),
3709 QueryParam::String(format!("Name {id}")),
3710 QueryParam::String(item_type.to_string()),
3711 album_id
3712 .map(|s| QueryParam::String(s.to_string()))
3713 .unwrap_or(QueryParam::Null),
3714 ],
3715 ))
3716 .await
3717 .unwrap();
3718 }
3719
3720 async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
3721 db.execute(Query::with_params(
3722 "INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
3723 vec![
3724 QueryParam::String(item_id.to_string()),
3725 QueryParam::Int64(file_size),
3726 ],
3727 ))
3728 .await
3729 .unwrap();
3730 }
3731
3732 async fn seed_library(db: &Arc<RusqliteService>, id: &str, collection_type: &str) {
3733 db.execute(Query::with_params(
3734 "INSERT INTO libraries (id, server_id, name, collection_type, sort_order)
3735 VALUES (?1, 'test-server', ?2, ?3, 0)",
3736 vec![
3737 QueryParam::String(id.to_string()),
3738 QueryParam::String(format!("Lib {id}")),
3739 QueryParam::String(collection_type.to_string()),
3740 ],
3741 ))
3742 .await
3743 .unwrap();
3744 }
3745
3746 fn make_repo(db: &Arc<RusqliteService>) -> OfflineRepository {
3747 OfflineRepository::new(
3748 db.clone(),
3749 "test-server".to_string(),
3750 "test-user".to_string(),
3751 )
3752 }
3753
3754 #[tokio::test]
3761 async fn test_get_latest_items_collapses_tracks_into_their_album() {
3762 let db = create_test_db();
3763 insert_library_item(&db, "album-1", "MusicAlbum", "lib-1", None).await;
3764 for track in ["track-1", "track-2", "track-3"] {
3765 insert_library_item(&db, track, "Audio", "lib-1", Some("album-1")).await;
3766 seed_completed_download(&db, track, 1000).await;
3767 }
3768 insert_library_item(&db, "movie-1", "Movie", "lib-1", None).await;
3770 seed_completed_download(&db, "movie-1", 2000).await;
3771
3772 let repo = make_repo(&db);
3773 let latest = repo.get_latest_items("lib-1", Some(16)).await.unwrap();
3774 let ids: Vec<&str> = latest.iter().map(|i| i.id.as_str()).collect();
3775
3776 assert!(
3777 !ids.iter().any(|id| id.starts_with("track-")),
3778 "individual tracks must collapse into their album, got: {ids:?}"
3779 );
3780 assert!(ids.contains(&"album-1"), "the album itself is listed");
3781 assert!(ids.contains(&"movie-1"), "containerless items still listed");
3782 }
3783
3784 #[tokio::test]
3789 async fn test_get_downloaded_items_returns_leaf_and_container() {
3790 let db = create_test_db();
3791 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
3792 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
3793 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
3794 seed_completed_download(&db, "track-1", 1000).await;
3796
3797 let repo = make_repo(&db);
3798
3799 let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
3801 let ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
3802 assert_eq!(ids, vec!["track-1"], "only the downloaded track is listed");
3803 }
3804
3805 #[tokio::test]
3811 async fn test_get_downloaded_items_library_lists_albums_not_tracks() {
3812 let db = create_test_db();
3813 seed_library(&db, "music-lib", "music").await;
3814 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
3815 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
3817 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
3818 seed_completed_download(&db, "track-1", 1000).await;
3819 seed_completed_download(&db, "track-2", 1000).await;
3820
3821 let repo = make_repo(&db);
3822
3823 let at_library = repo.get_downloaded_items("music-lib", None).await.unwrap();
3825 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
3826 assert_eq!(
3827 ids,
3828 vec!["album-1"],
3829 "library browse lists the album container, not its tracks"
3830 );
3831
3832 let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
3834 let mut track_ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
3835 track_ids.sort();
3836 assert_eq!(track_ids, vec!["track-1", "track-2"]);
3837 }
3838
3839 #[tokio::test]
3851 async fn test_get_downloaded_items_library_does_not_mix_media_types() {
3852 let db = create_test_db();
3853 seed_library(&db, "music-lib", "music").await;
3854 seed_library(&db, "movie-lib", "movies").await;
3855 seed_library(&db, "tv-lib", "tvshows").await;
3856
3857 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
3858 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
3859 insert_item(&db, "movie-1", "Movie", None, None, None).await;
3860 insert_item(&db, "series-1", "Series", None, None, None).await;
3861 insert_item(&db, "episode-1", "Episode", None, Some("series-1"), None).await;
3862
3863 seed_completed_download(&db, "track-1", 1000).await;
3864 seed_completed_download(&db, "movie-1", 2000).await;
3865 seed_completed_download(&db, "episode-1", 3000).await;
3866
3867 let repo = make_repo(&db);
3868
3869 let music: Vec<String> = repo
3870 .get_downloaded_items("music-lib", None)
3871 .await
3872 .unwrap()
3873 .items
3874 .iter()
3875 .map(|i| i.id.clone())
3876 .collect();
3877 assert_eq!(
3878 music,
3879 vec!["album-1"],
3880 "the music library must not list films or series; got {:?}",
3881 music
3882 );
3883
3884 let movies: Vec<String> = repo
3885 .get_downloaded_items("movie-lib", None)
3886 .await
3887 .unwrap()
3888 .items
3889 .iter()
3890 .map(|i| i.id.clone())
3891 .collect();
3892 assert_eq!(
3893 movies,
3894 vec!["movie-1"],
3895 "the movie library must not list albums or series; got {:?}",
3896 movies
3897 );
3898
3899 let tv: Vec<String> = repo
3900 .get_downloaded_items("tv-lib", None)
3901 .await
3902 .unwrap()
3903 .items
3904 .iter()
3905 .map(|i| i.id.clone())
3906 .collect();
3907 assert_eq!(
3908 tv,
3909 vec!["series-1"],
3910 "the TV library must not list albums or films; got {:?}",
3911 tv
3912 );
3913 }
3914
3915 #[tokio::test]
3921 async fn test_get_downloaded_items_library_lists_series_not_episodes() {
3922 let db = create_test_db();
3923 seed_library(&db, "tv-lib", "tvshows").await;
3924 insert_item(&db, "series-1", "Series", None, None, None).await;
3925 insert_item(&db, "season-1", "Season", None, Some("series-1"), None).await;
3927 insert_item(
3928 &db,
3929 "ep-1",
3930 "Episode",
3931 None,
3932 Some("series-1"),
3933 Some("season-1"),
3934 )
3935 .await;
3936 seed_completed_download(&db, "ep-1", 4000).await;
3937
3938 let repo = make_repo(&db);
3939
3940 let at_library = repo.get_downloaded_items("tv-lib", None).await.unwrap();
3942 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
3943 assert_eq!(
3944 ids,
3945 vec!["series-1"],
3946 "TV library browse lists the series, not seasons/episodes"
3947 );
3948
3949 let in_series = repo.get_downloaded_items("series-1", None).await.unwrap();
3951 assert!(
3952 in_series.items.iter().any(|i| i.id == "season-1"),
3953 "series drill returns the season"
3954 );
3955 let in_season = repo.get_downloaded_items("season-1", None).await.unwrap();
3956 assert!(
3957 in_season.items.iter().any(|i| i.id == "ep-1"),
3958 "season drill returns the episode"
3959 );
3960 }
3961
3962 #[tokio::test]
3967 async fn test_get_downloaded_items_library_keeps_orphan_leaves() {
3968 let db = create_test_db();
3969 seed_library(&db, "movie-lib", "movies").await;
3970 insert_item(&db, "movie-1", "Movie", None, None, None).await;
3971 seed_completed_download(&db, "movie-1", 5000).await;
3972
3973 let repo = make_repo(&db);
3974 let at_library = repo.get_downloaded_items("movie-lib", None).await.unwrap();
3975 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
3976 assert_eq!(
3977 ids,
3978 vec!["movie-1"],
3979 "a downloaded movie with no container shows"
3980 );
3981 }
3982
3983 #[tokio::test]
3988 async fn test_get_downloaded_items_empty_is_authoritative() {
3989 let db = create_test_db();
3990 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
3991 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
3992 set_include_catalog_browse(true);
3994 let repo = make_repo(&db);
3995
3996 let result = repo.get_downloaded_items("album-1", None).await.unwrap();
3997 assert!(
3998 result.items.is_empty(),
3999 "empty downloaded browse returns no items even with catalog-browse on"
4000 );
4001 }
4002
4003 #[tokio::test]
4007 async fn test_get_downloaded_libraries_omits_empty() {
4008 let db = create_test_db();
4009 seed_library(&db, "music-lib", "music").await;
4010 seed_library(&db, "movie-lib", "movies").await;
4011 insert_item(&db, "track-1", "Audio", None, None, None).await;
4012 seed_completed_download(&db, "track-1", 500).await;
4013
4014 let repo = make_repo(&db);
4015 let libs = repo.get_downloaded_libraries().await.unwrap();
4016 let ids: Vec<&str> = libs.iter().map(|l| l.id.as_str()).collect();
4017 assert_eq!(
4018 ids,
4019 vec!["music-lib"],
4020 "movie library with no downloads omitted"
4021 );
4022 }
4023
4024 #[tokio::test]
4029 async fn test_download_disk_usage_aggregates_containers() {
4030 let db = create_test_db();
4031 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4032 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4033 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
4034 seed_completed_download(&db, "track-1", 1000).await;
4035 seed_completed_download(&db, "track-2", 2000).await;
4036
4037 let repo = make_repo(&db);
4038 let usage = repo.get_download_disk_usage().await.unwrap();
4039
4040 assert_eq!(usage.item_count, 2, "two leaf downloads");
4041 assert_eq!(
4042 usage.device_total_bytes, 3000,
4043 "device total is the leaf sum"
4044 );
4045 assert_eq!(usage.sizes.get("track-1"), Some(&1000));
4046 assert_eq!(
4047 usage.sizes.get("album-1"),
4048 Some(&3000),
4049 "container = sum of children"
4050 );
4051 assert_eq!(
4053 usage.partial_containers.get("album-1"),
4054 None,
4055 "fully downloaded album is not partial"
4056 );
4057 let leaf_sum: i64 = ["track-1", "track-2"]
4059 .iter()
4060 .map(|id| usage.sizes[*id])
4061 .sum();
4062 assert_eq!(leaf_sum, usage.device_total_bytes);
4063 }
4064
4065 #[tokio::test]
4068 async fn test_download_disk_usage_flags_partial_container() {
4069 let db = create_test_db();
4070 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4071 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4072 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
4073 seed_completed_download(&db, "track-1", 1000).await;
4075
4076 let repo = make_repo(&db);
4077 let usage = repo.get_download_disk_usage().await.unwrap();
4078 assert_eq!(
4079 usage.partial_containers.get("album-1"),
4080 Some(&true),
4081 "album with a missing child is partial"
4082 );
4083 }
4084
4085 #[tokio::test]
4086 async fn test_playlist_create_empty() {
4087 let db_service = create_test_db();
4088 let repo = OfflineRepository::new(
4089 db_service.clone(),
4090 "test-server".to_string(),
4091 "test-user".to_string(),
4092 );
4093
4094 let result = repo.create_playlist("My Playlist", &[]).await;
4095 assert!(result.is_ok());
4096 let created = result.unwrap();
4097 assert!(
4098 !created.id.is_empty(),
4099 "Should return a non-empty playlist ID"
4100 );
4101
4102 let name: String = db_service
4104 .query_one(
4105 Query::with_params(
4106 "SELECT name FROM playlists WHERE id = ?",
4107 vec![QueryParam::String(created.id.clone())],
4108 ),
4109 |row| row.get(0),
4110 )
4111 .await
4112 .unwrap();
4113 assert_eq!(name, "My Playlist");
4114 }
4115
4116 #[tokio::test]
4117 async fn test_playlist_create_with_items() {
4118 let db_service = create_test_db();
4119 let repo = OfflineRepository::new(
4120 db_service.clone(),
4121 "test-server".to_string(),
4122 "test-user".to_string(),
4123 );
4124 seed_items(&repo, &["t1", "t2", "t3"]).await;
4125
4126 let created = repo
4127 .create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()])
4128 .await
4129 .unwrap();
4130
4131 let items = repo.get_playlist_items(&created.id).await.unwrap();
4132 assert_eq!(items.len(), 3);
4133 assert_eq!(items[0].item.id, "t1");
4134 assert_eq!(items[1].item.id, "t2");
4135 assert_eq!(items[2].item.id, "t3");
4136 }
4137
4138 #[tokio::test]
4139 async fn test_playlist_delete() {
4140 let db_service = create_test_db();
4141 let repo = OfflineRepository::new(
4142 db_service.clone(),
4143 "test-server".to_string(),
4144 "test-user".to_string(),
4145 );
4146 seed_items(&repo, &["t1"]).await;
4147
4148 let created = repo
4149 .create_playlist("To Delete", &["t1".into()])
4150 .await
4151 .unwrap();
4152
4153 repo.delete_playlist(&created.id).await.unwrap();
4155
4156 let count: i32 = db_service
4158 .query_one(
4159 Query::with_params(
4160 "SELECT COUNT(*) FROM playlists WHERE id = ?",
4161 vec![QueryParam::String(created.id.clone())],
4162 ),
4163 |row| row.get(0),
4164 )
4165 .await
4166 .unwrap();
4167 assert_eq!(count, 0);
4168
4169 let item_count: i32 = db_service
4171 .query_one(
4172 Query::with_params(
4173 "SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?",
4174 vec![QueryParam::String(created.id)],
4175 ),
4176 |row| row.get(0),
4177 )
4178 .await
4179 .unwrap();
4180 assert_eq!(item_count, 0);
4181 }
4182
4183 #[tokio::test]
4184 async fn test_playlist_rename() {
4185 let db_service = create_test_db();
4186 let repo = OfflineRepository::new(
4187 db_service.clone(),
4188 "test-server".to_string(),
4189 "test-user".to_string(),
4190 );
4191
4192 let created = repo.create_playlist("Original Name", &[]).await.unwrap();
4193 repo.rename_playlist(&created.id, "New Name").await.unwrap();
4194
4195 let name: String = db_service
4196 .query_one(
4197 Query::with_params(
4198 "SELECT name FROM playlists WHERE id = ?",
4199 vec![QueryParam::String(created.id)],
4200 ),
4201 |row| row.get(0),
4202 )
4203 .await
4204 .unwrap();
4205 assert_eq!(name, "New Name");
4206 }
4207
4208 #[tokio::test]
4209 async fn test_playlist_get_items_preserves_order() {
4210 let db_service = create_test_db();
4211 let repo = OfflineRepository::new(
4212 db_service.clone(),
4213 "test-server".to_string(),
4214 "test-user".to_string(),
4215 );
4216 seed_items(&repo, &["a", "b", "c"]).await;
4217
4218 let created = repo
4219 .create_playlist("Ordered", &["c".into(), "a".into(), "b".into()])
4220 .await
4221 .unwrap();
4222 let items = repo.get_playlist_items(&created.id).await.unwrap();
4223
4224 assert_eq!(items.len(), 3);
4225 assert_eq!(items[0].item.id, "c");
4227 assert_eq!(items[1].item.id, "a");
4228 assert_eq!(items[2].item.id, "b");
4229 assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id);
4231 assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id);
4232 }
4233
4234 #[tokio::test]
4235 async fn test_playlist_get_items_empty_playlist() {
4236 let db_service = create_test_db();
4237 let repo = OfflineRepository::new(
4238 db_service.clone(),
4239 "test-server".to_string(),
4240 "test-user".to_string(),
4241 );
4242
4243 let created = repo.create_playlist("Empty", &[]).await.unwrap();
4244 let items = repo.get_playlist_items(&created.id).await.unwrap();
4245 assert!(items.is_empty());
4246 }
4247
4248 #[tokio::test]
4249 async fn test_playlist_add_items() {
4250 let db_service = create_test_db();
4251 let repo = OfflineRepository::new(
4252 db_service.clone(),
4253 "test-server".to_string(),
4254 "test-user".to_string(),
4255 );
4256 seed_items(&repo, &["t1", "t2", "t3"]).await;
4257
4258 let created = repo
4259 .create_playlist("Addable", &["t1".into()])
4260 .await
4261 .unwrap();
4262
4263 repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()])
4265 .await
4266 .unwrap();
4267
4268 let items = repo.get_playlist_items(&created.id).await.unwrap();
4269 assert_eq!(items.len(), 3);
4270 assert_eq!(items[0].item.id, "t1");
4271 assert_eq!(items[1].item.id, "t2");
4272 assert_eq!(items[2].item.id, "t3");
4273 }
4274
4275 #[tokio::test]
4276 async fn test_playlist_add_duplicate_items_ignored() {
4277 let db_service = create_test_db();
4278 let repo = OfflineRepository::new(
4279 db_service.clone(),
4280 "test-server".to_string(),
4281 "test-user".to_string(),
4282 );
4283 seed_items(&repo, &["t1"]).await;
4284
4285 let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap();
4286
4287 repo.add_to_playlist(&created.id, &["t1".into()])
4289 .await
4290 .unwrap();
4291
4292 let items = repo.get_playlist_items(&created.id).await.unwrap();
4293 assert_eq!(
4294 items.len(),
4295 1,
4296 "Duplicate should be ignored (UNIQUE constraint)"
4297 );
4298 }
4299
4300 #[tokio::test]
4301 async fn test_playlist_remove_items() {
4302 let db_service = create_test_db();
4303 let repo = OfflineRepository::new(
4304 db_service.clone(),
4305 "test-server".to_string(),
4306 "test-user".to_string(),
4307 );
4308 seed_items(&repo, &["t1", "t2", "t3"]).await;
4309
4310 let created = repo
4311 .create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()])
4312 .await
4313 .unwrap();
4314 let items = repo.get_playlist_items(&created.id).await.unwrap();
4315 assert_eq!(items.len(), 3);
4316
4317 let entry_id_to_remove = items[1].playlist_item_id.clone();
4319 repo.remove_from_playlist(&created.id, &[entry_id_to_remove])
4320 .await
4321 .unwrap();
4322
4323 let items_after = repo.get_playlist_items(&created.id).await.unwrap();
4324 assert_eq!(items_after.len(), 2);
4325 assert_eq!(items_after[0].item.id, "t1");
4326 assert_eq!(items_after[1].item.id, "t3");
4327 }
4328
4329 #[tokio::test]
4330 async fn test_playlist_move_item_forward() {
4331 let db_service = create_test_db();
4332 let repo = OfflineRepository::new(
4333 db_service.clone(),
4334 "test-server".to_string(),
4335 "test-user".to_string(),
4336 );
4337 seed_items(&repo, &["a", "b", "c", "d"]).await;
4338
4339 let created = repo
4340 .create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()])
4341 .await
4342 .unwrap();
4343
4344 repo.move_playlist_item(&created.id, "a", 2).await.unwrap();
4346
4347 let items = repo.get_playlist_items(&created.id).await.unwrap();
4348 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4349 assert_eq!(ids, vec!["b", "c", "a", "d"]);
4350 }
4351
4352 #[tokio::test]
4353 async fn test_playlist_move_item_backward() {
4354 let db_service = create_test_db();
4355 let repo = OfflineRepository::new(
4356 db_service.clone(),
4357 "test-server".to_string(),
4358 "test-user".to_string(),
4359 );
4360 seed_items(&repo, &["a", "b", "c", "d"]).await;
4361
4362 let created = repo
4363 .create_playlist(
4364 "Reorder2",
4365 &["a".into(), "b".into(), "c".into(), "d".into()],
4366 )
4367 .await
4368 .unwrap();
4369
4370 repo.move_playlist_item(&created.id, "d", 0).await.unwrap();
4372
4373 let items = repo.get_playlist_items(&created.id).await.unwrap();
4374 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4375 assert_eq!(ids, vec!["d", "a", "b", "c"]);
4376 }
4377
4378 #[tokio::test]
4379 async fn test_playlist_move_item_to_end() {
4380 let db_service = create_test_db();
4381 let repo = OfflineRepository::new(
4382 db_service.clone(),
4383 "test-server".to_string(),
4384 "test-user".to_string(),
4385 );
4386 seed_items(&repo, &["a", "b", "c"]).await;
4387
4388 let created = repo
4389 .create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()])
4390 .await
4391 .unwrap();
4392
4393 repo.move_playlist_item(&created.id, "a", 99).await.unwrap();
4395
4396 let items = repo.get_playlist_items(&created.id).await.unwrap();
4397 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4398 assert_eq!(ids, vec!["b", "c", "a"]);
4399 }
4400
4401 #[tokio::test]
4402 async fn test_playlist_move_nonexistent_item_is_noop() {
4403 let db_service = create_test_db();
4404 let repo = OfflineRepository::new(
4405 db_service.clone(),
4406 "test-server".to_string(),
4407 "test-user".to_string(),
4408 );
4409 seed_items(&repo, &["a", "b"]).await;
4410
4411 let created = repo
4412 .create_playlist("NoOp", &["a".into(), "b".into()])
4413 .await
4414 .unwrap();
4415
4416 repo.move_playlist_item(&created.id, "nonexistent", 0)
4418 .await
4419 .unwrap();
4420
4421 let items = repo.get_playlist_items(&created.id).await.unwrap();
4422 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4423 assert_eq!(ids, vec!["a", "b"]);
4424 }
4425
4426 async fn seed_favorites(db_service: &Arc<RusqliteService>) {
4429 use crate::storage::db_service::DatabaseService;
4430 for sql in [
4431 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4432 VALUES ('movie-fav', 'test-server', 'Favourite Movie', 'Movie', 'lib-1', '2026-01-01', 'Favourite Movie')",
4433 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4434 VALUES ('movie-plain', 'test-server', 'Ordinary Movie', 'Movie', 'lib-1', '2026-01-01', 'Ordinary Movie')",
4435 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4436 VALUES ('album-fav', 'test-server', 'Favourite Album', 'MusicAlbum', 'lib-2', '2026-01-01', 'Favourite Album')",
4437 "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
4438 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-fav', 1)",
4439 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-fav', 1)",
4440 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-plain', 0)",
4442 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('other-user', 'movie-plain', 1)",
4444 ] {
4445 db_service.execute(Query::new(sql)).await.unwrap();
4446 }
4447 }
4448
4449 #[tokio::test]
4454 async fn test_get_favorites_returns_only_scoped_favorites() {
4455 let _guard = lock_catalog_browse();
4456 set_include_catalog_browse(true);
4457
4458 let db_service = create_test_db();
4459 seed_favorites(&db_service).await;
4460 let repo = OfflineRepository::new(
4461 db_service,
4462 "test-server".to_string(),
4463 "test-user".to_string(),
4464 );
4465
4466 let all = repo.get_favorites(SearchScope::All, None).await.unwrap();
4467 let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
4468 ids.sort();
4469 assert_eq!(
4470 ids,
4471 vec!["album-fav", "movie-fav"],
4472 "All scope should return every favourite and nothing else"
4473 );
4474
4475 let movies = repo.get_favorites(SearchScope::Movies, None).await.unwrap();
4476 let ids: Vec<&str> = movies.items.iter().map(|i| i.id.as_str()).collect();
4477 assert_eq!(ids, vec!["movie-fav"]);
4478
4479 let music = repo.get_favorites(SearchScope::Music, None).await.unwrap();
4480 let ids: Vec<&str> = music.items.iter().map(|i| i.id.as_str()).collect();
4481 assert_eq!(ids, vec!["album-fav"]);
4482 }
4483
4484 #[tokio::test]
4489 async fn test_get_favorites_respects_catalog_browse_gate() {
4490 use crate::storage::db_service::DatabaseService;
4491 let _guard = lock_catalog_browse();
4492
4493 let db_service = create_test_db();
4494 seed_favorites(&db_service).await;
4495 db_service
4497 .execute(Query::new(
4498 "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
4499 VALUES ('track-1', 'test-server', 'Track', 'Audio', 'album-fav', '2026-01-01')",
4500 ))
4501 .await
4502 .unwrap();
4503 db_service
4504 .execute(Query::new(
4505 "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
4506 ))
4507 .await
4508 .unwrap();
4509
4510 let repo = OfflineRepository::new(
4511 db_service,
4512 "test-server".to_string(),
4513 "test-user".to_string(),
4514 );
4515
4516 set_include_catalog_browse(false);
4517 let offline_only = repo.get_favorites(SearchScope::All, None).await.unwrap();
4518 let ids: Vec<&str> = offline_only.items.iter().map(|i| i.id.as_str()).collect();
4519 assert_eq!(
4520 ids,
4521 vec!["album-fav"],
4522 "with the gate off, only favourites on the device are listed"
4523 );
4524
4525 set_include_catalog_browse(true);
4526 let with_catalog = repo.get_favorites(SearchScope::All, None).await.unwrap();
4527 assert_eq!(with_catalog.items.len(), 2);
4528 }
4529
4530 #[tokio::test]
4534 async fn test_get_items_favorites_only_filters_listing() {
4535 let _guard = lock_catalog_browse();
4536 set_include_catalog_browse(true);
4537
4538 let db_service = create_test_db();
4539 seed_favorites(&db_service).await;
4540 let repo = OfflineRepository::new(
4541 db_service,
4542 "test-server".to_string(),
4543 "test-user".to_string(),
4544 );
4545
4546 let unfiltered = repo
4547 .get_items(
4548 "lib-1",
4549 Some(GetItemsOptions {
4550 include_item_types: Some(vec!["Movie".to_string()]),
4551 ..Default::default()
4552 }),
4553 )
4554 .await
4555 .unwrap();
4556 assert_eq!(unfiltered.items.len(), 2, "both movies without the filter");
4557
4558 let favourites = repo
4559 .get_items(
4560 "lib-1",
4561 Some(GetItemsOptions {
4562 include_item_types: Some(vec!["Movie".to_string()]),
4563 favorites_only: Some(true),
4564 ..Default::default()
4565 }),
4566 )
4567 .await
4568 .unwrap();
4569 let ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
4570 assert_eq!(ids, vec!["movie-fav"]);
4571 }
4572
4573 #[tokio::test]
4584 async fn test_get_items_type_filter_is_bound_not_interpolated() {
4585 let _guard = lock_catalog_browse();
4586 set_include_catalog_browse(true);
4587
4588 let db_service = create_test_db();
4589 seed_favorites(&db_service).await;
4590 let repo = OfflineRepository::new(
4591 db_service,
4592 "test-server".to_string(),
4593 "test-user".to_string(),
4594 );
4595
4596 let injected = repo
4597 .get_items(
4598 "lib-1",
4599 Some(GetItemsOptions {
4600 include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
4601 ..Default::default()
4602 }),
4603 )
4604 .await
4605 .expect("a hostile type name must be data, not a broken query");
4606 assert!(
4607 injected.items.is_empty(),
4608 "no cached item has that type, so nothing may come back; got {:?}",
4609 injected
4610 .items
4611 .iter()
4612 .map(|i| i.id.as_str())
4613 .collect::<Vec<_>>()
4614 );
4615
4616 let quoted = repo
4618 .get_items(
4619 "lib-1",
4620 Some(GetItemsOptions {
4621 include_item_types: Some(vec!["Mo'vie".to_string()]),
4622 ..Default::default()
4623 }),
4624 )
4625 .await
4626 .expect("an embedded quote must not break the query");
4627 assert!(quoted.items.is_empty());
4628 }
4629
4630 #[tokio::test]
4637 async fn test_get_items_binds_multiple_types_in_parameter_order() {
4638 let _guard = lock_catalog_browse();
4639 set_include_catalog_browse(true);
4640
4641 let db_service = create_test_db();
4642 seed_favorites(&db_service).await;
4643
4644 db_service
4651 .execute(Query::new(
4652 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4653 VALUES ('album-lib1', 'test-server', 'Album In Lib One', 'MusicAlbum', 'lib-1', '2026-01-01', 'Album In Lib One')",
4654 ))
4655 .await
4656 .unwrap();
4657 db_service
4658 .execute(Query::new(
4659 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-lib1', 1)",
4660 ))
4661 .await
4662 .unwrap();
4663
4664 let repo = OfflineRepository::new(
4665 db_service,
4666 "test-server".to_string(),
4667 "test-user".to_string(),
4668 );
4669
4670 let both = repo
4671 .get_items(
4672 "lib-1",
4673 Some(GetItemsOptions {
4674 include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
4675 ..Default::default()
4676 }),
4677 )
4678 .await
4679 .unwrap();
4680 let mut ids: Vec<&str> = both.items.iter().map(|i| i.id.as_str()).collect();
4681 ids.sort();
4682 assert_eq!(ids, vec!["album-lib1", "movie-fav", "movie-plain"]);
4683 assert!(
4684 !ids.contains(&"album-fav"),
4685 "album-fav belongs to lib-2 and must not appear in a lib-1 listing"
4686 );
4687
4688 let favourites = repo
4690 .get_items(
4691 "lib-1",
4692 Some(GetItemsOptions {
4693 include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
4694 favorites_only: Some(true),
4695 ..Default::default()
4696 }),
4697 )
4698 .await
4699 .unwrap();
4700 let mut ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
4701 ids.sort();
4702 assert_eq!(ids, vec!["album-lib1", "movie-fav"]);
4703 }
4704
4705 #[tokio::test]
4714 async fn test_save_to_cache_mirrors_favorites_without_clobbering_pending() {
4715 use crate::storage::db_service::DatabaseService;
4716 let db_service = create_test_db();
4717 let repo = OfflineRepository::new(
4718 db_service.clone(),
4719 "test-server".to_string(),
4720 "test-user".to_string(),
4721 );
4722
4723 let favourite_flag = |id: &'static str| {
4724 let db = db_service.clone();
4725 async move {
4726 db.query_optional(
4727 Query::with_params(
4728 "SELECT is_favorite, pending_sync FROM user_data \
4729 WHERE user_id = ? AND item_id = ?",
4730 vec![
4731 QueryParam::String("test-user".to_string()),
4732 QueryParam::String(id.to_string()),
4733 ],
4734 ),
4735 |row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
4736 )
4737 .await
4738 .unwrap()
4739 }
4740 };
4741
4742 let mut favourited = create_test_item("fav-1", "Favourited Elsewhere", None);
4744 favourited.user_data = Some(UserData {
4745 is_favorite: Some(true),
4746 ..Default::default()
4747 });
4748 let untouched = create_test_item("plain-1", "No User Data", None);
4750
4751 repo.save_to_cache("parent-1", &[favourited.clone(), untouched])
4752 .await
4753 .unwrap();
4754
4755 assert_eq!(
4756 favourite_flag("fav-1").await,
4757 Some((Some(1), Some(0))),
4758 "server favourite should be mirrored as synced"
4759 );
4760 assert_eq!(
4761 favourite_flag("plain-1").await,
4762 None,
4763 "an item without UserData should not get an invented user_data row"
4764 );
4765
4766 db_service
4768 .execute(Query::with_params(
4769 "UPDATE user_data SET is_favorite = 0, pending_sync = 1 \
4770 WHERE user_id = ? AND item_id = ?",
4771 vec![
4772 QueryParam::String("test-user".to_string()),
4773 QueryParam::String("fav-1".to_string()),
4774 ],
4775 ))
4776 .await
4777 .unwrap();
4778
4779 repo.save_to_cache("parent-1", &[favourited]).await.unwrap();
4781
4782 assert_eq!(
4783 favourite_flag("fav-1").await,
4784 Some((Some(0), Some(1))),
4785 "an unsynced local toggle must survive a cache write"
4786 );
4787 }
4788
4789 #[tokio::test]
4801 async fn test_save_to_cache_mirrors_playback_position_without_clobbering_pending() {
4802 use crate::storage::db_service::DatabaseService;
4803 let db_service = create_test_db();
4804 let repo = OfflineRepository::new(
4805 db_service.clone(),
4806 "test-server".to_string(),
4807 "test-user".to_string(),
4808 );
4809
4810 let position = |id: &'static str| {
4811 let db = db_service.clone();
4812 async move {
4813 db.query_optional(
4814 Query::with_params(
4815 "SELECT playback_position_ticks, pending_sync FROM user_data \
4816 WHERE user_id = ? AND item_id = ?",
4817 vec![
4818 QueryParam::String("test-user".to_string()),
4819 QueryParam::String(id.to_string()),
4820 ],
4821 ),
4822 |row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i32>>(1)?)),
4823 )
4824 .await
4825 .unwrap()
4826 }
4827 };
4828
4829 let mut watched = create_test_item("ep-1", "Watched Elsewhere", None);
4831 watched.user_data = Some(UserData {
4832 playback_position_ticks: Some(12_000_000_000),
4833 ..Default::default()
4834 });
4835 let untouched = create_test_item("ep-2", "No User Data", None);
4837
4838 repo.save_to_cache("parent-1", &[watched.clone(), untouched])
4839 .await
4840 .unwrap();
4841
4842 assert_eq!(
4843 position("ep-1").await,
4844 Some((Some(12_000_000_000), Some(0))),
4845 "the server's position should be mirrored as synced"
4846 );
4847 assert_eq!(
4848 position("ep-2").await,
4849 None,
4850 "an item without UserData should not get an invented position"
4851 );
4852
4853 db_service
4855 .execute(Query::with_params(
4856 "UPDATE user_data SET playback_position_ticks = ?, pending_sync = 1 \
4857 WHERE user_id = ? AND item_id = ?",
4858 vec![
4859 QueryParam::Int64(30_000_000_000),
4860 QueryParam::String("test-user".to_string()),
4861 QueryParam::String("ep-1".to_string()),
4862 ],
4863 ))
4864 .await
4865 .unwrap();
4866
4867 repo.save_to_cache("parent-1", &[watched]).await.unwrap();
4869
4870 assert_eq!(
4871 position("ep-1").await,
4872 Some((Some(30_000_000_000), Some(1))),
4873 "an unsynced local position must not be pulled backwards"
4874 );
4875 }
4876
4877 #[tokio::test]
4888 async fn test_save_to_cache_mirrors_played_flag_without_clobbering_pending() {
4889 use crate::storage::db_service::DatabaseService;
4890 let db_service = create_test_db();
4891 let repo = OfflineRepository::new(
4892 db_service.clone(),
4893 "test-server".to_string(),
4894 "test-user".to_string(),
4895 );
4896
4897 let played_flag = |id: &'static str| {
4898 let db = db_service.clone();
4899 async move {
4900 db.query_optional(
4901 Query::with_params(
4902 "SELECT is_played, pending_sync FROM user_data \
4903 WHERE user_id = ? AND item_id = ?",
4904 vec![
4905 QueryParam::String("test-user".to_string()),
4906 QueryParam::String(id.to_string()),
4907 ],
4908 ),
4909 |row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
4910 )
4911 .await
4912 .unwrap()
4913 }
4914 };
4915
4916 let mut watched = create_test_item("ep-4", "Watched Elsewhere", None);
4918 watched.user_data = Some(UserData {
4919 is_played: Some(true),
4920 ..Default::default()
4921 });
4922 let untouched = create_test_item("ep-5", "No User Data", None);
4924
4925 repo.save_to_cache("parent-1", &[watched, untouched])
4926 .await
4927 .unwrap();
4928
4929 assert_eq!(
4930 played_flag("ep-4").await,
4931 Some((Some(1), Some(0))),
4932 "the server's played flag should be mirrored as synced"
4933 );
4934 assert_eq!(
4935 played_flag("ep-5").await,
4936 None,
4937 "an item without UserData should not get an invented played flag"
4938 );
4939
4940 db_service
4942 .execute(Query::with_params(
4943 "UPDATE user_data SET is_played = 0, pending_sync = 1 \
4944 WHERE user_id = ? AND item_id = ?",
4945 vec![
4946 QueryParam::String("test-user".to_string()),
4947 QueryParam::String("ep-4".to_string()),
4948 ],
4949 ))
4950 .await
4951 .unwrap();
4952
4953 let mut still_played = create_test_item("ep-4", "Watched Elsewhere", None);
4954 still_played.user_data = Some(UserData {
4955 is_played: Some(true),
4956 ..Default::default()
4957 });
4958 repo.save_to_cache("parent-1", &[still_played])
4959 .await
4960 .unwrap();
4961
4962 assert_eq!(
4963 played_flag("ep-4").await,
4964 Some((Some(0), Some(1))),
4965 "an unsynced local toggle must survive a cache write"
4966 );
4967 }
4968
4969 #[tokio::test]
4979 async fn test_position_is_mirrored_even_when_no_favourite_flag_is_present() {
4980 use crate::storage::db_service::DatabaseService;
4981 let db_service = create_test_db();
4982 let repo = OfflineRepository::new(
4983 db_service.clone(),
4984 "test-server".to_string(),
4985 "test-user".to_string(),
4986 );
4987
4988 let mut watched = create_test_item("ep-3", "Position Only", None);
4989 watched.user_data = Some(UserData {
4990 is_favorite: None,
4991 playback_position_ticks: Some(9_000_000_000),
4992 ..Default::default()
4993 });
4994
4995 repo.save_to_cache("parent-1", &[watched]).await.unwrap();
4996
4997 let stored = db_service
4998 .query_optional(
4999 Query::with_params(
5000 "SELECT playback_position_ticks FROM user_data \
5001 WHERE user_id = ? AND item_id = ?",
5002 vec![
5003 QueryParam::String("test-user".to_string()),
5004 QueryParam::String("ep-3".to_string()),
5005 ],
5006 ),
5007 |row| row.get::<_, Option<i64>>(0),
5008 )
5009 .await
5010 .unwrap();
5011
5012 assert_eq!(
5013 stored,
5014 Some(Some(9_000_000_000)),
5015 "a position with no favourite flag must still be mirrored"
5016 );
5017 }
5018
5019 #[tokio::test]
5038 async fn test_get_items_unknown_library_type_does_not_return_whole_server() {
5039 let db = create_test_db();
5040
5041 insert_item(&db, "movie-1", "Movie", None, None, None).await;
5042 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
5043 insert_item(&db, "series-1", "Series", None, None, None).await;
5044
5045 for collection_type in ["books", "boxsets", "photos", "homevideos", ""] {
5048 let lib = format!("lib-{collection_type}");
5049 seed_library(&db, &lib, collection_type).await;
5050
5051 let repo = make_repo(&db);
5052 let ids: Vec<String> = repo
5053 .get_items(&lib, None)
5054 .await
5055 .unwrap()
5056 .items
5057 .iter()
5058 .map(|i| i.id.clone())
5059 .collect();
5060
5061 assert!(
5062 ids.is_empty(),
5063 "a '{collection_type}' library must not serve the server's films, \
5064 albums and shows; got {:?}",
5065 ids
5066 );
5067 }
5068 }
5069
5070 #[tokio::test]
5081 async fn test_get_items_collection_lists_its_own_children() {
5082 let db = create_test_db();
5083 seed_library(&db, "boxset-lib", "boxsets").await;
5084
5085 insert_item(&db, "boxset-1", "BoxSet", None, None, None).await;
5086 insert_item(&db, "outsider", "Movie", None, None, None).await;
5087
5088 db.execute(Query::with_params(
5091 "INSERT INTO items (id, server_id, name, item_type, parent_id, synced_at) \
5092 VALUES ('in-set', 'test-server', 'In The Set', 'Movie', ?1, '2024-01-01')",
5093 vec![QueryParam::String("boxset-1".to_string())],
5094 ))
5095 .await
5096 .unwrap();
5097
5098 let repo = make_repo(&db);
5099 let ids: Vec<String> = repo
5100 .get_items("boxset-1", None)
5101 .await
5102 .unwrap()
5103 .items
5104 .iter()
5105 .map(|i| i.id.clone())
5106 .collect();
5107
5108 assert_eq!(
5109 ids,
5110 vec!["in-set".to_string()],
5111 "a collection lists its own children and nothing else; got {:?}",
5112 ids
5113 );
5114 }
5115
5116 #[tokio::test]
5121 async fn test_get_items_typed_libraries_still_return_their_own_media() {
5122 let db = create_test_db();
5123 seed_library(&db, "music-lib", "music").await;
5124 seed_library(&db, "movie-lib", "movies").await;
5125 seed_library(&db, "tv-lib", "tvshows").await;
5126
5127 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
5128 insert_item(&db, "movie-1", "Movie", None, None, None).await;
5129 insert_item(&db, "series-1", "Series", None, None, None).await;
5130
5131 let repo = make_repo(&db);
5132
5133 for (lib, expected, forbidden) in [
5134 ("music-lib", "album-1", "movie-1"),
5135 ("movie-lib", "movie-1", "album-1"),
5136 ("tv-lib", "series-1", "album-1"),
5137 ] {
5138 let ids: Vec<String> = repo
5139 .get_items(lib, None)
5140 .await
5141 .unwrap()
5142 .items
5143 .iter()
5144 .map(|i| i.id.clone())
5145 .collect();
5146 assert!(
5147 ids.contains(&expected.to_string()),
5148 "{lib} should list {expected}; got {:?}",
5149 ids
5150 );
5151 assert!(
5152 !ids.contains(&forbidden.to_string()),
5153 "{lib} must not list {forbidden}; got {:?}",
5154 ids
5155 );
5156 }
5157 }
5158}