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
21enum CacheLeg<T> {
30 Ready(Result<T, RepoError>),
32 Slow(tokio::task::JoinHandle<Result<T, RepoError>>),
34}
35
36impl<T> CacheLeg<T> {
37 #[allow(clippy::type_complexity)]
40 fn split(
41 self,
42 ) -> (
43 Option<Result<T, RepoError>>,
44 Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
45 ) {
46 match self {
47 CacheLeg::Ready(result) => (Some(result), None),
48 CacheLeg::Slow(handle) => (None, Some(handle)),
49 }
50 }
51}
52
53pub struct HybridRepository {
65 online: Arc<OnlineRepository>,
66 offline: Arc<OfflineRepository>,
67}
68
69impl HybridRepository {
70 pub fn new(online: OnlineRepository, offline: OfflineRepository) -> Self {
71 Self {
72 online: Arc::new(online),
73 offline: Arc::new(offline),
74 }
75 }
76
77 pub fn user_id(&self) -> &str {
81 self.online.user_id()
82 }
83
84 pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
87 self.online.download_bytes(url).await
88 }
89
90 pub async fn prune_stale_catalog(
98 &self,
99 cutoff: &str,
100 item_types: &[String],
101 ) -> Result<usize, RepoError> {
102 self.offline.prune_stale_catalog(cutoff, item_types).await
103 }
104
105 pub async fn get_jray_actors(
108 &self,
109 item_id: &str,
110 t: f64,
111 ) -> Result<Vec<super::JRayActor>, RepoError> {
112 self.online.get_jray_actors(item_id, t).await
113 }
114
115 pub async fn get_video_stream_url(
122 &self,
123 item_id: &str,
124 media_source_id: Option<&str>,
125 audio_stream_index: Option<i32>,
126 ) -> Result<String, RepoError> {
127 self.online
128 .get_video_stream_url(item_id, media_source_id, audio_stream_index)
129 .await
130 }
131
132 pub async fn get_stream_selection(
140 &self,
141 item_id: &str,
142 media_source_id: Option<&str>,
143 audio_stream_index: Option<i32>,
144 ) -> Result<super::StreamSelection, RepoError> {
145 self.online
146 .get_stream_selection(item_id, media_source_id, audio_stream_index)
147 .await
148 }
149
150 pub async fn get_audio_only_stream_url_for_video(
155 &self,
156 item_id: &str,
157 media_source_id: Option<&str>,
158 start_time_seconds: Option<f64>,
159 audio_stream_index: Option<i32>,
160 ) -> Result<String, RepoError> {
161 self.online
162 .get_audio_only_stream_url_for_video(
163 item_id,
164 media_source_id,
165 start_time_seconds,
166 audio_stream_index,
167 )
168 .await
169 }
170
171 pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<MediaItem>, RepoError> {
187 let options = Some(GetItemsOptions {
188 include_item_types: Some(vec!["Audio".to_string()]),
189 sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
190 limit: Some(1000),
191 ..Default::default()
192 });
193
194 let result = self.online.get_items(album_id, options).await?;
195
196 if !result.items.is_empty() {
197 if let Err(e) = self.offline.save_to_cache(album_id, &result.items).await {
198 warn!("[HybridRepo] Failed to cache album tracks: {:?}", e);
199 }
200 }
201
202 Ok(result.items)
203 }
204
205 pub async fn get_items_unfiltered(
216 &self,
217 parent_id: &str,
218 options: Option<GetItemsOptions>,
219 ) -> Result<SearchResult, RepoError> {
220 match self.online.get_items(parent_id, options.clone()).await {
221 Ok(result) => Ok(result),
222 Err(e) => self.offline.get_items(parent_id, options).await.or(Err(e)),
223 }
224 }
225
226 pub async fn search_cache_only(
232 &self,
233 query: &str,
234 options: Option<SearchOptions>,
235 ) -> Result<SearchResult, RepoError> {
236 let offline = Arc::clone(&self.offline);
237 let query = query.to_string();
238 self.cache_with_timeout(async move { offline.search(&query, options).await })
239 .await
240 .map(ExcludeHidden::without_excluded)
241 }
242
243 pub async fn get_favorites_cache_only(
248 &self,
249 scope: SearchScope,
250 options: Option<GetItemsOptions>,
251 ) -> Result<SearchResult, RepoError> {
252 let offline = Arc::clone(&self.offline);
253 self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
254 .await
255 .map(ExcludeHidden::without_excluded)
256 }
257
258 pub async fn get_favorites_server_only(
264 &self,
265 scope: SearchScope,
266 options: Option<GetItemsOptions>,
267 ) -> Result<SearchResult, RepoError> {
268 let result = self.online.get_favorites(scope, options).await?;
269 if !result.items.is_empty() {
270 if let Err(e) = self.offline.save_to_cache("favorites", &result.items).await {
273 debug!("[HybridRepo] Failed to cache favourites: {:?}", e);
274 }
275 }
276 Ok(result.without_excluded())
277 }
278
279 pub async fn cache_items_from_server(
288 &self,
289 parent_id: &str,
290 options: Option<GetItemsOptions>,
291 ) -> Result<Vec<MediaItem>, RepoError> {
292 let result = self.online.get_items(parent_id, options).await?;
293 if !result.items.is_empty() {
294 self.offline.save_to_cache(parent_id, &result.items).await?;
295 }
296 Ok(result.items)
297 }
298
299 pub async fn get_downloaded_items(
308 &self,
309 parent_id: &str,
310 options: Option<GetItemsOptions>,
311 ) -> Result<SearchResult, RepoError> {
312 self.offline.get_downloaded_items(parent_id, options).await
317 }
318
319 pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
323 self.offline.get_downloaded_libraries().await
324 }
325
326 pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
330 self.offline.get_download_disk_usage().await
331 }
332
333 pub async fn search_server_only(
335 &self,
336 query: &str,
337 options: Option<SearchOptions>,
338 ) -> Result<SearchResult, RepoError> {
339 self.online
340 .search(query, options)
341 .await
342 .map(ExcludeHidden::without_excluded)
343 }
344
345 pub fn merge_search_results(cache: SearchResult, server: SearchResult) -> SearchResult {
351 use std::collections::HashMap;
352
353 let mut server_by_id: HashMap<String, MediaItem> = HashMap::new();
356 let mut server_order: Vec<String> = Vec::with_capacity(server.items.len());
357 for item in server.items {
358 if !server_by_id.contains_key(&item.id) {
359 server_order.push(item.id.clone());
360 }
361 server_by_id.insert(item.id.clone(), item);
362 }
363
364 let mut items: Vec<MediaItem> = Vec::new();
365 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
366
367 for local in cache.items {
370 if !seen.insert(local.id.clone()) {
371 continue;
372 }
373 match server_by_id.remove(&local.id) {
374 Some(server_item) => items.push(server_item),
375 None => items.push(local),
376 }
377 }
378
379 for id in server_order {
381 if let Some(server_item) = server_by_id.remove(&id) {
382 if seen.insert(id) {
383 items.push(server_item);
384 }
385 }
386 }
387
388 let total_record_count = items.len();
389 SearchResult {
390 items,
391 total_record_count,
392 }
393 }
394
395 async fn parallel_race<T, F2>(cache: CacheLeg<T>, server_future: F2) -> Result<T, RepoError>
415 where
416 T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
417 F2: std::future::Future<Output = Result<T, RepoError>> + Send,
418 {
419 Self::race_with_refresh(cache, server_future, || {}).await
420 }
421
422 async fn race_with_refresh<T, F2, R>(
437 cache: CacheLeg<T>,
438 server_future: F2,
439 on_cache_hit: R,
440 ) -> Result<T, RepoError>
441 where
442 T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
443 F2: std::future::Future<Output = Result<T, RepoError>> + Send,
444 R: FnOnce(),
445 {
446 let (fast, slow) = cache.split();
447 let fast = fast.map(|r| r.map(ExcludeHidden::without_excluded));
448
449 if let Some(Ok(data)) = &fast {
450 if data.has_content() {
451 debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
452 on_cache_hit();
453 return Ok(data.clone());
454 }
455 }
456
457 debug!("[HybridRepo] Cache miss or slow, querying server");
458 match server_future.await {
459 Ok(data) => Ok(data.without_excluded()),
460 Err(e) => {
461 if let Some(handle) = slow {
467 debug!("[HybridRepo] Server failed; waiting for the slow cache query");
468 return match handle.await {
469 Ok(Ok(data)) => Ok(data.without_excluded()),
470 Ok(Err(cache_err)) => {
471 debug!("[HybridRepo] Slow cache query also failed: {cache_err}");
472 Err(e)
473 }
474 Err(join) => {
475 debug!("[HybridRepo] Slow cache query panicked: {join}");
476 Err(e)
477 }
478 };
479 }
480 fast.unwrap_or(Err(e))
483 }
484 }
485 }
486
487 const CACHE_FAST_PATH: Duration = Duration::from_millis(100);
491
492 async fn cache_leg<T>(
506 future: impl std::future::Future<Output = Result<T, RepoError>> + Send + 'static,
507 ) -> CacheLeg<T>
508 where
509 T: Send + 'static,
510 {
511 let mut handle = tokio::spawn(future);
514 match timeout(Self::CACHE_FAST_PATH, &mut handle).await {
515 Ok(Ok(result)) => CacheLeg::Ready(result),
516 Ok(Err(join)) => CacheLeg::Ready(Err(RepoError::Database {
517 message: format!("Cache query failed: {join}"),
518 })),
519 Err(_) => {
520 debug!("[HybridRepo] Cache missed the fast path; leaving it running");
521 CacheLeg::Slow(handle)
522 }
523 }
524 }
525
526 async fn cache_with_timeout<T>(
528 &self,
529 future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
530 ) -> Result<T, RepoError> {
531 timeout(Self::CACHE_FAST_PATH, future)
532 .await
533 .unwrap_or_else(|_| {
534 Err(RepoError::Database {
535 message: "Cache query timeout".to_string(),
536 })
537 })
538 }
539}
540
541#[async_trait]
542impl MediaRepository for HybridRepository {
543 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
544 let cache_result = self.cache_with_timeout(self.offline.get_libraries()).await;
548
549 if let Ok(libs) = &cache_result {
550 if libs.has_content() {
551 debug!("[HybridRepo] Cache hit for libraries, returning immediately");
552 let online = Arc::clone(&self.online);
553 let offline = Arc::clone(&self.offline);
554 tokio::spawn(async move {
555 if let Ok(server_libs) = online.get_libraries().await {
556 if !server_libs.is_empty() {
557 if let Err(e) = offline.save_libraries_to_cache(&server_libs).await {
558 warn!(
559 "[HybridRepo] Background library cache update failed: {:?}",
560 e
561 );
562 }
563 }
564 }
565 });
566 return cache_result;
567 }
568 }
569
570 match self.online.get_libraries().await {
572 Ok(server_libs) => {
573 if !server_libs.is_empty() {
574 if let Err(e) = self.offline.save_libraries_to_cache(&server_libs).await {
575 warn!(
576 "[HybridRepo] Failed to cache {} libraries: {:?}",
577 server_libs.len(),
578 e
579 );
580 }
581 }
582 Ok(server_libs)
583 }
584 Err(e) => cache_result.or(Err(e)),
585 }
586 }
587
588 async fn get_items(
589 &self,
590 parent_id: &str,
591 options: Option<GetItemsOptions>,
592 ) -> Result<SearchResult, RepoError> {
593 let offline = Arc::clone(&self.offline);
594 let offline_for_save = Arc::clone(&self.offline);
595 let online = Arc::clone(&self.online);
596 let parent_id = parent_id.to_string();
597 let parent_id_clone = parent_id.clone();
598 let parent_id_for_save = parent_id.clone();
599 let opts_clone = options.clone();
600
601 let server_handle =
603 tokio::spawn(async move { online.get_items(&parent_id_clone, options).await });
604
605 let cache_result = self
614 .cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
615 .await
616 .map(ExcludeHidden::without_excluded);
617
618 if !crate::repository::offline::include_catalog_browse() {
626 if let Ok(data) = &cache_result {
627 debug!(
628 "[HybridRepo] Downloads-only gate: returning offline result ({} items) as authoritative for parent {}",
629 data.items.len(),
630 &parent_id_for_save[..8.min(parent_id_for_save.len())]
631 );
632 server_handle.abort();
634 return Ok(data.clone());
635 }
636 }
637
638 if let Ok(data) = &cache_result {
640 if data.has_content() {
641 debug!(
642 "[HybridRepo] Cache hit for get_items, returning immediately for parent {}",
643 &parent_id_for_save[..8.min(parent_id_for_save.len())]
644 );
645 tokio::spawn(async move {
647 match server_handle.await {
648 Ok(Ok(server_data)) if !server_data.items.is_empty() => {
649 if let Err(e) = offline_for_save
650 .save_to_cache(&parent_id_for_save, &server_data.items)
651 .await
652 {
653 warn!("[HybridRepo] Background cache update failed: {:?}", e);
654 } else {
655 debug!(
656 "[HybridRepo] Background updated {} cached items for parent {}",
657 server_data.items.len(),
658 &parent_id_for_save[..8.min(parent_id_for_save.len())]
659 );
660 }
661 }
662 _ => {} }
664 });
665 return Ok(data.clone());
666 }
667 }
668
669 match server_handle.await {
671 Ok(Ok(server_data)) => {
672 if !server_data.items.is_empty() {
673 let items_clone = server_data.items.clone();
674 tokio::spawn(async move {
675 if let Err(e) = offline_for_save
676 .save_to_cache(&parent_id_for_save, &items_clone)
677 .await
678 {
679 warn!(
680 "[HybridRepo] Failed to save {} items to cache: {:?}",
681 items_clone.len(),
682 e
683 );
684 } else {
685 debug!(
686 "[HybridRepo] Saved {} items to cache for parent {}",
687 items_clone.len(),
688 &parent_id_for_save[..8.min(parent_id_for_save.len())]
689 );
690 }
691 });
692 }
693 Ok(server_data.without_excluded())
699 }
700 Ok(Err(e)) => cache_result.or(Err(e)),
701 Err(join_err) => cache_result.or(Err(RepoError::Network {
702 message: format!("Server task failed: {}", join_err),
703 })),
704 }
705 }
706
707 async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
723 let offline = Arc::clone(&self.offline);
724 let online = Arc::clone(&self.online);
725 let item_id = item_id.to_string();
726 let item_id_clone = item_id.clone();
727
728 let cache_future = Self::cache_leg(async move { offline.get_item(&item_id).await }).await;
729
730 let online_for_refresh = Arc::clone(&self.online);
731 let offline_for_save = Arc::clone(&self.offline);
732 let refresh_id = item_id_clone.clone();
733 let on_cache_hit = move || {
734 tokio::spawn(async move {
735 match online_for_refresh.get_item(&refresh_id).await {
736 Ok(fresh) => {
737 let parent = fresh
740 .parent_id
741 .clone()
742 .unwrap_or_else(|| "item".to_string());
743 if let Err(e) = offline_for_save.save_to_cache(&parent, &[fresh]).await {
744 debug!("[HybridRepo] Background item refresh failed: {:?}", e);
745 }
746 }
747 Err(e) => debug!("[HybridRepo] Background item refresh unavailable: {:?}", e),
748 }
749 });
750 };
751
752 let server_future = async move { online.get_item(&item_id_clone).await };
753
754 Self::race_with_refresh(cache_future, server_future, on_cache_hit).await
755 }
756
757 async fn get_latest_items(
758 &self,
759 parent_id: &str,
760 limit: Option<usize>,
761 ) -> Result<Vec<MediaItem>, RepoError> {
762 let offline = Arc::clone(&self.offline);
763 let online = Arc::clone(&self.online);
764 let parent_id = parent_id.to_string();
765 let parent_id_clone = parent_id.clone();
766 let limit_clone = limit;
767
768 let cache_future =
769 Self::cache_leg(async move { offline.get_latest_items(&parent_id, limit).await }).await;
770
771 let server_future =
772 async move { online.get_latest_items(&parent_id_clone, limit_clone).await };
773
774 Self::parallel_race(cache_future, server_future).await
775 }
776
777 async fn get_resume_items(
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_str = parent_id.map(|s| s.to_string());
785 let parent_id_clone = parent_id_str.clone();
786 let limit_clone = limit;
787
788 let cache_future = Self::cache_leg(async move {
789 offline
790 .get_resume_items(parent_id_str.as_deref(), limit)
791 .await
792 })
793 .await;
794
795 let server_future = async move {
796 online
797 .get_resume_items(parent_id_clone.as_deref(), limit_clone)
798 .await
799 };
800
801 Self::parallel_race(cache_future, server_future).await
802 }
803
804 async fn get_next_up_episodes(
805 &self,
806 series_id: Option<&str>,
807 limit: Option<usize>,
808 ) -> Result<Vec<MediaItem>, RepoError> {
809 self.online
811 .get_next_up_episodes(series_id, limit)
812 .await
813 .map(ExcludeHidden::without_excluded)
814 }
815
816 async fn get_recently_played_audio(
817 &self,
818 limit: Option<usize>,
819 ) -> Result<Vec<MediaItem>, RepoError> {
820 let offline = Arc::clone(&self.offline);
821 let online = Arc::clone(&self.online);
822 let limit_clone = limit;
823
824 let cache_future =
825 Self::cache_leg(async move { offline.get_recently_played_audio(limit).await }).await;
826
827 let server_future = async move { online.get_recently_played_audio(limit_clone).await };
828
829 Self::parallel_race(cache_future, server_future).await
830 }
831
832 async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
833 let offline = Arc::clone(&self.offline);
834 let online = Arc::clone(&self.online);
835 let limit_clone = limit;
836
837 let cache_future =
838 Self::cache_leg(async move { offline.get_resume_movies(limit).await }).await;
839
840 let server_future = async move { online.get_resume_movies(limit_clone).await };
841
842 Self::parallel_race(cache_future, server_future).await
843 }
844
845 async fn get_rediscover_albums(
846 &self,
847 parent_id: Option<&str>,
848 limit: Option<usize>,
849 ) -> Result<Vec<MediaItem>, RepoError> {
850 let offline = Arc::clone(&self.offline);
851 let online = Arc::clone(&self.online);
852 let parent_id_owned = parent_id.map(|s| s.to_string());
853 let parent_id_clone = parent_id_owned.clone();
854
855 let cache_future = Self::cache_leg(async move {
856 offline
857 .get_rediscover_albums(parent_id_owned.as_deref(), limit)
858 .await
859 })
860 .await;
861
862 let server_future = async move {
863 online
864 .get_rediscover_albums(parent_id_clone.as_deref(), limit)
865 .await
866 };
867
868 Self::parallel_race(cache_future, server_future).await
869 }
870
871 async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
872 let parent_id_str = parent_id.map(|s| s.to_string());
878
879 let cache_offline = Arc::clone(&self.offline);
880 let cache_pid = parent_id_str.clone();
881 let cache_result = self
882 .cache_with_timeout(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
883 .await;
884
885 if let Ok(genres) = &cache_result {
886 if genres.has_content() {
887 debug!("[HybridRepo] Cache hit for genres, returning immediately");
888 let online = Arc::clone(&self.online);
889 let offline = Arc::clone(&self.offline);
890 let pid = parent_id_str.clone();
891 tokio::spawn(async move {
892 if let Ok(server_genres) = online.get_genres(pid.as_deref()).await {
893 if !server_genres.is_empty() {
894 if let Err(e) = offline
895 .save_genres_to_cache(pid.as_deref(), &server_genres)
896 .await
897 {
898 warn!("[HybridRepo] Background genre cache update failed: {:?}", e);
899 }
900 }
901 }
902 });
903 return cache_result;
904 }
905 }
906
907 match self.online.get_genres(parent_id_str.as_deref()).await {
909 Ok(server_genres) => {
910 if !server_genres.is_empty() {
911 if let Err(e) = self
912 .offline
913 .save_genres_to_cache(parent_id_str.as_deref(), &server_genres)
914 .await
915 {
916 warn!(
917 "[HybridRepo] Failed to cache {} genres: {:?}",
918 server_genres.len(),
919 e
920 );
921 }
922 }
923 Ok(server_genres)
924 }
925 Err(e) => cache_result.or(Err(e)),
926 }
927 }
928
929 async fn search(
930 &self,
931 query: &str,
932 options: Option<SearchOptions>,
933 ) -> Result<SearchResult, RepoError> {
934 let offline = Arc::clone(&self.offline);
935 let online = Arc::clone(&self.online);
936 let query = query.to_string();
937 let query_clone = query.clone();
938 let opts_clone = options.clone();
939
940 let cache_future =
941 Self::cache_leg(async move { offline.search(&query, opts_clone).await }).await;
942
943 let server_future = async move { online.search(&query_clone, options).await };
944
945 Self::parallel_race(cache_future, server_future).await
946 }
947
948 async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
949 self.online.get_playback_info(item_id).await
951 }
952
953 async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
954 self.online.get_audio_stream_url(item_id).await
956 }
957
958 async fn get_audio_only_stream_url_for_video(
959 &self,
960 item_id: &str,
961 media_source_id: Option<&str>,
962 start_time_seconds: Option<f64>,
963 audio_stream_index: Option<i32>,
964 ) -> Result<String, RepoError> {
965 self.online
967 .build_audio_only_stream_url_for_video(
968 item_id,
969 media_source_id,
970 start_time_seconds,
971 audio_stream_index,
972 )
973 .await
974 }
975
976 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
977 self.online
979 .get_live_tv_channels()
980 .await
981 .map(ExcludeHidden::without_excluded)
982 }
983
984 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
985 self.online
987 .get_channels()
988 .await
989 .map(ExcludeHidden::without_excluded)
990 }
991
992 async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
993 self.online.open_live_stream(item_id).await
995 }
996
997 async fn report_playback_start(
998 &self,
999 item_id: &str,
1000 position_ticks: i64,
1001 ) -> Result<(), RepoError> {
1002 self.online
1004 .report_playback_start(item_id, position_ticks)
1005 .await
1006 }
1007
1008 async fn report_playback_progress(
1009 &self,
1010 item_id: &str,
1011 position_ticks: i64,
1012 ) -> Result<(), RepoError> {
1013 self.online
1015 .report_playback_progress(item_id, position_ticks)
1016 .await
1017 }
1018
1019 async fn report_playback_stopped(
1020 &self,
1021 item_id: &str,
1022 position_ticks: i64,
1023 ) -> Result<(), RepoError> {
1024 self.online
1026 .report_playback_stopped(item_id, position_ticks)
1027 .await
1028 }
1029
1030 fn get_image_url(
1031 &self,
1032 item_id: &str,
1033 image_type: ImageType,
1034 options: Option<ImageOptions>,
1035 ) -> String {
1036 self.online.get_image_url(item_id, image_type, options)
1038 }
1039
1040 fn get_subtitle_url(
1041 &self,
1042 item_id: &str,
1043 media_source_id: &str,
1044 stream_index: i32,
1045 format: &str,
1046 ) -> String {
1047 self.online
1049 .get_subtitle_url(item_id, media_source_id, stream_index, format)
1050 }
1051
1052 fn get_video_download_url(
1053 &self,
1054 item_id: &str,
1055 quality: &str,
1056 media_source_id: Option<&str>,
1057 source_audio_codec: Option<&str>,
1058 ) -> String {
1059 self.online
1061 .get_video_download_url(item_id, quality, media_source_id, source_audio_codec)
1062 }
1063
1064 async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
1065 self.online.mark_favorite(item_id).await
1067 }
1068
1069 async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
1070 self.online.unmark_favorite(item_id).await
1072 }
1073
1074 async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
1075 self.online.clear_watch_history(item_id).await
1077 }
1078
1079 async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
1080 self.online.mark_played(item_id).await
1082 }
1083
1084 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
1085 let offline = Arc::clone(&self.offline);
1086 let online = Arc::clone(&self.online);
1087 let person_id = person_id.to_string();
1088 let person_id_clone = person_id.clone();
1089
1090 let cache_future =
1091 Self::cache_leg(async move { offline.get_person(&person_id).await }).await;
1092
1093 let server_future = async move { online.get_person(&person_id_clone).await };
1094
1095 Self::parallel_race(cache_future, server_future).await
1096 }
1097
1098 async fn get_items_by_person(
1099 &self,
1100 person_id: &str,
1101 options: Option<GetItemsOptions>,
1102 ) -> Result<SearchResult, RepoError> {
1103 let offline = Arc::clone(&self.offline);
1104 let online = Arc::clone(&self.online);
1105 let person_id = person_id.to_string();
1106 let person_id_clone = person_id.clone();
1107 let opts_clone = options.clone();
1108
1109 let cache_future =
1110 Self::cache_leg(
1111 async move { offline.get_items_by_person(&person_id, opts_clone).await },
1112 )
1113 .await;
1114
1115 let server_future =
1116 async move { online.get_items_by_person(&person_id_clone, options).await };
1117
1118 Self::parallel_race(cache_future, server_future).await
1119 }
1120
1121 async fn get_favorites(
1123 &self,
1124 scope: SearchScope,
1125 options: Option<GetItemsOptions>,
1126 ) -> Result<SearchResult, RepoError> {
1127 let cache_result = self.get_favorites_cache_only(scope, options.clone()).await;
1128
1129 if !crate::repository::offline::include_catalog_browse() {
1134 if let Ok(data) = &cache_result {
1135 return Ok(data.clone());
1136 }
1137 }
1138
1139 if let Ok(data) = &cache_result {
1140 if data.has_content() {
1141 return Ok(data.clone());
1142 }
1143 }
1144
1145 match self.get_favorites_server_only(scope, options).await {
1151 Ok(data) => Ok(data),
1152 Err(e) => cache_result.or(Err(e)),
1153 }
1154 }
1155
1156 async fn get_similar_items(
1157 &self,
1158 item_id: &str,
1159 limit: Option<usize>,
1160 ) -> Result<SearchResult, RepoError> {
1161 let offline = Arc::clone(&self.offline);
1162 let online = Arc::clone(&self.online);
1163 let item_id = item_id.to_string();
1164 let item_id_clone = item_id.clone();
1165
1166 let cache_future =
1167 Self::cache_leg(async move { offline.get_similar_items(&item_id, limit).await }).await;
1168
1169 let server_future = async move { online.get_similar_items(&item_id_clone, limit).await };
1170
1171 Self::parallel_race(cache_future, server_future).await
1172 }
1173
1174 async fn create_playlist(
1177 &self,
1178 name: &str,
1179 item_ids: &[String],
1180 ) -> Result<PlaylistCreatedResult, RepoError> {
1181 self.online.create_playlist(name, item_ids).await
1183 }
1184
1185 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
1186 self.online.delete_playlist(playlist_id).await
1188 }
1189
1190 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
1191 self.online.rename_playlist(playlist_id, name).await
1193 }
1194
1195 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
1196 let offline = Arc::clone(&self.offline);
1197 let offline_for_save = Arc::clone(&self.offline);
1198 let online = Arc::clone(&self.online);
1199 let playlist_id = playlist_id.to_string();
1200 let playlist_id_clone = playlist_id.clone();
1201 let playlist_id_for_save = playlist_id.clone();
1202
1203 let server_handle =
1205 tokio::spawn(async move { online.get_playlist_items(&playlist_id_clone).await });
1206
1207 let cache_result = self
1209 .cache_with_timeout(async move { offline.get_playlist_items(&playlist_id).await })
1210 .await;
1211
1212 if let Ok(data) = &cache_result {
1214 if data.has_content() {
1215 debug!("[HybridRepo] Cache hit for playlist items, returning immediately");
1216 tokio::spawn(async move {
1217 if let Ok(Ok(server_entries)) = server_handle.await {
1218 if let Err(e) = offline_for_save
1219 .save_playlist_items_to_cache(&playlist_id_for_save, &server_entries)
1220 .await
1221 {
1222 warn!("[HybridRepo] Failed to update playlist cache: {:?}", e);
1223 }
1224 }
1225 });
1226 return cache_result;
1227 }
1228 }
1229
1230 match server_handle.await {
1232 Ok(Ok(entries)) => {
1233 let entries_clone = entries.clone();
1234 tokio::spawn(async move {
1235 if let Err(e) = offline_for_save
1236 .save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone)
1237 .await
1238 {
1239 warn!(
1240 "[HybridRepo] Failed to save playlist items to cache: {:?}",
1241 e
1242 );
1243 }
1244 });
1245 Ok(entries)
1246 }
1247 Ok(Err(e)) => cache_result.or(Err(e)),
1248 Err(join_err) => cache_result.or(Err(RepoError::Network {
1249 message: format!("Server task failed: {}", join_err),
1250 })),
1251 }
1252 }
1253
1254 async fn add_to_playlist(
1255 &self,
1256 playlist_id: &str,
1257 item_ids: &[String],
1258 ) -> Result<(), RepoError> {
1259 self.online.add_to_playlist(playlist_id, item_ids).await
1261 }
1262
1263 async fn remove_from_playlist(
1264 &self,
1265 playlist_id: &str,
1266 entry_ids: &[String],
1267 ) -> Result<(), RepoError> {
1268 self.online
1270 .remove_from_playlist(playlist_id, entry_ids)
1271 .await
1272 }
1273
1274 async fn move_playlist_item(
1275 &self,
1276 playlist_id: &str,
1277 item_id: &str,
1278 new_index: u32,
1279 ) -> Result<(), RepoError> {
1280 self.online
1282 .move_playlist_item(playlist_id, item_id, new_index)
1283 .await
1284 }
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289 #![allow(clippy::await_holding_lock)]
1298
1299 use super::*;
1300 use std::sync::Mutex;
1301
1302 #[tokio::test]
1312 async fn a_slow_cache_still_answers_when_the_server_is_gone() {
1313 let cache = HybridRepository::cache_leg(async {
1314 tokio::time::sleep(Duration::from_millis(250)).await;
1315 Ok(vec![MediaItem {
1316 id: "cached-item".to_string(),
1317 ..Default::default()
1318 }])
1319 })
1320 .await;
1321
1322 let server = async {
1323 Err(RepoError::Network {
1324 message: "offline".to_string(),
1325 })
1326 };
1327
1328 let got = HybridRepository::parallel_race(cache, server)
1329 .await
1330 .expect("a slow cache read must still be delivered when the server is gone");
1331 assert_eq!(got.len(), 1);
1332 assert_eq!(got[0].id, "cached-item");
1333 }
1334
1335 #[tokio::test]
1339 async fn a_fast_cache_hit_never_reaches_the_server() {
1340 let cache = HybridRepository::cache_leg(async {
1341 Ok(vec![MediaItem {
1342 id: "fast".to_string(),
1343 ..Default::default()
1344 }])
1345 })
1346 .await;
1347
1348 let server = async {
1349 panic!("the server leg must not run on a cache hit");
1350 };
1351
1352 let got = HybridRepository::parallel_race(cache, server)
1353 .await
1354 .unwrap();
1355 assert_eq!(got[0].id, "fast");
1356 }
1357
1358 #[tokio::test]
1362 async fn a_failing_slow_cache_reports_the_server_error() {
1363 let cache: CacheLeg<Vec<MediaItem>> = HybridRepository::cache_leg(async {
1364 tokio::time::sleep(Duration::from_millis(250)).await;
1365 Err(RepoError::Database {
1366 message: "disk gone".to_string(),
1367 })
1368 })
1369 .await;
1370
1371 let server = async {
1372 Err(RepoError::Network {
1373 message: "offline".to_string(),
1374 })
1375 };
1376
1377 let err = HybridRepository::parallel_race(cache, server)
1378 .await
1379 .unwrap_err();
1380 assert!(matches!(err, RepoError::Network { .. }), "got {err:?}");
1381 }
1382
1383 struct MockOfflineRepo {
1385 items: Arc<Mutex<Vec<MediaItem>>>,
1386 query_count: Arc<Mutex<usize>>,
1387 save_count: Arc<Mutex<usize>>,
1388 }
1389
1390 impl MockOfflineRepo {
1391 fn new() -> Self {
1392 Self {
1393 items: Arc::new(Mutex::new(Vec::new())),
1394 query_count: Arc::new(Mutex::new(0)),
1395 save_count: Arc::new(Mutex::new(0)),
1396 }
1397 }
1398
1399 fn get_query_count(&self) -> usize {
1400 *self.query_count.lock_safe()
1401 }
1402
1403 fn get_save_count(&self) -> usize {
1404 *self.save_count.lock_safe()
1405 }
1406
1407 async fn save_to_cache(
1408 &self,
1409 _parent_id: &str,
1410 items: &[MediaItem],
1411 ) -> Result<usize, RepoError> {
1412 *self.save_count.lock_safe() += 1;
1413 *self.items.lock_safe() = items.to_vec();
1414 Ok(items.len())
1415 }
1416 }
1417
1418 #[async_trait]
1419 impl MediaRepository for MockOfflineRepo {
1420 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1421 unimplemented!()
1422 }
1423
1424 async fn get_items(
1425 &self,
1426 _parent_id: &str,
1427 _options: Option<GetItemsOptions>,
1428 ) -> Result<SearchResult, RepoError> {
1429 *self.query_count.lock_safe() += 1;
1430 let items = self.items.lock_safe().clone();
1431 let count = items.len();
1432 Ok(SearchResult {
1433 items,
1434 total_record_count: count,
1435 })
1436 }
1437
1438 async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
1439 unimplemented!()
1440 }
1441
1442 async fn get_latest_items(
1443 &self,
1444 _parent_id: &str,
1445 _limit: Option<usize>,
1446 ) -> Result<Vec<MediaItem>, RepoError> {
1447 unimplemented!()
1448 }
1449
1450 async fn get_resume_items(
1451 &self,
1452 _parent_id: Option<&str>,
1453 _limit: Option<usize>,
1454 ) -> Result<Vec<MediaItem>, RepoError> {
1455 unimplemented!()
1456 }
1457
1458 async fn get_next_up_episodes(
1459 &self,
1460 _series_id: Option<&str>,
1461 _limit: Option<usize>,
1462 ) -> Result<Vec<MediaItem>, RepoError> {
1463 unimplemented!()
1464 }
1465
1466 async fn get_recently_played_audio(
1467 &self,
1468 _limit: Option<usize>,
1469 ) -> Result<Vec<MediaItem>, RepoError> {
1470 unimplemented!()
1471 }
1472
1473 async fn get_resume_movies(
1474 &self,
1475 _limit: Option<usize>,
1476 ) -> Result<Vec<MediaItem>, RepoError> {
1477 unimplemented!()
1478 }
1479
1480 async fn get_rediscover_albums(
1481 &self,
1482 _parent_id: Option<&str>,
1483 _limit: Option<usize>,
1484 ) -> Result<Vec<MediaItem>, RepoError> {
1485 unimplemented!()
1486 }
1487
1488 async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1489 unimplemented!()
1490 }
1491
1492 async fn search(
1493 &self,
1494 _query: &str,
1495 _options: Option<SearchOptions>,
1496 ) -> Result<SearchResult, RepoError> {
1497 unimplemented!()
1498 }
1499
1500 async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
1501 unimplemented!()
1502 }
1503
1504 async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
1505 unimplemented!()
1506 }
1507
1508 async fn get_audio_only_stream_url_for_video(
1509 &self,
1510 _item_id: &str,
1511 _media_source_id: Option<&str>,
1512 _start_time_seconds: Option<f64>,
1513 _audio_stream_index: Option<i32>,
1514 ) -> Result<String, RepoError> {
1515 unimplemented!()
1516 }
1517
1518 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
1519 unimplemented!()
1520 }
1521
1522 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
1523 unimplemented!()
1524 }
1525
1526 async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
1527 unimplemented!()
1528 }
1529
1530 async fn report_playback_start(
1531 &self,
1532 _item_id: &str,
1533 _position_ticks: i64,
1534 ) -> Result<(), RepoError> {
1535 unimplemented!()
1536 }
1537
1538 async fn report_playback_progress(
1539 &self,
1540 _item_id: &str,
1541 _position_ticks: i64,
1542 ) -> Result<(), RepoError> {
1543 unimplemented!()
1544 }
1545
1546 async fn report_playback_stopped(
1547 &self,
1548 _item_id: &str,
1549 _position_ticks: i64,
1550 ) -> Result<(), RepoError> {
1551 unimplemented!()
1552 }
1553
1554 fn get_image_url(
1555 &self,
1556 _item_id: &str,
1557 _image_type: ImageType,
1558 _options: Option<ImageOptions>,
1559 ) -> String {
1560 unimplemented!()
1561 }
1562
1563 fn get_subtitle_url(
1564 &self,
1565 _item_id: &str,
1566 _media_source_id: &str,
1567 _stream_index: i32,
1568 _format: &str,
1569 ) -> String {
1570 unimplemented!()
1571 }
1572
1573 fn get_video_download_url(
1574 &self,
1575 _item_id: &str,
1576 _quality: &str,
1577 _media_source_id: Option<&str>,
1578 _source_audio_codec: Option<&str>,
1579 ) -> String {
1580 unimplemented!()
1581 }
1582
1583 async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1584 unimplemented!()
1585 }
1586
1587 async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1588 unimplemented!()
1589 }
1590
1591 async fn get_favorites(
1592 &self,
1593 _scope: SearchScope,
1594 _options: Option<GetItemsOptions>,
1595 ) -> Result<SearchResult, RepoError> {
1596 unimplemented!()
1597 }
1598
1599 async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
1600 unimplemented!()
1601 }
1602
1603 async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
1604 unimplemented!()
1605 }
1606
1607 async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
1608 unimplemented!()
1609 }
1610
1611 async fn get_items_by_person(
1612 &self,
1613 _person_id: &str,
1614 _options: Option<GetItemsOptions>,
1615 ) -> Result<SearchResult, RepoError> {
1616 unimplemented!()
1617 }
1618
1619 async fn get_similar_items(
1620 &self,
1621 _item_id: &str,
1622 _limit: Option<usize>,
1623 ) -> Result<SearchResult, RepoError> {
1624 unimplemented!()
1625 }
1626
1627 async fn create_playlist(
1628 &self,
1629 _name: &str,
1630 _item_ids: &[String],
1631 ) -> Result<PlaylistCreatedResult, RepoError> {
1632 unimplemented!()
1633 }
1634
1635 async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
1636 unimplemented!()
1637 }
1638
1639 async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
1640 unimplemented!()
1641 }
1642
1643 async fn get_playlist_items(
1644 &self,
1645 _playlist_id: &str,
1646 ) -> Result<Vec<PlaylistEntry>, RepoError> {
1647 unimplemented!()
1648 }
1649
1650 async fn add_to_playlist(
1651 &self,
1652 _playlist_id: &str,
1653 _item_ids: &[String],
1654 ) -> Result<(), RepoError> {
1655 unimplemented!()
1656 }
1657
1658 async fn remove_from_playlist(
1659 &self,
1660 _playlist_id: &str,
1661 _entry_ids: &[String],
1662 ) -> Result<(), RepoError> {
1663 unimplemented!()
1664 }
1665
1666 async fn move_playlist_item(
1667 &self,
1668 _playlist_id: &str,
1669 _item_id: &str,
1670 _new_index: u32,
1671 ) -> Result<(), RepoError> {
1672 unimplemented!()
1673 }
1674 }
1675
1676 struct MockOnlineRepo {
1678 items: Vec<MediaItem>,
1679 query_count: Arc<Mutex<usize>>,
1680 }
1681
1682 impl MockOnlineRepo {
1683 fn new(items: Vec<MediaItem>) -> Self {
1684 Self {
1685 items,
1686 query_count: Arc::new(Mutex::new(0)),
1687 }
1688 }
1689
1690 fn get_query_count(&self) -> usize {
1691 *self.query_count.lock_safe()
1692 }
1693 }
1694
1695 #[async_trait]
1696 impl MediaRepository for MockOnlineRepo {
1697 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1698 unimplemented!()
1699 }
1700
1701 async fn get_items(
1702 &self,
1703 _parent_id: &str,
1704 _options: Option<GetItemsOptions>,
1705 ) -> Result<SearchResult, RepoError> {
1706 *self.query_count.lock_safe() += 1;
1707 Ok(SearchResult {
1708 items: self.items.clone(),
1709 total_record_count: self.items.len(),
1710 })
1711 }
1712
1713 async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
1714 unimplemented!()
1715 }
1716
1717 async fn get_latest_items(
1718 &self,
1719 _parent_id: &str,
1720 _limit: Option<usize>,
1721 ) -> Result<Vec<MediaItem>, RepoError> {
1722 unimplemented!()
1723 }
1724
1725 async fn get_resume_items(
1726 &self,
1727 _parent_id: Option<&str>,
1728 _limit: Option<usize>,
1729 ) -> Result<Vec<MediaItem>, RepoError> {
1730 unimplemented!()
1731 }
1732
1733 async fn get_next_up_episodes(
1734 &self,
1735 _series_id: Option<&str>,
1736 _limit: Option<usize>,
1737 ) -> Result<Vec<MediaItem>, RepoError> {
1738 unimplemented!()
1739 }
1740
1741 async fn get_recently_played_audio(
1742 &self,
1743 _limit: Option<usize>,
1744 ) -> Result<Vec<MediaItem>, RepoError> {
1745 unimplemented!()
1746 }
1747
1748 async fn get_resume_movies(
1749 &self,
1750 _limit: Option<usize>,
1751 ) -> Result<Vec<MediaItem>, RepoError> {
1752 unimplemented!()
1753 }
1754
1755 async fn get_rediscover_albums(
1756 &self,
1757 _parent_id: Option<&str>,
1758 _limit: Option<usize>,
1759 ) -> Result<Vec<MediaItem>, RepoError> {
1760 unimplemented!()
1761 }
1762
1763 async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1764 unimplemented!()
1765 }
1766
1767 async fn search(
1768 &self,
1769 _query: &str,
1770 _options: Option<SearchOptions>,
1771 ) -> Result<SearchResult, RepoError> {
1772 unimplemented!()
1773 }
1774
1775 async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
1776 unimplemented!()
1777 }
1778
1779 async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
1780 unimplemented!()
1781 }
1782
1783 async fn get_audio_only_stream_url_for_video(
1784 &self,
1785 _item_id: &str,
1786 _media_source_id: Option<&str>,
1787 _start_time_seconds: Option<f64>,
1788 _audio_stream_index: Option<i32>,
1789 ) -> Result<String, RepoError> {
1790 unimplemented!()
1791 }
1792
1793 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
1794 unimplemented!()
1795 }
1796
1797 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
1798 unimplemented!()
1799 }
1800
1801 async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
1802 unimplemented!()
1803 }
1804
1805 async fn report_playback_start(
1806 &self,
1807 _item_id: &str,
1808 _position_ticks: i64,
1809 ) -> Result<(), RepoError> {
1810 unimplemented!()
1811 }
1812
1813 async fn report_playback_progress(
1814 &self,
1815 _item_id: &str,
1816 _position_ticks: i64,
1817 ) -> Result<(), RepoError> {
1818 unimplemented!()
1819 }
1820
1821 async fn report_playback_stopped(
1822 &self,
1823 _item_id: &str,
1824 _position_ticks: i64,
1825 ) -> Result<(), RepoError> {
1826 unimplemented!()
1827 }
1828
1829 fn get_image_url(
1830 &self,
1831 _item_id: &str,
1832 _image_type: ImageType,
1833 _options: Option<ImageOptions>,
1834 ) -> String {
1835 unimplemented!()
1836 }
1837
1838 fn get_subtitle_url(
1839 &self,
1840 _item_id: &str,
1841 _media_source_id: &str,
1842 _stream_index: i32,
1843 _format: &str,
1844 ) -> String {
1845 unimplemented!()
1846 }
1847
1848 fn get_video_download_url(
1849 &self,
1850 _item_id: &str,
1851 _quality: &str,
1852 _media_source_id: Option<&str>,
1853 _source_audio_codec: Option<&str>,
1854 ) -> String {
1855 unimplemented!()
1856 }
1857
1858 async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1859 unimplemented!()
1860 }
1861
1862 async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1863 unimplemented!()
1864 }
1865
1866 async fn get_favorites(
1867 &self,
1868 _scope: SearchScope,
1869 _options: Option<GetItemsOptions>,
1870 ) -> Result<SearchResult, RepoError> {
1871 unimplemented!()
1872 }
1873
1874 async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
1875 unimplemented!()
1876 }
1877
1878 async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
1879 unimplemented!()
1880 }
1881
1882 async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
1883 unimplemented!()
1884 }
1885
1886 async fn get_items_by_person(
1887 &self,
1888 _person_id: &str,
1889 _options: Option<GetItemsOptions>,
1890 ) -> Result<SearchResult, RepoError> {
1891 unimplemented!()
1892 }
1893
1894 async fn get_similar_items(
1895 &self,
1896 _item_id: &str,
1897 _limit: Option<usize>,
1898 ) -> Result<SearchResult, RepoError> {
1899 unimplemented!()
1900 }
1901
1902 async fn create_playlist(
1903 &self,
1904 _name: &str,
1905 _item_ids: &[String],
1906 ) -> Result<PlaylistCreatedResult, RepoError> {
1907 unimplemented!()
1908 }
1909
1910 async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
1911 unimplemented!()
1912 }
1913
1914 async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
1915 unimplemented!()
1916 }
1917
1918 async fn get_playlist_items(
1919 &self,
1920 _playlist_id: &str,
1921 ) -> Result<Vec<PlaylistEntry>, RepoError> {
1922 unimplemented!()
1923 }
1924
1925 async fn add_to_playlist(
1926 &self,
1927 _playlist_id: &str,
1928 _item_ids: &[String],
1929 ) -> Result<(), RepoError> {
1930 unimplemented!()
1931 }
1932
1933 async fn remove_from_playlist(
1934 &self,
1935 _playlist_id: &str,
1936 _entry_ids: &[String],
1937 ) -> Result<(), RepoError> {
1938 unimplemented!()
1939 }
1940
1941 async fn move_playlist_item(
1942 &self,
1943 _playlist_id: &str,
1944 _item_id: &str,
1945 _new_index: u32,
1946 ) -> Result<(), RepoError> {
1947 unimplemented!()
1948 }
1949 }
1950
1951 fn create_test_item(id: &str, name: &str) -> MediaItem {
1952 MediaItem {
1953 id: id.to_string(),
1954 name: name.to_string(),
1955 item_type: "Movie".to_string(),
1956 kind: crate::domain::MediaKind::Movie,
1957 is_folder: false,
1958 server_id: "test-server".to_string(),
1959 parent_id: Some("parent-123".to_string()),
1960 library_id: Some("library-456".to_string()),
1961 overview: Some("Test overview".to_string()),
1962 genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
1963 runtime_ticks: Some(7200000000),
1964 duration_ms: Some(720000),
1965 production_year: Some(2024),
1966 premiere_date: None,
1967 community_rating: Some(8.5),
1968 official_rating: Some("PG-13".to_string()),
1969 primary_image_tag: Some("image-tag-123".to_string()),
1970 image_id: Some("image-tag-123".to_string()),
1971 backdrop_image_tags: Some(vec!["backdrop-1".to_string()]),
1972 parent_backdrop_image_tags: None,
1973 album_id: None,
1974 album_name: None,
1975 album_artist: None,
1976 artists: None,
1977 artist_items: None,
1978 index_number: None,
1979 series_id: None,
1980 series_name: None,
1981 season_id: None,
1982 season_name: None,
1983 parent_index_number: None,
1984 user_data: None,
1985 media_streams: None,
1986 media_sources: None,
1987 people: None,
1988 }
1989 }
1990
1991 struct TestHybridRepo {
1993 offline: Arc<MockOfflineRepo>,
1994 online: Arc<MockOnlineRepo>,
1995 }
1996
1997 impl TestHybridRepo {
1998 fn new(server_items: Vec<MediaItem>) -> Self {
1999 let offline = Arc::new(MockOfflineRepo::new());
2000 let online = Arc::new(MockOnlineRepo::new(server_items));
2001 Self { offline, online }
2002 }
2003
2004 async fn get_items(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
2006 let offline = Arc::clone(&self.offline);
2007 let offline_for_save = Arc::clone(&self.offline);
2008 let online = Arc::clone(&self.online);
2009 let parent_id = parent_id.to_string();
2010 let parent_id_clone = parent_id.clone();
2011 let parent_id_for_save = parent_id.clone();
2012
2013 let cache_future = async move { offline.get_items(&parent_id, None).await };
2015
2016 let server_future = async move { online.get_items(&parent_id_clone, None).await };
2017
2018 let (cache_result, server_result) = tokio::join!(cache_future, server_future);
2020
2021 let cache_had_content = cache_result
2023 .as_ref()
2024 .map(|data| data.has_content())
2025 .unwrap_or(false);
2026
2027 let result = if cache_had_content {
2029 cache_result?
2030 } else {
2031 let server_data = server_result?;
2033
2034 if !server_data.items.is_empty() {
2035 let items_clone = server_data.items.clone();
2036 offline_for_save
2037 .save_to_cache(&parent_id_for_save, &items_clone)
2038 .await?;
2039 }
2040
2041 server_data
2042 };
2043
2044 Ok(result)
2045 }
2046
2047 async fn get_items_gated(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
2053 if !crate::repository::offline::include_catalog_browse() {
2054 let items = self.offline.get_items(parent_id, None).await?;
2055 return Ok(items);
2057 }
2058 self.get_items(parent_id).await
2059 }
2060 }
2061
2062 static GATE_TEST_LOCK: Mutex<()> = Mutex::new(());
2065
2066 #[tokio::test]
2072 async fn test_get_items_gate_off_empty_does_not_query_server() {
2073 let _guard = GATE_TEST_LOCK.lock_safe();
2074 crate::repository::offline::set_include_catalog_browse(false);
2075
2076 let repo = TestHybridRepo::new(vec![
2078 create_test_item("s-1", "Server 1"),
2079 create_test_item("s-2", "Server 2"),
2080 ]);
2081
2082 let result = repo.get_items_gated("parent-123").await.unwrap();
2083
2084 assert_eq!(
2085 result.items.len(),
2086 0,
2087 "empty offline result is authoritative when the gate is off"
2088 );
2089 assert_eq!(
2090 repo.online.get_query_count(),
2091 0,
2092 "server must NOT be queried when the gate is off"
2093 );
2094
2095 crate::repository::offline::set_include_catalog_browse(true);
2096 }
2097
2098 #[tokio::test]
2104 async fn test_get_items_gate_on_empty_falls_through_to_server() {
2105 let _guard = GATE_TEST_LOCK.lock_safe();
2106 crate::repository::offline::set_include_catalog_browse(true);
2107
2108 let repo = TestHybridRepo::new(vec![
2109 create_test_item("s-1", "Server 1"),
2110 create_test_item("s-2", "Server 2"),
2111 ]);
2112
2113 let result = repo.get_items_gated("parent-123").await.unwrap();
2114
2115 assert_eq!(result.items.len(), 2, "server result used on empty cache");
2116 assert_eq!(
2117 repo.online.get_query_count(),
2118 1,
2119 "server IS queried when the gate is on and the cache is empty"
2120 );
2121 }
2122
2123 #[tokio::test]
2129 async fn test_cache_miss_saves_to_cache() {
2130 let server_items = vec![
2132 create_test_item("item-1", "Movie 1"),
2133 create_test_item("item-2", "Movie 2"),
2134 create_test_item("item-3", "Movie 3"),
2135 ];
2136
2137 let repo = TestHybridRepo::new(server_items.clone());
2138
2139 let result = repo.get_items("parent-123").await.unwrap();
2141
2142 assert_eq!(result.items.len(), 3);
2144 assert_eq!(result.items[0].id, "item-1");
2145
2146 assert_eq!(
2148 repo.offline.get_query_count(),
2149 1,
2150 "Cache should be queried once"
2151 );
2152 assert_eq!(
2153 repo.online.get_query_count(),
2154 1,
2155 "Server should be queried once"
2156 );
2157
2158 assert_eq!(
2160 repo.offline.get_save_count(),
2161 1,
2162 "Should save to cache on miss"
2163 );
2164 }
2165
2166 #[tokio::test]
2175 async fn test_cache_hit_no_save() {
2176 let server_items = vec![
2178 create_test_item("item-1", "Movie 1"),
2179 create_test_item("item-2", "Movie 2"),
2180 create_test_item("item-3", "Movie 3"),
2181 ];
2182
2183 let repo = TestHybridRepo::new(server_items.clone());
2184
2185 repo.offline
2187 .save_to_cache("parent-123", &server_items)
2188 .await
2189 .unwrap();
2190 assert_eq!(repo.offline.get_save_count(), 1);
2191
2192 let result = repo.get_items("parent-123").await.unwrap();
2194
2195 assert_eq!(result.items.len(), 3);
2197 assert_eq!(result.items[0].id, "item-1");
2198
2199 assert_eq!(repo.offline.get_query_count(), 1, "Cache should be queried");
2201 assert_eq!(
2202 repo.online.get_query_count(),
2203 1,
2204 "Server is queried in parallel"
2205 );
2206
2207 assert_eq!(
2209 repo.offline.get_save_count(),
2210 1,
2211 "Should NOT save when using cache"
2212 );
2213 }
2214
2215 #[tokio::test]
2220 async fn test_empty_cache_returns_empty_result() {
2221 let repo = TestHybridRepo::new(vec![]);
2223
2224 let result = repo.get_items("parent-123").await.unwrap();
2226
2227 assert_eq!(result.items.len(), 0);
2229
2230 assert_eq!(
2232 repo.offline.get_save_count(),
2233 0,
2234 "Should not save empty results"
2235 );
2236 }
2237
2238 #[tokio::test]
2242 async fn test_has_content_check() {
2243 let empty_result = SearchResult {
2245 items: vec![],
2246 total_record_count: 0,
2247 };
2248 assert!(
2249 !empty_result.has_content(),
2250 "Empty result should not have content"
2251 );
2252
2253 let result_with_items = SearchResult {
2254 items: vec![create_test_item("item-1", "Movie 1")],
2255 total_record_count: 1,
2256 };
2257 assert!(
2258 result_with_items.has_content(),
2259 "Result with items should have content"
2260 );
2261 }
2262
2263 #[test]
2264 fn test_merge_search_local_first_then_server_appended() {
2265 let cache = SearchResult {
2266 items: vec![
2267 create_test_item("a", "Cached A"),
2268 create_test_item("b", "Cached B"),
2269 ],
2270 total_record_count: 2,
2271 };
2272 let server = SearchResult {
2273 items: vec![
2274 create_test_item("c", "Server C"),
2275 create_test_item("d", "Server D"),
2276 ],
2277 total_record_count: 2,
2278 };
2279
2280 let merged = HybridRepository::merge_search_results(cache, server);
2281
2282 let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
2284 assert_eq!(ids, vec!["a", "b", "c", "d"]);
2285 assert_eq!(merged.total_record_count, 4);
2286 }
2287
2288 #[test]
2289 fn test_merge_search_dedupes_with_server_winning() {
2290 let cache = SearchResult {
2293 items: vec![
2294 create_test_item("a", "Cached A"),
2295 create_test_item("b", "Cached B"),
2296 ],
2297 total_record_count: 2,
2298 };
2299 let server = SearchResult {
2300 items: vec![
2301 create_test_item("b", "Server B (fresher)"),
2302 create_test_item("c", "Server C"),
2303 ],
2304 total_record_count: 2,
2305 };
2306
2307 let merged = HybridRepository::merge_search_results(cache, server);
2308
2309 let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
2310 assert_eq!(
2311 ids,
2312 vec!["a", "b", "c"],
2313 "no duplicate, local position kept"
2314 );
2315
2316 let b = merged.items.iter().find(|i| i.id == "b").unwrap();
2317 assert_eq!(
2318 b.name, "Server B (fresher)",
2319 "server metadata wins on conflict"
2320 );
2321 assert_eq!(merged.total_record_count, 3);
2322 }
2323
2324 #[test]
2325 fn test_merge_search_handles_empty_sides() {
2326 let only_server = HybridRepository::merge_search_results(
2327 SearchResult {
2328 items: vec![],
2329 total_record_count: 0,
2330 },
2331 SearchResult {
2332 items: vec![create_test_item("x", "X")],
2333 total_record_count: 1,
2334 },
2335 );
2336 assert_eq!(only_server.items.len(), 1);
2337 assert_eq!(only_server.items[0].id, "x");
2338
2339 let only_cache = HybridRepository::merge_search_results(
2340 SearchResult {
2341 items: vec![create_test_item("y", "Y")],
2342 total_record_count: 1,
2343 },
2344 SearchResult {
2345 items: vec![],
2346 total_record_count: 0,
2347 },
2348 );
2349 assert_eq!(only_cache.items.len(), 1);
2350 assert_eq!(only_cache.items[0].id, "y");
2351 }
2352}