1#[cfg(test)]
11use crate::utils::lock::MutexSafe;
12use std::sync::Arc;
13
14use async_trait::async_trait;
15use log::{debug, warn};
16use tokio::time::{timeout, Duration};
17
18use super::exclusions::ExcludeHidden;
19use super::{types::*, MediaRepository, OfflineRepository, OnlineRepository};
20
21pub struct HybridRepository {
33 online: Arc<OnlineRepository>,
34 offline: Arc<OfflineRepository>,
35}
36
37impl HybridRepository {
38 pub fn new(online: OnlineRepository, offline: OfflineRepository) -> Self {
39 Self {
40 online: Arc::new(online),
41 offline: Arc::new(offline),
42 }
43 }
44
45 pub fn user_id(&self) -> &str {
49 self.online.user_id()
50 }
51
52 pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
55 self.online.download_bytes(url).await
56 }
57
58 pub async fn prune_stale_catalog(
66 &self,
67 cutoff: &str,
68 item_types: &[String],
69 ) -> Result<usize, RepoError> {
70 self.offline.prune_stale_catalog(cutoff, item_types).await
71 }
72
73 pub async fn get_jray_actors(
76 &self,
77 item_id: &str,
78 t: f64,
79 ) -> Result<Vec<super::JRayActor>, RepoError> {
80 self.online.get_jray_actors(item_id, t).await
81 }
82
83 pub async fn get_video_stream_url(
90 &self,
91 item_id: &str,
92 media_source_id: Option<&str>,
93 audio_stream_index: Option<i32>,
94 ) -> Result<String, RepoError> {
95 self.online
96 .get_video_stream_url(item_id, media_source_id, audio_stream_index)
97 .await
98 }
99
100 pub async fn get_stream_selection(
108 &self,
109 item_id: &str,
110 media_source_id: Option<&str>,
111 audio_stream_index: Option<i32>,
112 ) -> Result<super::StreamSelection, RepoError> {
113 self.online
114 .get_stream_selection(item_id, media_source_id, audio_stream_index)
115 .await
116 }
117
118 pub async fn get_audio_only_stream_url_for_video(
123 &self,
124 item_id: &str,
125 media_source_id: Option<&str>,
126 start_time_seconds: Option<f64>,
127 audio_stream_index: Option<i32>,
128 ) -> Result<String, RepoError> {
129 self.online
130 .get_audio_only_stream_url_for_video(
131 item_id,
132 media_source_id,
133 start_time_seconds,
134 audio_stream_index,
135 )
136 .await
137 }
138
139 pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<MediaItem>, RepoError> {
155 let options = Some(GetItemsOptions {
156 include_item_types: Some(vec!["Audio".to_string()]),
157 sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
158 limit: Some(1000),
159 ..Default::default()
160 });
161
162 let result = self.online.get_items(album_id, options).await?;
163
164 if !result.items.is_empty() {
165 if let Err(e) = self.offline.save_to_cache(album_id, &result.items).await {
166 warn!("[HybridRepo] Failed to cache album tracks: {:?}", e);
167 }
168 }
169
170 Ok(result.items)
171 }
172
173 pub async fn get_items_unfiltered(
184 &self,
185 parent_id: &str,
186 options: Option<GetItemsOptions>,
187 ) -> Result<SearchResult, RepoError> {
188 match self.online.get_items(parent_id, options.clone()).await {
189 Ok(result) => Ok(result),
190 Err(e) => self.offline.get_items(parent_id, options).await.or(Err(e)),
191 }
192 }
193
194 pub async fn search_cache_only(
200 &self,
201 query: &str,
202 options: Option<SearchOptions>,
203 ) -> Result<SearchResult, RepoError> {
204 let offline = Arc::clone(&self.offline);
205 let query = query.to_string();
206 self.cache_with_timeout(async move { offline.search(&query, options).await })
207 .await
208 .map(ExcludeHidden::without_excluded)
209 }
210
211 pub async fn get_favorites_cache_only(
216 &self,
217 scope: SearchScope,
218 options: Option<GetItemsOptions>,
219 ) -> Result<SearchResult, RepoError> {
220 let offline = Arc::clone(&self.offline);
221 self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
222 .await
223 .map(ExcludeHidden::without_excluded)
224 }
225
226 pub async fn get_favorites_server_only(
232 &self,
233 scope: SearchScope,
234 options: Option<GetItemsOptions>,
235 ) -> Result<SearchResult, RepoError> {
236 let result = self.online.get_favorites(scope, options).await?;
237 if !result.items.is_empty() {
238 if let Err(e) = self.offline.save_to_cache("favorites", &result.items).await {
241 debug!("[HybridRepo] Failed to cache favourites: {:?}", e);
242 }
243 }
244 Ok(result.without_excluded())
245 }
246
247 pub async fn cache_items_from_server(
256 &self,
257 parent_id: &str,
258 options: Option<GetItemsOptions>,
259 ) -> Result<Vec<MediaItem>, RepoError> {
260 let result = self.online.get_items(parent_id, options).await?;
261 if !result.items.is_empty() {
262 self.offline.save_to_cache(parent_id, &result.items).await?;
263 }
264 Ok(result.items)
265 }
266
267 pub async fn get_downloaded_items(
276 &self,
277 parent_id: &str,
278 options: Option<GetItemsOptions>,
279 ) -> Result<SearchResult, RepoError> {
280 self.offline.get_downloaded_items(parent_id, options).await
285 }
286
287 pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
291 self.offline.get_downloaded_libraries().await
292 }
293
294 pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
298 self.offline.get_download_disk_usage().await
299 }
300
301 pub async fn search_server_only(
303 &self,
304 query: &str,
305 options: Option<SearchOptions>,
306 ) -> Result<SearchResult, RepoError> {
307 self.online
308 .search(query, options)
309 .await
310 .map(ExcludeHidden::without_excluded)
311 }
312
313 pub fn merge_search_results(cache: SearchResult, server: SearchResult) -> SearchResult {
319 use std::collections::HashMap;
320
321 let mut server_by_id: HashMap<String, MediaItem> = HashMap::new();
324 let mut server_order: Vec<String> = Vec::with_capacity(server.items.len());
325 for item in server.items {
326 if !server_by_id.contains_key(&item.id) {
327 server_order.push(item.id.clone());
328 }
329 server_by_id.insert(item.id.clone(), item);
330 }
331
332 let mut items: Vec<MediaItem> = Vec::new();
333 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
334
335 for local in cache.items {
338 if !seen.insert(local.id.clone()) {
339 continue;
340 }
341 match server_by_id.remove(&local.id) {
342 Some(server_item) => items.push(server_item),
343 None => items.push(local),
344 }
345 }
346
347 for id in server_order {
349 if let Some(server_item) = server_by_id.remove(&id) {
350 if seen.insert(id) {
351 items.push(server_item);
352 }
353 }
354 }
355
356 let total_record_count = items.len();
357 SearchResult {
358 items,
359 total_record_count,
360 }
361 }
362
363 async fn parallel_race<T, F1, F2>(
383 &self,
384 cache_future: F1,
385 server_future: F2,
386 ) -> Result<T, RepoError>
387 where
388 T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
389 F1: std::future::Future<Output = Result<T, RepoError>> + Send,
390 F2: std::future::Future<Output = Result<T, RepoError>> + Send,
391 {
392 let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
394
395 if let Ok(data) = &cache_result {
396 if data.has_content() {
397 debug!("[HybridRepo] Cache hit, returning immediately");
398 return Ok(data.clone());
399 }
400 }
401
402 debug!("[HybridRepo] Cache miss, querying server");
404 match server_future.await {
405 Ok(data) => Ok(data.without_excluded()),
406 Err(e) => {
407 cache_result.or(Err(e))
409 }
410 }
411 }
412
413 async fn race_with_refresh<T, F1, F2, R>(
428 &self,
429 cache_future: F1,
430 server_future: F2,
431 on_cache_hit: R,
432 ) -> Result<T, RepoError>
433 where
434 T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
435 F1: std::future::Future<Output = Result<T, RepoError>> + Send,
436 F2: std::future::Future<Output = Result<T, RepoError>> + Send,
437 R: FnOnce(),
438 {
439 let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
440
441 if let Ok(data) = &cache_result {
442 if data.has_content() {
443 debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
444 on_cache_hit();
445 return Ok(data.clone());
446 }
447 }
448
449 debug!("[HybridRepo] Cache miss, querying server");
450 match server_future.await {
451 Ok(data) => Ok(data.without_excluded()),
452 Err(e) => cache_result.or(Err(e)),
453 }
454 }
455
456 async fn cache_with_timeout<T>(
460 &self,
461 future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
462 ) -> Result<T, RepoError> {
463 timeout(Duration::from_millis(100), future)
464 .await
465 .unwrap_or_else(|_| {
466 Err(RepoError::Database {
467 message: "Cache query timeout".to_string(),
468 })
469 })
470 }
471}
472
473#[async_trait]
474impl MediaRepository for HybridRepository {
475 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
476 let cache_result = self.cache_with_timeout(self.offline.get_libraries()).await;
480
481 if let Ok(libs) = &cache_result {
482 if libs.has_content() {
483 debug!("[HybridRepo] Cache hit for libraries, returning immediately");
484 let online = Arc::clone(&self.online);
485 let offline = Arc::clone(&self.offline);
486 tokio::spawn(async move {
487 if let Ok(server_libs) = online.get_libraries().await {
488 if !server_libs.is_empty() {
489 if let Err(e) = offline.save_libraries_to_cache(&server_libs).await {
490 warn!(
491 "[HybridRepo] Background library cache update failed: {:?}",
492 e
493 );
494 }
495 }
496 }
497 });
498 return cache_result;
499 }
500 }
501
502 match self.online.get_libraries().await {
504 Ok(server_libs) => {
505 if !server_libs.is_empty() {
506 if let Err(e) = self.offline.save_libraries_to_cache(&server_libs).await {
507 warn!(
508 "[HybridRepo] Failed to cache {} libraries: {:?}",
509 server_libs.len(),
510 e
511 );
512 }
513 }
514 Ok(server_libs)
515 }
516 Err(e) => cache_result.or(Err(e)),
517 }
518 }
519
520 async fn get_items(
521 &self,
522 parent_id: &str,
523 options: Option<GetItemsOptions>,
524 ) -> Result<SearchResult, RepoError> {
525 let offline = Arc::clone(&self.offline);
526 let offline_for_save = Arc::clone(&self.offline);
527 let online = Arc::clone(&self.online);
528 let parent_id = parent_id.to_string();
529 let parent_id_clone = parent_id.clone();
530 let parent_id_for_save = parent_id.clone();
531 let opts_clone = options.clone();
532
533 let server_handle =
535 tokio::spawn(async move { online.get_items(&parent_id_clone, options).await });
536
537 let cache_result = self
546 .cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
547 .await
548 .map(ExcludeHidden::without_excluded);
549
550 if !crate::repository::offline::include_catalog_browse() {
558 if let Ok(data) = &cache_result {
559 debug!(
560 "[HybridRepo] Downloads-only gate: returning offline result ({} items) as authoritative for parent {}",
561 data.items.len(),
562 &parent_id_for_save[..8.min(parent_id_for_save.len())]
563 );
564 server_handle.abort();
566 return Ok(data.clone());
567 }
568 }
569
570 if let Ok(data) = &cache_result {
572 if data.has_content() {
573 debug!(
574 "[HybridRepo] Cache hit for get_items, returning immediately for parent {}",
575 &parent_id_for_save[..8.min(parent_id_for_save.len())]
576 );
577 tokio::spawn(async move {
579 match server_handle.await {
580 Ok(Ok(server_data)) if !server_data.items.is_empty() => {
581 if let Err(e) = offline_for_save
582 .save_to_cache(&parent_id_for_save, &server_data.items)
583 .await
584 {
585 warn!("[HybridRepo] Background cache update failed: {:?}", e);
586 } else {
587 debug!(
588 "[HybridRepo] Background updated {} cached items for parent {}",
589 server_data.items.len(),
590 &parent_id_for_save[..8.min(parent_id_for_save.len())]
591 );
592 }
593 }
594 _ => {} }
596 });
597 return Ok(data.clone());
598 }
599 }
600
601 match server_handle.await {
603 Ok(Ok(server_data)) => {
604 if !server_data.items.is_empty() {
605 let items_clone = server_data.items.clone();
606 tokio::spawn(async move {
607 if let Err(e) = offline_for_save
608 .save_to_cache(&parent_id_for_save, &items_clone)
609 .await
610 {
611 warn!(
612 "[HybridRepo] Failed to save {} items to cache: {:?}",
613 items_clone.len(),
614 e
615 );
616 } else {
617 debug!(
618 "[HybridRepo] Saved {} items to cache for parent {}",
619 items_clone.len(),
620 &parent_id_for_save[..8.min(parent_id_for_save.len())]
621 );
622 }
623 });
624 }
625 Ok(server_data.without_excluded())
631 }
632 Ok(Err(e)) => cache_result.or(Err(e)),
633 Err(join_err) => cache_result.or(Err(RepoError::Network {
634 message: format!("Server task failed: {}", join_err),
635 })),
636 }
637 }
638
639 async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
655 let offline = Arc::clone(&self.offline);
656 let online = Arc::clone(&self.online);
657 let item_id = item_id.to_string();
658 let item_id_clone = item_id.clone();
659
660 let cache_future = self.cache_with_timeout(async move { offline.get_item(&item_id).await });
661
662 let online_for_refresh = Arc::clone(&self.online);
663 let offline_for_save = Arc::clone(&self.offline);
664 let refresh_id = item_id_clone.clone();
665 let on_cache_hit = move || {
666 tokio::spawn(async move {
667 match online_for_refresh.get_item(&refresh_id).await {
668 Ok(fresh) => {
669 let parent = fresh
672 .parent_id
673 .clone()
674 .unwrap_or_else(|| "item".to_string());
675 if let Err(e) = offline_for_save.save_to_cache(&parent, &[fresh]).await {
676 debug!("[HybridRepo] Background item refresh failed: {:?}", e);
677 }
678 }
679 Err(e) => debug!("[HybridRepo] Background item refresh unavailable: {:?}", e),
680 }
681 });
682 };
683
684 let server_future = async move { online.get_item(&item_id_clone).await };
685
686 self.race_with_refresh(cache_future, server_future, on_cache_hit)
687 .await
688 }
689
690 async fn get_latest_items(
691 &self,
692 parent_id: &str,
693 limit: Option<usize>,
694 ) -> Result<Vec<MediaItem>, RepoError> {
695 let offline = Arc::clone(&self.offline);
696 let online = Arc::clone(&self.online);
697 let parent_id = parent_id.to_string();
698 let parent_id_clone = parent_id.clone();
699 let limit_clone = limit;
700
701 let cache_future = self
702 .cache_with_timeout(async move { offline.get_latest_items(&parent_id, limit).await });
703
704 let server_future =
705 async move { online.get_latest_items(&parent_id_clone, limit_clone).await };
706
707 self.parallel_race(cache_future, server_future).await
708 }
709
710 async fn get_resume_items(
711 &self,
712 parent_id: Option<&str>,
713 limit: Option<usize>,
714 ) -> Result<Vec<MediaItem>, RepoError> {
715 let offline = Arc::clone(&self.offline);
716 let online = Arc::clone(&self.online);
717 let parent_id_str = parent_id.map(|s| s.to_string());
718 let parent_id_clone = parent_id_str.clone();
719 let limit_clone = limit;
720
721 let cache_future = self.cache_with_timeout(async move {
722 offline
723 .get_resume_items(parent_id_str.as_deref(), limit)
724 .await
725 });
726
727 let server_future = async move {
728 online
729 .get_resume_items(parent_id_clone.as_deref(), limit_clone)
730 .await
731 };
732
733 self.parallel_race(cache_future, server_future).await
734 }
735
736 async fn get_next_up_episodes(
737 &self,
738 series_id: Option<&str>,
739 limit: Option<usize>,
740 ) -> Result<Vec<MediaItem>, RepoError> {
741 self.online
743 .get_next_up_episodes(series_id, limit)
744 .await
745 .map(ExcludeHidden::without_excluded)
746 }
747
748 async fn get_recently_played_audio(
749 &self,
750 limit: Option<usize>,
751 ) -> Result<Vec<MediaItem>, RepoError> {
752 let offline = Arc::clone(&self.offline);
753 let online = Arc::clone(&self.online);
754 let limit_clone = limit;
755
756 let cache_future =
757 self.cache_with_timeout(async move { offline.get_recently_played_audio(limit).await });
758
759 let server_future = async move { online.get_recently_played_audio(limit_clone).await };
760
761 self.parallel_race(cache_future, server_future).await
762 }
763
764 async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
765 let offline = Arc::clone(&self.offline);
766 let online = Arc::clone(&self.online);
767 let limit_clone = limit;
768
769 let cache_future =
770 self.cache_with_timeout(async move { offline.get_resume_movies(limit).await });
771
772 let server_future = async move { online.get_resume_movies(limit_clone).await };
773
774 self.parallel_race(cache_future, server_future).await
775 }
776
777 async fn get_rediscover_albums(
778 &self,
779 parent_id: Option<&str>,
780 limit: Option<usize>,
781 ) -> Result<Vec<MediaItem>, RepoError> {
782 let offline = Arc::clone(&self.offline);
783 let online = Arc::clone(&self.online);
784 let parent_id_owned = parent_id.map(|s| s.to_string());
785 let parent_id_clone = parent_id_owned.clone();
786
787 let cache_future = self.cache_with_timeout(async move {
788 offline
789 .get_rediscover_albums(parent_id_owned.as_deref(), limit)
790 .await
791 });
792
793 let server_future = async move {
794 online
795 .get_rediscover_albums(parent_id_clone.as_deref(), limit)
796 .await
797 };
798
799 self.parallel_race(cache_future, server_future).await
800 }
801
802 async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
803 let parent_id_str = parent_id.map(|s| s.to_string());
809
810 let cache_offline = Arc::clone(&self.offline);
811 let cache_pid = parent_id_str.clone();
812 let cache_result = self
813 .cache_with_timeout(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
814 .await;
815
816 if let Ok(genres) = &cache_result {
817 if genres.has_content() {
818 debug!("[HybridRepo] Cache hit for genres, returning immediately");
819 let online = Arc::clone(&self.online);
820 let offline = Arc::clone(&self.offline);
821 let pid = parent_id_str.clone();
822 tokio::spawn(async move {
823 if let Ok(server_genres) = online.get_genres(pid.as_deref()).await {
824 if !server_genres.is_empty() {
825 if let Err(e) = offline
826 .save_genres_to_cache(pid.as_deref(), &server_genres)
827 .await
828 {
829 warn!("[HybridRepo] Background genre cache update failed: {:?}", e);
830 }
831 }
832 }
833 });
834 return cache_result;
835 }
836 }
837
838 match self.online.get_genres(parent_id_str.as_deref()).await {
840 Ok(server_genres) => {
841 if !server_genres.is_empty() {
842 if let Err(e) = self
843 .offline
844 .save_genres_to_cache(parent_id_str.as_deref(), &server_genres)
845 .await
846 {
847 warn!(
848 "[HybridRepo] Failed to cache {} genres: {:?}",
849 server_genres.len(),
850 e
851 );
852 }
853 }
854 Ok(server_genres)
855 }
856 Err(e) => cache_result.or(Err(e)),
857 }
858 }
859
860 async fn search(
861 &self,
862 query: &str,
863 options: Option<SearchOptions>,
864 ) -> Result<SearchResult, RepoError> {
865 let offline = Arc::clone(&self.offline);
866 let online = Arc::clone(&self.online);
867 let query = query.to_string();
868 let query_clone = query.clone();
869 let opts_clone = options.clone();
870
871 let cache_future =
872 self.cache_with_timeout(async move { offline.search(&query, opts_clone).await });
873
874 let server_future = async move { online.search(&query_clone, options).await };
875
876 self.parallel_race(cache_future, server_future).await
877 }
878
879 async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
880 self.online.get_playback_info(item_id).await
882 }
883
884 async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
885 self.online.get_audio_stream_url(item_id).await
887 }
888
889 async fn get_audio_only_stream_url_for_video(
890 &self,
891 item_id: &str,
892 media_source_id: Option<&str>,
893 start_time_seconds: Option<f64>,
894 audio_stream_index: Option<i32>,
895 ) -> Result<String, RepoError> {
896 self.online
898 .build_audio_only_stream_url_for_video(
899 item_id,
900 media_source_id,
901 start_time_seconds,
902 audio_stream_index,
903 )
904 .await
905 }
906
907 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
908 self.online
910 .get_live_tv_channels()
911 .await
912 .map(ExcludeHidden::without_excluded)
913 }
914
915 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
916 self.online
918 .get_channels()
919 .await
920 .map(ExcludeHidden::without_excluded)
921 }
922
923 async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
924 self.online.open_live_stream(item_id).await
926 }
927
928 async fn report_playback_start(
929 &self,
930 item_id: &str,
931 position_ticks: i64,
932 ) -> Result<(), RepoError> {
933 self.online
935 .report_playback_start(item_id, position_ticks)
936 .await
937 }
938
939 async fn report_playback_progress(
940 &self,
941 item_id: &str,
942 position_ticks: i64,
943 ) -> Result<(), RepoError> {
944 self.online
946 .report_playback_progress(item_id, position_ticks)
947 .await
948 }
949
950 async fn report_playback_stopped(
951 &self,
952 item_id: &str,
953 position_ticks: i64,
954 ) -> Result<(), RepoError> {
955 self.online
957 .report_playback_stopped(item_id, position_ticks)
958 .await
959 }
960
961 fn get_image_url(
962 &self,
963 item_id: &str,
964 image_type: ImageType,
965 options: Option<ImageOptions>,
966 ) -> String {
967 self.online.get_image_url(item_id, image_type, options)
969 }
970
971 fn get_subtitle_url(
972 &self,
973 item_id: &str,
974 media_source_id: &str,
975 stream_index: i32,
976 format: &str,
977 ) -> String {
978 self.online
980 .get_subtitle_url(item_id, media_source_id, stream_index, format)
981 }
982
983 fn get_video_download_url(
984 &self,
985 item_id: &str,
986 quality: &str,
987 media_source_id: Option<&str>,
988 source_audio_codec: Option<&str>,
989 ) -> String {
990 self.online
992 .get_video_download_url(item_id, quality, media_source_id, source_audio_codec)
993 }
994
995 async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
996 self.online.mark_favorite(item_id).await
998 }
999
1000 async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
1001 self.online.unmark_favorite(item_id).await
1003 }
1004
1005 async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
1006 self.online.clear_watch_history(item_id).await
1008 }
1009
1010 async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
1011 self.online.mark_played(item_id).await
1013 }
1014
1015 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
1016 let offline = Arc::clone(&self.offline);
1017 let online = Arc::clone(&self.online);
1018 let person_id = person_id.to_string();
1019 let person_id_clone = person_id.clone();
1020
1021 let cache_future =
1022 self.cache_with_timeout(async move { offline.get_person(&person_id).await });
1023
1024 let server_future = async move { online.get_person(&person_id_clone).await };
1025
1026 self.parallel_race(cache_future, server_future).await
1027 }
1028
1029 async fn get_items_by_person(
1030 &self,
1031 person_id: &str,
1032 options: Option<GetItemsOptions>,
1033 ) -> Result<SearchResult, RepoError> {
1034 let offline = Arc::clone(&self.offline);
1035 let online = Arc::clone(&self.online);
1036 let person_id = person_id.to_string();
1037 let person_id_clone = person_id.clone();
1038 let opts_clone = options.clone();
1039
1040 let cache_future = self.cache_with_timeout(async move {
1041 offline.get_items_by_person(&person_id, opts_clone).await
1042 });
1043
1044 let server_future =
1045 async move { online.get_items_by_person(&person_id_clone, options).await };
1046
1047 self.parallel_race(cache_future, server_future).await
1048 }
1049
1050 async fn get_favorites(
1052 &self,
1053 scope: SearchScope,
1054 options: Option<GetItemsOptions>,
1055 ) -> Result<SearchResult, RepoError> {
1056 let cache_result = self.get_favorites_cache_only(scope, options.clone()).await;
1057
1058 if !crate::repository::offline::include_catalog_browse() {
1063 if let Ok(data) = &cache_result {
1064 return Ok(data.clone());
1065 }
1066 }
1067
1068 if let Ok(data) = &cache_result {
1069 if data.has_content() {
1070 return Ok(data.clone());
1071 }
1072 }
1073
1074 match self.get_favorites_server_only(scope, options).await {
1080 Ok(data) => Ok(data),
1081 Err(e) => cache_result.or(Err(e)),
1082 }
1083 }
1084
1085 async fn get_similar_items(
1086 &self,
1087 item_id: &str,
1088 limit: Option<usize>,
1089 ) -> Result<SearchResult, RepoError> {
1090 let offline = Arc::clone(&self.offline);
1091 let online = Arc::clone(&self.online);
1092 let item_id = item_id.to_string();
1093 let item_id_clone = item_id.clone();
1094
1095 let cache_future = self
1096 .cache_with_timeout(async move { offline.get_similar_items(&item_id, limit).await });
1097
1098 let server_future = async move { online.get_similar_items(&item_id_clone, limit).await };
1099
1100 self.parallel_race(cache_future, server_future).await
1101 }
1102
1103 async fn create_playlist(
1106 &self,
1107 name: &str,
1108 item_ids: &[String],
1109 ) -> Result<PlaylistCreatedResult, RepoError> {
1110 self.online.create_playlist(name, item_ids).await
1112 }
1113
1114 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
1115 self.online.delete_playlist(playlist_id).await
1117 }
1118
1119 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
1120 self.online.rename_playlist(playlist_id, name).await
1122 }
1123
1124 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
1125 let offline = Arc::clone(&self.offline);
1126 let offline_for_save = Arc::clone(&self.offline);
1127 let online = Arc::clone(&self.online);
1128 let playlist_id = playlist_id.to_string();
1129 let playlist_id_clone = playlist_id.clone();
1130 let playlist_id_for_save = playlist_id.clone();
1131
1132 let server_handle =
1134 tokio::spawn(async move { online.get_playlist_items(&playlist_id_clone).await });
1135
1136 let cache_result = self
1138 .cache_with_timeout(async move { offline.get_playlist_items(&playlist_id).await })
1139 .await;
1140
1141 if let Ok(data) = &cache_result {
1143 if data.has_content() {
1144 debug!("[HybridRepo] Cache hit for playlist items, returning immediately");
1145 tokio::spawn(async move {
1146 if let Ok(Ok(server_entries)) = server_handle.await {
1147 if let Err(e) = offline_for_save
1148 .save_playlist_items_to_cache(&playlist_id_for_save, &server_entries)
1149 .await
1150 {
1151 warn!("[HybridRepo] Failed to update playlist cache: {:?}", e);
1152 }
1153 }
1154 });
1155 return cache_result;
1156 }
1157 }
1158
1159 match server_handle.await {
1161 Ok(Ok(entries)) => {
1162 let entries_clone = entries.clone();
1163 tokio::spawn(async move {
1164 if let Err(e) = offline_for_save
1165 .save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone)
1166 .await
1167 {
1168 warn!(
1169 "[HybridRepo] Failed to save playlist items to cache: {:?}",
1170 e
1171 );
1172 }
1173 });
1174 Ok(entries)
1175 }
1176 Ok(Err(e)) => cache_result.or(Err(e)),
1177 Err(join_err) => cache_result.or(Err(RepoError::Network {
1178 message: format!("Server task failed: {}", join_err),
1179 })),
1180 }
1181 }
1182
1183 async fn add_to_playlist(
1184 &self,
1185 playlist_id: &str,
1186 item_ids: &[String],
1187 ) -> Result<(), RepoError> {
1188 self.online.add_to_playlist(playlist_id, item_ids).await
1190 }
1191
1192 async fn remove_from_playlist(
1193 &self,
1194 playlist_id: &str,
1195 entry_ids: &[String],
1196 ) -> Result<(), RepoError> {
1197 self.online
1199 .remove_from_playlist(playlist_id, entry_ids)
1200 .await
1201 }
1202
1203 async fn move_playlist_item(
1204 &self,
1205 playlist_id: &str,
1206 item_id: &str,
1207 new_index: u32,
1208 ) -> Result<(), RepoError> {
1209 self.online
1211 .move_playlist_item(playlist_id, item_id, new_index)
1212 .await
1213 }
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 #![allow(clippy::await_holding_lock)]
1227
1228 use super::*;
1229 use std::sync::Mutex;
1230
1231 struct MockOfflineRepo {
1233 items: Arc<Mutex<Vec<MediaItem>>>,
1234 query_count: Arc<Mutex<usize>>,
1235 save_count: Arc<Mutex<usize>>,
1236 }
1237
1238 impl MockOfflineRepo {
1239 fn new() -> Self {
1240 Self {
1241 items: Arc::new(Mutex::new(Vec::new())),
1242 query_count: Arc::new(Mutex::new(0)),
1243 save_count: Arc::new(Mutex::new(0)),
1244 }
1245 }
1246
1247 fn get_query_count(&self) -> usize {
1248 *self.query_count.lock_safe()
1249 }
1250
1251 fn get_save_count(&self) -> usize {
1252 *self.save_count.lock_safe()
1253 }
1254
1255 async fn save_to_cache(
1256 &self,
1257 _parent_id: &str,
1258 items: &[MediaItem],
1259 ) -> Result<usize, RepoError> {
1260 *self.save_count.lock_safe() += 1;
1261 *self.items.lock_safe() = items.to_vec();
1262 Ok(items.len())
1263 }
1264 }
1265
1266 #[async_trait]
1267 impl MediaRepository for MockOfflineRepo {
1268 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1269 unimplemented!()
1270 }
1271
1272 async fn get_items(
1273 &self,
1274 _parent_id: &str,
1275 _options: Option<GetItemsOptions>,
1276 ) -> Result<SearchResult, RepoError> {
1277 *self.query_count.lock_safe() += 1;
1278 let items = self.items.lock_safe().clone();
1279 let count = items.len();
1280 Ok(SearchResult {
1281 items,
1282 total_record_count: count,
1283 })
1284 }
1285
1286 async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
1287 unimplemented!()
1288 }
1289
1290 async fn get_latest_items(
1291 &self,
1292 _parent_id: &str,
1293 _limit: Option<usize>,
1294 ) -> Result<Vec<MediaItem>, RepoError> {
1295 unimplemented!()
1296 }
1297
1298 async fn get_resume_items(
1299 &self,
1300 _parent_id: Option<&str>,
1301 _limit: Option<usize>,
1302 ) -> Result<Vec<MediaItem>, RepoError> {
1303 unimplemented!()
1304 }
1305
1306 async fn get_next_up_episodes(
1307 &self,
1308 _series_id: Option<&str>,
1309 _limit: Option<usize>,
1310 ) -> Result<Vec<MediaItem>, RepoError> {
1311 unimplemented!()
1312 }
1313
1314 async fn get_recently_played_audio(
1315 &self,
1316 _limit: Option<usize>,
1317 ) -> Result<Vec<MediaItem>, RepoError> {
1318 unimplemented!()
1319 }
1320
1321 async fn get_resume_movies(
1322 &self,
1323 _limit: Option<usize>,
1324 ) -> Result<Vec<MediaItem>, RepoError> {
1325 unimplemented!()
1326 }
1327
1328 async fn get_rediscover_albums(
1329 &self,
1330 _parent_id: Option<&str>,
1331 _limit: Option<usize>,
1332 ) -> Result<Vec<MediaItem>, RepoError> {
1333 unimplemented!()
1334 }
1335
1336 async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1337 unimplemented!()
1338 }
1339
1340 async fn search(
1341 &self,
1342 _query: &str,
1343 _options: Option<SearchOptions>,
1344 ) -> Result<SearchResult, RepoError> {
1345 unimplemented!()
1346 }
1347
1348 async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
1349 unimplemented!()
1350 }
1351
1352 async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
1353 unimplemented!()
1354 }
1355
1356 async fn get_audio_only_stream_url_for_video(
1357 &self,
1358 _item_id: &str,
1359 _media_source_id: Option<&str>,
1360 _start_time_seconds: Option<f64>,
1361 _audio_stream_index: Option<i32>,
1362 ) -> Result<String, RepoError> {
1363 unimplemented!()
1364 }
1365
1366 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
1367 unimplemented!()
1368 }
1369
1370 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
1371 unimplemented!()
1372 }
1373
1374 async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
1375 unimplemented!()
1376 }
1377
1378 async fn report_playback_start(
1379 &self,
1380 _item_id: &str,
1381 _position_ticks: i64,
1382 ) -> Result<(), RepoError> {
1383 unimplemented!()
1384 }
1385
1386 async fn report_playback_progress(
1387 &self,
1388 _item_id: &str,
1389 _position_ticks: i64,
1390 ) -> Result<(), RepoError> {
1391 unimplemented!()
1392 }
1393
1394 async fn report_playback_stopped(
1395 &self,
1396 _item_id: &str,
1397 _position_ticks: i64,
1398 ) -> Result<(), RepoError> {
1399 unimplemented!()
1400 }
1401
1402 fn get_image_url(
1403 &self,
1404 _item_id: &str,
1405 _image_type: ImageType,
1406 _options: Option<ImageOptions>,
1407 ) -> String {
1408 unimplemented!()
1409 }
1410
1411 fn get_subtitle_url(
1412 &self,
1413 _item_id: &str,
1414 _media_source_id: &str,
1415 _stream_index: i32,
1416 _format: &str,
1417 ) -> String {
1418 unimplemented!()
1419 }
1420
1421 fn get_video_download_url(
1422 &self,
1423 _item_id: &str,
1424 _quality: &str,
1425 _media_source_id: Option<&str>,
1426 _source_audio_codec: Option<&str>,
1427 ) -> String {
1428 unimplemented!()
1429 }
1430
1431 async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1432 unimplemented!()
1433 }
1434
1435 async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1436 unimplemented!()
1437 }
1438
1439 async fn get_favorites(
1440 &self,
1441 _scope: SearchScope,
1442 _options: Option<GetItemsOptions>,
1443 ) -> Result<SearchResult, RepoError> {
1444 unimplemented!()
1445 }
1446
1447 async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
1448 unimplemented!()
1449 }
1450
1451 async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
1452 unimplemented!()
1453 }
1454
1455 async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
1456 unimplemented!()
1457 }
1458
1459 async fn get_items_by_person(
1460 &self,
1461 _person_id: &str,
1462 _options: Option<GetItemsOptions>,
1463 ) -> Result<SearchResult, RepoError> {
1464 unimplemented!()
1465 }
1466
1467 async fn get_similar_items(
1468 &self,
1469 _item_id: &str,
1470 _limit: Option<usize>,
1471 ) -> Result<SearchResult, RepoError> {
1472 unimplemented!()
1473 }
1474
1475 async fn create_playlist(
1476 &self,
1477 _name: &str,
1478 _item_ids: &[String],
1479 ) -> Result<PlaylistCreatedResult, RepoError> {
1480 unimplemented!()
1481 }
1482
1483 async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
1484 unimplemented!()
1485 }
1486
1487 async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
1488 unimplemented!()
1489 }
1490
1491 async fn get_playlist_items(
1492 &self,
1493 _playlist_id: &str,
1494 ) -> Result<Vec<PlaylistEntry>, RepoError> {
1495 unimplemented!()
1496 }
1497
1498 async fn add_to_playlist(
1499 &self,
1500 _playlist_id: &str,
1501 _item_ids: &[String],
1502 ) -> Result<(), RepoError> {
1503 unimplemented!()
1504 }
1505
1506 async fn remove_from_playlist(
1507 &self,
1508 _playlist_id: &str,
1509 _entry_ids: &[String],
1510 ) -> Result<(), RepoError> {
1511 unimplemented!()
1512 }
1513
1514 async fn move_playlist_item(
1515 &self,
1516 _playlist_id: &str,
1517 _item_id: &str,
1518 _new_index: u32,
1519 ) -> Result<(), RepoError> {
1520 unimplemented!()
1521 }
1522 }
1523
1524 struct MockOnlineRepo {
1526 items: Vec<MediaItem>,
1527 query_count: Arc<Mutex<usize>>,
1528 }
1529
1530 impl MockOnlineRepo {
1531 fn new(items: Vec<MediaItem>) -> Self {
1532 Self {
1533 items,
1534 query_count: Arc::new(Mutex::new(0)),
1535 }
1536 }
1537
1538 fn get_query_count(&self) -> usize {
1539 *self.query_count.lock_safe()
1540 }
1541 }
1542
1543 #[async_trait]
1544 impl MediaRepository for MockOnlineRepo {
1545 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1546 unimplemented!()
1547 }
1548
1549 async fn get_items(
1550 &self,
1551 _parent_id: &str,
1552 _options: Option<GetItemsOptions>,
1553 ) -> Result<SearchResult, RepoError> {
1554 *self.query_count.lock_safe() += 1;
1555 Ok(SearchResult {
1556 items: self.items.clone(),
1557 total_record_count: self.items.len(),
1558 })
1559 }
1560
1561 async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
1562 unimplemented!()
1563 }
1564
1565 async fn get_latest_items(
1566 &self,
1567 _parent_id: &str,
1568 _limit: Option<usize>,
1569 ) -> Result<Vec<MediaItem>, RepoError> {
1570 unimplemented!()
1571 }
1572
1573 async fn get_resume_items(
1574 &self,
1575 _parent_id: Option<&str>,
1576 _limit: Option<usize>,
1577 ) -> Result<Vec<MediaItem>, RepoError> {
1578 unimplemented!()
1579 }
1580
1581 async fn get_next_up_episodes(
1582 &self,
1583 _series_id: Option<&str>,
1584 _limit: Option<usize>,
1585 ) -> Result<Vec<MediaItem>, RepoError> {
1586 unimplemented!()
1587 }
1588
1589 async fn get_recently_played_audio(
1590 &self,
1591 _limit: Option<usize>,
1592 ) -> Result<Vec<MediaItem>, RepoError> {
1593 unimplemented!()
1594 }
1595
1596 async fn get_resume_movies(
1597 &self,
1598 _limit: Option<usize>,
1599 ) -> Result<Vec<MediaItem>, RepoError> {
1600 unimplemented!()
1601 }
1602
1603 async fn get_rediscover_albums(
1604 &self,
1605 _parent_id: Option<&str>,
1606 _limit: Option<usize>,
1607 ) -> Result<Vec<MediaItem>, RepoError> {
1608 unimplemented!()
1609 }
1610
1611 async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1612 unimplemented!()
1613 }
1614
1615 async fn search(
1616 &self,
1617 _query: &str,
1618 _options: Option<SearchOptions>,
1619 ) -> Result<SearchResult, RepoError> {
1620 unimplemented!()
1621 }
1622
1623 async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
1624 unimplemented!()
1625 }
1626
1627 async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
1628 unimplemented!()
1629 }
1630
1631 async fn get_audio_only_stream_url_for_video(
1632 &self,
1633 _item_id: &str,
1634 _media_source_id: Option<&str>,
1635 _start_time_seconds: Option<f64>,
1636 _audio_stream_index: Option<i32>,
1637 ) -> Result<String, RepoError> {
1638 unimplemented!()
1639 }
1640
1641 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
1642 unimplemented!()
1643 }
1644
1645 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
1646 unimplemented!()
1647 }
1648
1649 async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
1650 unimplemented!()
1651 }
1652
1653 async fn report_playback_start(
1654 &self,
1655 _item_id: &str,
1656 _position_ticks: i64,
1657 ) -> Result<(), RepoError> {
1658 unimplemented!()
1659 }
1660
1661 async fn report_playback_progress(
1662 &self,
1663 _item_id: &str,
1664 _position_ticks: i64,
1665 ) -> Result<(), RepoError> {
1666 unimplemented!()
1667 }
1668
1669 async fn report_playback_stopped(
1670 &self,
1671 _item_id: &str,
1672 _position_ticks: i64,
1673 ) -> Result<(), RepoError> {
1674 unimplemented!()
1675 }
1676
1677 fn get_image_url(
1678 &self,
1679 _item_id: &str,
1680 _image_type: ImageType,
1681 _options: Option<ImageOptions>,
1682 ) -> String {
1683 unimplemented!()
1684 }
1685
1686 fn get_subtitle_url(
1687 &self,
1688 _item_id: &str,
1689 _media_source_id: &str,
1690 _stream_index: i32,
1691 _format: &str,
1692 ) -> String {
1693 unimplemented!()
1694 }
1695
1696 fn get_video_download_url(
1697 &self,
1698 _item_id: &str,
1699 _quality: &str,
1700 _media_source_id: Option<&str>,
1701 _source_audio_codec: Option<&str>,
1702 ) -> String {
1703 unimplemented!()
1704 }
1705
1706 async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1707 unimplemented!()
1708 }
1709
1710 async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1711 unimplemented!()
1712 }
1713
1714 async fn get_favorites(
1715 &self,
1716 _scope: SearchScope,
1717 _options: Option<GetItemsOptions>,
1718 ) -> Result<SearchResult, RepoError> {
1719 unimplemented!()
1720 }
1721
1722 async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
1723 unimplemented!()
1724 }
1725
1726 async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
1727 unimplemented!()
1728 }
1729
1730 async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
1731 unimplemented!()
1732 }
1733
1734 async fn get_items_by_person(
1735 &self,
1736 _person_id: &str,
1737 _options: Option<GetItemsOptions>,
1738 ) -> Result<SearchResult, RepoError> {
1739 unimplemented!()
1740 }
1741
1742 async fn get_similar_items(
1743 &self,
1744 _item_id: &str,
1745 _limit: Option<usize>,
1746 ) -> Result<SearchResult, RepoError> {
1747 unimplemented!()
1748 }
1749
1750 async fn create_playlist(
1751 &self,
1752 _name: &str,
1753 _item_ids: &[String],
1754 ) -> Result<PlaylistCreatedResult, RepoError> {
1755 unimplemented!()
1756 }
1757
1758 async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
1759 unimplemented!()
1760 }
1761
1762 async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
1763 unimplemented!()
1764 }
1765
1766 async fn get_playlist_items(
1767 &self,
1768 _playlist_id: &str,
1769 ) -> Result<Vec<PlaylistEntry>, RepoError> {
1770 unimplemented!()
1771 }
1772
1773 async fn add_to_playlist(
1774 &self,
1775 _playlist_id: &str,
1776 _item_ids: &[String],
1777 ) -> Result<(), RepoError> {
1778 unimplemented!()
1779 }
1780
1781 async fn remove_from_playlist(
1782 &self,
1783 _playlist_id: &str,
1784 _entry_ids: &[String],
1785 ) -> Result<(), RepoError> {
1786 unimplemented!()
1787 }
1788
1789 async fn move_playlist_item(
1790 &self,
1791 _playlist_id: &str,
1792 _item_id: &str,
1793 _new_index: u32,
1794 ) -> Result<(), RepoError> {
1795 unimplemented!()
1796 }
1797 }
1798
1799 fn create_test_item(id: &str, name: &str) -> MediaItem {
1800 MediaItem {
1801 id: id.to_string(),
1802 name: name.to_string(),
1803 item_type: "Movie".to_string(),
1804 kind: crate::domain::MediaKind::Movie,
1805 is_folder: false,
1806 server_id: "test-server".to_string(),
1807 parent_id: Some("parent-123".to_string()),
1808 library_id: Some("library-456".to_string()),
1809 overview: Some("Test overview".to_string()),
1810 genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
1811 runtime_ticks: Some(7200000000),
1812 duration_ms: Some(720000),
1813 production_year: Some(2024),
1814 premiere_date: None,
1815 community_rating: Some(8.5),
1816 official_rating: Some("PG-13".to_string()),
1817 primary_image_tag: Some("image-tag-123".to_string()),
1818 image_id: Some("image-tag-123".to_string()),
1819 backdrop_image_tags: Some(vec!["backdrop-1".to_string()]),
1820 parent_backdrop_image_tags: None,
1821 album_id: None,
1822 album_name: None,
1823 album_artist: None,
1824 artists: None,
1825 artist_items: None,
1826 index_number: None,
1827 series_id: None,
1828 series_name: None,
1829 season_id: None,
1830 season_name: None,
1831 parent_index_number: None,
1832 user_data: None,
1833 media_streams: None,
1834 media_sources: None,
1835 people: None,
1836 }
1837 }
1838
1839 struct TestHybridRepo {
1841 offline: Arc<MockOfflineRepo>,
1842 online: Arc<MockOnlineRepo>,
1843 }
1844
1845 impl TestHybridRepo {
1846 fn new(server_items: Vec<MediaItem>) -> Self {
1847 let offline = Arc::new(MockOfflineRepo::new());
1848 let online = Arc::new(MockOnlineRepo::new(server_items));
1849 Self { offline, online }
1850 }
1851
1852 async fn get_items(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
1854 let offline = Arc::clone(&self.offline);
1855 let offline_for_save = Arc::clone(&self.offline);
1856 let online = Arc::clone(&self.online);
1857 let parent_id = parent_id.to_string();
1858 let parent_id_clone = parent_id.clone();
1859 let parent_id_for_save = parent_id.clone();
1860
1861 let cache_future = async move { offline.get_items(&parent_id, None).await };
1863
1864 let server_future = async move { online.get_items(&parent_id_clone, None).await };
1865
1866 let (cache_result, server_result) = tokio::join!(cache_future, server_future);
1868
1869 let cache_had_content = cache_result
1871 .as_ref()
1872 .map(|data| data.has_content())
1873 .unwrap_or(false);
1874
1875 let result = if cache_had_content {
1877 cache_result?
1878 } else {
1879 let server_data = server_result?;
1881
1882 if !server_data.items.is_empty() {
1883 let items_clone = server_data.items.clone();
1884 offline_for_save
1885 .save_to_cache(&parent_id_for_save, &items_clone)
1886 .await?;
1887 }
1888
1889 server_data
1890 };
1891
1892 Ok(result)
1893 }
1894
1895 async fn get_items_gated(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
1901 if !crate::repository::offline::include_catalog_browse() {
1902 let items = self.offline.get_items(parent_id, None).await?;
1903 return Ok(items);
1905 }
1906 self.get_items(parent_id).await
1907 }
1908 }
1909
1910 static GATE_TEST_LOCK: Mutex<()> = Mutex::new(());
1913
1914 #[tokio::test]
1920 async fn test_get_items_gate_off_empty_does_not_query_server() {
1921 let _guard = GATE_TEST_LOCK.lock_safe();
1922 crate::repository::offline::set_include_catalog_browse(false);
1923
1924 let repo = TestHybridRepo::new(vec![
1926 create_test_item("s-1", "Server 1"),
1927 create_test_item("s-2", "Server 2"),
1928 ]);
1929
1930 let result = repo.get_items_gated("parent-123").await.unwrap();
1931
1932 assert_eq!(
1933 result.items.len(),
1934 0,
1935 "empty offline result is authoritative when the gate is off"
1936 );
1937 assert_eq!(
1938 repo.online.get_query_count(),
1939 0,
1940 "server must NOT be queried when the gate is off"
1941 );
1942
1943 crate::repository::offline::set_include_catalog_browse(true);
1944 }
1945
1946 #[tokio::test]
1952 async fn test_get_items_gate_on_empty_falls_through_to_server() {
1953 let _guard = GATE_TEST_LOCK.lock_safe();
1954 crate::repository::offline::set_include_catalog_browse(true);
1955
1956 let repo = TestHybridRepo::new(vec![
1957 create_test_item("s-1", "Server 1"),
1958 create_test_item("s-2", "Server 2"),
1959 ]);
1960
1961 let result = repo.get_items_gated("parent-123").await.unwrap();
1962
1963 assert_eq!(result.items.len(), 2, "server result used on empty cache");
1964 assert_eq!(
1965 repo.online.get_query_count(),
1966 1,
1967 "server IS queried when the gate is on and the cache is empty"
1968 );
1969 }
1970
1971 #[tokio::test]
1977 async fn test_cache_miss_saves_to_cache() {
1978 let server_items = vec![
1980 create_test_item("item-1", "Movie 1"),
1981 create_test_item("item-2", "Movie 2"),
1982 create_test_item("item-3", "Movie 3"),
1983 ];
1984
1985 let repo = TestHybridRepo::new(server_items.clone());
1986
1987 let result = repo.get_items("parent-123").await.unwrap();
1989
1990 assert_eq!(result.items.len(), 3);
1992 assert_eq!(result.items[0].id, "item-1");
1993
1994 assert_eq!(
1996 repo.offline.get_query_count(),
1997 1,
1998 "Cache should be queried once"
1999 );
2000 assert_eq!(
2001 repo.online.get_query_count(),
2002 1,
2003 "Server should be queried once"
2004 );
2005
2006 assert_eq!(
2008 repo.offline.get_save_count(),
2009 1,
2010 "Should save to cache on miss"
2011 );
2012 }
2013
2014 #[tokio::test]
2023 async fn test_cache_hit_no_save() {
2024 let server_items = vec![
2026 create_test_item("item-1", "Movie 1"),
2027 create_test_item("item-2", "Movie 2"),
2028 create_test_item("item-3", "Movie 3"),
2029 ];
2030
2031 let repo = TestHybridRepo::new(server_items.clone());
2032
2033 repo.offline
2035 .save_to_cache("parent-123", &server_items)
2036 .await
2037 .unwrap();
2038 assert_eq!(repo.offline.get_save_count(), 1);
2039
2040 let result = repo.get_items("parent-123").await.unwrap();
2042
2043 assert_eq!(result.items.len(), 3);
2045 assert_eq!(result.items[0].id, "item-1");
2046
2047 assert_eq!(repo.offline.get_query_count(), 1, "Cache should be queried");
2049 assert_eq!(
2050 repo.online.get_query_count(),
2051 1,
2052 "Server is queried in parallel"
2053 );
2054
2055 assert_eq!(
2057 repo.offline.get_save_count(),
2058 1,
2059 "Should NOT save when using cache"
2060 );
2061 }
2062
2063 #[tokio::test]
2068 async fn test_empty_cache_returns_empty_result() {
2069 let repo = TestHybridRepo::new(vec![]);
2071
2072 let result = repo.get_items("parent-123").await.unwrap();
2074
2075 assert_eq!(result.items.len(), 0);
2077
2078 assert_eq!(
2080 repo.offline.get_save_count(),
2081 0,
2082 "Should not save empty results"
2083 );
2084 }
2085
2086 #[tokio::test]
2090 async fn test_has_content_check() {
2091 let empty_result = SearchResult {
2093 items: vec![],
2094 total_record_count: 0,
2095 };
2096 assert!(
2097 !empty_result.has_content(),
2098 "Empty result should not have content"
2099 );
2100
2101 let result_with_items = SearchResult {
2102 items: vec![create_test_item("item-1", "Movie 1")],
2103 total_record_count: 1,
2104 };
2105 assert!(
2106 result_with_items.has_content(),
2107 "Result with items should have content"
2108 );
2109 }
2110
2111 #[test]
2112 fn test_merge_search_local_first_then_server_appended() {
2113 let cache = SearchResult {
2114 items: vec![
2115 create_test_item("a", "Cached A"),
2116 create_test_item("b", "Cached B"),
2117 ],
2118 total_record_count: 2,
2119 };
2120 let server = SearchResult {
2121 items: vec![
2122 create_test_item("c", "Server C"),
2123 create_test_item("d", "Server D"),
2124 ],
2125 total_record_count: 2,
2126 };
2127
2128 let merged = HybridRepository::merge_search_results(cache, server);
2129
2130 let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
2132 assert_eq!(ids, vec!["a", "b", "c", "d"]);
2133 assert_eq!(merged.total_record_count, 4);
2134 }
2135
2136 #[test]
2137 fn test_merge_search_dedupes_with_server_winning() {
2138 let cache = SearchResult {
2141 items: vec![
2142 create_test_item("a", "Cached A"),
2143 create_test_item("b", "Cached B"),
2144 ],
2145 total_record_count: 2,
2146 };
2147 let server = SearchResult {
2148 items: vec![
2149 create_test_item("b", "Server B (fresher)"),
2150 create_test_item("c", "Server C"),
2151 ],
2152 total_record_count: 2,
2153 };
2154
2155 let merged = HybridRepository::merge_search_results(cache, server);
2156
2157 let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
2158 assert_eq!(
2159 ids,
2160 vec!["a", "b", "c"],
2161 "no duplicate, local position kept"
2162 );
2163
2164 let b = merged.items.iter().find(|i| i.id == "b").unwrap();
2165 assert_eq!(
2166 b.name, "Server B (fresher)",
2167 "server metadata wins on conflict"
2168 );
2169 assert_eq!(merged.total_record_count, 3);
2170 }
2171
2172 #[test]
2173 fn test_merge_search_handles_empty_sides() {
2174 let only_server = HybridRepository::merge_search_results(
2175 SearchResult {
2176 items: vec![],
2177 total_record_count: 0,
2178 },
2179 SearchResult {
2180 items: vec![create_test_item("x", "X")],
2181 total_record_count: 1,
2182 },
2183 );
2184 assert_eq!(only_server.items.len(), 1);
2185 assert_eq!(only_server.items[0].id, "x");
2186
2187 let only_cache = HybridRepository::merge_search_results(
2188 SearchResult {
2189 items: vec![create_test_item("y", "Y")],
2190 total_record_count: 1,
2191 },
2192 SearchResult {
2193 items: vec![],
2194 total_record_count: 0,
2195 },
2196 );
2197 assert_eq!(only_cache.items.len(), 1);
2198 assert_eq!(only_cache.items[0].id, "y");
2199 }
2200}