1use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use log::debug;
9
10use super::{types::*, MediaRepository};
11use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
12
13static INCLUDE_CATALOG_BROWSE: AtomicBool = AtomicBool::new(true);
26
27pub fn set_include_catalog_browse(include: bool) {
32 INCLUDE_CATALOG_BROWSE.store(include, Ordering::Relaxed);
33}
34
35pub fn include_catalog_browse() -> bool {
45 INCLUDE_CATALOG_BROWSE.load(Ordering::Relaxed)
46}
47
48fn build_fts_prefix_query(query: &str) -> Option<String> {
64 let tokens: Vec<String> = query
65 .split_whitespace()
66 .filter(|token| token.chars().any(char::is_alphanumeric))
67 .map(|token| format!("\"{}\"", token.replace('"', "\"\"")))
68 .collect();
69
70 let last = tokens.len().checked_sub(1)?;
71 Some(
72 tokens
73 .iter()
74 .enumerate()
75 .map(|(i, token)| {
76 if i == last {
77 format!("{}*", token)
78 } else {
79 token.clone()
80 }
81 })
82 .collect::<Vec<_>>()
83 .join(" "),
84 )
85}
86
87macro_rules! library_type_matches_item {
102 () => {
103 "(
104 (l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
105 OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
106 OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
107 )"
108 };
109}
110
111pub struct OfflineRepository {
112 db_service: Arc<RusqliteService>,
113 server_id: String,
114 user_id: String,
115}
116
117impl OfflineRepository {
118 pub async fn local_playback_info(
134 &self,
135 item_id: &str,
136 ) -> Result<Option<PlaybackInfo>, RepoError> {
137 let rows = self
138 .db_service
139 .query_many(
140 Query::with_params(
141 "SELECT file_path FROM downloads \
142 WHERE item_id = ? AND user_id = ? AND status = 'completed' \
143 AND file_path IS NOT NULL \
144 LIMIT 1",
145 vec![
146 QueryParam::String(item_id.to_string()),
147 QueryParam::String(self.user_id.clone()),
148 ],
149 ),
150 |row| row.get::<_, String>(0),
151 )
152 .await
153 .map_err(|e| RepoError::Database { message: e })?;
154
155 Ok(rows.into_iter().next().map(|file_path| PlaybackInfo {
156 media_source_id: item_id.to_string(),
157 play_session_id: String::new(),
160 stream_url: file_path,
161 direct_play: true,
162 needs_transcoding: false,
163 }))
164 }
165
166 pub fn new(db_service: Arc<RusqliteService>, server_id: String, user_id: String) -> Self {
167 Self {
168 db_service,
169 server_id,
170 user_id,
171 }
172 }
173
174 fn cached_item_to_media_item(item: CachedItem, user_data: Option<UserData>) -> MediaItem {
176 let artists_vec = item
177 .artists
178 .as_ref()
179 .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
180 .unwrap_or_default();
181
182 let kind = crate::domain::kind_from_jellyfin(&item.item_type, item.is_folder);
183
184 MediaItem {
185 id: item.id.clone(),
186 name: item.name,
187 item_type: item.item_type,
188 kind,
189 is_folder: item.is_folder,
190 server_id: item.server_id,
191 parent_id: item.parent_id,
192 library_id: item.library_id,
193 overview: item.overview,
194 genres: item
195 .genres
196 .as_ref()
197 .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
198 runtime_ticks: item.runtime_ticks,
199 duration_ms: item.runtime_ticks.map(crate::domain::ticks_to_ms),
200 production_year: item.production_year,
201 premiere_date: item.premiere_date,
202 community_rating: item.community_rating,
203 official_rating: item.official_rating,
204 primary_image_tag: item.primary_image_tag.clone(),
205 image_id: item.primary_image_tag,
206 backdrop_image_tags: item.backdrop_image_tags,
207 parent_backdrop_image_tags: item.parent_backdrop_image_tags,
208 album_id: item.album_id,
209 album_name: item.album_name,
210 album_artist: item.album_artist,
211 artists: Some(artists_vec),
212 artist_items: None, index_number: item.index_number,
214 series_id: item.series_id,
215 series_name: item.series_name,
216 season_id: item.season_id,
217 season_name: item.season_name,
218 parent_index_number: item.parent_index_number,
219 user_data,
220 media_streams: None, media_sources: None, people: None, }
224 }
225
226 async fn get_user_data(&self, item_id: &str) -> Option<UserData> {
228 let query = Query::with_params(
229 "SELECT playback_position_ticks, is_played, is_favorite, play_count, last_played_at, playback_context_type, playback_context_id
230 FROM user_data WHERE user_id = ? AND item_id = ?",
231 vec![
232 QueryParam::String(self.user_id.clone()),
233 QueryParam::String(item_id.to_string()),
234 ],
235 );
236
237 self.db_service
238 .query_optional(query, |row| Ok(row_to_user_data(row, 0)))
239 .await
240 .ok()
241 .flatten()
242 }
243
244 async fn with_user_data(&self, cached_items: Vec<CachedItem>) -> Vec<MediaItem> {
254 const CHUNK: usize = 500;
255 let mut by_id: std::collections::HashMap<String, UserData> =
256 std::collections::HashMap::new();
257
258 for chunk in cached_items.chunks(CHUNK) {
259 let placeholders = vec!["?"; chunk.len()].join(",");
260 let mut params = vec![QueryParam::String(self.user_id.clone())];
261 params.extend(chunk.iter().map(|c| QueryParam::String(c.id.clone())));
262 let query = Query::with_params(
263 format!(
264 "SELECT item_id, playback_position_ticks, is_played, is_favorite, play_count,
265 last_played_at, playback_context_type, playback_context_id
266 FROM user_data WHERE user_id = ? AND item_id IN ({placeholders})"
267 ),
268 params,
269 );
270 match self
271 .db_service
272 .query_many(query, |row| {
273 Ok((row.get::<_, String>(0)?, row_to_user_data(row, 1)))
274 })
275 .await
276 {
277 Ok(rows) => by_id.extend(rows),
278 Err(e) => debug!("[OfflineRepo] user_data batch lookup failed: {}", e),
280 }
281 }
282
283 cached_items
284 .into_iter()
285 .map(|cached| {
286 let user_data = by_id.remove(&cached.id);
287 Self::cached_item_to_media_item(cached, user_data)
288 })
289 .collect()
290 }
291}
292
293struct ContainerPlaceholder {
295 id: String,
296 name: String,
297 item_type: &'static str,
298 series_id: Option<String>,
299 series_name: Option<String>,
300 album_artist: Option<String>,
301}
302
303fn container_placeholders(item: &MediaItem) -> Vec<ContainerPlaceholder> {
309 let mut out = Vec::new();
310 let series = |item: &MediaItem| {
311 item.series_id.clone().map(|id| ContainerPlaceholder {
312 id,
313 name: item
314 .series_name
315 .clone()
316 .unwrap_or_else(|| "Series".to_string()),
317 item_type: "Series",
318 series_id: None,
319 series_name: None,
320 album_artist: None,
321 })
322 };
323 match item.item_type.as_str() {
324 "Episode" => {
325 if let Some(id) = item.season_id.clone() {
326 out.push(ContainerPlaceholder {
327 id,
328 name: item
329 .season_name
330 .clone()
331 .unwrap_or_else(|| "Season".to_string()),
332 item_type: "Season",
333 series_id: item.series_id.clone(),
334 series_name: item.series_name.clone(),
335 album_artist: None,
336 });
337 }
338 out.extend(series(item));
339 }
340 "Season" => out.extend(series(item)),
341 "Audio" => {
342 if let Some(id) = item.album_id.clone() {
343 out.push(ContainerPlaceholder {
344 id,
345 name: item
346 .album_name
347 .clone()
348 .unwrap_or_else(|| "Album".to_string()),
349 item_type: "MusicAlbum",
350 series_id: None,
351 series_name: None,
352 album_artist: item.album_artist.clone(),
353 });
354 }
355 }
356 _ => {}
357 }
358 out
359}
360
361fn row_to_user_data(row: &rusqlite::Row, offset: usize) -> UserData {
365 let playback_position_ticks: Option<i64> = row.get(offset).ok();
366 UserData {
367 playback_position_ticks,
368 playback_position_ms: playback_position_ticks.map(crate::domain::ticks_to_ms),
369 is_played: row
370 .get::<_, Option<i32>>(offset + 1)
371 .ok()
372 .flatten()
373 .map(|v| v != 0),
374 is_favorite: row
375 .get::<_, Option<i32>>(offset + 2)
376 .ok()
377 .flatten()
378 .map(|v| v != 0),
379 play_count: row.get(offset + 3).ok(),
380 last_played_date: row.get(offset + 4).ok(),
381 playback_context_type: row.get(offset + 5).ok(),
382 playback_context_id: row.get(offset + 6).ok(),
383 }
384}
385
386const PLAYABLE_TYPES: &str = "'Audio', 'Movie', 'Episode'";
388const CONTAINER_TYPES: &str =
390 "'MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder'";
391
392fn downloaded_sql(alias: &str, playable: &str, containers: &str) -> String {
404 format!(
405 "(({a}.item_type IN ({playable})
406 AND EXISTS (SELECT 1 FROM downloads dl
407 WHERE dl.item_id = {a}.id AND dl.status = 'completed'))
408 OR ({a}.item_type IN ({containers})
409 AND EXISTS (SELECT 1 FROM items dc
410 INNER JOIN downloads dl ON dl.item_id = dc.id AND dl.status = 'completed'
411 WHERE dc.parent_id = {a}.id OR dc.album_id = {a}.id
412 OR dc.season_id = {a}.id OR dc.series_id = {a}.id)))",
413 a = alias
414 )
415}
416
417fn available_sql(alias: &str, include_catalog: bool) -> String {
423 let downloaded = downloaded_sql(alias, PLAYABLE_TYPES, CONTAINER_TYPES);
424 if include_catalog {
425 format!("({alias}.synced_at IS NOT NULL OR {downloaded})")
426 } else {
427 downloaded
428 }
429}
430
431#[allow(clippy::too_many_arguments)]
458fn items_listing_sql(
459 parent_is_library: bool,
460 include_catalog: bool,
461 type_filter: &str,
462 favorites_filter: &str,
463 order_by: &str,
464 limit: usize,
465 start_index: usize,
466) -> String {
467 let available = available_sql("i", include_catalog);
468 let parent_match = if parent_is_library {
469 format!(
470 "i.server_id = ?
471 AND (
472 i.container_id = ?
473 -- When the requested parent is a LIBRARY, there is no per-item
474 -- link back to it (library_id/parent_id are NULL in the cache),
475 -- so match every item on the server and let the type filter
476 -- (e.g. MusicAlbum / Movie / Series) narrow it. This is what
477 -- makes library landing pages show albums/movies/shows offline.
478 --
479 -- The type correlation is NOT optional. Without it this
480 -- EXISTS never mentions the item, so it is true for every
481 -- cached row as soon as the requested parent is any library.
482 -- Music/Movies/TV got away with that because their landing
483 -- pages pass `include_item_types`, which narrowed the result;
484 -- the generic library page passes none, so a Books or Photos
485 -- library served the entire cached server (DR-277).
486 --
487 -- `library_id` wins wherever it survived the cache write:
488 -- it is the server's own answer, and it is the only thing
489 -- that can scope a library whose type has no mapping (Books,
490 -- Photos, Collections) or none at all (a mixed library, where
491 -- Jellyfin sends CollectionType null). The taxonomy is the
492 -- fallback for rows that predate it being stored.
493 --
494 -- A library with neither a stored link nor a mapped type now
495 -- matches nothing here and falls through to the server, which
496 -- does know what is in it. Showing nothing briefly beats
497 -- showing somebody else's films with confidence.
498 OR EXISTS (
499 SELECT 1 FROM libraries l
500 WHERE l.id = ? AND l.server_id = i.server_id
501 AND (
502 i.library_id = l.id
503 OR (i.library_id IS NULL AND {})
504 )
505 )
506 )",
507 library_type_matches_item!()
508 )
509 } else {
510 "+i.server_id = ? AND i.container_id = ?".to_string()
511 };
512 format!(
513 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
514 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
515 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
516 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
517 i.parent_index_number, i.is_folder, i.premiere_date
518 FROM items i
519 WHERE {parent_match}
520 AND {available}{type_filter}{favorites_filter}
521 ORDER BY {order_by}
522 LIMIT {limit} OFFSET {start_index}"
523 )
524}
525
526#[derive(Debug)]
528struct CachedItem {
529 id: String,
530 name: String,
531 item_type: String,
532 is_folder: bool,
533 server_id: String,
534 parent_id: Option<String>,
535 library_id: Option<String>,
536 overview: Option<String>,
537 genres: Option<String>,
538 runtime_ticks: Option<i64>,
539 production_year: Option<i32>,
540 premiere_date: Option<String>,
541 community_rating: Option<f64>,
542 official_rating: Option<String>,
543 primary_image_tag: Option<String>,
544 backdrop_image_tags: Option<Vec<String>>,
545 parent_backdrop_image_tags: Option<Vec<String>>,
546 album_id: Option<String>,
547 album_name: Option<String>,
548 album_artist: Option<String>,
549 artists: Option<String>,
550 index_number: Option<i32>,
551 series_id: Option<String>,
552 series_name: Option<String>,
553 season_id: Option<String>,
554 season_name: Option<String>,
555 parent_index_number: Option<i32>,
556}
557
558fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
559 Ok(CachedItem {
560 id: row.get(0)?,
561 name: row.get(1)?,
562 item_type: row.get(2)?,
563 server_id: row.get(3)?,
564 parent_id: row.get(4)?,
565 library_id: row.get(5)?,
566 overview: row.get(6)?,
567 genres: row.get(7)?,
568 runtime_ticks: row.get(8)?,
569 production_year: row.get(9)?,
570 community_rating: row.get(10)?,
571 official_rating: row.get(11)?,
572 primary_image_tag: row.get(12)?,
573 backdrop_image_tags: None, parent_backdrop_image_tags: None, album_id: row.get(13)?,
576 album_name: row.get(14)?,
577 album_artist: row.get(15)?,
578 artists: row.get(16)?,
579 index_number: row.get(17)?,
580 series_id: row.get(18)?,
581 series_name: row.get(19)?,
582 season_id: row.get(20)?,
583 season_name: row.get(21)?,
584 parent_index_number: row.get(22)?,
585 is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0,
587 premiere_date: row.get(24)?,
588 })
589}
590
591impl OfflineRepository {
592 pub async fn prune_stale_catalog(
619 &self,
620 cutoff: &str,
621 item_types: &[String],
622 ) -> Result<usize, RepoError> {
623 if item_types.is_empty() {
624 return Ok(0);
625 }
626 let placeholders = vec!["?"; item_types.len()].join(",");
627 let sql = format!(
628 "DELETE FROM items
629 WHERE server_id = ?
630 AND synced_at IS NOT NULL
631 AND synced_at < ?
632 AND item_type IN ({})
633 AND id NOT IN (
634 -- Playable items with completed downloads
635 SELECT i.id
636 FROM items i
637 INNER JOIN downloads d ON i.id = d.item_id
638 WHERE d.status = 'completed'
639
640 UNION
641
642 -- Containers with downloaded children
643 SELECT i.id
644 FROM items i
645 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)
646 INNER JOIN downloads d ON children.id = d.item_id
647 WHERE d.status = 'completed'
648 )",
649 placeholders
650 );
651
652 let mut params = vec![
653 QueryParam::String(self.server_id.clone()),
654 QueryParam::String(cutoff.to_string()),
655 ];
656 params.extend(item_types.iter().cloned().map(QueryParam::String));
657
658 let removed = self
659 .db_service
660 .execute(Query::with_params(sql, params))
661 .await
662 .map_err(|e| RepoError::Database { message: e })?;
663
664 Ok(removed)
665 }
666
667 async fn search_people(
676 &self,
677 fts_query: &str,
678 limit: usize,
679 ) -> Result<Vec<MediaItem>, RepoError> {
680 let sql = format!(
681 "SELECT p.id, p.name, p.overview, p.primary_image_tag, p.premiere_date
682 FROM people p
683 JOIN people_fts fts ON fts.rowid = p.rowid
684 WHERE p.server_id = ? AND people_fts MATCH ?
685 ORDER BY rank
686 LIMIT {}",
687 limit
688 );
689
690 let rows = self
691 .db_service
692 .query_many(
693 Query::with_params(
694 sql,
695 vec![
696 QueryParam::String(self.server_id.clone()),
697 QueryParam::String(fts_query.to_string()),
698 ],
699 ),
700 |row| {
701 Ok((
702 row.get::<_, String>(0)?,
703 row.get::<_, String>(1)?,
704 row.get::<_, Option<String>>(2)?,
705 row.get::<_, Option<String>>(3)?,
706 row.get::<_, Option<String>>(4)?,
707 ))
708 },
709 )
710 .await
711 .map_err(|e| RepoError::Database { message: e })?;
712
713 Ok(rows
714 .into_iter()
715 .map(
716 |(id, name, overview, primary_image_tag, premiere_date)| MediaItem {
717 id,
718 name,
719 item_type: "Person".to_string(),
720 kind: crate::domain::MediaKind::Person,
721 is_folder: false,
722 server_id: self.server_id.clone(),
723 overview,
724 primary_image_tag,
725 premiere_date,
726 ..Default::default()
727 },
728 )
729 .collect())
730 }
731
732 pub async fn save_to_cache(
737 &self,
738 parent_id: &str,
739 items: &[MediaItem],
740 ) -> Result<usize, RepoError> {
741 if items.is_empty() {
742 return Ok(0);
743 }
744
745 let now = chrono::Utc::now().to_rfc3339();
746
747 let owning_library = self.resolve_owning_library(parent_id).await;
772
773 let mut placeholder_ids = std::collections::HashSet::new();
783 let mut placeholders: Vec<Query> = Vec::new();
784 for item in items {
785 for p in container_placeholders(item) {
786 if !placeholder_ids.insert(p.id.clone()) {
787 continue;
788 }
789 placeholders.push(Query::with_params(
790 "INSERT OR IGNORE INTO items
791 (id, server_id, library_id, name, item_type, is_folder,
792 series_id, series_name, album_artist)
793 VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)",
794 vec![
795 QueryParam::String(p.id),
796 QueryParam::String(self.server_id.clone()),
797 owning_library
798 .clone()
799 .map(QueryParam::String)
800 .unwrap_or(QueryParam::Null),
801 QueryParam::String(p.name),
802 QueryParam::String(p.item_type.to_string()),
803 p.series_id
804 .map(QueryParam::String)
805 .unwrap_or(QueryParam::Null),
806 p.series_name
807 .map(QueryParam::String)
808 .unwrap_or(QueryParam::Null),
809 p.album_artist
810 .map(QueryParam::String)
811 .unwrap_or(QueryParam::Null),
812 ],
813 ));
814 }
815 }
816
817 let mut parent_ids = std::collections::HashSet::new();
820 parent_ids.insert(parent_id.to_string());
821 for item in items {
822 if let Some(pid) = &item.parent_id {
823 parent_ids.insert(pid.clone());
824 }
825 }
826 let stubs: Vec<Query> = parent_ids
827 .into_iter()
828 .map(|pid| {
829 Query::with_params(
830 "INSERT OR IGNORE INTO items (id, server_id, name, item_type, synced_at)
831 VALUES (?1, ?2, ?3, ?4, ?5)",
832 vec![
833 QueryParam::String(pid),
834 QueryParam::String(self.server_id.clone()),
835 QueryParam::String("Parent".to_string()),
836 QueryParam::String("Folder".to_string()),
837 QueryParam::String(now.clone()),
838 ],
839 )
840 })
841 .collect();
842
843 let rows: Vec<(String, Query, Option<Query>)> = items
844 .iter()
845 .map(|item| {
846 (
847 item.id.clone(),
848 self.item_upsert_query(item, &owning_library, &now),
849 self.user_data_mirror_query(item, &now),
850 )
851 })
852 .collect();
853
854 self.db_service
865 .transaction_without_foreign_keys(move |tx| {
866 for placeholder in placeholders {
867 tx.execute(placeholder)?;
868 }
869 for stub in stubs {
870 tx.execute(stub)?;
871 }
872 let mut count = 0;
873 for (id, item_query, user_data_query) in rows {
874 tx.execute(item_query)
875 .map_err(|e| format!("Failed to insert item {}: {}", id, e))?;
876 if let Some(query) = user_data_query {
877 if let Err(e) = tx.execute(query) {
878 debug!("[OfflineRepo] user_data mirror skipped for {}: {}", id, e);
879 }
880 }
881 count += 1;
882 }
883 Ok(count)
884 })
885 .await
886 .map_err(|e| RepoError::Database { message: e })
887 }
888
889 async fn is_library(&self, id: &str) -> Result<bool, RepoError> {
893 self.db_service
894 .query_optional(
895 Query::with_params(
896 "SELECT 1 FROM libraries WHERE id = ? AND server_id = ?",
897 vec![
898 QueryParam::String(id.to_string()),
899 QueryParam::String(self.server_id.clone()),
900 ],
901 ),
902 |row| row.get::<_, i64>(0),
903 )
904 .await
905 .map(|row| row.is_some())
906 .map_err(|e| RepoError::Database { message: e })
907 }
908
909 async fn resolve_owning_library(&self, parent_id: &str) -> Option<String> {
919 let is_library: Option<String> = self
920 .db_service
921 .query_optional(
922 Query::with_params(
923 "SELECT id FROM libraries WHERE id = ? AND server_id = ?",
924 vec![
925 QueryParam::String(parent_id.to_string()),
926 QueryParam::String(self.server_id.clone()),
927 ],
928 ),
929 |row| row.get(0),
930 )
931 .await
932 .ok()
933 .flatten();
934
935 if is_library.is_some() {
936 return is_library;
937 }
938
939 self.db_service
940 .query_optional(
941 Query::with_params(
942 "SELECT library_id FROM items WHERE id = ? AND library_id IS NOT NULL",
943 vec![QueryParam::String(parent_id.to_string())],
944 ),
945 |row| row.get(0),
946 )
947 .await
948 .ok()
949 .flatten()
950 }
951
952 fn item_upsert_query(
954 &self,
955 item: &MediaItem,
956 owning_library: &Option<String>,
957 now: &str,
958 ) -> Query {
959 let genres_json = item
961 .genres
962 .as_ref()
963 .map(|g| serde_json::to_string(g).unwrap_or_else(|_| "[]".to_string()));
964 let artists_json = item
965 .artists
966 .as_ref()
967 .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
968 let backdrop_tags_json = item
969 .backdrop_image_tags
970 .as_ref()
971 .map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".to_string()));
972
973 Query::with_params(
989 "INSERT INTO items (
990 id, server_id, library_id, parent_id,
991 name, item_type, is_folder, overview,
992 genres, series_id, series_name,
993 season_id, season_name, index_number, parent_index_number,
994 album_id, album_name, album_artist, artists,
995 production_year, premiere_date, runtime_ticks,
996 primary_image_tag, backdrop_image_tags,
997 community_rating, official_rating,
998 synced_at
999 ) VALUES (
1000 ?1, ?2, ?3, ?4,
1001 ?5, ?6, ?7, ?8,
1002 ?9, ?10, ?11,
1003 ?12, ?13, ?14, ?15,
1004 ?16, ?17, ?18, ?19,
1005 ?20, ?21, ?22,
1006 ?23, ?24,
1007 ?25, ?26,
1008 ?27
1009 )
1010 ON CONFLICT(id) DO UPDATE SET
1011 server_id = excluded.server_id,
1012 -- This call site never supplies library_id (it is always
1013 -- bound NULL), so keep whatever another path recorded rather
1014 -- than clearing it the way REPLACE did.
1015 library_id = COALESCE(excluded.library_id, items.library_id),
1016 parent_id = excluded.parent_id,
1017 name = excluded.name,
1018 item_type = excluded.item_type,
1019 is_folder = excluded.is_folder,
1020 overview = excluded.overview,
1021 genres = excluded.genres,
1022 series_id = excluded.series_id,
1023 series_name = excluded.series_name,
1024 season_id = excluded.season_id,
1025 season_name = excluded.season_name,
1026 index_number = excluded.index_number,
1027 parent_index_number = excluded.parent_index_number,
1028 album_id = excluded.album_id,
1029 album_name = excluded.album_name,
1030 album_artist = excluded.album_artist,
1031 artists = excluded.artists,
1032 production_year = excluded.production_year,
1033 premiere_date = excluded.premiere_date,
1034 runtime_ticks = excluded.runtime_ticks,
1035 primary_image_tag = excluded.primary_image_tag,
1036 backdrop_image_tags = excluded.backdrop_image_tags,
1037 community_rating = excluded.community_rating,
1038 official_rating = excluded.official_rating,
1039 synced_at = excluded.synced_at",
1040 vec![
1041 QueryParam::String(item.id.clone()),
1042 QueryParam::String(self.server_id.clone()),
1043 match &owning_library {
1046 Some(lib) => QueryParam::String(lib.clone()),
1047 None => QueryParam::Null,
1048 }, match &item.parent_id {
1051 Some(pid) => QueryParam::String(pid.clone()),
1052 None => QueryParam::Null,
1053 },
1054 QueryParam::String(item.name.clone()),
1055 QueryParam::String(item.item_type.clone()),
1056 QueryParam::Int(if item.is_folder { 1 } else { 0 }),
1057 match &item.overview {
1058 Some(o) => QueryParam::String(o.clone()),
1059 None => QueryParam::Null,
1060 },
1061 match genres_json {
1062 Some(g) => QueryParam::String(g),
1063 None => QueryParam::Null,
1064 },
1065 match &item.series_id {
1066 Some(s) => QueryParam::String(s.clone()),
1067 None => QueryParam::Null,
1068 },
1069 match &item.series_name {
1070 Some(s) => QueryParam::String(s.clone()),
1071 None => QueryParam::Null,
1072 },
1073 match &item.season_id {
1074 Some(s) => QueryParam::String(s.clone()),
1075 None => QueryParam::Null,
1076 },
1077 match &item.season_name {
1078 Some(s) => QueryParam::String(s.clone()),
1079 None => QueryParam::Null,
1080 },
1081 match item.index_number {
1082 Some(i) => QueryParam::Int(i),
1083 None => QueryParam::Null,
1084 },
1085 match item.parent_index_number {
1086 Some(i) => QueryParam::Int(i),
1087 None => QueryParam::Null,
1088 },
1089 match &item.album_id {
1090 Some(a) => QueryParam::String(a.clone()),
1091 None => QueryParam::Null,
1092 },
1093 match &item.album_name {
1094 Some(a) => QueryParam::String(a.clone()),
1095 None => QueryParam::Null,
1096 },
1097 match &item.album_artist {
1098 Some(a) => QueryParam::String(a.clone()),
1099 None => QueryParam::Null,
1100 },
1101 match artists_json {
1102 Some(a) => QueryParam::String(a),
1103 None => QueryParam::Null,
1104 },
1105 match item.production_year {
1106 Some(y) => QueryParam::Int(y),
1107 None => QueryParam::Null,
1108 },
1109 match &item.premiere_date {
1110 Some(d) => QueryParam::String(d.clone()),
1111 None => QueryParam::Null,
1112 },
1113 match item.runtime_ticks {
1114 Some(r) => QueryParam::Int64(r),
1115 None => QueryParam::Null,
1116 },
1117 match &item.primary_image_tag {
1118 Some(t) => QueryParam::String(t.clone()),
1119 None => QueryParam::Null,
1120 },
1121 match backdrop_tags_json {
1122 Some(b) => QueryParam::String(b),
1123 None => QueryParam::Null,
1124 },
1125 match item.community_rating {
1126 Some(r) => QueryParam::Float(r),
1127 None => QueryParam::Null,
1128 },
1129 match &item.official_rating {
1130 Some(r) => QueryParam::String(r.clone()),
1131 None => QueryParam::Null,
1132 },
1133 QueryParam::String(now.to_string()),
1134 ],
1135 )
1136 }
1137
1138 fn user_data_mirror_query(&self, item: &MediaItem, now: &str) -> Option<Query> {
1168 let user_data = item.user_data.as_ref();
1169 let is_favorite = user_data.and_then(|ud| ud.is_favorite);
1170 let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
1171 let is_played = user_data.and_then(|ud| ud.is_played);
1172
1173 if is_favorite.is_none() && position_ticks.is_none() && is_played.is_none() {
1175 return None;
1176 }
1177
1178 let query = Query::with_params(
1179 "INSERT INTO user_data
1180 (user_id, item_id, is_favorite, playback_position_ticks, is_played,
1181 synced_at, pending_sync)
1182 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)
1183 ON CONFLICT(user_id, item_id) DO UPDATE SET
1184 is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
1185 playback_position_ticks = COALESCE(
1186 excluded.playback_position_ticks, user_data.playback_position_ticks),
1187 is_played = COALESCE(excluded.is_played, user_data.is_played),
1188 synced_at = excluded.synced_at
1189 WHERE user_data.pending_sync = 0",
1190 vec![
1191 QueryParam::String(self.user_id.clone()),
1192 QueryParam::String(item.id.clone()),
1193 is_favorite
1194 .map(|f| QueryParam::Int(if f { 1 } else { 0 }))
1195 .unwrap_or(QueryParam::Null),
1196 position_ticks
1197 .map(QueryParam::Int64)
1198 .unwrap_or(QueryParam::Null),
1199 is_played
1200 .map(|p| QueryParam::Int(if p { 1 } else { 0 }))
1201 .unwrap_or(QueryParam::Null),
1202 QueryParam::String(now.to_string()),
1203 ],
1204 );
1205
1206 Some(query)
1207 }
1208
1209 pub async fn save_libraries_to_cache(&self, libraries: &[Library]) -> Result<usize, RepoError> {
1214 if libraries.is_empty() {
1215 return Ok(0);
1216 }
1217
1218 let mut count = 0;
1219 for (idx, lib) in libraries.iter().enumerate() {
1220 let query = Query::with_params(
1221 "INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
1222 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
1223 vec![
1224 QueryParam::String(lib.id.clone()),
1225 QueryParam::String(self.server_id.clone()),
1226 QueryParam::String(lib.name.clone()),
1227 QueryParam::String(lib.collection_type.clone()),
1228 lib.image_tag.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
1229 QueryParam::Int(idx as i32),
1230 ],
1231 );
1232 self.db_service
1233 .execute(query)
1234 .await
1235 .map_err(|e| RepoError::Database { message: e })?;
1236 count += 1;
1237 }
1238 Ok(count)
1239 }
1240
1241 pub async fn save_genres_to_cache(
1246 &self,
1247 parent_id: Option<&str>,
1248 genres: &[Genre],
1249 ) -> Result<usize, RepoError> {
1250 if genres.is_empty() {
1251 return Ok(0);
1252 }
1253
1254 let library_id = parent_id.unwrap_or("").to_string();
1257 let server_id = self.server_id.clone();
1258 let genres: Vec<(String, String, Option<u32>)> = genres
1259 .iter()
1260 .map(|g| (g.id.clone(), g.name.clone(), g.album_count))
1261 .collect();
1262 let saved = genres.len();
1263
1264 self.db_service
1265 .transaction(move |tx| {
1266 use crate::storage::db_service::{Query, QueryParam};
1267
1268 tx.execute(Query::with_params(
1270 "DELETE FROM genres WHERE server_id = ? AND library_id = ?",
1271 vec![
1272 QueryParam::String(server_id.clone()),
1273 QueryParam::String(library_id.clone()),
1274 ],
1275 ))?;
1276
1277 for (id, name, album_count) in &genres {
1278 tx.execute(Query::with_params(
1279 "INSERT OR REPLACE INTO genres (id, server_id, library_id, name, album_count, synced_at)
1280 VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
1281 vec![
1282 QueryParam::String(id.clone()),
1283 QueryParam::String(server_id.clone()),
1284 QueryParam::String(library_id.clone()),
1285 QueryParam::String(name.clone()),
1286 album_count.map(|c| QueryParam::Int(c as i32)).unwrap_or(QueryParam::Null),
1287 ],
1288 ))?;
1289 }
1290
1291 Ok(())
1292 })
1293 .await
1294 .map_err(|e| RepoError::Database { message: e })?;
1295
1296 Ok(saved)
1297 }
1298
1299 const LIBRARY_HOLDS_ITEM: &'static str = concat!(
1324 "(",
1325 library_type_matches_item!(),
1326 " OR l.collection_type IS NULL
1327 OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
1328 )"
1329 );
1330
1331 pub async fn get_downloaded_items(
1341 &self,
1342 parent_id: &str,
1343 options: Option<GetItemsOptions>,
1344 ) -> Result<SearchResult, RepoError> {
1345 let opts = options.unwrap_or_default();
1346 let limit = opts.limit.unwrap_or(10000);
1347 let start_index = opts.start_index.unwrap_or(0);
1348
1349 let type_filter = if let Some(include_item_types) = &opts.include_item_types {
1350 if !include_item_types.is_empty() {
1351 let types = include_item_types
1352 .iter()
1353 .map(|t| format!("'{}'", t.replace('\'', "''")))
1354 .collect::<Vec<_>>()
1355 .join(",");
1356 format!(" AND i.item_type IN ({})", types)
1357 } else {
1358 String::new()
1359 }
1360 } else {
1361 String::new()
1362 };
1363
1364 let parent_is_library = self.is_library(parent_id).await?;
1377 let downloaded = downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES);
1378 let downloaded_parent = downloaded_sql("parent", PLAYABLE_TYPES, CONTAINER_TYPES);
1379 let parent_match = if parent_is_library {
1383 format!(
1384 "i.server_id = ?
1385 AND (
1386 i.container_id = ?
1387 OR (
1388 EXISTS (
1389 SELECT 1 FROM libraries l
1390 WHERE l.id = ? AND l.server_id = i.server_id
1391 AND {membership}
1392 )
1393 -- Top-level only: hide leaves whose container is downloaded.
1394 AND NOT EXISTS (
1395 SELECT 1 FROM items parent
1396 WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
1397 AND {downloaded_parent}
1398 )
1399 )
1400 )",
1401 membership = Self::LIBRARY_HOLDS_ITEM,
1402 )
1403 } else {
1404 "+i.server_id = ? AND i.container_id = ?".to_string()
1405 };
1406 let sql = format!(
1407 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1408 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1409 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1410 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1411 i.parent_index_number, i.is_folder, i.premiere_date
1412 FROM items i
1413 WHERE {parent_match}
1414 AND {downloaded}{type_filter}
1415 ORDER BY i.sort_name ASC, i.name ASC
1416 LIMIT {limit} OFFSET {start_index}",
1417 );
1418
1419 let mut params = vec![
1420 QueryParam::String(self.server_id.clone()),
1421 QueryParam::String(parent_id.to_string()),
1422 ];
1423 if parent_is_library {
1424 params.push(QueryParam::String(parent_id.to_string()));
1425 }
1426 let query = Query::with_params(sql, params);
1427
1428 let cached_items: Vec<CachedItem> = self
1429 .db_service
1430 .query_many(query, row_to_cached_item)
1431 .await
1432 .map_err(|e| RepoError::Database { message: e })?;
1433
1434 let items = self.with_user_data(cached_items).await;
1435
1436 let total_record_count = items.len();
1437 Ok(SearchResult {
1438 items,
1439 total_record_count,
1440 })
1441 }
1442
1443 pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
1449 let query = Query::with_params(
1454 format!(
1455 "SELECT l.id, l.name, l.collection_type, l.image_tag
1456 FROM libraries l
1457 WHERE l.server_id = ?
1458 AND EXISTS (
1459 SELECT 1 FROM items i
1460 WHERE i.server_id = l.server_id
1461 AND {membership}
1462 AND {downloaded}
1463 )
1464 ORDER BY l.sort_order ASC, l.name ASC",
1465 membership = Self::LIBRARY_HOLDS_ITEM,
1466 downloaded = downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES),
1467 ),
1468 vec![QueryParam::String(self.server_id.clone())],
1469 );
1470
1471 self.db_service
1472 .query_many(query, |row| {
1473 Ok(Library::new(
1474 row.get(0)?,
1475 row.get(1)?,
1476 row.get::<_, Option<String>>(2)?
1477 .unwrap_or_else(|| "unknown".to_string()),
1478 row.get(3)?,
1479 ))
1480 })
1481 .await
1482 .map_err(|e| RepoError::Database { message: e })
1483 }
1484
1485 pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
1494 let leaf_query = Query::with_params(
1496 "SELECT d.item_id, COALESCE(d.file_size, 0)
1497 FROM downloads d
1498 INNER JOIN items i ON i.id = d.item_id
1499 WHERE d.status = 'completed'
1500 AND i.server_id = ?
1501 AND i.item_type IN ('Audio', 'Movie', 'Episode')",
1502 vec![QueryParam::String(self.server_id.clone())],
1503 );
1504 let leaves: Vec<(String, i64)> = self
1505 .db_service
1506 .query_many(leaf_query, |row| Ok((row.get(0)?, row.get(1)?)))
1507 .await
1508 .map_err(|e| RepoError::Database { message: e })?;
1509
1510 let container_query = Query::with_params(
1512 "SELECT c.id, COALESCE(SUM(d.file_size), 0)
1513 FROM items c
1514 INNER JOIN items children
1515 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1516 INNER JOIN downloads d ON children.id = d.item_id
1517 WHERE d.status = 'completed'
1518 AND c.server_id = ?
1519 AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1520 GROUP BY c.id",
1521 vec![QueryParam::String(self.server_id.clone())],
1522 );
1523 let containers: Vec<(String, i64)> = self
1524 .db_service
1525 .query_many(container_query, |row| Ok((row.get(0)?, row.get(1)?)))
1526 .await
1527 .map_err(|e| RepoError::Database { message: e })?;
1528
1529 let partial_query = Query::with_params(
1540 "WITH downloaded_containers AS (
1541 SELECT DISTINCT c.id
1542 FROM items c
1543 INNER JOIN items children
1544 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1545 INNER JOIN downloads d ON children.id = d.item_id
1546 WHERE d.status = 'completed'
1547 AND c.server_id = ?
1548 AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
1549 )
1550 SELECT c.id,
1551 COUNT(children.id) AS total_children,
1552 SUM(CASE WHEN d.status = 'completed' THEN 1 ELSE 0 END) AS downloaded_children
1553 FROM items c
1554 INNER JOIN downloaded_containers dc ON dc.id = c.id
1555 INNER JOIN items children
1556 ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
1557 LEFT JOIN downloads d ON children.id = d.item_id AND d.status = 'completed'
1558 WHERE children.item_type IN ('Audio', 'Movie', 'Episode', 'Season')
1559 GROUP BY c.id",
1560 vec![QueryParam::String(self.server_id.clone())],
1561 );
1562 let partial_rows: Vec<(String, i64, i64)> = self
1563 .db_service
1564 .query_many(partial_query, |row| {
1565 Ok((
1566 row.get(0)?,
1567 row.get(1)?,
1568 row.get::<_, Option<i64>>(2)?.unwrap_or(0),
1569 ))
1570 })
1571 .await
1572 .map_err(|e| RepoError::Database { message: e })?;
1573
1574 let mut partial_containers = std::collections::HashMap::new();
1575 for (id, total, downloaded) in partial_rows {
1576 if downloaded > 0 && downloaded < total {
1579 partial_containers.insert(id, true);
1580 }
1581 }
1582
1583 let item_count = leaves.len() as u32;
1584 let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum();
1585
1586 let mut sizes = std::collections::HashMap::new();
1587 for (id, bytes) in leaves.into_iter().chain(containers) {
1588 *sizes.entry(id).or_insert(0) += bytes;
1591 }
1592
1593 Ok(DownloadDiskUsage {
1594 sizes,
1595 partial_containers,
1596 device_total_bytes,
1597 item_count,
1598 })
1599 }
1600
1601 pub async fn save_playlist_items_to_cache(
1604 &self,
1605 playlist_id: &str,
1606 entries: &[PlaylistEntry],
1607 ) -> Result<(), RepoError> {
1608 let playlist_id = playlist_id.to_string();
1609 let user_id = self.user_id.clone();
1610 let entries: Vec<(String, String, usize)> = entries
1611 .iter()
1612 .enumerate()
1613 .map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i))
1614 .collect();
1615
1616 self.db_service
1617 .transaction(move |tx| {
1618 use crate::storage::db_service::{Query, QueryParam};
1619
1620 tx.execute(Query::with_params(
1622 "INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)",
1623 vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)],
1624 ))?;
1625
1626 tx.execute(Query::with_params(
1628 "DELETE FROM playlist_items WHERE playlist_id = ?",
1629 vec![QueryParam::String(playlist_id.clone())],
1630 ))?;
1631
1632 for (_, item_id, sort_order) in &entries {
1633 tx.execute(Query::with_params(
1634 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
1635 vec![
1636 QueryParam::String(playlist_id.clone()),
1637 QueryParam::String(item_id.clone()),
1638 QueryParam::Int(*sort_order as i32),
1639 ],
1640 ))?;
1641 }
1642
1643 Ok(())
1644 })
1645 .await
1646 .map_err(|e| RepoError::Database {
1647 message: format!("Failed to cache playlist items: {}", e),
1648 })
1649 }
1650}
1651
1652#[async_trait]
1653impl MediaRepository for OfflineRepository {
1654 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1655 let query = Query::with_params(
1663 "SELECT l.id, l.name, l.collection_type, l.image_tag
1664 FROM libraries l
1665 WHERE l.server_id = ?
1666 ORDER BY l.sort_order ASC, l.name ASC",
1667 vec![QueryParam::String(self.server_id.clone())],
1668 );
1669
1670 self.db_service
1671 .query_many(query, |row| {
1672 Ok(Library::new(
1673 row.get(0)?,
1674 row.get(1)?,
1675 row.get::<_, Option<String>>(2)?
1676 .unwrap_or_else(|| "unknown".to_string()),
1677 row.get(3)?,
1678 ))
1679 })
1680 .await
1681 .map_err(|e| RepoError::Database { message: e })
1682 }
1683
1684 async fn get_items(
1685 &self,
1686 parent_id: &str,
1687 options: Option<GetItemsOptions>,
1688 ) -> Result<SearchResult, RepoError> {
1689 debug!(
1690 "[OfflineRepo] get_items called for parent_id: {}",
1691 &parent_id[..8.min(parent_id.len())]
1692 );
1693 let opts = options.unwrap_or_default();
1694 let limit = opts.limit.unwrap_or(10000); let start_index = opts.start_index.unwrap_or(0);
1696
1697 let default_sort = default_listing_sort(opts.parent_kind);
1705 let sort_field = opts
1706 .sort_by
1707 .as_deref()
1708 .or(default_sort.map(|(field, _)| field));
1709 let descending = opts
1710 .sort_order
1711 .as_deref()
1712 .or(default_sort.map(|(_, order)| order))
1713 == Some("Descending");
1714 let order_by = match sort_field {
1715 Some("Random") => "RANDOM()".to_string(),
1716 Some("PremiereDate") => format!(
1717 "i.premiere_date IS NULL, i.premiere_date {}, i.sort_name ASC",
1718 if descending { "DESC" } else { "ASC" }
1719 ),
1720 _ => "i.sort_name ASC, i.name ASC".to_string(),
1721 };
1722
1723 let type_values: &[String] = opts
1730 .include_item_types
1731 .as_deref()
1732 .filter(|types| !types.is_empty())
1733 .unwrap_or(&[]);
1734 let type_filter = if type_values.is_empty() {
1735 String::new()
1736 } else {
1737 let placeholders = vec!["?"; type_values.len()].join(",");
1738 format!(" AND i.item_type IN ({})", placeholders)
1739 };
1740
1741 let favorites_filter = if opts.favorites_only == Some(true) {
1746 " AND EXISTS (
1747 SELECT 1 FROM user_data ud
1748 WHERE ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1
1749 )"
1750 } else {
1751 ""
1752 };
1753
1754 let parent_is_library = self.is_library(parent_id).await?;
1757
1758 let sql = items_listing_sql(
1759 parent_is_library,
1760 include_catalog_browse(),
1761 &type_filter,
1762 favorites_filter,
1763 &order_by,
1764 limit,
1765 start_index,
1766 );
1767
1768 let mut params = vec![
1771 QueryParam::String(self.server_id.clone()),
1772 QueryParam::String(parent_id.to_string()), ];
1774 if parent_is_library {
1775 params.push(QueryParam::String(parent_id.to_string())); }
1777 params.extend(type_values.iter().cloned().map(QueryParam::String));
1782 if !favorites_filter.is_empty() {
1783 params.push(QueryParam::String(self.user_id.clone())); }
1785 let query = Query::with_params(sql, params);
1786
1787 let cached_items: Vec<CachedItem> = self
1788 .db_service
1789 .query_many(query, row_to_cached_item)
1790 .await
1791 .map_err(|e| RepoError::Database { message: e })?;
1792
1793 debug!(
1794 "[OfflineRepo] Found {} cached items for parent {}",
1795 cached_items.len(),
1796 &parent_id[..8.min(parent_id.len())]
1797 );
1798
1799 let items = self.with_user_data(cached_items).await;
1800
1801 let total_record_count = items.len();
1802
1803 debug!(
1804 "[OfflineRepo] Returning {} items for parent {}",
1805 total_record_count,
1806 &parent_id[..8.min(parent_id.len())]
1807 );
1808
1809 Ok(SearchResult {
1810 items,
1811 total_record_count,
1812 })
1813 }
1814
1815 async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
1816 let query = Query::with_params(
1818 format!(
1819 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1820 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1821 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1822 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1823 i.parent_index_number, i.is_folder, i.premiere_date
1824 FROM items i
1825 WHERE i.id = ? AND {}",
1826 downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES)
1827 ),
1828 vec![QueryParam::String(item_id.to_string())],
1829 );
1830
1831 let cached = self
1832 .db_service
1833 .query_optional(query, row_to_cached_item)
1834 .await
1835 .map_err(|e| RepoError::Database { message: e })?
1836 .ok_or_else(|| RepoError::NotFound {
1837 message: format!(
1838 "Item {} not found in offline cache or not downloaded",
1839 item_id
1840 ),
1841 })?;
1842
1843 let user_data = self.get_user_data(item_id).await;
1844 Ok(Self::cached_item_to_media_item(cached, user_data))
1845 }
1846
1847 async fn get_latest_items(
1848 &self,
1849 parent_id: &str,
1850 limit: Option<usize>,
1851 ) -> Result<Vec<MediaItem>, RepoError> {
1852 let limit_val = limit.unwrap_or(16);
1853
1854 let query = Query::with_params(
1855 format!(
1856 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
1857 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
1858 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
1859 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
1860 i.parent_index_number, i.is_folder, i.premiere_date
1861 FROM items i
1862 WHERE i.server_id = ? AND i.library_id = ?
1863 AND {downloaded}
1864 -- Collapse leaves into the container that was added: a new
1865 -- 14-track album should read as one album, not 14 songs. Only
1866 -- drops a leaf when its own container is present in the same
1867 -- result, so a standalone track or movie still appears.
1868 AND NOT EXISTS (
1869 SELECT 1 FROM items parent
1870 WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
1871 AND {downloaded_parent}
1872 )
1873 ORDER BY i.synced_at DESC
1874 LIMIT {limit_val}",
1875 downloaded = downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES),
1876 downloaded_parent = downloaded_sql("parent", PLAYABLE_TYPES, CONTAINER_TYPES),
1877 ),
1878 vec![
1879 QueryParam::String(self.server_id.clone()),
1880 QueryParam::String(parent_id.to_string()),
1881 ],
1882 );
1883
1884 let cached_items: Vec<CachedItem> = self
1885 .db_service
1886 .query_many(query, row_to_cached_item)
1887 .await
1888 .map_err(|e| RepoError::Database { message: e })?;
1889
1890 let items = self.with_user_data(cached_items).await;
1891
1892 Ok(items)
1893 }
1894
1895 async fn get_resume_items(
1896 &self,
1897 parent_id: Option<&str>,
1898 limit: Option<usize>,
1899 ) -> Result<Vec<MediaItem>, RepoError> {
1900 let limit_val = limit.unwrap_or(12);
1901
1902 let (sql, params) = if let Some(pid) = parent_id {
1904 (
1905 format!(
1906 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1907 i.overview, i.genres, i.runtime_ticks, i.production_year,
1908 i.community_rating, i.official_rating, i.primary_image_tag,
1909 i.album_id, i.album_name, i.album_artist, i.artists,
1910 i.index_number, i.series_id, i.series_name, i.season_id,
1911 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1912 FROM items i
1913 JOIN user_data ud ON i.id = ud.item_id
1914 INNER JOIN downloads d ON i.id = d.item_id
1915 WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
1916 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1917 AND d.status = 'completed'
1918 AND i.item_type IN ('Movie', 'Episode')
1919 ORDER BY ud.last_played_at DESC
1920 LIMIT {}",
1921 limit_val
1922 ),
1923 vec![
1924 QueryParam::String(self.server_id.clone()),
1925 QueryParam::String(self.user_id.clone()),
1926 QueryParam::String(pid.to_string()),
1927 ],
1928 )
1929 } else {
1930 (
1931 format!(
1932 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
1933 i.overview, i.genres, i.runtime_ticks, i.production_year,
1934 i.community_rating, i.official_rating, i.primary_image_tag,
1935 i.album_id, i.album_name, i.album_artist, i.artists,
1936 i.index_number, i.series_id, i.series_name, i.season_id,
1937 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
1938 FROM items i
1939 JOIN user_data ud ON i.id = ud.item_id
1940 INNER JOIN downloads d ON i.id = d.item_id
1941 WHERE i.server_id = ? AND ud.user_id = ?
1942 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
1943 AND d.status = 'completed'
1944 AND i.item_type IN ('Movie', 'Episode')
1945 ORDER BY ud.last_played_at DESC
1946 LIMIT {}",
1947 limit_val
1948 ),
1949 vec![
1950 QueryParam::String(self.server_id.clone()),
1951 QueryParam::String(self.user_id.clone()),
1952 ],
1953 )
1954 };
1955
1956 let query = Query::with_params(sql, params);
1957
1958 let cached_items: Vec<CachedItem> = self
1959 .db_service
1960 .query_many(query, row_to_cached_item)
1961 .await
1962 .map_err(|e| RepoError::Database { message: e })?;
1963
1964 let items = self.with_user_data(cached_items).await;
1965
1966 Ok(items)
1967 }
1968
1969 async fn get_next_up_episodes(
1970 &self,
1971 _series_id: Option<&str>,
1972 _limit: Option<usize>,
1973 ) -> Result<Vec<MediaItem>, RepoError> {
1974 Ok(Vec::new())
1977 }
1978
1979 async fn get_recently_played_audio(
1980 &self,
1981 limit: Option<usize>,
1982 ) -> Result<Vec<MediaItem>, RepoError> {
1983 let limit_val = limit.unwrap_or(12);
1984
1985 let query = Query::with_params(
1990 format!(
1991 "WITH ranked_plays AS (
1992 SELECT
1993 CASE
1994 WHEN ud.playback_context_type = 'container' THEN ud.playback_context_id
1995 WHEN ud.playback_context_type = 'single' THEN ud.item_id
1996 ELSE COALESCE(i.album_id, ud.item_id)
1997 END AS display_id,
1998 MAX(ud.last_played_at) AS most_recent_play
1999 FROM user_data ud
2000 JOIN items i ON ud.item_id = i.id
2001 WHERE ud.user_id = ? AND i.server_id = ?
2002 AND i.item_type = 'Audio'
2003 AND ud.last_played_at IS NOT NULL
2004 GROUP BY display_id
2005 ORDER BY most_recent_play DESC
2006 LIMIT {}
2007 )
2008 SELECT DISTINCT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
2009 i.overview, i.genres, i.runtime_ticks, i.production_year,
2010 i.community_rating, i.official_rating, i.primary_image_tag,
2011 i.album_id, i.album_name, i.album_artist, i.artists,
2012 i.index_number, i.series_id, i.series_name, i.season_id,
2013 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
2014 FROM ranked_plays rp
2015 JOIN items i ON rp.display_id = i.id
2016 WHERE {downloaded}
2017 ORDER BY rp.most_recent_play DESC",
2018 limit_val,
2019 downloaded = downloaded_sql("i", "'Audio'", "'MusicAlbum'")
2020 ),
2021 vec![
2022 QueryParam::String(self.user_id.clone()),
2023 QueryParam::String(self.server_id.clone()),
2024 ],
2025 );
2026
2027 let cached_items: Vec<CachedItem> = self
2028 .db_service
2029 .query_many(query, row_to_cached_item)
2030 .await
2031 .map_err(|e| RepoError::Database { message: e })?;
2032
2033 let items = self.with_user_data(cached_items).await;
2034
2035 Ok(items)
2036 }
2037
2038 async fn get_rediscover_albums(
2039 &self,
2040 _parent_id: Option<&str>,
2041 _limit: Option<usize>,
2042 ) -> Result<Vec<MediaItem>, RepoError> {
2043 Ok(Vec::new())
2047 }
2048
2049 async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
2050 let limit_val = limit.unwrap_or(12);
2051
2052 let query = Query::with_params(
2054 format!(
2055 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
2056 i.overview, i.genres, i.runtime_ticks, i.production_year,
2057 i.community_rating, i.official_rating, i.primary_image_tag,
2058 i.album_id, i.album_name, i.album_artist, i.artists,
2059 i.index_number, i.series_id, i.series_name, i.season_id,
2060 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
2061 FROM items i
2062 JOIN user_data ud ON i.id = ud.item_id
2063 INNER JOIN downloads d ON i.id = d.item_id
2064 WHERE i.server_id = ? AND ud.user_id = ? AND i.item_type = 'Movie'
2065 AND ud.playback_position_ticks > 0 AND ud.is_played = 0
2066 AND d.status = 'completed'
2067 ORDER BY ud.last_played_at DESC
2068 LIMIT {}",
2069 limit_val
2070 ),
2071 vec![
2072 QueryParam::String(self.server_id.clone()),
2073 QueryParam::String(self.user_id.clone()),
2074 ],
2075 );
2076
2077 let cached_items: Vec<CachedItem> = self
2078 .db_service
2079 .query_many(query, row_to_cached_item)
2080 .await
2081 .map_err(|e| RepoError::Database { message: e })?;
2082
2083 let items = self.with_user_data(cached_items).await;
2084
2085 Ok(items)
2086 }
2087
2088 async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
2089 let library_id = parent_id.unwrap_or("").to_string();
2094
2095 let query = Query::with_params(
2096 "SELECT id, name, album_count FROM genres WHERE server_id = ? AND library_id = ?",
2097 vec![
2098 QueryParam::String(self.server_id.clone()),
2099 QueryParam::String(library_id),
2100 ],
2101 );
2102
2103 let genres: Vec<Genre> = self
2104 .db_service
2105 .query_many(query, |row| {
2106 Ok(Genre {
2107 id: row.get(0)?,
2108 name: row.get(1)?,
2109 album_count: row.get::<_, Option<i64>>(2)?.map(|c| c as u32),
2110 })
2111 })
2112 .await
2113 .map_err(|e| RepoError::Database { message: e })?;
2114
2115 Ok(genres)
2116 }
2117
2118 async fn search(
2119 &self,
2120 query: &str,
2121 options: Option<SearchOptions>,
2122 ) -> Result<SearchResult, RepoError> {
2123 let opts = options.unwrap_or_default();
2124 let limit = opts.limit.unwrap_or(20);
2125
2126 let Some(fts_query) = build_fts_prefix_query(query) else {
2129 return Ok(SearchResult {
2130 items: Vec::new(),
2131 total_record_count: 0,
2132 });
2133 };
2134
2135 let type_values: &[String] = opts
2139 .include_item_types
2140 .as_deref()
2141 .filter(|types| !types.is_empty())
2142 .unwrap_or(&[]);
2143 let type_filter = if type_values.is_empty() {
2144 String::new()
2145 } else {
2146 let placeholders = vec!["?"; type_values.len()].join(",");
2147 format!(" AND i.item_type IN ({})", placeholders)
2148 };
2149
2150 let available = available_sql("i", include_catalog_browse());
2156
2157 let sql = format!(
2158 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
2159 i.overview, i.genres, i.runtime_ticks, i.production_year,
2160 i.community_rating, i.official_rating, i.primary_image_tag,
2161 i.album_id, i.album_name, i.album_artist, i.artists,
2162 i.index_number, i.series_id, i.series_name, i.season_id,
2163 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
2164 FROM items i
2165 JOIN items_fts fts ON fts.rowid = i.rowid
2166 WHERE i.server_id = ? AND items_fts MATCH ? AND {}{}
2167 ORDER BY rank
2168 LIMIT {}",
2169 available, type_filter, limit
2170 );
2171
2172 let mut params = vec![
2173 QueryParam::String(self.server_id.clone()),
2174 QueryParam::String(fts_query.clone()),
2175 ];
2176 params.extend(type_values.iter().cloned().map(QueryParam::String));
2177
2178 let db_query = Query::with_params(sql, params);
2179
2180 let cached_items: Vec<CachedItem> = self
2181 .db_service
2182 .query_many(db_query, row_to_cached_item)
2183 .await
2184 .map_err(|e| RepoError::Database { message: e })?;
2185
2186 let mut items = self.with_user_data(cached_items).await;
2187
2188 if type_values.is_empty() {
2194 items.extend(self.search_people(&fts_query, limit).await?);
2195 }
2196
2197 let total_record_count = items.len();
2198
2199 Ok(SearchResult {
2200 items,
2201 total_record_count,
2202 })
2203 }
2204
2205 async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
2206 Err(RepoError::Offline)
2208 }
2209
2210 async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
2211 Err(RepoError::Offline)
2213 }
2214
2215 async fn get_audio_only_stream_url_for_video(
2216 &self,
2217 _item_id: &str,
2218 _media_source_id: Option<&str>,
2219 _start_time_seconds: Option<f64>,
2220 _audio_stream_index: Option<i32>,
2221 ) -> Result<String, RepoError> {
2222 Err(RepoError::Offline)
2224 }
2225
2226 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
2227 Err(RepoError::Offline)
2229 }
2230
2231 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
2232 Err(RepoError::Offline)
2234 }
2235
2236 async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
2237 Err(RepoError::Offline)
2239 }
2240
2241 async fn report_playback_start(
2242 &self,
2243 _item_id: &str,
2244 _position_ticks: i64,
2245 ) -> Result<(), RepoError> {
2246 Err(RepoError::Offline)
2248 }
2249
2250 async fn report_playback_progress(
2251 &self,
2252 _item_id: &str,
2253 _position_ticks: i64,
2254 ) -> Result<(), RepoError> {
2255 Err(RepoError::Offline)
2257 }
2258
2259 async fn report_playback_stopped(
2260 &self,
2261 _item_id: &str,
2262 _position_ticks: i64,
2263 ) -> Result<(), RepoError> {
2264 Err(RepoError::Offline)
2266 }
2267
2268 fn get_image_url(
2269 &self,
2270 item_id: &str,
2271 image_type: ImageType,
2272 options: Option<ImageOptions>,
2273 ) -> String {
2274 let type_str = match image_type {
2277 ImageType::Primary => "Primary",
2278 ImageType::Backdrop => "Backdrop",
2279 ImageType::Logo => "Logo",
2280 ImageType::Thumb => "Thumb",
2281 ImageType::Banner => "Banner",
2282 };
2283
2284 if let Some(opts) = options {
2285 if let Some(tag) = opts.tag {
2286 return format!("offline://{}/{}/{}", item_id, type_str, tag);
2287 }
2288 }
2289
2290 format!("offline://{}/{}", item_id, type_str)
2291 }
2292
2293 fn get_subtitle_url(
2294 &self,
2295 _item_id: &str,
2296 _media_source_id: &str,
2297 _stream_index: i32,
2298 _format: &str,
2299 ) -> String {
2300 String::new()
2302 }
2303
2304 fn get_video_download_url(
2305 &self,
2306 _item_id: &str,
2307 _quality: &str,
2308 _media_source_id: Option<&str>,
2309 _source_audio_codec: Option<&str>,
2310 ) -> String {
2311 String::new()
2313 }
2314
2315 async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
2316 Err(RepoError::Offline)
2318 }
2319
2320 async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
2321 Err(RepoError::Offline)
2323 }
2324
2325 async fn get_favorites(
2334 &self,
2335 scope: SearchScope,
2336 options: Option<GetItemsOptions>,
2337 ) -> Result<SearchResult, RepoError> {
2338 let opts = options.unwrap_or_default();
2339 let limit = opts.limit.unwrap_or(10000);
2340 let start_index = opts.start_index.unwrap_or(0);
2341
2342 let type_filter = match scope.item_types() {
2345 Some(types) if !types.is_empty() => {
2346 let placeholders = vec!["?"; types.len()].join(",");
2347 format!(" AND i.item_type IN ({})", placeholders)
2348 }
2349 _ => String::new(),
2350 };
2351
2352 let available = available_sql("i", include_catalog_browse());
2353
2354 let sql = format!(
2355 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
2356 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
2357 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
2358 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
2359 i.parent_index_number, i.is_folder, i.premiere_date
2360 FROM items i
2361 INNER JOIN user_data ud ON ud.item_id = i.id
2362 WHERE i.server_id = ?
2363 AND ud.user_id = ?
2364 AND ud.is_favorite = 1
2365 AND {available}{}
2366 ORDER BY i.sort_name ASC, i.name ASC
2367 LIMIT {} OFFSET {}",
2368 type_filter, limit, start_index
2369 );
2370
2371 let mut params = vec![
2372 QueryParam::String(self.server_id.clone()),
2373 QueryParam::String(self.user_id.clone()),
2374 ];
2375 if let Some(types) = scope.item_types() {
2376 params.extend(types.into_iter().map(QueryParam::String));
2377 }
2378
2379 let cached_items: Vec<CachedItem> = self
2380 .db_service
2381 .query_many(Query::with_params(sql, params), row_to_cached_item)
2382 .await
2383 .map_err(|e| RepoError::Database { message: e })?;
2384
2385 let items = self.with_user_data(cached_items).await;
2386
2387 let total_record_count = items.len();
2388 debug!("[OfflineRepo] Returning {} favourites", total_record_count);
2389
2390 Ok(SearchResult {
2391 items,
2392 total_record_count,
2393 })
2394 }
2395
2396 async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
2397 Err(RepoError::Offline)
2400 }
2401
2402 async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
2403 Err(RepoError::Offline)
2406 }
2407
2408 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
2409 let query = Query::with_params(
2410 "SELECT id, name, overview, primary_image_tag
2411 FROM people WHERE id = ?",
2412 vec![QueryParam::String(person_id.to_string())],
2413 );
2414
2415 let person_data = self
2416 .db_service
2417 .query_optional(query, |row| {
2418 Ok((
2419 row.get::<_, String>(0)?,
2420 row.get::<_, String>(1)?,
2421 row.get::<_, Option<String>>(2)?,
2422 row.get::<_, Option<String>>(3)?,
2423 ))
2424 })
2425 .await
2426 .map_err(|e| RepoError::Database { message: e })?
2427 .ok_or_else(|| RepoError::NotFound {
2428 message: format!("Person {} not found in cache", person_id),
2429 })?;
2430
2431 Ok(MediaItem {
2432 id: person_data.0,
2433 name: person_data.1,
2434 item_type: "Person".to_string(),
2435 kind: crate::domain::MediaKind::Person,
2436 is_folder: false,
2437 server_id: self.server_id.clone(),
2438 parent_id: None,
2439 library_id: None,
2440 overview: person_data.2,
2441 genres: None,
2442 runtime_ticks: None,
2443 duration_ms: None,
2444 production_year: None,
2445 premiere_date: None,
2446 community_rating: None,
2447 official_rating: None,
2448 primary_image_tag: person_data.3.clone(),
2449 image_id: person_data.3,
2450 backdrop_image_tags: None,
2451 parent_backdrop_image_tags: None,
2452 album_id: None,
2453 album_name: None,
2454 album_artist: None,
2455 artists: None,
2456 artist_items: None,
2457 index_number: None,
2458 series_id: None,
2459 series_name: None,
2460 season_id: None,
2461 season_name: None,
2462 parent_index_number: None,
2463 user_data: None,
2464 media_streams: None,
2465 media_sources: None,
2466 people: None,
2467 })
2468 }
2469
2470 async fn get_items_by_person(
2471 &self,
2472 person_id: &str,
2473 options: Option<GetItemsOptions>,
2474 ) -> Result<SearchResult, RepoError> {
2475 let opts = options.unwrap_or_default();
2476 let limit = opts.limit.unwrap_or(10000); let query = Query::with_params(
2480 format!(
2481 "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
2482 i.overview, i.genres, i.runtime_ticks, i.production_year,
2483 i.community_rating, i.official_rating, i.primary_image_tag,
2484 i.album_id, i.album_name, i.album_artist, i.artists,
2485 i.index_number, i.series_id, i.series_name, i.season_id,
2486 i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
2487 FROM items i
2488 JOIN item_people ip ON i.id = ip.item_id
2489 WHERE i.server_id = ? AND ip.person_id = ? AND {downloaded}
2490 ORDER BY i.production_year DESC, i.sort_name ASC
2491 LIMIT {}",
2492 limit,
2493 downloaded = downloaded_sql("i", PLAYABLE_TYPES, CONTAINER_TYPES)
2494 ),
2495 vec![
2496 QueryParam::String(self.server_id.clone()),
2497 QueryParam::String(person_id.to_string()),
2498 ],
2499 );
2500
2501 let cached_items: Vec<CachedItem> = self
2502 .db_service
2503 .query_many(query, row_to_cached_item)
2504 .await
2505 .map_err(|e| RepoError::Database { message: e })?;
2506
2507 let items = self.with_user_data(cached_items).await;
2508
2509 let total_record_count = items.len();
2510
2511 Ok(SearchResult {
2512 items,
2513 total_record_count,
2514 })
2515 }
2516
2517 async fn get_similar_items(
2518 &self,
2519 _item_id: &str,
2520 _limit: Option<usize>,
2521 ) -> Result<SearchResult, RepoError> {
2522 Err(RepoError::Offline)
2524 }
2525
2526 async fn create_playlist(
2529 &self,
2530 name: &str,
2531 item_ids: &[String],
2532 ) -> Result<PlaylistCreatedResult, RepoError> {
2533 let playlist_id = uuid::Uuid::new_v4().to_string();
2534 let user_id = self.user_id.clone();
2535 let name = name.to_string();
2536 let item_ids = item_ids.to_vec();
2537 let pid = playlist_id.clone();
2538
2539 self.db_service
2540 .transaction(move |tx| {
2541 use crate::storage::db_service::{Query, QueryParam};
2542
2543 tx.execute(Query::with_params(
2544 "INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)",
2545 vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)],
2546 ))?;
2547
2548 for (i, item_id) in item_ids.iter().enumerate() {
2549 tx.execute(Query::with_params(
2550 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
2551 vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)],
2552 ))?;
2553 }
2554
2555 Ok(())
2556 })
2557 .await
2558 .map_err(|e| RepoError::Database {
2559 message: format!("Failed to create playlist: {}", e),
2560 })?;
2561
2562 Ok(PlaylistCreatedResult { id: playlist_id })
2563 }
2564
2565 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
2566 let query = Query::with_params(
2567 "DELETE FROM playlists WHERE id = ?",
2568 vec![QueryParam::String(playlist_id.to_string())],
2569 );
2570 self.db_service
2571 .execute(query)
2572 .await
2573 .map_err(|e| RepoError::Database {
2574 message: format!("Failed to delete playlist: {}", e),
2575 })?;
2576 Ok(())
2577 }
2578
2579 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
2580 let query = Query::with_params(
2581 "UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
2582 vec![
2583 QueryParam::String(name.to_string()),
2584 QueryParam::String(playlist_id.to_string()),
2585 ],
2586 );
2587 self.db_service
2588 .execute(query)
2589 .await
2590 .map_err(|e| RepoError::Database {
2591 message: format!("Failed to rename playlist: {}", e),
2592 })?;
2593 Ok(())
2594 }
2595
2596 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
2597 let query = Query::with_params(
2598 "SELECT pi.id, \
2599 i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \
2600 i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
2601 i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
2602 i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
2603 i.parent_index_number, i.is_folder, i.premiere_date \
2604 FROM playlist_items pi \
2605 JOIN items i ON pi.item_id = i.id \
2606 WHERE pi.playlist_id = ? \
2607 ORDER BY pi.sort_order ASC",
2608 vec![QueryParam::String(playlist_id.to_string())],
2609 );
2610
2611 let items = self
2612 .db_service
2613 .query_many(query, |row| {
2614 let entry_id: i64 = row.get(0)?;
2615 let cached = CachedItem {
2617 id: row.get(1)?,
2618 name: row.get(2)?,
2619 item_type: row.get(3)?,
2620 server_id: row.get(4)?,
2621 parent_id: row.get(5)?,
2622 library_id: row.get(6)?,
2623 overview: row.get(7)?,
2624 genres: row.get(8)?,
2625 runtime_ticks: row.get(9)?,
2626 production_year: row.get(10)?,
2627 community_rating: row.get(11)?,
2628 official_rating: row.get(12)?,
2629 primary_image_tag: row.get(13)?,
2630 backdrop_image_tags: None,
2631 parent_backdrop_image_tags: None,
2632 album_id: row.get(14)?,
2633 album_name: row.get(15)?,
2634 album_artist: row.get(16)?,
2635 artists: row.get(17)?,
2636 index_number: row.get(18)?,
2637 series_id: row.get(19)?,
2638 series_name: row.get(20)?,
2639 season_id: row.get(21)?,
2640 season_name: row.get(22)?,
2641 parent_index_number: row.get(23)?,
2642 is_folder: row.get::<_, Option<i64>>(24)?.unwrap_or(0) != 0,
2643 premiere_date: row.get(25)?,
2644 };
2645 Ok((entry_id.to_string(), cached))
2646 })
2647 .await
2648 .map_err(|e| RepoError::Database {
2649 message: format!("Failed to get playlist items: {}", e),
2650 })?;
2651
2652 Ok(items
2653 .into_iter()
2654 .map(|(entry_id, cached)| PlaylistEntry {
2655 playlist_item_id: entry_id,
2656 item: Self::cached_item_to_media_item(cached, None),
2657 })
2658 .collect())
2659 }
2660
2661 async fn add_to_playlist(
2662 &self,
2663 playlist_id: &str,
2664 item_ids: &[String],
2665 ) -> Result<(), RepoError> {
2666 let max_query = Query::with_params(
2668 "SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?",
2669 vec![QueryParam::String(playlist_id.to_string())],
2670 );
2671 let max_order: i32 = self
2672 .db_service
2673 .query_one(max_query, |row| row.get(0))
2674 .await
2675 .unwrap_or(-1);
2676
2677 let playlist_id = playlist_id.to_string();
2678 let item_ids = item_ids.to_vec();
2679
2680 self.db_service
2681 .transaction(move |tx| {
2682 use crate::storage::db_service::{Query, QueryParam};
2683
2684 for (i, item_id) in item_ids.iter().enumerate() {
2685 tx.execute(Query::with_params(
2686 "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
2687 vec![
2688 QueryParam::String(playlist_id.clone()),
2689 QueryParam::String(item_id.clone()),
2690 QueryParam::Int(max_order + 1 + i as i32),
2691 ],
2692 ))?;
2693 }
2694 Ok(())
2695 })
2696 .await
2697 .map_err(|e| RepoError::Database {
2698 message: format!("Failed to add items to playlist: {}", e),
2699 })?;
2700
2701 Ok(())
2702 }
2703
2704 async fn remove_from_playlist(
2705 &self,
2706 playlist_id: &str,
2707 entry_ids: &[String],
2708 ) -> Result<(), RepoError> {
2709 let playlist_id = playlist_id.to_string();
2710 let entry_ids = entry_ids.to_vec();
2711
2712 self.db_service
2713 .transaction(move |tx| {
2714 use crate::storage::db_service::{Query, QueryParam};
2715
2716 for entry_id in &entry_ids {
2717 tx.execute(Query::with_params(
2718 "DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?",
2719 vec![
2720 QueryParam::String(playlist_id.clone()),
2721 QueryParam::String(entry_id.clone()),
2722 ],
2723 ))?;
2724 }
2725 Ok(())
2726 })
2727 .await
2728 .map_err(|e| RepoError::Database {
2729 message: format!("Failed to remove items from playlist: {}", e),
2730 })?;
2731
2732 Ok(())
2733 }
2734
2735 async fn move_playlist_item(
2736 &self,
2737 playlist_id: &str,
2738 item_id: &str,
2739 new_index: u32,
2740 ) -> Result<(), RepoError> {
2741 let playlist_id = playlist_id.to_string();
2742 let item_id = item_id.to_string();
2743
2744 self.db_service
2745 .transaction(move |tx| {
2746 use crate::storage::db_service::{Query, QueryParam};
2747
2748 let items: Vec<(i64, String)> = tx.query_many(
2750 Query::with_params(
2751 "SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order",
2752 vec![QueryParam::String(playlist_id)],
2753 ),
2754 |row| Ok((row.get(0)?, row.get(1)?)),
2755 )?;
2756
2757 let old_idx = items.iter().position(|(_, iid)| iid == &item_id);
2759 if let Some(old_pos) = old_idx {
2760 let mut ids = items;
2761 let entry = ids.remove(old_pos);
2762 let insert_at = (new_index as usize).min(ids.len());
2763 ids.insert(insert_at, entry);
2764
2765 for (i, (entry_id, _)) in ids.iter().enumerate() {
2767 tx.execute(Query::with_params(
2768 "UPDATE playlist_items SET sort_order = ? WHERE id = ?",
2769 vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)],
2770 ))?;
2771 }
2772 }
2773
2774 Ok(())
2775 })
2776 .await
2777 .map_err(|e| RepoError::Database {
2778 message: format!("Failed to move playlist item: {}", e),
2779 })?;
2780
2781 Ok(())
2782 }
2783}
2784
2785#[cfg(test)]
2786mod tests {
2787 #![allow(clippy::await_holding_lock)]
2796
2797 use super::*;
2798 use crate::storage::db_service::RusqliteService;
2799 use rusqlite::Connection;
2800 use std::sync::{Arc, Mutex};
2801
2802 static CATALOG_BROWSE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2809
2810 fn lock_catalog_browse() -> std::sync::MutexGuard<'static, ()> {
2811 use crate::utils::lock::MutexSafe;
2812 CATALOG_BROWSE_LOCK.lock_safe()
2813 }
2814
2815 #[test]
2817 fn test_build_fts_prefix_query() {
2818 assert_eq!(build_fts_prefix_query("Arr").as_deref(), Some("\"Arr\"*"));
2820
2821 assert_eq!(
2824 build_fts_prefix_query("parks rec").as_deref(),
2825 Some("\"parks\" \"rec\"*")
2826 );
2827
2828 for query in ["Bob's Burgers", "Spider-Man", "AC/DC", "Wall-E", "9-1-1"] {
2831 let built = build_fts_prefix_query(query).expect("should build");
2832 assert!(
2833 built.starts_with('"') && built.ends_with("*"),
2834 "{query:?} produced {built:?}"
2835 );
2836 }
2837
2838 assert_eq!(
2841 build_fts_prefix_query("say \"hi\"").as_deref(),
2842 Some("\"say\" \"\"\"hi\"\"\"*")
2843 );
2844
2845 assert_eq!(build_fts_prefix_query(""), None);
2848 assert_eq!(build_fts_prefix_query(" "), None);
2849 assert_eq!(build_fts_prefix_query("-"), None);
2850 }
2851
2852 #[tokio::test]
2857 async fn test_search_empty_query_returns_empty_not_error() {
2858 let db_service = create_test_db();
2859 let repo = OfflineRepository::new(
2860 db_service,
2861 "test-server".to_string(),
2862 "test-user".to_string(),
2863 );
2864
2865 let result = repo.search("", None).await;
2866 assert!(
2867 result.is_ok(),
2868 "empty query must not error: {:?}",
2869 result.err()
2870 );
2871 assert!(result.unwrap().items.is_empty());
2872 }
2873
2874 fn create_test_db() -> Arc<RusqliteService> {
2875 Arc::new(RusqliteService::new(create_test_conn()))
2876 }
2877
2878 fn create_test_conn() -> Arc<Mutex<Connection>> {
2881 let conn = Connection::open_in_memory().unwrap();
2882
2883 conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
2885
2886 conn.execute_batch(
2888 r#"
2889 CREATE TABLE servers (
2890 id TEXT PRIMARY KEY,
2891 name TEXT NOT NULL,
2892 url TEXT NOT NULL UNIQUE
2893 );
2894
2895 CREATE TABLE items (
2896 id TEXT PRIMARY KEY,
2897 server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
2898 library_id TEXT,
2899 parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
2900 name TEXT NOT NULL,
2901 item_type TEXT NOT NULL,
2902 is_folder INTEGER DEFAULT 0,
2903 overview TEXT,
2904 genres TEXT,
2905 runtime_ticks INTEGER,
2906 production_year INTEGER,
2907 premiere_date TEXT,
2908 community_rating REAL,
2909 official_rating TEXT,
2910 primary_image_tag TEXT,
2911 backdrop_image_tags TEXT,
2912 album_id TEXT,
2913 album_name TEXT,
2914 album_artist TEXT,
2915 artists TEXT,
2916 index_number INTEGER,
2917 series_id TEXT,
2918 series_name TEXT,
2919 season_id TEXT,
2920 season_name TEXT,
2921 parent_index_number INTEGER,
2922 synced_at TEXT,
2923 sort_name TEXT,
2924 -- Same rule as schema.rs migration 027.
2925 container_id TEXT GENERATED ALWAYS AS (
2926 CASE item_type
2927 WHEN 'Episode' THEN COALESCE(season_id, series_id, parent_id)
2928 WHEN 'Season' THEN COALESCE(series_id, parent_id)
2929 WHEN 'Audio' THEN COALESCE(album_id, parent_id)
2930 ELSE parent_id
2931 END
2932 ) VIRTUAL
2933 );
2934 CREATE INDEX idx_items_container ON items(container_id, sort_name, name);
2935
2936 -- Mirrors the real FTS5 index and its triggers (schema.rs migration
2937 -- 001) so search can be exercised in tests at all.
2938 CREATE VIRTUAL TABLE items_fts USING fts5(
2939 name, overview, album_name, album_artist, artists, series_name,
2940 content='items', content_rowid='rowid'
2941 );
2942
2943 CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
2944 INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
2945 VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
2946 END;
2947
2948 CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
2949 INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
2950 VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
2951 END;
2952
2953 CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
2954 INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
2955 VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
2956 INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
2957 VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
2958 END;
2959
2960 CREATE TABLE user_data (
2961 user_id TEXT NOT NULL,
2962 item_id TEXT NOT NULL,
2963 playback_position_ticks INTEGER,
2964 is_played INTEGER,
2965 is_favorite INTEGER,
2966 play_count INTEGER,
2967 last_played_at TEXT,
2968 playback_context_type TEXT,
2969 playback_context_id TEXT,
2970 synced_at TEXT,
2971 pending_sync INTEGER DEFAULT 0,
2972 PRIMARY KEY (user_id, item_id)
2973 );
2974
2975 CREATE TABLE playlists (
2976 id TEXT PRIMARY KEY,
2977 user_id TEXT NOT NULL,
2978 name TEXT NOT NULL,
2979 is_local INTEGER DEFAULT 0,
2980 jellyfin_id TEXT,
2981 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
2982 updated_at TEXT
2983 );
2984
2985 CREATE TABLE playlist_items (
2986 id INTEGER PRIMARY KEY AUTOINCREMENT,
2987 playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
2988 item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
2989 sort_order INTEGER NOT NULL,
2990 added_at TEXT DEFAULT CURRENT_TIMESTAMP,
2991 UNIQUE(playlist_id, item_id)
2992 );
2993
2994 CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
2995
2996 CREATE TABLE downloads (
2997 id INTEGER PRIMARY KEY AUTOINCREMENT,
2998 item_id TEXT NOT NULL,
2999 user_id TEXT,
3000 file_path TEXT,
3001 status TEXT NOT NULL,
3002 file_size INTEGER
3003 );
3004
3005 CREATE TABLE libraries (
3006 id TEXT PRIMARY KEY,
3007 server_id TEXT NOT NULL,
3008 name TEXT NOT NULL,
3009 collection_type TEXT,
3010 image_tag TEXT,
3011 sort_order INTEGER DEFAULT 0,
3012 synced_at TEXT
3013 );
3014
3015 -- Mirrors migration 009 + the migration 022 FTS index.
3016 CREATE TABLE people (
3017 id TEXT PRIMARY KEY,
3018 server_id TEXT NOT NULL,
3019 name TEXT NOT NULL,
3020 overview TEXT,
3021 primary_image_tag TEXT,
3022 premiere_date TEXT,
3023 end_date TEXT,
3024 synced_at TEXT DEFAULT CURRENT_TIMESTAMP
3025 );
3026
3027 CREATE VIRTUAL TABLE people_fts USING fts5(
3028 name, overview, content='people', content_rowid='rowid'
3029 );
3030
3031 CREATE TRIGGER people_ai AFTER INSERT ON people BEGIN
3032 INSERT INTO people_fts(rowid, name, overview)
3033 VALUES (new.rowid, new.name, new.overview);
3034 END;
3035
3036 CREATE TRIGGER people_ad AFTER DELETE ON people BEGIN
3037 INSERT INTO people_fts(people_fts, rowid, name, overview)
3038 VALUES('delete', old.rowid, old.name, old.overview);
3039 END;
3040
3041 CREATE TRIGGER people_au AFTER UPDATE ON people BEGIN
3042 INSERT INTO people_fts(people_fts, rowid, name, overview)
3043 VALUES('delete', old.rowid, old.name, old.overview);
3044 INSERT INTO people_fts(rowid, name, overview)
3045 VALUES (new.rowid, new.name, new.overview);
3046 END;
3047
3048 CREATE TABLE genres (
3049 id TEXT NOT NULL,
3050 server_id TEXT NOT NULL,
3051 library_id TEXT,
3052 name TEXT NOT NULL,
3053 album_count INTEGER,
3054 synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
3055 PRIMARY KEY (server_id, library_id, name)
3056 );
3057 "#,
3058 )
3059 .unwrap();
3060
3061 conn.execute(
3063 "INSERT INTO servers (id, name, url) VALUES ('test-server', 'Test Server', 'http://test')",
3064 [],
3065 ).unwrap();
3066
3067 Arc::new(Mutex::new(conn))
3068 }
3069
3070 #[tokio::test]
3083 async fn test_a_downloaded_item_gets_playback_info_without_the_server() {
3084 let db_service = create_test_db();
3085 for sql in [
3086 "INSERT INTO downloads (item_id, user_id, file_path, status) \
3087 VALUES ('ep-6', 'test-user', '/data/videos/S01E06.mp4', 'completed')",
3088 "INSERT INTO downloads (item_id, user_id, file_path, status) \
3090 VALUES ('ep-7', 'test-user', '/data/videos/S01E07.mp4', 'downloading')",
3091 "INSERT INTO downloads (item_id, user_id, file_path, status) \
3093 VALUES ('ep-8', 'other-user', '/data/videos/S01E08.mp4', 'completed')",
3094 ] {
3095 db_service.execute(Query::new(sql)).await.unwrap();
3096 }
3097 let offline = OfflineRepository::new(
3098 db_service.clone(),
3099 "test-server".to_string(),
3100 "test-user".to_string(),
3101 );
3102 let local = OfflineRepository::new(
3103 db_service,
3104 "test-server".to_string(),
3105 "test-user".to_string(),
3106 );
3107 let online = crate::repository::OnlineRepository::new(
3109 Arc::new(
3110 crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
3111 ),
3112 "http://127.0.0.1:9".to_string(),
3113 "test-user".to_string(),
3114 "test-token".to_string(),
3115 );
3116 let hybrid = crate::repository::HybridRepository::new(online, offline);
3117
3118 let started = std::time::Instant::now();
3119 let info = hybrid
3120 .get_playback_info("ep-6")
3121 .await
3122 .expect("a downloaded item must get playback info with no server");
3123 assert!(
3124 started.elapsed() < std::time::Duration::from_secs(1),
3125 "answered locally, not after the network gave up: {:?}",
3126 started.elapsed()
3127 );
3128 assert_eq!(info.stream_url, "/data/videos/S01E06.mp4");
3129 assert!(info.direct_play && !info.needs_transcoding);
3130 assert_eq!(info.media_source_id, "ep-6");
3133
3134 for not_local in ["ep-7", "ep-8", "never-downloaded"] {
3136 assert!(
3137 local
3138 .local_playback_info(not_local)
3139 .await
3140 .unwrap()
3141 .is_none(),
3142 "{not_local} has no completed local file for this user"
3143 );
3144 }
3145 }
3146
3147 #[tokio::test]
3155 async fn test_next_up_answers_from_the_cache_when_the_server_is_unreachable() {
3156 let offline = OfflineRepository::new(
3157 create_test_db(),
3158 "test-server".to_string(),
3159 "test-user".to_string(),
3160 );
3161 let online = crate::repository::OnlineRepository::new(
3162 Arc::new(
3163 crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
3164 ),
3165 "http://127.0.0.1:9".to_string(),
3166 "test-user".to_string(),
3167 "test-token".to_string(),
3168 );
3169 let hybrid = crate::repository::HybridRepository::new(online, offline);
3170
3171 let next_up = hybrid.get_next_up_episodes(None, Some(12)).await;
3172 assert!(
3173 next_up.is_ok(),
3174 "an unreachable server must not fail Next Up offline: {next_up:?}"
3175 );
3176 }
3177
3178 #[tokio::test]
3192 async fn test_get_items_waits_for_a_slow_cache_when_the_server_is_unreachable() {
3193 let _guard = lock_catalog_browse();
3194 set_include_catalog_browse(true);
3195 let conn = create_test_conn();
3196 let db_service = Arc::new(RusqliteService::new(Arc::clone(&conn)));
3197 for sql in [
3198 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3199 VALUES ('series-1', 'test-server', 'Show', 'Series', '2026-01-01')",
3200 "INSERT INTO items (id, server_id, parent_id, name, item_type, synced_at) \
3201 VALUES ('season-1', 'test-server', 'series-1', 'Season 1', 'Season', '2026-01-01')",
3202 ] {
3203 db_service.execute(Query::new(sql)).await.unwrap();
3204 }
3205 let offline = OfflineRepository::new(
3206 db_service,
3207 "test-server".to_string(),
3208 "test-user".to_string(),
3209 );
3210 let online = crate::repository::OnlineRepository::new(
3211 Arc::new(
3212 crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
3213 ),
3214 "http://127.0.0.1:9".to_string(),
3215 "test-user".to_string(),
3216 "test-token".to_string(),
3217 );
3218 let hybrid = crate::repository::HybridRepository::new(online, offline);
3219
3220 let quick = hybrid
3223 .get_items("series-1", None)
3224 .await
3225 .expect("unlocked read");
3226 assert_eq!(quick.items.len(), 1, "fixture: the season is cached");
3227
3228 let held = Arc::clone(&conn);
3230 let writer = std::thread::spawn(move || {
3231 let _lock = held.lock().unwrap();
3232 std::thread::sleep(std::time::Duration::from_millis(300));
3233 });
3234 std::thread::sleep(std::time::Duration::from_millis(20));
3235
3236 let slow = hybrid.get_items("series-1", None).await;
3237 writer.join().unwrap();
3238 let slow = slow.expect("a slow cache must still answer when the server cannot");
3239 assert_eq!(slow.items.len(), 1);
3240 assert_eq!(slow.items[0].id, "season-1");
3241 }
3242
3243 fn hybrid_without_server(conn: &Arc<Mutex<Connection>>) -> crate::repository::HybridRepository {
3245 let offline = OfflineRepository::new(
3246 Arc::new(RusqliteService::new(Arc::clone(conn))),
3247 "test-server".to_string(),
3248 "test-user".to_string(),
3249 );
3250 let online = crate::repository::OnlineRepository::new(
3251 Arc::new(
3252 crate::jellyfin::HttpClient::new(crate::jellyfin::HttpConfig::default()).unwrap(),
3253 ),
3254 "http://127.0.0.1:9".to_string(),
3255 "test-user".to_string(),
3256 "test-token".to_string(),
3257 );
3258 crate::repository::HybridRepository::new(online, offline)
3259 }
3260
3261 fn hold_database(conn: &Arc<Mutex<Connection>>, ms: u64) -> std::thread::JoinHandle<()> {
3263 let held = Arc::clone(conn);
3264 let writer = std::thread::spawn(move || {
3265 let _lock = held.lock().unwrap();
3266 std::thread::sleep(std::time::Duration::from_millis(ms));
3267 });
3268 std::thread::sleep(std::time::Duration::from_millis(20));
3269 writer
3270 }
3271
3272 #[tokio::test]
3279 async fn test_libraries_wait_for_a_slow_cache_when_the_server_is_unreachable() {
3280 let conn = create_test_conn();
3281 conn.lock()
3282 .unwrap()
3283 .execute(
3284 "INSERT INTO libraries (id, server_id, name, collection_type) \
3285 VALUES ('lib-tv', 'test-server', 'Shows', 'tvshows')",
3286 [],
3287 )
3288 .unwrap();
3289 let hybrid = hybrid_without_server(&conn);
3290
3291 let writer = hold_database(&conn, 300);
3292 let libs = hybrid.get_libraries().await;
3293 writer.join().unwrap();
3294
3295 let libs = libs.expect("a slow cache must still answer when the server cannot");
3296 assert_eq!(libs.len(), 1);
3297 assert_eq!(libs[0].id, "lib-tv");
3298 }
3299
3300 #[tokio::test]
3306 async fn test_cache_only_reads_wait_out_a_busy_database() {
3307 let conn = create_test_conn();
3308 let hybrid = hybrid_without_server(&conn);
3309 hybrid
3311 .search_cache_only("anything", None)
3312 .await
3313 .expect("unlocked search");
3314
3315 let writer = hold_database(&conn, 300);
3316 let search = hybrid.search_cache_only("anything", None).await;
3317 writer.join().unwrap();
3318 search.expect("a busy database delays a cache-only search, it must not fail it");
3319
3320 let writer = hold_database(&conn, 300);
3321 let favourites = hybrid
3322 .get_favorites_cache_only(crate::repository::SearchScope::All, None)
3323 .await;
3324 writer.join().unwrap();
3325 favourites.expect("a busy database delays cache-only favourites, it must not fail them");
3326 }
3327
3328 fn create_test_item(id: &str, name: &str, parent_id: Option<&str>) -> MediaItem {
3329 MediaItem {
3330 id: id.to_string(),
3331 name: name.to_string(),
3332 item_type: "Audio".to_string(),
3333 kind: crate::domain::MediaKind::Track,
3334 is_folder: false,
3335 server_id: "test-server".to_string(),
3336 parent_id: parent_id.map(|s| s.to_string()),
3337 library_id: None,
3338 overview: None,
3339 genres: None,
3340 runtime_ticks: None,
3341 duration_ms: None,
3342 production_year: None,
3343 premiere_date: None,
3344 community_rating: None,
3345 official_rating: None,
3346 primary_image_tag: None,
3347 image_id: None,
3348 backdrop_image_tags: None,
3349 parent_backdrop_image_tags: None,
3350 album_id: None,
3351 album_name: None,
3352 album_artist: None,
3353 artists: None,
3354 artist_items: None,
3355 index_number: None,
3356 series_id: None,
3357 series_name: None,
3358 season_id: None,
3359 season_name: None,
3360 parent_index_number: None,
3361 user_data: None,
3362 media_streams: None,
3363 media_sources: None,
3364 people: None,
3365 }
3366 }
3367
3368 #[tokio::test]
3369 async fn test_save_to_cache_with_missing_parent_fk() {
3370 let db_service = create_test_db();
3371
3372 let fk_enabled: i32 = db_service
3374 .query_one(Query::new("PRAGMA foreign_keys"), |row| row.get(0))
3375 .await
3376 .unwrap();
3377 println!("Foreign keys enabled: {}", fk_enabled);
3378 assert_eq!(fk_enabled, 1, "Foreign keys should be enabled");
3379
3380 let repo = OfflineRepository::new(
3381 db_service.clone(),
3382 "test-server".to_string(),
3383 "test-user".to_string(),
3384 );
3385
3386 let items = vec![
3390 create_test_item("track-1", "Track 1", Some("album-1")),
3392 create_test_item("track-2", "Track 2", Some("album-1")),
3393 create_test_item("track-3", "Track 3", Some("album-2")),
3395 create_test_item("track-4", "Track 4", Some("album-2")),
3396 create_test_item("album-1", "Album One", Some("library-123")),
3398 create_test_item("album-2", "Album Two", Some("library-123")),
3399 ];
3400
3401 println!("Attempting to save {} items...", items.len());
3402 for (i, item) in items.iter().enumerate() {
3403 println!(" Item {}: {} (parent: {:?})", i, item.id, item.parent_id);
3404 }
3405
3406 let result = repo.save_to_cache("library-123", &items).await;
3411
3412 match &result {
3414 Ok(count) => {
3415 println!("✓ Saved {} items", count);
3416 assert_eq!(*count, 6);
3417
3418 let all_items: Vec<(String, Option<String>)> = db_service
3420 .query_many(
3421 Query::new("SELECT id, parent_id FROM items ORDER BY id"),
3422 |row| Ok((row.get(0)?, row.get(1)?)),
3423 )
3424 .await
3425 .unwrap();
3426
3427 println!("\nAll items in database:");
3428 for (id, parent) in &all_items {
3429 println!(" {} -> parent: {:?}", id, parent);
3430 }
3431
3432 let track1_parent: Option<String> = db_service
3434 .query_optional(
3435 Query::with_params(
3436 "SELECT parent_id FROM items WHERE id = ?",
3437 vec![QueryParam::String("track-1".to_string())],
3438 ),
3439 |row| row.get(0),
3440 )
3441 .await
3442 .unwrap()
3443 .flatten();
3444
3445 println!("\ntrack-1 parent_id in DB: {:?}", track1_parent);
3446 println!("track-1 expected parent_id: Some(\"album-1\")");
3447
3448 assert_eq!(
3450 track1_parent,
3451 Some("album-1".to_string()),
3452 "track-1 should have parent_id='album-1'"
3453 );
3454
3455 let album1_parent: Option<String> = db_service
3457 .query_optional(
3458 Query::with_params(
3459 "SELECT parent_id FROM items WHERE id = ?",
3460 vec![QueryParam::String("album-1".to_string())],
3461 ),
3462 |row| row.get(0),
3463 )
3464 .await
3465 .unwrap()
3466 .flatten();
3467
3468 assert_eq!(
3469 album1_parent,
3470 Some("library-123".to_string()),
3471 "album-1 should have parent_id='library-123'"
3472 );
3473 }
3474 Err(e) => panic!("Unexpected error: {:?}", e),
3475 }
3476 }
3477
3478 #[tokio::test]
3479 async fn test_save_to_cache_simple_case() {
3480 let db_service = create_test_db();
3481 let repo = OfflineRepository::new(
3482 db_service.clone(),
3483 "test-server".to_string(),
3484 "test-user".to_string(),
3485 );
3486
3487 let items = vec![
3489 create_test_item("item-1", "Item 1", Some("parent-123")),
3490 create_test_item("item-2", "Item 2", Some("parent-123")),
3491 create_test_item("item-3", "Item 3", Some("parent-123")),
3492 ];
3493
3494 let result = repo.save_to_cache("parent-123", &items).await;
3495 assert!(result.is_ok(), "Simple case should work: {:?}", result);
3496 assert_eq!(result.unwrap(), 3);
3497 }
3498
3499 #[tokio::test]
3507 async fn test_get_item_album_available_via_album_id_link() {
3508 use crate::storage::db_service::DatabaseService;
3509 let db_service = create_test_db();
3510
3511 for sql in [
3512 "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
3514 VALUES ('album-1', 'test-server', 'Hadestown', 'MusicAlbum', NULL, NULL)",
3515 "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
3517 VALUES ('track-1', 'test-server', 'Wait For Me', 'Audio', 'album-1', NULL)",
3518 "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
3519 ] {
3520 db_service.execute(Query::new(sql)).await.unwrap();
3521 }
3522
3523 let repo = OfflineRepository::new(
3524 db_service.clone(),
3525 "test-server".to_string(),
3526 "test-user".to_string(),
3527 );
3528
3529 assert!(
3531 repo.get_item("track-1").await.is_ok(),
3532 "downloaded track should be available offline"
3533 );
3534
3535 let album = repo.get_item("album-1").await;
3538 assert!(
3539 album.is_ok(),
3540 "album with an album_id-linked downloaded track should be available offline, got {:?}",
3541 album.err()
3542 );
3543 assert_eq!(album.unwrap().id, "album-1");
3544
3545 let tracks = repo.get_items("album-1", None).await.unwrap();
3549 assert_eq!(
3550 tracks.items.len(),
3551 1,
3552 "get_items(album_id) should return the track"
3553 );
3554 assert_eq!(tracks.items[0].id, "track-1");
3555 }
3556
3557 #[tokio::test]
3570 async fn test_get_items_toggle_gates_synced_catalog() {
3571 use crate::storage::db_service::DatabaseService;
3572 let _guard = lock_catalog_browse();
3573 let db_service = create_test_db();
3574
3575 for sql in [
3576 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3578 VALUES ('movie-dl', 'test-server', 'Downloaded', 'Movie', 'lib-1', '2026-01-01')",
3579 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3580 VALUES ('movie-cat', 'test-server', 'CatalogOnly', 'Movie', 'lib-1', '2026-01-01')",
3581 "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
3583 "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
3585 ] {
3586 db_service.execute(Query::new(sql)).await.unwrap();
3587 }
3588
3589 let repo = OfflineRepository::new(
3590 db_service.clone(),
3591 "test-server".to_string(),
3592 "test-user".to_string(),
3593 );
3594 let opts = Some(GetItemsOptions {
3595 include_item_types: Some(vec!["Movie".to_string()]),
3596 ..Default::default()
3597 });
3598
3599 set_include_catalog_browse(false);
3601 let local_only = repo.get_items("lib-1", opts.clone()).await.unwrap();
3602 let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
3603 assert_eq!(
3604 ids,
3605 vec!["movie-dl"],
3606 "toggle off should show downloaded media only"
3607 );
3608
3609 set_include_catalog_browse(true);
3611 let full_catalog = repo.get_items("lib-1", opts).await.unwrap();
3612 let mut ids: Vec<&str> = full_catalog.items.iter().map(|i| i.id.as_str()).collect();
3613 ids.sort();
3614 assert_eq!(
3615 ids,
3616 vec!["movie-cat", "movie-dl"],
3617 "toggle on should reveal the full catalog"
3618 );
3619
3620 set_include_catalog_browse(true);
3622 }
3623
3624 #[tokio::test]
3631 async fn test_search_includes_cached_people() {
3632 use crate::storage::db_service::DatabaseService;
3633 let _guard = lock_catalog_browse();
3634 let db_service = create_test_db();
3635
3636 for sql in [
3637 "INSERT INTO people (id, server_id, name, overview) \
3638 VALUES ('p1', 'test-server', 'Tilda Swinton', 'Actor')",
3639 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3642 VALUES ('m1', 'test-server', 'Tilda the Movie', 'Movie', '2026-01-01')",
3643 ] {
3644 db_service.execute(Query::new(sql)).await.unwrap();
3645 }
3646
3647 let repo = OfflineRepository::new(
3648 db_service.clone(),
3649 "test-server".to_string(),
3650 "test-user".to_string(),
3651 );
3652 set_include_catalog_browse(true);
3653
3654 let all = repo.search("Tilda", None).await.unwrap();
3656 let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
3657 ids.sort();
3658 assert_eq!(ids, vec!["m1", "p1"], "unscoped search must include people");
3659
3660 let person = all.items.iter().find(|i| i.id == "p1").unwrap();
3661 assert_eq!(person.item_type, "Person");
3662 assert_eq!(person.kind, crate::domain::MediaKind::Person);
3663
3664 let scoped = repo
3666 .search(
3667 "Tilda",
3668 Some(SearchOptions {
3669 include_item_types: Some(vec!["Movie".to_string()]),
3670 ..Default::default()
3671 }),
3672 )
3673 .await
3674 .unwrap();
3675 let ids: Vec<&str> = scoped.items.iter().map(|i| i.id.as_str()).collect();
3676 assert_eq!(ids, vec!["m1"], "a scoped search must not leak people in");
3677
3678 set_include_catalog_browse(true);
3679 }
3680
3681 #[tokio::test]
3688 async fn test_prune_stale_catalog() {
3689 use crate::storage::db_service::DatabaseService;
3690 let db_service = create_test_db();
3691
3692 let old = "2026-01-01T00:00:00+00:00";
3694 let new = "2026-06-01T00:00:00+00:00";
3695 let cutoff = "2026-03-01T00:00:00+00:00";
3696
3697 for sql in [
3698 "INSERT INTO servers (id, name, url) \
3700 VALUES ('other-server', 'Other', 'http://other')"
3701 .to_string(),
3702 format!(
3704 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3705 VALUES ('keep-fresh', 'test-server', 'Fresh', 'Movie', '{new}')"
3706 ),
3707 format!(
3709 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3710 VALUES ('gone', 'test-server', 'Vanished', 'Movie', '{old}')"
3711 ),
3712 format!(
3714 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3715 VALUES ('keep-dl', 'test-server', 'Downloaded', 'Movie', '{old}')"
3716 ),
3717 "INSERT INTO downloads (item_id, status) VALUES ('keep-dl', 'completed')".to_string(),
3718 format!(
3720 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3721 VALUES ('keep-album', 'test-server', 'Album', 'MusicAlbum', '{old}')"
3722 ),
3723 format!(
3724 "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
3725 VALUES ('keep-track', 'test-server', 'Track', 'Audio', 'keep-album', '{old}')"
3726 ),
3727 "INSERT INTO downloads (item_id, status) VALUES ('keep-track', 'completed')"
3728 .to_string(),
3729 format!(
3732 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3733 VALUES ('keep-artist', 'test-server', 'Artist', 'MusicArtist', '{old}')"
3734 ),
3735 format!(
3737 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3738 VALUES ('keep-other', 'other-server', 'Elsewhere', 'Movie', '{old}')"
3739 ),
3740 ] {
3741 db_service.execute(Query::new(&sql)).await.unwrap();
3742 }
3743
3744 let repo = OfflineRepository::new(
3745 db_service.clone(),
3746 "test-server".to_string(),
3747 "test-user".to_string(),
3748 );
3749
3750 let crawled_types = vec![
3751 "Movie".to_string(),
3752 "MusicAlbum".to_string(),
3753 "Audio".to_string(),
3754 ];
3755 let removed = repo
3756 .prune_stale_catalog(cutoff, &crawled_types)
3757 .await
3758 .unwrap();
3759 assert_eq!(removed, 1, "only the vanished movie should be swept");
3760
3761 let mut surviving: Vec<String> = db_service
3762 .query_many(Query::new("SELECT id FROM items"), |row| row.get(0))
3763 .await
3764 .unwrap();
3765 surviving.sort();
3766 assert_eq!(
3767 surviving,
3768 vec![
3769 "keep-album",
3770 "keep-artist",
3771 "keep-dl",
3772 "keep-fresh",
3773 "keep-other",
3774 "keep-track",
3775 ]
3776 );
3777
3778 assert_eq!(repo.prune_stale_catalog(cutoff, &[]).await.unwrap(), 0);
3780 }
3781
3782 #[tokio::test]
3797 async fn test_repeated_cache_does_not_duplicate_fts_entries() {
3798 use crate::storage::db_service::DatabaseService;
3799 let db_service = create_test_db();
3800 let repo = OfflineRepository::new(
3801 db_service.clone(),
3802 "test-server".to_string(),
3803 "test-user".to_string(),
3804 );
3805
3806 let items = vec![create_test_item("track-1", "Wait For Me", None)];
3807
3808 for _ in 0..3 {
3811 repo.save_to_cache("parent-1", &items).await.unwrap();
3812 }
3813
3814 let item_rows: i64 = db_service
3817 .query_one(
3818 Query::new("SELECT COUNT(*) FROM items WHERE id = 'track-1'"),
3819 |row| row.get(0),
3820 )
3821 .await
3822 .unwrap();
3823 assert_eq!(item_rows, 1, "three passes must leave one item row");
3824
3825 let fts_hits: i64 = db_service
3826 .query_one(
3827 Query::with_params(
3828 "SELECT COUNT(*) FROM items_fts WHERE items_fts MATCH ?",
3829 vec![QueryParam::String("\"Wait\"*".to_string())],
3830 ),
3831 |row| row.get(0),
3832 )
3833 .await
3834 .unwrap();
3835 assert_eq!(
3836 fts_hits, 1,
3837 "the FTS index must hold one entry per item, not one per sync pass"
3838 );
3839 }
3840
3841 #[tokio::test]
3850 async fn test_search_toggle_gates_synced_catalog() {
3851 use crate::storage::db_service::DatabaseService;
3852 let _guard = lock_catalog_browse();
3853 let db_service = create_test_db();
3854
3855 for sql in [
3856 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3858 VALUES ('movie-dl', 'test-server', 'Arrival', 'Movie', 'lib-1', '2026-01-01')",
3859 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
3860 VALUES ('movie-cat', 'test-server', 'Arrakis', 'Movie', 'lib-1', '2026-01-01')",
3861 "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
3863 ] {
3864 db_service.execute(Query::new(sql)).await.unwrap();
3865 }
3866
3867 let repo = OfflineRepository::new(
3868 db_service.clone(),
3869 "test-server".to_string(),
3870 "test-user".to_string(),
3871 );
3872
3873 set_include_catalog_browse(true);
3876 let full = repo.search("Arr", None).await.unwrap();
3877 let mut ids: Vec<&str> = full.items.iter().map(|i| i.id.as_str()).collect();
3878 ids.sort();
3879 assert_eq!(
3880 ids,
3881 vec!["movie-cat", "movie-dl"],
3882 "with catalog browse on, search must cover synced-but-not-downloaded items"
3883 );
3884
3885 set_include_catalog_browse(false);
3887 let local_only = repo.search("Arr", None).await.unwrap();
3888 let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
3889 assert_eq!(
3890 ids,
3891 vec!["movie-dl"],
3892 "with catalog browse off, search stays downloads-only"
3893 );
3894
3895 set_include_catalog_browse(true);
3897 }
3898
3899 #[tokio::test]
3906 async fn test_search_type_filter_is_parameterised() {
3907 use crate::storage::db_service::DatabaseService;
3908 let _guard = lock_catalog_browse();
3909 let db_service = create_test_db();
3910
3911 db_service
3912 .execute(Query::new(
3913 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
3914 VALUES ('m1', 'test-server', 'Arrival', 'Movie', '2026-01-01')",
3915 ))
3916 .await
3917 .unwrap();
3918
3919 let repo = OfflineRepository::new(
3920 db_service.clone(),
3921 "test-server".to_string(),
3922 "test-user".to_string(),
3923 );
3924 set_include_catalog_browse(true);
3925
3926 let opts = SearchOptions {
3928 include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
3929 ..Default::default()
3930 };
3931 let result = repo.search("Arr", Some(opts)).await;
3932 assert!(
3933 result.is_ok(),
3934 "a quote in an item type must not break the query: {:?}",
3935 result.err()
3936 );
3937 assert!(
3938 result.unwrap().items.is_empty(),
3939 "an injected type filter must not widen the result set"
3940 );
3941
3942 let opts = SearchOptions {
3944 include_item_types: Some(vec!["Movie".to_string()]),
3945 ..Default::default()
3946 };
3947 assert_eq!(repo.search("Arr", Some(opts)).await.unwrap().items.len(), 1);
3948 }
3949
3950 #[tokio::test]
3955 async fn test_get_item_tv_available_via_season_series_link() {
3956 use crate::storage::db_service::DatabaseService;
3957 let db_service = create_test_db();
3958
3959 for sql in [
3960 "INSERT INTO items (id, server_id, name, item_type, parent_id) \
3961 VALUES ('series-1', 'test-server', 'Gilmore Girls', 'Series', NULL)",
3962 "INSERT INTO items (id, server_id, name, item_type, series_id, parent_id) \
3963 VALUES ('season-1', 'test-server', 'Season 1', 'Season', 'series-1', NULL)",
3964 "INSERT INTO items (id, server_id, name, item_type, season_id, series_id, parent_id) \
3966 VALUES ('ep-1', 'test-server', 'Pilot', 'Episode', 'season-1', 'series-1', NULL)",
3967 "INSERT INTO downloads (item_id, status) VALUES ('ep-1', 'completed')",
3968 ] {
3969 db_service.execute(Query::new(sql)).await.unwrap();
3970 }
3971
3972 let repo = OfflineRepository::new(
3973 db_service.clone(),
3974 "test-server".to_string(),
3975 "test-user".to_string(),
3976 );
3977
3978 assert!(
3979 repo.get_item("ep-1").await.is_ok(),
3980 "downloaded episode available offline"
3981 );
3982 assert!(
3983 repo.get_item("season-1").await.is_ok(),
3984 "season with a season_id-linked downloaded episode should be available offline"
3985 );
3986 assert!(
3987 repo.get_item("series-1").await.is_ok(),
3988 "series with a series_id-linked downloaded episode should be available offline"
3989 );
3990
3991 let season_items = repo.get_items("season-1", None).await.unwrap();
3993 assert!(
3994 season_items.items.iter().any(|i| i.id == "ep-1"),
3995 "get_items(season_id) should return the episode"
3996 );
3997
3998 let series_items = repo.get_items("series-1", None).await.unwrap();
4003 let ids: Vec<&str> = series_items.items.iter().map(|i| i.id.as_str()).collect();
4004 assert_eq!(
4005 ids,
4006 vec!["season-1"],
4007 "get_items(series_id) should list the season holding the download"
4008 );
4009 }
4010
4011 #[tokio::test]
4016 async fn test_libraries_cache_roundtrip_available_offline() {
4017 let db_service = create_test_db();
4018 let repo = OfflineRepository::new(
4019 db_service.clone(),
4020 "test-server".to_string(),
4021 "test-user".to_string(),
4022 );
4023
4024 assert!(repo.get_libraries().await.unwrap().is_empty());
4027
4028 let server_libs = vec![
4030 Library::new("music".into(), "Music".into(), "music".into(), None),
4031 Library::new(
4032 "movies".into(),
4033 "Movies".into(),
4034 "movies".into(),
4035 Some("tag".into()),
4036 ),
4037 ];
4038 let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
4039 assert_eq!(saved, 2);
4040
4041 let offline_libs = repo.get_libraries().await.unwrap();
4043 let names: Vec<&str> = offline_libs.iter().map(|l| l.name.as_str()).collect();
4044 assert_eq!(
4045 names,
4046 vec!["Music", "Movies"],
4047 "cached libraries available offline in sort order"
4048 );
4049
4050 repo.save_libraries_to_cache(&server_libs).await.unwrap();
4052 assert_eq!(repo.get_libraries().await.unwrap().len(), 2);
4053 }
4054
4055 #[tokio::test]
4062 async fn test_genres_cache_roundtrip_scoped_by_library() {
4063 let db_service = create_test_db();
4064 let repo = OfflineRepository::new(
4065 db_service.clone(),
4066 "test-server".to_string(),
4067 "test-user".to_string(),
4068 );
4069
4070 assert!(repo.get_genres(Some("music-lib")).await.unwrap().is_empty());
4072
4073 let server_genres = vec![
4075 Genre {
4076 id: "g1".into(),
4077 name: "Rock".into(),
4078 album_count: Some(42),
4079 },
4080 Genre {
4081 id: "g2".into(),
4082 name: "Jazz".into(),
4083 album_count: Some(17),
4084 },
4085 Genre {
4086 id: "g3".into(),
4087 name: "Ambient".into(),
4088 album_count: None,
4089 },
4090 ];
4091 let saved = repo
4092 .save_genres_to_cache(Some("music-lib"), &server_genres)
4093 .await
4094 .unwrap();
4095 assert_eq!(saved, 3);
4096
4097 let mut offline_genres = repo.get_genres(Some("music-lib")).await.unwrap();
4099 offline_genres.sort_by(|a, b| a.name.cmp(&b.name));
4100 let names: Vec<&str> = offline_genres.iter().map(|g| g.name.as_str()).collect();
4101 assert_eq!(names, vec!["Ambient", "Jazz", "Rock"]);
4102 let rock = offline_genres.iter().find(|g| g.name == "Rock").unwrap();
4103 assert_eq!(rock.album_count, Some(42));
4104
4105 assert!(repo.get_genres(Some("other-lib")).await.unwrap().is_empty());
4107
4108 let updated = vec![Genre {
4110 id: "g1".into(),
4111 name: "Rock".into(),
4112 album_count: Some(50),
4113 }];
4114 repo.save_genres_to_cache(Some("music-lib"), &updated)
4115 .await
4116 .unwrap();
4117 let after = repo.get_genres(Some("music-lib")).await.unwrap();
4118 assert_eq!(after.len(), 1, "stale genres removed on refresh");
4119 assert_eq!(after[0].album_count, Some(50), "counts updated on refresh");
4120 }
4121
4122 async fn seed_items(repo: &OfflineRepository, ids: &[&str]) {
4126 let items: Vec<MediaItem> = ids
4127 .iter()
4128 .map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1")))
4129 .collect();
4130 repo.save_to_cache("library-1", &items).await.unwrap();
4131 }
4132
4133 async fn insert_item(
4136 db: &Arc<RusqliteService>,
4137 id: &str,
4138 item_type: &str,
4139 album_id: Option<&str>,
4140 series_id: Option<&str>,
4141 season_id: Option<&str>,
4142 ) {
4143 db.execute(Query::with_params(
4144 "INSERT INTO items (id, server_id, name, item_type, album_id, series_id, season_id, synced_at)
4145 VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, ?6, '2024-01-01')",
4146 vec![
4147 QueryParam::String(id.to_string()),
4148 QueryParam::String(format!("Name {id}")),
4149 QueryParam::String(item_type.to_string()),
4150 album_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
4151 series_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
4152 season_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
4153 ],
4154 ))
4155 .await
4156 .unwrap();
4157 }
4158
4159 async fn insert_library_item(
4162 db: &Arc<RusqliteService>,
4163 id: &str,
4164 item_type: &str,
4165 library_id: &str,
4166 album_id: Option<&str>,
4167 ) {
4168 db.execute(Query::with_params(
4169 "INSERT INTO items (id, server_id, library_id, name, item_type, album_id, synced_at)
4170 VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, '2024-01-01')",
4171 vec![
4172 QueryParam::String(id.to_string()),
4173 QueryParam::String(library_id.to_string()),
4174 QueryParam::String(format!("Name {id}")),
4175 QueryParam::String(item_type.to_string()),
4176 album_id
4177 .map(|s| QueryParam::String(s.to_string()))
4178 .unwrap_or(QueryParam::Null),
4179 ],
4180 ))
4181 .await
4182 .unwrap();
4183 }
4184
4185 async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
4186 db.execute(Query::with_params(
4187 "INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
4188 vec![
4189 QueryParam::String(item_id.to_string()),
4190 QueryParam::Int64(file_size),
4191 ],
4192 ))
4193 .await
4194 .unwrap();
4195 }
4196
4197 async fn seed_library(db: &Arc<RusqliteService>, id: &str, collection_type: &str) {
4198 db.execute(Query::with_params(
4199 "INSERT INTO libraries (id, server_id, name, collection_type, sort_order)
4200 VALUES (?1, 'test-server', ?2, ?3, 0)",
4201 vec![
4202 QueryParam::String(id.to_string()),
4203 QueryParam::String(format!("Lib {id}")),
4204 QueryParam::String(collection_type.to_string()),
4205 ],
4206 ))
4207 .await
4208 .unwrap();
4209 }
4210
4211 fn make_repo(db: &Arc<RusqliteService>) -> OfflineRepository {
4212 OfflineRepository::new(
4213 db.clone(),
4214 "test-server".to_string(),
4215 "test-user".to_string(),
4216 )
4217 }
4218
4219 #[tokio::test]
4226 async fn test_get_latest_items_collapses_tracks_into_their_album() {
4227 let db = create_test_db();
4228 insert_library_item(&db, "album-1", "MusicAlbum", "lib-1", None).await;
4229 for track in ["track-1", "track-2", "track-3"] {
4230 insert_library_item(&db, track, "Audio", "lib-1", Some("album-1")).await;
4231 seed_completed_download(&db, track, 1000).await;
4232 }
4233 insert_library_item(&db, "movie-1", "Movie", "lib-1", None).await;
4235 seed_completed_download(&db, "movie-1", 2000).await;
4236
4237 let repo = make_repo(&db);
4238 let latest = repo.get_latest_items("lib-1", Some(16)).await.unwrap();
4239 let ids: Vec<&str> = latest.iter().map(|i| i.id.as_str()).collect();
4240
4241 assert!(
4242 !ids.iter().any(|id| id.starts_with("track-")),
4243 "individual tracks must collapse into their album, got: {ids:?}"
4244 );
4245 assert!(ids.contains(&"album-1"), "the album itself is listed");
4246 assert!(ids.contains(&"movie-1"), "containerless items still listed");
4247 }
4248
4249 #[tokio::test]
4254 async fn test_get_downloaded_items_returns_leaf_and_container() {
4255 let db = create_test_db();
4256 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4257 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4258 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
4259 seed_completed_download(&db, "track-1", 1000).await;
4261
4262 let repo = make_repo(&db);
4263
4264 let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
4266 let ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
4267 assert_eq!(ids, vec!["track-1"], "only the downloaded track is listed");
4268 }
4269
4270 #[tokio::test]
4276 async fn test_get_downloaded_items_library_lists_albums_not_tracks() {
4277 let db = create_test_db();
4278 seed_library(&db, "music-lib", "music").await;
4279 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4280 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4282 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
4283 seed_completed_download(&db, "track-1", 1000).await;
4284 seed_completed_download(&db, "track-2", 1000).await;
4285
4286 let repo = make_repo(&db);
4287
4288 let at_library = repo.get_downloaded_items("music-lib", None).await.unwrap();
4290 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
4291 assert_eq!(
4292 ids,
4293 vec!["album-1"],
4294 "library browse lists the album container, not its tracks"
4295 );
4296
4297 let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
4299 let mut track_ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
4300 track_ids.sort();
4301 assert_eq!(track_ids, vec!["track-1", "track-2"]);
4302 }
4303
4304 #[tokio::test]
4316 async fn test_get_downloaded_items_library_does_not_mix_media_types() {
4317 let db = create_test_db();
4318 seed_library(&db, "music-lib", "music").await;
4319 seed_library(&db, "movie-lib", "movies").await;
4320 seed_library(&db, "tv-lib", "tvshows").await;
4321
4322 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4323 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4324 insert_item(&db, "movie-1", "Movie", None, None, None).await;
4325 insert_item(&db, "series-1", "Series", None, None, None).await;
4326 insert_item(&db, "episode-1", "Episode", None, Some("series-1"), None).await;
4327
4328 seed_completed_download(&db, "track-1", 1000).await;
4329 seed_completed_download(&db, "movie-1", 2000).await;
4330 seed_completed_download(&db, "episode-1", 3000).await;
4331
4332 let repo = make_repo(&db);
4333
4334 let music: Vec<String> = repo
4335 .get_downloaded_items("music-lib", None)
4336 .await
4337 .unwrap()
4338 .items
4339 .iter()
4340 .map(|i| i.id.clone())
4341 .collect();
4342 assert_eq!(
4343 music,
4344 vec!["album-1"],
4345 "the music library must not list films or series; got {:?}",
4346 music
4347 );
4348
4349 let movies: Vec<String> = repo
4350 .get_downloaded_items("movie-lib", None)
4351 .await
4352 .unwrap()
4353 .items
4354 .iter()
4355 .map(|i| i.id.clone())
4356 .collect();
4357 assert_eq!(
4358 movies,
4359 vec!["movie-1"],
4360 "the movie library must not list albums or series; got {:?}",
4361 movies
4362 );
4363
4364 let tv: Vec<String> = repo
4365 .get_downloaded_items("tv-lib", None)
4366 .await
4367 .unwrap()
4368 .items
4369 .iter()
4370 .map(|i| i.id.clone())
4371 .collect();
4372 assert_eq!(
4373 tv,
4374 vec!["series-1"],
4375 "the TV library must not list albums or films; got {:?}",
4376 tv
4377 );
4378 }
4379
4380 #[tokio::test]
4386 async fn test_get_downloaded_items_library_lists_series_not_episodes() {
4387 let db = create_test_db();
4388 seed_library(&db, "tv-lib", "tvshows").await;
4389 insert_item(&db, "series-1", "Series", None, None, None).await;
4390 insert_item(&db, "season-1", "Season", None, Some("series-1"), None).await;
4392 insert_item(
4393 &db,
4394 "ep-1",
4395 "Episode",
4396 None,
4397 Some("series-1"),
4398 Some("season-1"),
4399 )
4400 .await;
4401 seed_completed_download(&db, "ep-1", 4000).await;
4402
4403 let repo = make_repo(&db);
4404
4405 let at_library = repo.get_downloaded_items("tv-lib", None).await.unwrap();
4407 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
4408 assert_eq!(
4409 ids,
4410 vec!["series-1"],
4411 "TV library browse lists the series, not seasons/episodes"
4412 );
4413
4414 let in_series = repo.get_downloaded_items("series-1", None).await.unwrap();
4416 let in_series_ids: Vec<&str> = in_series.items.iter().map(|i| i.id.as_str()).collect();
4417 assert_eq!(
4418 in_series_ids,
4419 vec!["season-1"],
4420 "series drill returns the season — not the season's episodes"
4421 );
4422 let in_season = repo.get_downloaded_items("season-1", None).await.unwrap();
4423 assert!(
4424 in_season.items.iter().any(|i| i.id == "ep-1"),
4425 "season drill returns the episode"
4426 );
4427 }
4428
4429 #[tokio::test]
4434 async fn test_get_downloaded_items_library_keeps_orphan_leaves() {
4435 let db = create_test_db();
4436 seed_library(&db, "movie-lib", "movies").await;
4437 insert_item(&db, "movie-1", "Movie", None, None, None).await;
4438 seed_completed_download(&db, "movie-1", 5000).await;
4439
4440 let repo = make_repo(&db);
4441 let at_library = repo.get_downloaded_items("movie-lib", None).await.unwrap();
4442 let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
4443 assert_eq!(
4444 ids,
4445 vec!["movie-1"],
4446 "a downloaded movie with no container shows"
4447 );
4448 }
4449
4450 #[tokio::test]
4455 async fn test_get_downloaded_items_empty_is_authoritative() {
4456 let db = create_test_db();
4457 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4458 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4459 set_include_catalog_browse(true);
4461 let repo = make_repo(&db);
4462
4463 let result = repo.get_downloaded_items("album-1", None).await.unwrap();
4464 assert!(
4465 result.items.is_empty(),
4466 "empty downloaded browse returns no items even with catalog-browse on"
4467 );
4468 }
4469
4470 #[tokio::test]
4474 async fn test_get_downloaded_libraries_omits_empty() {
4475 let db = create_test_db();
4476 seed_library(&db, "music-lib", "music").await;
4477 seed_library(&db, "movie-lib", "movies").await;
4478 insert_item(&db, "track-1", "Audio", None, None, None).await;
4479 seed_completed_download(&db, "track-1", 500).await;
4480
4481 let repo = make_repo(&db);
4482 let libs = repo.get_downloaded_libraries().await.unwrap();
4483 let ids: Vec<&str> = libs.iter().map(|l| l.id.as_str()).collect();
4484 assert_eq!(
4485 ids,
4486 vec!["music-lib"],
4487 "movie library with no downloads omitted"
4488 );
4489 }
4490
4491 #[tokio::test]
4496 async fn test_download_disk_usage_aggregates_containers() {
4497 let db = create_test_db();
4498 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4499 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4500 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
4501 seed_completed_download(&db, "track-1", 1000).await;
4502 seed_completed_download(&db, "track-2", 2000).await;
4503
4504 let repo = make_repo(&db);
4505 let usage = repo.get_download_disk_usage().await.unwrap();
4506
4507 assert_eq!(usage.item_count, 2, "two leaf downloads");
4508 assert_eq!(
4509 usage.device_total_bytes, 3000,
4510 "device total is the leaf sum"
4511 );
4512 assert_eq!(usage.sizes.get("track-1"), Some(&1000));
4513 assert_eq!(
4514 usage.sizes.get("album-1"),
4515 Some(&3000),
4516 "container = sum of children"
4517 );
4518 assert_eq!(
4520 usage.partial_containers.get("album-1"),
4521 None,
4522 "fully downloaded album is not partial"
4523 );
4524 let leaf_sum: i64 = ["track-1", "track-2"]
4526 .iter()
4527 .map(|id| usage.sizes[*id])
4528 .sum();
4529 assert_eq!(leaf_sum, usage.device_total_bytes);
4530 }
4531
4532 #[tokio::test]
4535 async fn test_download_disk_usage_flags_partial_container() {
4536 let db = create_test_db();
4537 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
4538 insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
4539 insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
4540 seed_completed_download(&db, "track-1", 1000).await;
4542
4543 let repo = make_repo(&db);
4544 let usage = repo.get_download_disk_usage().await.unwrap();
4545 assert_eq!(
4546 usage.partial_containers.get("album-1"),
4547 Some(&true),
4548 "album with a missing child is partial"
4549 );
4550 }
4551
4552 #[tokio::test]
4553 async fn test_playlist_create_empty() {
4554 let db_service = create_test_db();
4555 let repo = OfflineRepository::new(
4556 db_service.clone(),
4557 "test-server".to_string(),
4558 "test-user".to_string(),
4559 );
4560
4561 let result = repo.create_playlist("My Playlist", &[]).await;
4562 assert!(result.is_ok());
4563 let created = result.unwrap();
4564 assert!(
4565 !created.id.is_empty(),
4566 "Should return a non-empty playlist ID"
4567 );
4568
4569 let name: String = db_service
4571 .query_one(
4572 Query::with_params(
4573 "SELECT name FROM playlists WHERE id = ?",
4574 vec![QueryParam::String(created.id.clone())],
4575 ),
4576 |row| row.get(0),
4577 )
4578 .await
4579 .unwrap();
4580 assert_eq!(name, "My Playlist");
4581 }
4582
4583 #[tokio::test]
4584 async fn test_playlist_create_with_items() {
4585 let db_service = create_test_db();
4586 let repo = OfflineRepository::new(
4587 db_service.clone(),
4588 "test-server".to_string(),
4589 "test-user".to_string(),
4590 );
4591 seed_items(&repo, &["t1", "t2", "t3"]).await;
4592
4593 let created = repo
4594 .create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()])
4595 .await
4596 .unwrap();
4597
4598 let items = repo.get_playlist_items(&created.id).await.unwrap();
4599 assert_eq!(items.len(), 3);
4600 assert_eq!(items[0].item.id, "t1");
4601 assert_eq!(items[1].item.id, "t2");
4602 assert_eq!(items[2].item.id, "t3");
4603 }
4604
4605 #[tokio::test]
4606 async fn test_playlist_delete() {
4607 let db_service = create_test_db();
4608 let repo = OfflineRepository::new(
4609 db_service.clone(),
4610 "test-server".to_string(),
4611 "test-user".to_string(),
4612 );
4613 seed_items(&repo, &["t1"]).await;
4614
4615 let created = repo
4616 .create_playlist("To Delete", &["t1".into()])
4617 .await
4618 .unwrap();
4619
4620 repo.delete_playlist(&created.id).await.unwrap();
4622
4623 let count: i32 = db_service
4625 .query_one(
4626 Query::with_params(
4627 "SELECT COUNT(*) FROM playlists WHERE id = ?",
4628 vec![QueryParam::String(created.id.clone())],
4629 ),
4630 |row| row.get(0),
4631 )
4632 .await
4633 .unwrap();
4634 assert_eq!(count, 0);
4635
4636 let item_count: i32 = db_service
4638 .query_one(
4639 Query::with_params(
4640 "SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?",
4641 vec![QueryParam::String(created.id)],
4642 ),
4643 |row| row.get(0),
4644 )
4645 .await
4646 .unwrap();
4647 assert_eq!(item_count, 0);
4648 }
4649
4650 #[tokio::test]
4651 async fn test_playlist_rename() {
4652 let db_service = create_test_db();
4653 let repo = OfflineRepository::new(
4654 db_service.clone(),
4655 "test-server".to_string(),
4656 "test-user".to_string(),
4657 );
4658
4659 let created = repo.create_playlist("Original Name", &[]).await.unwrap();
4660 repo.rename_playlist(&created.id, "New Name").await.unwrap();
4661
4662 let name: String = db_service
4663 .query_one(
4664 Query::with_params(
4665 "SELECT name FROM playlists WHERE id = ?",
4666 vec![QueryParam::String(created.id)],
4667 ),
4668 |row| row.get(0),
4669 )
4670 .await
4671 .unwrap();
4672 assert_eq!(name, "New Name");
4673 }
4674
4675 #[tokio::test]
4676 async fn test_playlist_get_items_preserves_order() {
4677 let db_service = create_test_db();
4678 let repo = OfflineRepository::new(
4679 db_service.clone(),
4680 "test-server".to_string(),
4681 "test-user".to_string(),
4682 );
4683 seed_items(&repo, &["a", "b", "c"]).await;
4684
4685 let created = repo
4686 .create_playlist("Ordered", &["c".into(), "a".into(), "b".into()])
4687 .await
4688 .unwrap();
4689 let items = repo.get_playlist_items(&created.id).await.unwrap();
4690
4691 assert_eq!(items.len(), 3);
4692 assert_eq!(items[0].item.id, "c");
4694 assert_eq!(items[1].item.id, "a");
4695 assert_eq!(items[2].item.id, "b");
4696 assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id);
4698 assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id);
4699 }
4700
4701 #[tokio::test]
4702 async fn test_playlist_get_items_empty_playlist() {
4703 let db_service = create_test_db();
4704 let repo = OfflineRepository::new(
4705 db_service.clone(),
4706 "test-server".to_string(),
4707 "test-user".to_string(),
4708 );
4709
4710 let created = repo.create_playlist("Empty", &[]).await.unwrap();
4711 let items = repo.get_playlist_items(&created.id).await.unwrap();
4712 assert!(items.is_empty());
4713 }
4714
4715 #[tokio::test]
4716 async fn test_playlist_add_items() {
4717 let db_service = create_test_db();
4718 let repo = OfflineRepository::new(
4719 db_service.clone(),
4720 "test-server".to_string(),
4721 "test-user".to_string(),
4722 );
4723 seed_items(&repo, &["t1", "t2", "t3"]).await;
4724
4725 let created = repo
4726 .create_playlist("Addable", &["t1".into()])
4727 .await
4728 .unwrap();
4729
4730 repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()])
4732 .await
4733 .unwrap();
4734
4735 let items = repo.get_playlist_items(&created.id).await.unwrap();
4736 assert_eq!(items.len(), 3);
4737 assert_eq!(items[0].item.id, "t1");
4738 assert_eq!(items[1].item.id, "t2");
4739 assert_eq!(items[2].item.id, "t3");
4740 }
4741
4742 #[tokio::test]
4743 async fn test_playlist_add_duplicate_items_ignored() {
4744 let db_service = create_test_db();
4745 let repo = OfflineRepository::new(
4746 db_service.clone(),
4747 "test-server".to_string(),
4748 "test-user".to_string(),
4749 );
4750 seed_items(&repo, &["t1"]).await;
4751
4752 let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap();
4753
4754 repo.add_to_playlist(&created.id, &["t1".into()])
4756 .await
4757 .unwrap();
4758
4759 let items = repo.get_playlist_items(&created.id).await.unwrap();
4760 assert_eq!(
4761 items.len(),
4762 1,
4763 "Duplicate should be ignored (UNIQUE constraint)"
4764 );
4765 }
4766
4767 #[tokio::test]
4768 async fn test_playlist_remove_items() {
4769 let db_service = create_test_db();
4770 let repo = OfflineRepository::new(
4771 db_service.clone(),
4772 "test-server".to_string(),
4773 "test-user".to_string(),
4774 );
4775 seed_items(&repo, &["t1", "t2", "t3"]).await;
4776
4777 let created = repo
4778 .create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()])
4779 .await
4780 .unwrap();
4781 let items = repo.get_playlist_items(&created.id).await.unwrap();
4782 assert_eq!(items.len(), 3);
4783
4784 let entry_id_to_remove = items[1].playlist_item_id.clone();
4786 repo.remove_from_playlist(&created.id, &[entry_id_to_remove])
4787 .await
4788 .unwrap();
4789
4790 let items_after = repo.get_playlist_items(&created.id).await.unwrap();
4791 assert_eq!(items_after.len(), 2);
4792 assert_eq!(items_after[0].item.id, "t1");
4793 assert_eq!(items_after[1].item.id, "t3");
4794 }
4795
4796 #[tokio::test]
4797 async fn test_playlist_move_item_forward() {
4798 let db_service = create_test_db();
4799 let repo = OfflineRepository::new(
4800 db_service.clone(),
4801 "test-server".to_string(),
4802 "test-user".to_string(),
4803 );
4804 seed_items(&repo, &["a", "b", "c", "d"]).await;
4805
4806 let created = repo
4807 .create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()])
4808 .await
4809 .unwrap();
4810
4811 repo.move_playlist_item(&created.id, "a", 2).await.unwrap();
4813
4814 let items = repo.get_playlist_items(&created.id).await.unwrap();
4815 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4816 assert_eq!(ids, vec!["b", "c", "a", "d"]);
4817 }
4818
4819 #[tokio::test]
4820 async fn test_playlist_move_item_backward() {
4821 let db_service = create_test_db();
4822 let repo = OfflineRepository::new(
4823 db_service.clone(),
4824 "test-server".to_string(),
4825 "test-user".to_string(),
4826 );
4827 seed_items(&repo, &["a", "b", "c", "d"]).await;
4828
4829 let created = repo
4830 .create_playlist(
4831 "Reorder2",
4832 &["a".into(), "b".into(), "c".into(), "d".into()],
4833 )
4834 .await
4835 .unwrap();
4836
4837 repo.move_playlist_item(&created.id, "d", 0).await.unwrap();
4839
4840 let items = repo.get_playlist_items(&created.id).await.unwrap();
4841 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4842 assert_eq!(ids, vec!["d", "a", "b", "c"]);
4843 }
4844
4845 #[tokio::test]
4846 async fn test_playlist_move_item_to_end() {
4847 let db_service = create_test_db();
4848 let repo = OfflineRepository::new(
4849 db_service.clone(),
4850 "test-server".to_string(),
4851 "test-user".to_string(),
4852 );
4853 seed_items(&repo, &["a", "b", "c"]).await;
4854
4855 let created = repo
4856 .create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()])
4857 .await
4858 .unwrap();
4859
4860 repo.move_playlist_item(&created.id, "a", 99).await.unwrap();
4862
4863 let items = repo.get_playlist_items(&created.id).await.unwrap();
4864 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4865 assert_eq!(ids, vec!["b", "c", "a"]);
4866 }
4867
4868 #[tokio::test]
4869 async fn test_playlist_move_nonexistent_item_is_noop() {
4870 let db_service = create_test_db();
4871 let repo = OfflineRepository::new(
4872 db_service.clone(),
4873 "test-server".to_string(),
4874 "test-user".to_string(),
4875 );
4876 seed_items(&repo, &["a", "b"]).await;
4877
4878 let created = repo
4879 .create_playlist("NoOp", &["a".into(), "b".into()])
4880 .await
4881 .unwrap();
4882
4883 repo.move_playlist_item(&created.id, "nonexistent", 0)
4885 .await
4886 .unwrap();
4887
4888 let items = repo.get_playlist_items(&created.id).await.unwrap();
4889 let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
4890 assert_eq!(ids, vec!["a", "b"]);
4891 }
4892
4893 async fn seed_favorites(db_service: &Arc<RusqliteService>) {
4896 use crate::storage::db_service::DatabaseService;
4897 for sql in [
4898 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4899 VALUES ('movie-fav', 'test-server', 'Favourite Movie', 'Movie', 'lib-1', '2026-01-01', 'Favourite Movie')",
4900 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4901 VALUES ('movie-plain', 'test-server', 'Ordinary Movie', 'Movie', 'lib-1', '2026-01-01', 'Ordinary Movie')",
4902 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
4903 VALUES ('album-fav', 'test-server', 'Favourite Album', 'MusicAlbum', 'lib-2', '2026-01-01', 'Favourite Album')",
4904 "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
4905 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-fav', 1)",
4906 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-fav', 1)",
4907 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-plain', 0)",
4909 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('other-user', 'movie-plain', 1)",
4911 ] {
4912 db_service.execute(Query::new(sql)).await.unwrap();
4913 }
4914 }
4915
4916 #[tokio::test]
4921 async fn test_get_favorites_returns_only_scoped_favorites() {
4922 let _guard = lock_catalog_browse();
4923 set_include_catalog_browse(true);
4924
4925 let db_service = create_test_db();
4926 seed_favorites(&db_service).await;
4927 let repo = OfflineRepository::new(
4928 db_service,
4929 "test-server".to_string(),
4930 "test-user".to_string(),
4931 );
4932
4933 let all = repo.get_favorites(SearchScope::All, None).await.unwrap();
4934 let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
4935 ids.sort();
4936 assert_eq!(
4937 ids,
4938 vec!["album-fav", "movie-fav"],
4939 "All scope should return every favourite and nothing else"
4940 );
4941
4942 let movies = repo.get_favorites(SearchScope::Movies, None).await.unwrap();
4943 let ids: Vec<&str> = movies.items.iter().map(|i| i.id.as_str()).collect();
4944 assert_eq!(ids, vec!["movie-fav"]);
4945
4946 let music = repo.get_favorites(SearchScope::Music, None).await.unwrap();
4947 let ids: Vec<&str> = music.items.iter().map(|i| i.id.as_str()).collect();
4948 assert_eq!(ids, vec!["album-fav"]);
4949 }
4950
4951 #[tokio::test]
4956 async fn test_get_favorites_respects_catalog_browse_gate() {
4957 use crate::storage::db_service::DatabaseService;
4958 let _guard = lock_catalog_browse();
4959
4960 let db_service = create_test_db();
4961 seed_favorites(&db_service).await;
4962 db_service
4964 .execute(Query::new(
4965 "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
4966 VALUES ('track-1', 'test-server', 'Track', 'Audio', 'album-fav', '2026-01-01')",
4967 ))
4968 .await
4969 .unwrap();
4970 db_service
4971 .execute(Query::new(
4972 "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
4973 ))
4974 .await
4975 .unwrap();
4976
4977 let repo = OfflineRepository::new(
4978 db_service,
4979 "test-server".to_string(),
4980 "test-user".to_string(),
4981 );
4982
4983 set_include_catalog_browse(false);
4984 let offline_only = repo.get_favorites(SearchScope::All, None).await.unwrap();
4985 let ids: Vec<&str> = offline_only.items.iter().map(|i| i.id.as_str()).collect();
4986 assert_eq!(
4987 ids,
4988 vec!["album-fav"],
4989 "with the gate off, only favourites on the device are listed"
4990 );
4991
4992 set_include_catalog_browse(true);
4993 let with_catalog = repo.get_favorites(SearchScope::All, None).await.unwrap();
4994 assert_eq!(with_catalog.items.len(), 2);
4995 }
4996
4997 #[tokio::test]
5001 async fn test_get_items_favorites_only_filters_listing() {
5002 let _guard = lock_catalog_browse();
5003 set_include_catalog_browse(true);
5004
5005 let db_service = create_test_db();
5006 seed_favorites(&db_service).await;
5007 let repo = OfflineRepository::new(
5008 db_service,
5009 "test-server".to_string(),
5010 "test-user".to_string(),
5011 );
5012
5013 let unfiltered = repo
5014 .get_items(
5015 "lib-1",
5016 Some(GetItemsOptions {
5017 include_item_types: Some(vec!["Movie".to_string()]),
5018 ..Default::default()
5019 }),
5020 )
5021 .await
5022 .unwrap();
5023 assert_eq!(unfiltered.items.len(), 2, "both movies without the filter");
5024
5025 let favourites = repo
5026 .get_items(
5027 "lib-1",
5028 Some(GetItemsOptions {
5029 include_item_types: Some(vec!["Movie".to_string()]),
5030 favorites_only: Some(true),
5031 ..Default::default()
5032 }),
5033 )
5034 .await
5035 .unwrap();
5036 let ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
5037 assert_eq!(ids, vec!["movie-fav"]);
5038 }
5039
5040 #[tokio::test]
5051 async fn test_get_items_type_filter_is_bound_not_interpolated() {
5052 let _guard = lock_catalog_browse();
5053 set_include_catalog_browse(true);
5054
5055 let db_service = create_test_db();
5056 seed_favorites(&db_service).await;
5057 let repo = OfflineRepository::new(
5058 db_service,
5059 "test-server".to_string(),
5060 "test-user".to_string(),
5061 );
5062
5063 let injected = repo
5064 .get_items(
5065 "lib-1",
5066 Some(GetItemsOptions {
5067 include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
5068 ..Default::default()
5069 }),
5070 )
5071 .await
5072 .expect("a hostile type name must be data, not a broken query");
5073 assert!(
5074 injected.items.is_empty(),
5075 "no cached item has that type, so nothing may come back; got {:?}",
5076 injected
5077 .items
5078 .iter()
5079 .map(|i| i.id.as_str())
5080 .collect::<Vec<_>>()
5081 );
5082
5083 let quoted = repo
5085 .get_items(
5086 "lib-1",
5087 Some(GetItemsOptions {
5088 include_item_types: Some(vec!["Mo'vie".to_string()]),
5089 ..Default::default()
5090 }),
5091 )
5092 .await
5093 .expect("an embedded quote must not break the query");
5094 assert!(quoted.items.is_empty());
5095 }
5096
5097 #[tokio::test]
5104 async fn test_get_items_binds_multiple_types_in_parameter_order() {
5105 let _guard = lock_catalog_browse();
5106 set_include_catalog_browse(true);
5107
5108 let db_service = create_test_db();
5109 seed_favorites(&db_service).await;
5110
5111 db_service
5118 .execute(Query::new(
5119 "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
5120 VALUES ('album-lib1', 'test-server', 'Album In Lib One', 'MusicAlbum', 'lib-1', '2026-01-01', 'Album In Lib One')",
5121 ))
5122 .await
5123 .unwrap();
5124 db_service
5125 .execute(Query::new(
5126 "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-lib1', 1)",
5127 ))
5128 .await
5129 .unwrap();
5130
5131 let repo = OfflineRepository::new(
5132 db_service,
5133 "test-server".to_string(),
5134 "test-user".to_string(),
5135 );
5136
5137 let both = repo
5138 .get_items(
5139 "lib-1",
5140 Some(GetItemsOptions {
5141 include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
5142 ..Default::default()
5143 }),
5144 )
5145 .await
5146 .unwrap();
5147 let mut ids: Vec<&str> = both.items.iter().map(|i| i.id.as_str()).collect();
5148 ids.sort();
5149 assert_eq!(ids, vec!["album-lib1", "movie-fav", "movie-plain"]);
5150 assert!(
5151 !ids.contains(&"album-fav"),
5152 "album-fav belongs to lib-2 and must not appear in a lib-1 listing"
5153 );
5154
5155 let favourites = repo
5157 .get_items(
5158 "lib-1",
5159 Some(GetItemsOptions {
5160 include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
5161 favorites_only: Some(true),
5162 ..Default::default()
5163 }),
5164 )
5165 .await
5166 .unwrap();
5167 let mut ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
5168 ids.sort();
5169 assert_eq!(ids, vec!["album-lib1", "movie-fav"]);
5170 }
5171
5172 #[tokio::test]
5181 async fn test_save_to_cache_mirrors_favorites_without_clobbering_pending() {
5182 use crate::storage::db_service::DatabaseService;
5183 let db_service = create_test_db();
5184 let repo = OfflineRepository::new(
5185 db_service.clone(),
5186 "test-server".to_string(),
5187 "test-user".to_string(),
5188 );
5189
5190 let favourite_flag = |id: &'static str| {
5191 let db = db_service.clone();
5192 async move {
5193 db.query_optional(
5194 Query::with_params(
5195 "SELECT is_favorite, pending_sync FROM user_data \
5196 WHERE user_id = ? AND item_id = ?",
5197 vec![
5198 QueryParam::String("test-user".to_string()),
5199 QueryParam::String(id.to_string()),
5200 ],
5201 ),
5202 |row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
5203 )
5204 .await
5205 .unwrap()
5206 }
5207 };
5208
5209 let mut favourited = create_test_item("fav-1", "Favourited Elsewhere", None);
5211 favourited.user_data = Some(UserData {
5212 is_favorite: Some(true),
5213 ..Default::default()
5214 });
5215 let untouched = create_test_item("plain-1", "No User Data", None);
5217
5218 repo.save_to_cache("parent-1", &[favourited.clone(), untouched])
5219 .await
5220 .unwrap();
5221
5222 assert_eq!(
5223 favourite_flag("fav-1").await,
5224 Some((Some(1), Some(0))),
5225 "server favourite should be mirrored as synced"
5226 );
5227 assert_eq!(
5228 favourite_flag("plain-1").await,
5229 None,
5230 "an item without UserData should not get an invented user_data row"
5231 );
5232
5233 db_service
5235 .execute(Query::with_params(
5236 "UPDATE user_data SET is_favorite = 0, pending_sync = 1 \
5237 WHERE user_id = ? AND item_id = ?",
5238 vec![
5239 QueryParam::String("test-user".to_string()),
5240 QueryParam::String("fav-1".to_string()),
5241 ],
5242 ))
5243 .await
5244 .unwrap();
5245
5246 repo.save_to_cache("parent-1", &[favourited]).await.unwrap();
5248
5249 assert_eq!(
5250 favourite_flag("fav-1").await,
5251 Some((Some(0), Some(1))),
5252 "an unsynced local toggle must survive a cache write"
5253 );
5254 }
5255
5256 #[tokio::test]
5268 async fn test_save_to_cache_mirrors_playback_position_without_clobbering_pending() {
5269 use crate::storage::db_service::DatabaseService;
5270 let db_service = create_test_db();
5271 let repo = OfflineRepository::new(
5272 db_service.clone(),
5273 "test-server".to_string(),
5274 "test-user".to_string(),
5275 );
5276
5277 let position = |id: &'static str| {
5278 let db = db_service.clone();
5279 async move {
5280 db.query_optional(
5281 Query::with_params(
5282 "SELECT playback_position_ticks, pending_sync FROM user_data \
5283 WHERE user_id = ? AND item_id = ?",
5284 vec![
5285 QueryParam::String("test-user".to_string()),
5286 QueryParam::String(id.to_string()),
5287 ],
5288 ),
5289 |row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i32>>(1)?)),
5290 )
5291 .await
5292 .unwrap()
5293 }
5294 };
5295
5296 let mut watched = create_test_item("ep-1", "Watched Elsewhere", None);
5298 watched.user_data = Some(UserData {
5299 playback_position_ticks: Some(12_000_000_000),
5300 ..Default::default()
5301 });
5302 let untouched = create_test_item("ep-2", "No User Data", None);
5304
5305 repo.save_to_cache("parent-1", &[watched.clone(), untouched])
5306 .await
5307 .unwrap();
5308
5309 assert_eq!(
5310 position("ep-1").await,
5311 Some((Some(12_000_000_000), Some(0))),
5312 "the server's position should be mirrored as synced"
5313 );
5314 assert_eq!(
5315 position("ep-2").await,
5316 None,
5317 "an item without UserData should not get an invented position"
5318 );
5319
5320 db_service
5322 .execute(Query::with_params(
5323 "UPDATE user_data SET playback_position_ticks = ?, pending_sync = 1 \
5324 WHERE user_id = ? AND item_id = ?",
5325 vec![
5326 QueryParam::Int64(30_000_000_000),
5327 QueryParam::String("test-user".to_string()),
5328 QueryParam::String("ep-1".to_string()),
5329 ],
5330 ))
5331 .await
5332 .unwrap();
5333
5334 repo.save_to_cache("parent-1", &[watched]).await.unwrap();
5336
5337 assert_eq!(
5338 position("ep-1").await,
5339 Some((Some(30_000_000_000), Some(1))),
5340 "an unsynced local position must not be pulled backwards"
5341 );
5342 }
5343
5344 #[tokio::test]
5355 async fn test_save_to_cache_mirrors_played_flag_without_clobbering_pending() {
5356 use crate::storage::db_service::DatabaseService;
5357 let db_service = create_test_db();
5358 let repo = OfflineRepository::new(
5359 db_service.clone(),
5360 "test-server".to_string(),
5361 "test-user".to_string(),
5362 );
5363
5364 let played_flag = |id: &'static str| {
5365 let db = db_service.clone();
5366 async move {
5367 db.query_optional(
5368 Query::with_params(
5369 "SELECT is_played, pending_sync FROM user_data \
5370 WHERE user_id = ? AND item_id = ?",
5371 vec![
5372 QueryParam::String("test-user".to_string()),
5373 QueryParam::String(id.to_string()),
5374 ],
5375 ),
5376 |row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
5377 )
5378 .await
5379 .unwrap()
5380 }
5381 };
5382
5383 let mut watched = create_test_item("ep-4", "Watched Elsewhere", None);
5385 watched.user_data = Some(UserData {
5386 is_played: Some(true),
5387 ..Default::default()
5388 });
5389 let untouched = create_test_item("ep-5", "No User Data", None);
5391
5392 repo.save_to_cache("parent-1", &[watched, untouched])
5393 .await
5394 .unwrap();
5395
5396 assert_eq!(
5397 played_flag("ep-4").await,
5398 Some((Some(1), Some(0))),
5399 "the server's played flag should be mirrored as synced"
5400 );
5401 assert_eq!(
5402 played_flag("ep-5").await,
5403 None,
5404 "an item without UserData should not get an invented played flag"
5405 );
5406
5407 db_service
5409 .execute(Query::with_params(
5410 "UPDATE user_data SET is_played = 0, pending_sync = 1 \
5411 WHERE user_id = ? AND item_id = ?",
5412 vec![
5413 QueryParam::String("test-user".to_string()),
5414 QueryParam::String("ep-4".to_string()),
5415 ],
5416 ))
5417 .await
5418 .unwrap();
5419
5420 let mut still_played = create_test_item("ep-4", "Watched Elsewhere", None);
5421 still_played.user_data = Some(UserData {
5422 is_played: Some(true),
5423 ..Default::default()
5424 });
5425 repo.save_to_cache("parent-1", &[still_played])
5426 .await
5427 .unwrap();
5428
5429 assert_eq!(
5430 played_flag("ep-4").await,
5431 Some((Some(0), Some(1))),
5432 "an unsynced local toggle must survive a cache write"
5433 );
5434 }
5435
5436 #[tokio::test]
5446 async fn test_position_is_mirrored_even_when_no_favourite_flag_is_present() {
5447 use crate::storage::db_service::DatabaseService;
5448 let db_service = create_test_db();
5449 let repo = OfflineRepository::new(
5450 db_service.clone(),
5451 "test-server".to_string(),
5452 "test-user".to_string(),
5453 );
5454
5455 let mut watched = create_test_item("ep-3", "Position Only", None);
5456 watched.user_data = Some(UserData {
5457 is_favorite: None,
5458 playback_position_ticks: Some(9_000_000_000),
5459 ..Default::default()
5460 });
5461
5462 repo.save_to_cache("parent-1", &[watched]).await.unwrap();
5463
5464 let stored = db_service
5465 .query_optional(
5466 Query::with_params(
5467 "SELECT playback_position_ticks FROM user_data \
5468 WHERE user_id = ? AND item_id = ?",
5469 vec![
5470 QueryParam::String("test-user".to_string()),
5471 QueryParam::String("ep-3".to_string()),
5472 ],
5473 ),
5474 |row| row.get::<_, Option<i64>>(0),
5475 )
5476 .await
5477 .unwrap();
5478
5479 assert_eq!(
5480 stored,
5481 Some(Some(9_000_000_000)),
5482 "a position with no favourite flag must still be mirrored"
5483 );
5484 }
5485
5486 #[tokio::test]
5505 async fn test_get_items_unknown_library_type_does_not_return_whole_server() {
5506 let _guard = lock_catalog_browse();
5509 set_include_catalog_browse(true);
5510
5511 let db = create_test_db();
5512
5513 insert_item(&db, "movie-1", "Movie", None, None, None).await;
5514 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
5515 insert_item(&db, "series-1", "Series", None, None, None).await;
5516
5517 for collection_type in ["books", "boxsets", "photos", "homevideos", ""] {
5520 let lib = format!("lib-{collection_type}");
5521 seed_library(&db, &lib, collection_type).await;
5522
5523 let repo = make_repo(&db);
5524 let ids: Vec<String> = repo
5525 .get_items(&lib, None)
5526 .await
5527 .unwrap()
5528 .items
5529 .iter()
5530 .map(|i| i.id.clone())
5531 .collect();
5532
5533 assert!(
5534 ids.is_empty(),
5535 "a '{collection_type}' library must not serve the server's films, \
5536 albums and shows; got {:?}",
5537 ids
5538 );
5539 }
5540 }
5541
5542 #[tokio::test]
5553 async fn test_get_items_two_libraries_of_one_type_are_not_interchangeable() {
5554 let _guard = lock_catalog_browse();
5557 set_include_catalog_browse(true);
5558
5559 let db = create_test_db();
5560 seed_library(&db, "tv-lib", "tvshows").await;
5561 seed_library(&db, "shows-lib", "tvshows").await;
5562
5563 let repo = make_repo(&db);
5564
5565 for (id, lib) in [("series-a", "tv-lib"), ("series-b", "shows-lib")] {
5568 let mut item = create_test_item(id, id, None);
5569 item.item_type = "Series".to_string();
5570 item.kind = crate::domain::MediaKind::Series;
5571 repo.save_to_cache(lib, &[item]).await.unwrap();
5572 }
5573 for (lib, own, other) in [
5574 ("tv-lib", "series-a", "series-b"),
5575 ("shows-lib", "series-b", "series-a"),
5576 ] {
5577 let ids: Vec<String> = repo
5578 .get_items(lib, None)
5579 .await
5580 .unwrap()
5581 .items
5582 .iter()
5583 .map(|i| i.id.clone())
5584 .collect();
5585 assert!(
5586 ids.contains(&own.to_string()),
5587 "{lib} should list {own}; got {:?}",
5588 ids
5589 );
5590 assert!(
5591 !ids.contains(&other.to_string()),
5592 "{lib} must not list {other}, which lives in the other library; got {:?}",
5593 ids
5594 );
5595 }
5596 }
5597
5598 #[tokio::test]
5609 async fn test_get_items_collection_lists_its_own_children() {
5610 let _guard = lock_catalog_browse();
5613 set_include_catalog_browse(true);
5614
5615 let db = create_test_db();
5616 seed_library(&db, "boxset-lib", "boxsets").await;
5617
5618 insert_item(&db, "boxset-1", "BoxSet", None, None, None).await;
5619 insert_item(&db, "outsider", "Movie", None, None, None).await;
5620
5621 db.execute(Query::with_params(
5624 "INSERT INTO items (id, server_id, name, item_type, parent_id, synced_at) \
5625 VALUES ('in-set', 'test-server', 'In The Set', 'Movie', ?1, '2024-01-01')",
5626 vec![QueryParam::String("boxset-1".to_string())],
5627 ))
5628 .await
5629 .unwrap();
5630
5631 let repo = make_repo(&db);
5632 let ids: Vec<String> = repo
5633 .get_items("boxset-1", None)
5634 .await
5635 .unwrap()
5636 .items
5637 .iter()
5638 .map(|i| i.id.clone())
5639 .collect();
5640
5641 assert_eq!(
5642 ids,
5643 vec!["in-set".to_string()],
5644 "a collection lists its own children and nothing else; got {:?}",
5645 ids
5646 );
5647 }
5648
5649 #[tokio::test]
5654 async fn test_get_items_typed_libraries_still_return_their_own_media() {
5655 let _guard = lock_catalog_browse();
5658 set_include_catalog_browse(true);
5659
5660 let db = create_test_db();
5661 seed_library(&db, "music-lib", "music").await;
5662 seed_library(&db, "movie-lib", "movies").await;
5663 seed_library(&db, "tv-lib", "tvshows").await;
5664
5665 insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
5666 insert_item(&db, "movie-1", "Movie", None, None, None).await;
5667 insert_item(&db, "series-1", "Series", None, None, None).await;
5668
5669 let repo = make_repo(&db);
5670
5671 for (lib, expected, forbidden) in [
5672 ("music-lib", "album-1", "movie-1"),
5673 ("movie-lib", "movie-1", "album-1"),
5674 ("tv-lib", "series-1", "album-1"),
5675 ] {
5676 let ids: Vec<String> = repo
5677 .get_items(lib, None)
5678 .await
5679 .unwrap()
5680 .items
5681 .iter()
5682 .map(|i| i.id.clone())
5683 .collect();
5684 assert!(
5685 ids.contains(&expected.to_string()),
5686 "{lib} should list {expected}; got {:?}",
5687 ids
5688 );
5689 assert!(
5690 !ids.contains(&forbidden.to_string()),
5691 "{lib} must not list {forbidden}; got {:?}",
5692 ids
5693 );
5694 }
5695 }
5696
5697 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
5708 async fn save_to_cache_does_not_disable_foreign_keys_for_concurrent_writes() {
5709 let db_service = create_test_db();
5712
5713 let repo = OfflineRepository::new(
5714 db_service.clone(),
5715 "test-server".to_string(),
5716 "test-user".to_string(),
5717 );
5718 let items: Vec<MediaItem> = (0..1500)
5719 .map(|i| create_test_item(&format!("item-{i}"), "Item", Some("library-1")))
5720 .collect();
5721
5722 let save = tokio::spawn(async move { repo.save_to_cache("library-1", &items).await });
5723
5724 let mut orphans_accepted = 0;
5726 let mut attempt = 0;
5727 while !save.is_finished() {
5728 let inserted = db_service
5729 .execute(Query::with_params(
5730 "INSERT INTO items (id, server_id, name, item_type, parent_id)
5731 VALUES (?, 'test-server', 'Orphan', 'Audio', 'no-such-parent')",
5732 vec![QueryParam::String(format!("orphan-{attempt}"))],
5733 ))
5734 .await;
5735 if inserted.is_ok() {
5736 orphans_accepted += 1;
5737 }
5738 attempt += 1;
5739 }
5740 save.await.unwrap().unwrap();
5741
5742 assert!(
5743 attempt > 0,
5744 "the save finished before any write could race it"
5745 );
5746 assert_eq!(
5747 orphans_accepted, 0,
5748 "{orphans_accepted} of {attempt} FK-violating writes were accepted mid-save"
5749 );
5750 }
5751
5752 #[tokio::test]
5757 async fn listing_user_data_is_batched_per_row() {
5758 let _guard = lock_catalog_browse();
5759 set_include_catalog_browse(true);
5760 let db_service = create_test_db();
5761 let repo = OfflineRepository::new(
5762 db_service.clone(),
5763 "test-server".to_string(),
5764 "test-user".to_string(),
5765 );
5766
5767 let items: Vec<MediaItem> = (0..1203)
5769 .map(|i| {
5770 create_test_item(
5771 &format!("ep-{i:04}"),
5772 &format!("Ep {i:04}"),
5773 Some("season-1"),
5774 )
5775 })
5776 .collect();
5777 repo.save_to_cache("season-1", &items).await.unwrap();
5778 for i in (0..1203).step_by(3) {
5779 db_service
5780 .execute(Query::with_params(
5781 "INSERT INTO user_data (user_id, item_id, playback_position_ticks) VALUES ('test-user', ?, ?)",
5782 vec![QueryParam::String(format!("ep-{i:04}")), QueryParam::Int64(i as i64)],
5783 ))
5784 .await
5785 .unwrap();
5786 }
5787
5788 let result = repo.get_items("season-1", None).await.unwrap();
5789 assert_eq!(result.items.len(), 1203);
5790 for item in &result.items {
5791 let i: i64 = item.id.trim_start_matches("ep-").parse().unwrap();
5792 let position = item
5793 .user_data
5794 .as_ref()
5795 .and_then(|u| u.playback_position_ticks);
5796 if i % 3 == 0 {
5797 assert_eq!(
5798 position,
5799 Some(i),
5800 "{} got someone else's user data",
5801 item.id
5802 );
5803 } else {
5804 assert_eq!(position, None, "{} got user data it does not have", item.id);
5805 }
5806 }
5807 }
5808
5809 #[test]
5822 fn listing_a_non_library_parent_uses_the_container_index() {
5823 use crate::utils::lock::MutexSafe;
5824 let db = crate::storage::Database::open_in_memory().unwrap();
5825 let conn = db.connection();
5826 let conn = conn.lock_safe();
5827 for include_catalog in [true, false] {
5828 let sql = items_listing_sql(
5829 false,
5830 include_catalog,
5831 "",
5832 "",
5833 "i.sort_name ASC, i.name ASC",
5834 100,
5835 0,
5836 );
5837 let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}")).unwrap();
5838 let plan: Vec<String> = stmt
5839 .query_map(rusqlite::params!["srv", "p"], |row| row.get::<_, String>(3))
5840 .unwrap()
5841 .map(Result::unwrap)
5842 .collect();
5843 let plan = plan.join("\n");
5844 assert!(
5845 plan.contains("idx_items_container"),
5846 "expected a container index lookup, got:\n{plan}"
5847 );
5848 assert!(
5849 !plan.contains("SCAN i") && !plan.contains("idx_items_server"),
5850 "the listing walks every item on the server:\n{plan}"
5851 );
5852 }
5853 }
5854
5855 #[tokio::test]
5865 async fn a_series_lists_its_seasons_not_their_episodes() {
5866 let _guard = lock_catalog_browse();
5867 set_include_catalog_browse(true);
5868 let db_service = create_test_db();
5869 for sql in [
5870 "INSERT INTO items (id, server_id, name, item_type, synced_at) \
5871 VALUES ('show', 'test-server', 'Show', 'Series', '2026-01-01')",
5872 "INSERT INTO items (id, server_id, parent_id, series_id, name, item_type, synced_at) \
5873 VALUES ('s1', 'test-server', 'show', 'show', 'Season 1', 'Season', '2026-01-01')",
5874 "INSERT INTO items (id, server_id, parent_id, season_id, series_id, name, item_type, synced_at) \
5875 VALUES ('e1', 'test-server', 's1', 's1', 'show', 'A Pilot', 'Episode', '2026-01-01')",
5876 ] {
5877 db_service.execute(Query::new(sql)).await.unwrap();
5878 }
5879 let repo = OfflineRepository::new(
5880 db_service.clone(),
5881 "test-server".to_string(),
5882 "test-user".to_string(),
5883 );
5884
5885 let ids: Vec<String> = repo
5886 .get_items("show", None)
5887 .await
5888 .unwrap()
5889 .items
5890 .into_iter()
5891 .map(|i| i.id)
5892 .collect();
5893 assert_eq!(ids, vec!["s1".to_string()]);
5894 }
5895
5896 #[tokio::test]
5903 async fn caching_a_child_creates_its_missing_containers() {
5904 let db_service = create_test_db();
5905 let repo = OfflineRepository::new(
5906 db_service.clone(),
5907 "test-server".to_string(),
5908 "test-user".to_string(),
5909 );
5910 let mut episode = create_test_item("ep", "Pilot", Some("next-up"));
5911 episode.item_type = "Episode".to_string();
5912 episode.season_id = Some("season-9".to_string());
5913 episode.season_name = Some("Season 9".to_string());
5914 episode.series_id = Some("show-9".to_string());
5915 episode.series_name = Some("Show 9".to_string());
5916 let mut track = create_test_item("trk", "Song", Some("next-up"));
5917 track.item_type = "Audio".to_string();
5918 track.album_id = Some("alb-9".to_string());
5919 track.album_name = Some("Record".to_string());
5920 repo.save_to_cache("next-up", &[episode, track])
5921 .await
5922 .unwrap();
5923
5924 let row = |id: &'static str| {
5925 let db_service = db_service.clone();
5926 async move {
5927 db_service
5928 .query_optional(
5929 Query::with_params(
5930 "SELECT name, item_type, container_id FROM items WHERE id = ?",
5931 vec![QueryParam::String(id.to_string())],
5932 ),
5933 |r| {
5934 Ok((
5935 r.get::<_, String>(0)?,
5936 r.get::<_, String>(1)?,
5937 r.get::<_, Option<String>>(2)?,
5938 ))
5939 },
5940 )
5941 .await
5942 .unwrap()
5943 }
5944 };
5945 assert_eq!(
5946 row("season-9").await,
5947 Some(("Season 9".into(), "Season".into(), Some("show-9".into())))
5948 );
5949 assert_eq!(
5950 row("show-9").await,
5951 Some(("Show 9".into(), "Series".into(), None))
5952 );
5953 assert_eq!(
5954 row("alb-9").await,
5955 Some(("Record".into(), "MusicAlbum".into(), None))
5956 );
5957 }
5958}