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 offline
241 .search(&query, options)
242 .await
243 .map(ExcludeHidden::without_excluded)
244 }
245
246 pub async fn get_favorites_cache_only(
251 &self,
252 scope: SearchScope,
253 options: Option<GetItemsOptions>,
254 ) -> Result<SearchResult, RepoError> {
255 let offline = Arc::clone(&self.offline);
256 offline
258 .get_favorites(scope, options)
259 .await
260 .map(ExcludeHidden::without_excluded)
261 }
262
263 pub async fn get_favorites_server_only(
269 &self,
270 scope: SearchScope,
271 options: Option<GetItemsOptions>,
272 ) -> Result<SearchResult, RepoError> {
273 let result = self.online.get_favorites(scope, options).await?;
274 if !result.items.is_empty() {
275 if let Err(e) = self.offline.save_to_cache("favorites", &result.items).await {
278 debug!("[HybridRepo] Failed to cache favourites: {:?}", e);
279 }
280 }
281 Ok(result.without_excluded())
282 }
283
284 pub async fn cache_items_from_server(
293 &self,
294 parent_id: &str,
295 options: Option<GetItemsOptions>,
296 ) -> Result<Vec<MediaItem>, RepoError> {
297 let result = self.online.get_items(parent_id, options).await?;
298 if !result.items.is_empty() {
299 self.offline.save_to_cache(parent_id, &result.items).await?;
300 }
301 Ok(result.items)
302 }
303
304 pub async fn get_downloaded_items(
313 &self,
314 parent_id: &str,
315 options: Option<GetItemsOptions>,
316 ) -> Result<SearchResult, RepoError> {
317 self.offline.get_downloaded_items(parent_id, options).await
322 }
323
324 pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
328 self.offline.get_downloaded_libraries().await
329 }
330
331 pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
335 self.offline.get_download_disk_usage().await
336 }
337
338 pub async fn search_server_only(
340 &self,
341 query: &str,
342 options: Option<SearchOptions>,
343 ) -> Result<SearchResult, RepoError> {
344 self.online
345 .search(query, options)
346 .await
347 .map(ExcludeHidden::without_excluded)
348 }
349
350 pub fn merge_search_results(cache: SearchResult, server: SearchResult) -> SearchResult {
356 use std::collections::HashMap;
357
358 let mut server_by_id: HashMap<String, MediaItem> = HashMap::new();
361 let mut server_order: Vec<String> = Vec::with_capacity(server.items.len());
362 for item in server.items {
363 if !server_by_id.contains_key(&item.id) {
364 server_order.push(item.id.clone());
365 }
366 server_by_id.insert(item.id.clone(), item);
367 }
368
369 let mut items: Vec<MediaItem> = Vec::new();
370 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
371
372 for local in cache.items {
375 if !seen.insert(local.id.clone()) {
376 continue;
377 }
378 match server_by_id.remove(&local.id) {
379 Some(server_item) => items.push(server_item),
380 None => items.push(local),
381 }
382 }
383
384 for id in server_order {
386 if let Some(server_item) = server_by_id.remove(&id) {
387 if seen.insert(id) {
388 items.push(server_item);
389 }
390 }
391 }
392
393 let total_record_count = items.len();
394 SearchResult {
395 items,
396 total_record_count,
397 }
398 }
399
400 async fn parallel_race<T, F2>(cache: CacheLeg<T>, server_future: F2) -> Result<T, RepoError>
420 where
421 T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
422 F2: std::future::Future<Output = Result<T, RepoError>> + Send,
423 {
424 Self::race_with_refresh(cache, server_future, || {}).await
425 }
426
427 async fn race_with_refresh<T, F2, R>(
442 cache: CacheLeg<T>,
443 server_future: F2,
444 on_cache_hit: R,
445 ) -> Result<T, RepoError>
446 where
447 T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
448 F2: std::future::Future<Output = Result<T, RepoError>> + Send,
449 R: FnOnce(),
450 {
451 let (fast, slow) = cache.split();
452 let fast = fast.map(|r| r.map(ExcludeHidden::without_excluded));
453
454 if let Some(Ok(data)) = &fast {
455 if data.has_content() {
456 debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
457 on_cache_hit();
458 return Ok(data.clone());
459 }
460 }
461
462 debug!("[HybridRepo] Cache miss or slow, querying server");
463 match server_future.await {
464 Ok(data) => Ok(data.without_excluded()),
465 Err(e) => {
466 if let Some(handle) = slow {
472 debug!("[HybridRepo] Server failed; waiting for the slow cache query");
473 return match handle.await {
474 Ok(Ok(data)) => Ok(data.without_excluded()),
475 Ok(Err(cache_err)) => {
476 debug!("[HybridRepo] Slow cache query also failed: {cache_err}");
477 Err(e)
478 }
479 Err(join) => {
480 debug!("[HybridRepo] Slow cache query panicked: {join}");
481 Err(e)
482 }
483 };
484 }
485 fast.unwrap_or(Err(e))
488 }
489 }
490 }
491
492 const CACHE_FAST_PATH: Duration = Duration::from_millis(100);
496
497 async fn cache_leg<T>(
511 future: impl std::future::Future<Output = Result<T, RepoError>> + Send + 'static,
512 ) -> CacheLeg<T>
513 where
514 T: Send + 'static,
515 {
516 let mut handle = tokio::spawn(future);
519 match timeout(Self::CACHE_FAST_PATH, &mut handle).await {
520 Ok(Ok(result)) => CacheLeg::Ready(result),
521 Ok(Err(join)) => CacheLeg::Ready(Err(RepoError::Database {
522 message: format!("Cache query failed: {join}"),
523 })),
524 Err(_) => {
525 debug!("[HybridRepo] Cache missed the fast path; leaving it running");
526 CacheLeg::Slow(handle)
527 }
528 }
529 }
530
531 async fn cache_try<T>(
543 future: impl std::future::Future<Output = Result<T, RepoError>> + Send + 'static,
544 ) -> (
545 Result<T, RepoError>,
546 Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
547 )
548 where
549 T: Send + 'static,
550 {
551 let (fast, slow) = Self::cache_leg(future).await.split();
552 let fast = fast.unwrap_or_else(|| {
553 Err(RepoError::Database {
554 message: "Cache query still running".to_string(),
555 })
556 });
557 (fast, slow)
558 }
559
560 async fn settle<T>(
565 fast: Result<T, RepoError>,
566 slow: Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
567 server_err: RepoError,
568 ) -> Result<T, RepoError> {
569 if let Some(handle) = slow {
570 debug!("[HybridRepo] Server failed; waiting for the slow cache read");
571 return match handle.await {
572 Ok(Ok(data)) => Ok(data),
573 _ => Err(server_err),
574 };
575 }
576 fast.or(Err(server_err))
577 }
578
579 async fn cache_or(
582 fast: Result<SearchResult, RepoError>,
583 slow: Option<tokio::task::JoinHandle<Result<SearchResult, RepoError>>>,
584 server_err: RepoError,
585 ) -> Result<SearchResult, RepoError> {
586 Self::settle(fast, slow, server_err)
587 .await
588 .map(ExcludeHidden::without_excluded)
589 }
590}
591
592#[async_trait]
593impl MediaRepository for HybridRepository {
594 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
595 let offline_read = Arc::clone(&self.offline);
599 let (cache_result, slow_cache) =
600 Self::cache_try(async move { offline_read.get_libraries().await }).await;
601
602 if let Ok(libs) = &cache_result {
603 if libs.has_content() {
604 debug!("[HybridRepo] Cache hit for libraries, returning immediately");
605 let online = Arc::clone(&self.online);
606 let offline = Arc::clone(&self.offline);
607 tokio::spawn(async move {
608 if let Ok(server_libs) = online.get_libraries().await {
609 if !server_libs.is_empty() {
610 if let Err(e) = offline.save_libraries_to_cache(&server_libs).await {
611 warn!(
612 "[HybridRepo] Background library cache update failed: {:?}",
613 e
614 );
615 }
616 }
617 }
618 });
619 return cache_result;
620 }
621 }
622
623 match self.online.get_libraries().await {
625 Ok(server_libs) => {
626 if !server_libs.is_empty() {
627 if let Err(e) = self.offline.save_libraries_to_cache(&server_libs).await {
628 warn!(
629 "[HybridRepo] Failed to cache {} libraries: {:?}",
630 server_libs.len(),
631 e
632 );
633 }
634 }
635 Ok(server_libs)
636 }
637 Err(e) => Self::settle(cache_result, slow_cache, e).await,
639 }
640 }
641
642 async fn get_items(
643 &self,
644 parent_id: &str,
645 options: Option<GetItemsOptions>,
646 ) -> Result<SearchResult, RepoError> {
647 let offline = Arc::clone(&self.offline);
648 let offline_for_save = Arc::clone(&self.offline);
649 let online = Arc::clone(&self.online);
650 let parent_id = parent_id.to_string();
651 let parent_id_clone = parent_id.clone();
652 let parent_id_for_save = parent_id.clone();
653 let opts_clone = options.clone();
654
655 let server_handle =
657 tokio::spawn(async move { online.get_items(&parent_id_clone, options).await });
658
659 let (cache_result, slow_cache) =
675 Self::cache_try(async move { offline.get_items(&parent_id, opts_clone).await }).await;
676 let cache_result = cache_result.map(ExcludeHidden::without_excluded);
677
678 if !crate::repository::offline::include_catalog_browse() {
686 if let Ok(data) = &cache_result {
687 debug!(
688 "[HybridRepo] Downloads-only gate: returning offline result ({} items) as authoritative for parent {}",
689 data.items.len(),
690 &parent_id_for_save[..8.min(parent_id_for_save.len())]
691 );
692 server_handle.abort();
694 return Ok(data.clone());
695 }
696 }
697
698 if let Ok(data) = &cache_result {
700 if data.has_content() {
701 debug!(
702 "[HybridRepo] Cache hit for get_items, returning immediately for parent {}",
703 &parent_id_for_save[..8.min(parent_id_for_save.len())]
704 );
705 tokio::spawn(async move {
707 match server_handle.await {
708 Ok(Ok(server_data)) if !server_data.items.is_empty() => {
709 if let Err(e) = offline_for_save
710 .save_to_cache(&parent_id_for_save, &server_data.items)
711 .await
712 {
713 warn!("[HybridRepo] Background cache update failed: {:?}", e);
714 } else {
715 debug!(
716 "[HybridRepo] Background updated {} cached items for parent {}",
717 server_data.items.len(),
718 &parent_id_for_save[..8.min(parent_id_for_save.len())]
719 );
720 }
721 }
722 _ => {} }
724 });
725 return Ok(data.clone());
726 }
727 }
728
729 match server_handle.await {
731 Ok(Ok(server_data)) => {
732 if !server_data.items.is_empty() {
733 let items_clone = server_data.items.clone();
734 tokio::spawn(async move {
735 if let Err(e) = offline_for_save
736 .save_to_cache(&parent_id_for_save, &items_clone)
737 .await
738 {
739 warn!(
740 "[HybridRepo] Failed to save {} items to cache: {:?}",
741 items_clone.len(),
742 e
743 );
744 } else {
745 debug!(
746 "[HybridRepo] Saved {} items to cache for parent {}",
747 items_clone.len(),
748 &parent_id_for_save[..8.min(parent_id_for_save.len())]
749 );
750 }
751 });
752 }
753 Ok(server_data.without_excluded())
759 }
760 Ok(Err(e)) => Self::cache_or(cache_result, slow_cache, e).await,
761 Err(join_err) => {
762 let e = RepoError::Network {
763 message: format!("Server task failed: {}", join_err),
764 };
765 Self::cache_or(cache_result, slow_cache, e).await
766 }
767 }
768 }
769
770 async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
786 let offline = Arc::clone(&self.offline);
787 let online = Arc::clone(&self.online);
788 let item_id = item_id.to_string();
789 let item_id_clone = item_id.clone();
790
791 let cache_future = Self::cache_leg(async move { offline.get_item(&item_id).await }).await;
792
793 let online_for_refresh = Arc::clone(&self.online);
794 let offline_for_save = Arc::clone(&self.offline);
795 let refresh_id = item_id_clone.clone();
796 let on_cache_hit = move || {
797 tokio::spawn(async move {
798 match online_for_refresh.get_item(&refresh_id).await {
799 Ok(fresh) => {
800 let parent = fresh
803 .parent_id
804 .clone()
805 .unwrap_or_else(|| "item".to_string());
806 if let Err(e) = offline_for_save.save_to_cache(&parent, &[fresh]).await {
807 debug!("[HybridRepo] Background item refresh failed: {:?}", e);
808 }
809 }
810 Err(e) => debug!("[HybridRepo] Background item refresh unavailable: {:?}", e),
811 }
812 });
813 };
814
815 let server_future = async move { online.get_item(&item_id_clone).await };
816
817 Self::race_with_refresh(cache_future, server_future, on_cache_hit).await
818 }
819
820 async fn get_latest_items(
821 &self,
822 parent_id: &str,
823 limit: Option<usize>,
824 ) -> Result<Vec<MediaItem>, RepoError> {
825 let offline = Arc::clone(&self.offline);
826 let online = Arc::clone(&self.online);
827 let parent_id = parent_id.to_string();
828 let parent_id_clone = parent_id.clone();
829 let limit_clone = limit;
830
831 let cache_future =
832 Self::cache_leg(async move { offline.get_latest_items(&parent_id, limit).await }).await;
833
834 let server_future =
835 async move { online.get_latest_items(&parent_id_clone, limit_clone).await };
836
837 Self::parallel_race(cache_future, server_future).await
838 }
839
840 async fn get_resume_items(
841 &self,
842 parent_id: Option<&str>,
843 limit: Option<usize>,
844 ) -> Result<Vec<MediaItem>, RepoError> {
845 let offline = Arc::clone(&self.offline);
846 let online = Arc::clone(&self.online);
847 let parent_id_str = parent_id.map(|s| s.to_string());
848 let parent_id_clone = parent_id_str.clone();
849 let limit_clone = limit;
850
851 let cache_future = Self::cache_leg(async move {
852 offline
853 .get_resume_items(parent_id_str.as_deref(), limit)
854 .await
855 })
856 .await;
857
858 let server_future = async move {
859 online
860 .get_resume_items(parent_id_clone.as_deref(), limit_clone)
861 .await
862 };
863
864 Self::parallel_race(cache_future, server_future).await
865 }
866
867 async fn get_next_up_episodes(
868 &self,
869 series_id: Option<&str>,
870 limit: Option<usize>,
871 ) -> Result<Vec<MediaItem>, RepoError> {
872 match self.online.get_next_up_episodes(series_id, limit).await {
879 Ok(items) => Ok(items.without_excluded()),
880 Err(e) => {
881 debug!("[HybridRepo] Next Up from server failed ({e}); using the cache");
882 self.offline
883 .get_next_up_episodes(series_id, limit)
884 .await
885 .map(ExcludeHidden::without_excluded)
886 }
887 }
888 }
889
890 async fn get_recently_played_audio(
891 &self,
892 limit: Option<usize>,
893 ) -> Result<Vec<MediaItem>, RepoError> {
894 let offline = Arc::clone(&self.offline);
895 let online = Arc::clone(&self.online);
896 let limit_clone = limit;
897
898 let cache_future =
899 Self::cache_leg(async move { offline.get_recently_played_audio(limit).await }).await;
900
901 let server_future = async move { online.get_recently_played_audio(limit_clone).await };
902
903 Self::parallel_race(cache_future, server_future).await
904 }
905
906 async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
907 let offline = Arc::clone(&self.offline);
908 let online = Arc::clone(&self.online);
909 let limit_clone = limit;
910
911 let cache_future =
912 Self::cache_leg(async move { offline.get_resume_movies(limit).await }).await;
913
914 let server_future = async move { online.get_resume_movies(limit_clone).await };
915
916 Self::parallel_race(cache_future, server_future).await
917 }
918
919 async fn get_rediscover_albums(
920 &self,
921 parent_id: Option<&str>,
922 limit: Option<usize>,
923 ) -> Result<Vec<MediaItem>, RepoError> {
924 let offline = Arc::clone(&self.offline);
925 let online = Arc::clone(&self.online);
926 let parent_id_owned = parent_id.map(|s| s.to_string());
927 let parent_id_clone = parent_id_owned.clone();
928
929 let cache_future = Self::cache_leg(async move {
930 offline
931 .get_rediscover_albums(parent_id_owned.as_deref(), limit)
932 .await
933 })
934 .await;
935
936 let server_future = async move {
937 online
938 .get_rediscover_albums(parent_id_clone.as_deref(), limit)
939 .await
940 };
941
942 Self::parallel_race(cache_future, server_future).await
943 }
944
945 async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
946 let parent_id_str = parent_id.map(|s| s.to_string());
952
953 let cache_offline = Arc::clone(&self.offline);
954 let cache_pid = parent_id_str.clone();
955 let (cache_result, slow_cache) =
956 Self::cache_try(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
957 .await;
958
959 if let Ok(genres) = &cache_result {
960 if genres.has_content() {
961 debug!("[HybridRepo] Cache hit for genres, returning immediately");
962 let online = Arc::clone(&self.online);
963 let offline = Arc::clone(&self.offline);
964 let pid = parent_id_str.clone();
965 tokio::spawn(async move {
966 if let Ok(server_genres) = online.get_genres(pid.as_deref()).await {
967 if !server_genres.is_empty() {
968 if let Err(e) = offline
969 .save_genres_to_cache(pid.as_deref(), &server_genres)
970 .await
971 {
972 warn!("[HybridRepo] Background genre cache update failed: {:?}", e);
973 }
974 }
975 }
976 });
977 return cache_result;
978 }
979 }
980
981 match self.online.get_genres(parent_id_str.as_deref()).await {
983 Ok(server_genres) => {
984 if !server_genres.is_empty() {
985 if let Err(e) = self
986 .offline
987 .save_genres_to_cache(parent_id_str.as_deref(), &server_genres)
988 .await
989 {
990 warn!(
991 "[HybridRepo] Failed to cache {} genres: {:?}",
992 server_genres.len(),
993 e
994 );
995 }
996 }
997 Ok(server_genres)
998 }
999 Err(e) => Self::settle(cache_result, slow_cache, e).await,
1000 }
1001 }
1002
1003 async fn search(
1004 &self,
1005 query: &str,
1006 options: Option<SearchOptions>,
1007 ) -> Result<SearchResult, RepoError> {
1008 let offline = Arc::clone(&self.offline);
1009 let online = Arc::clone(&self.online);
1010 let query = query.to_string();
1011 let query_clone = query.clone();
1012 let opts_clone = options.clone();
1013
1014 let cache_future =
1015 Self::cache_leg(async move { offline.search(&query, opts_clone).await }).await;
1016
1017 let server_future = async move { online.search(&query_clone, options).await };
1018
1019 Self::parallel_race(cache_future, server_future).await
1020 }
1021
1022 async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
1023 if let Some(local) = self.offline.local_playback_info(item_id).await? {
1030 return Ok(local);
1031 }
1032 self.online.get_playback_info(item_id).await
1033 }
1034
1035 async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
1036 self.online.get_audio_stream_url(item_id).await
1038 }
1039
1040 async fn get_audio_only_stream_url_for_video(
1041 &self,
1042 item_id: &str,
1043 media_source_id: Option<&str>,
1044 start_time_seconds: Option<f64>,
1045 audio_stream_index: Option<i32>,
1046 ) -> Result<String, RepoError> {
1047 self.online
1049 .build_audio_only_stream_url_for_video(
1050 item_id,
1051 media_source_id,
1052 start_time_seconds,
1053 audio_stream_index,
1054 )
1055 .await
1056 }
1057
1058 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
1059 self.online
1061 .get_live_tv_channels()
1062 .await
1063 .map(ExcludeHidden::without_excluded)
1064 }
1065
1066 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
1067 self.online
1069 .get_channels()
1070 .await
1071 .map(ExcludeHidden::without_excluded)
1072 }
1073
1074 async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
1075 self.online.open_live_stream(item_id).await
1077 }
1078
1079 async fn report_playback_start(
1080 &self,
1081 item_id: &str,
1082 position_ticks: i64,
1083 ) -> Result<(), RepoError> {
1084 self.online
1086 .report_playback_start(item_id, position_ticks)
1087 .await
1088 }
1089
1090 async fn report_playback_progress(
1091 &self,
1092 item_id: &str,
1093 position_ticks: i64,
1094 ) -> Result<(), RepoError> {
1095 self.online
1097 .report_playback_progress(item_id, position_ticks)
1098 .await
1099 }
1100
1101 async fn report_playback_stopped(
1102 &self,
1103 item_id: &str,
1104 position_ticks: i64,
1105 ) -> Result<(), RepoError> {
1106 self.online
1108 .report_playback_stopped(item_id, position_ticks)
1109 .await
1110 }
1111
1112 fn get_image_url(
1113 &self,
1114 item_id: &str,
1115 image_type: ImageType,
1116 options: Option<ImageOptions>,
1117 ) -> String {
1118 self.online.get_image_url(item_id, image_type, options)
1120 }
1121
1122 fn get_subtitle_url(
1123 &self,
1124 item_id: &str,
1125 media_source_id: &str,
1126 stream_index: i32,
1127 format: &str,
1128 ) -> String {
1129 self.online
1131 .get_subtitle_url(item_id, media_source_id, stream_index, format)
1132 }
1133
1134 fn get_video_download_url(
1135 &self,
1136 item_id: &str,
1137 quality: &str,
1138 media_source_id: Option<&str>,
1139 source_audio_codec: Option<&str>,
1140 ) -> String {
1141 self.online
1143 .get_video_download_url(item_id, quality, media_source_id, source_audio_codec)
1144 }
1145
1146 async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
1147 self.online.mark_favorite(item_id).await
1149 }
1150
1151 async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
1152 self.online.unmark_favorite(item_id).await
1154 }
1155
1156 async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
1157 self.online.clear_watch_history(item_id).await
1159 }
1160
1161 async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
1162 self.online.mark_played(item_id).await
1164 }
1165
1166 async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
1167 let offline = Arc::clone(&self.offline);
1168 let online = Arc::clone(&self.online);
1169 let person_id = person_id.to_string();
1170 let person_id_clone = person_id.clone();
1171
1172 let cache_future =
1173 Self::cache_leg(async move { offline.get_person(&person_id).await }).await;
1174
1175 let server_future = async move { online.get_person(&person_id_clone).await };
1176
1177 Self::parallel_race(cache_future, server_future).await
1178 }
1179
1180 async fn get_items_by_person(
1181 &self,
1182 person_id: &str,
1183 options: Option<GetItemsOptions>,
1184 ) -> Result<SearchResult, RepoError> {
1185 let offline = Arc::clone(&self.offline);
1186 let online = Arc::clone(&self.online);
1187 let person_id = person_id.to_string();
1188 let person_id_clone = person_id.clone();
1189 let opts_clone = options.clone();
1190
1191 let cache_future =
1192 Self::cache_leg(
1193 async move { offline.get_items_by_person(&person_id, opts_clone).await },
1194 )
1195 .await;
1196
1197 let server_future =
1198 async move { online.get_items_by_person(&person_id_clone, options).await };
1199
1200 Self::parallel_race(cache_future, server_future).await
1201 }
1202
1203 async fn get_favorites(
1205 &self,
1206 scope: SearchScope,
1207 options: Option<GetItemsOptions>,
1208 ) -> Result<SearchResult, RepoError> {
1209 let cache_result = self.get_favorites_cache_only(scope, options.clone()).await;
1210
1211 if !crate::repository::offline::include_catalog_browse() {
1216 if let Ok(data) = &cache_result {
1217 return Ok(data.clone());
1218 }
1219 }
1220
1221 if let Ok(data) = &cache_result {
1222 if data.has_content() {
1223 return Ok(data.clone());
1224 }
1225 }
1226
1227 match self.get_favorites_server_only(scope, options).await {
1233 Ok(data) => Ok(data),
1234 Err(e) => cache_result.or(Err(e)),
1235 }
1236 }
1237
1238 async fn get_similar_items(
1239 &self,
1240 item_id: &str,
1241 limit: Option<usize>,
1242 ) -> Result<SearchResult, RepoError> {
1243 let offline = Arc::clone(&self.offline);
1244 let online = Arc::clone(&self.online);
1245 let item_id = item_id.to_string();
1246 let item_id_clone = item_id.clone();
1247
1248 let cache_future =
1249 Self::cache_leg(async move { offline.get_similar_items(&item_id, limit).await }).await;
1250
1251 let server_future = async move { online.get_similar_items(&item_id_clone, limit).await };
1252
1253 Self::parallel_race(cache_future, server_future).await
1254 }
1255
1256 async fn create_playlist(
1259 &self,
1260 name: &str,
1261 item_ids: &[String],
1262 ) -> Result<PlaylistCreatedResult, RepoError> {
1263 self.online.create_playlist(name, item_ids).await
1265 }
1266
1267 async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
1268 self.online.delete_playlist(playlist_id).await
1270 }
1271
1272 async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
1273 self.online.rename_playlist(playlist_id, name).await
1275 }
1276
1277 async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
1278 let offline = Arc::clone(&self.offline);
1279 let offline_for_save = Arc::clone(&self.offline);
1280 let online = Arc::clone(&self.online);
1281 let playlist_id = playlist_id.to_string();
1282 let playlist_id_clone = playlist_id.clone();
1283 let playlist_id_for_save = playlist_id.clone();
1284
1285 let server_handle =
1287 tokio::spawn(async move { online.get_playlist_items(&playlist_id_clone).await });
1288
1289 let (cache_result, slow_cache) =
1291 Self::cache_try(async move { offline.get_playlist_items(&playlist_id).await }).await;
1292
1293 if let Ok(data) = &cache_result {
1295 if data.has_content() {
1296 debug!("[HybridRepo] Cache hit for playlist items, returning immediately");
1297 tokio::spawn(async move {
1298 if let Ok(Ok(server_entries)) = server_handle.await {
1299 if let Err(e) = offline_for_save
1300 .save_playlist_items_to_cache(&playlist_id_for_save, &server_entries)
1301 .await
1302 {
1303 warn!("[HybridRepo] Failed to update playlist cache: {:?}", e);
1304 }
1305 }
1306 });
1307 return cache_result;
1308 }
1309 }
1310
1311 match server_handle.await {
1313 Ok(Ok(entries)) => {
1314 let entries_clone = entries.clone();
1315 tokio::spawn(async move {
1316 if let Err(e) = offline_for_save
1317 .save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone)
1318 .await
1319 {
1320 warn!(
1321 "[HybridRepo] Failed to save playlist items to cache: {:?}",
1322 e
1323 );
1324 }
1325 });
1326 Ok(entries)
1327 }
1328 Ok(Err(e)) => Self::settle(cache_result, slow_cache, e).await,
1329 Err(join_err) => {
1330 let e = RepoError::Network {
1331 message: format!("Server task failed: {}", join_err),
1332 };
1333 Self::settle(cache_result, slow_cache, e).await
1334 }
1335 }
1336 }
1337
1338 async fn add_to_playlist(
1339 &self,
1340 playlist_id: &str,
1341 item_ids: &[String],
1342 ) -> Result<(), RepoError> {
1343 self.online.add_to_playlist(playlist_id, item_ids).await
1345 }
1346
1347 async fn remove_from_playlist(
1348 &self,
1349 playlist_id: &str,
1350 entry_ids: &[String],
1351 ) -> Result<(), RepoError> {
1352 self.online
1354 .remove_from_playlist(playlist_id, entry_ids)
1355 .await
1356 }
1357
1358 async fn move_playlist_item(
1359 &self,
1360 playlist_id: &str,
1361 item_id: &str,
1362 new_index: u32,
1363 ) -> Result<(), RepoError> {
1364 self.online
1366 .move_playlist_item(playlist_id, item_id, new_index)
1367 .await
1368 }
1369}
1370
1371#[cfg(test)]
1372mod tests {
1373 #![allow(clippy::await_holding_lock)]
1382
1383 use super::*;
1384 use std::sync::Mutex;
1385
1386 #[tokio::test]
1396 async fn a_slow_cache_still_answers_when_the_server_is_gone() {
1397 let cache = HybridRepository::cache_leg(async {
1398 tokio::time::sleep(Duration::from_millis(250)).await;
1399 Ok(vec![MediaItem {
1400 id: "cached-item".to_string(),
1401 ..Default::default()
1402 }])
1403 })
1404 .await;
1405
1406 let server = async {
1407 Err(RepoError::Network {
1408 message: "offline".to_string(),
1409 })
1410 };
1411
1412 let got = HybridRepository::parallel_race(cache, server)
1413 .await
1414 .expect("a slow cache read must still be delivered when the server is gone");
1415 assert_eq!(got.len(), 1);
1416 assert_eq!(got[0].id, "cached-item");
1417 }
1418
1419 #[tokio::test]
1423 async fn a_fast_cache_hit_never_reaches_the_server() {
1424 let cache = HybridRepository::cache_leg(async {
1425 Ok(vec![MediaItem {
1426 id: "fast".to_string(),
1427 ..Default::default()
1428 }])
1429 })
1430 .await;
1431
1432 let server = async {
1433 panic!("the server leg must not run on a cache hit");
1434 };
1435
1436 let got = HybridRepository::parallel_race(cache, server)
1437 .await
1438 .unwrap();
1439 assert_eq!(got[0].id, "fast");
1440 }
1441
1442 #[tokio::test]
1446 async fn a_failing_slow_cache_reports_the_server_error() {
1447 let cache: CacheLeg<Vec<MediaItem>> = HybridRepository::cache_leg(async {
1448 tokio::time::sleep(Duration::from_millis(250)).await;
1449 Err(RepoError::Database {
1450 message: "disk gone".to_string(),
1451 })
1452 })
1453 .await;
1454
1455 let server = async {
1456 Err(RepoError::Network {
1457 message: "offline".to_string(),
1458 })
1459 };
1460
1461 let err = HybridRepository::parallel_race(cache, server)
1462 .await
1463 .unwrap_err();
1464 assert!(matches!(err, RepoError::Network { .. }), "got {err:?}");
1465 }
1466
1467 struct MockOfflineRepo {
1469 items: Arc<Mutex<Vec<MediaItem>>>,
1470 query_count: Arc<Mutex<usize>>,
1471 save_count: Arc<Mutex<usize>>,
1472 }
1473
1474 impl MockOfflineRepo {
1475 fn new() -> Self {
1476 Self {
1477 items: Arc::new(Mutex::new(Vec::new())),
1478 query_count: Arc::new(Mutex::new(0)),
1479 save_count: Arc::new(Mutex::new(0)),
1480 }
1481 }
1482
1483 fn get_query_count(&self) -> usize {
1484 *self.query_count.lock_safe()
1485 }
1486
1487 fn get_save_count(&self) -> usize {
1488 *self.save_count.lock_safe()
1489 }
1490
1491 async fn save_to_cache(
1492 &self,
1493 _parent_id: &str,
1494 items: &[MediaItem],
1495 ) -> Result<usize, RepoError> {
1496 *self.save_count.lock_safe() += 1;
1497 *self.items.lock_safe() = items.to_vec();
1498 Ok(items.len())
1499 }
1500 }
1501
1502 #[async_trait]
1503 impl MediaRepository for MockOfflineRepo {
1504 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1505 unimplemented!()
1506 }
1507
1508 async fn get_items(
1509 &self,
1510 _parent_id: &str,
1511 _options: Option<GetItemsOptions>,
1512 ) -> Result<SearchResult, RepoError> {
1513 *self.query_count.lock_safe() += 1;
1514 let items = self.items.lock_safe().clone();
1515 let count = items.len();
1516 Ok(SearchResult {
1517 items,
1518 total_record_count: count,
1519 })
1520 }
1521
1522 async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
1523 unimplemented!()
1524 }
1525
1526 async fn get_latest_items(
1527 &self,
1528 _parent_id: &str,
1529 _limit: Option<usize>,
1530 ) -> Result<Vec<MediaItem>, RepoError> {
1531 unimplemented!()
1532 }
1533
1534 async fn get_resume_items(
1535 &self,
1536 _parent_id: Option<&str>,
1537 _limit: Option<usize>,
1538 ) -> Result<Vec<MediaItem>, RepoError> {
1539 unimplemented!()
1540 }
1541
1542 async fn get_next_up_episodes(
1543 &self,
1544 _series_id: Option<&str>,
1545 _limit: Option<usize>,
1546 ) -> Result<Vec<MediaItem>, RepoError> {
1547 unimplemented!()
1548 }
1549
1550 async fn get_recently_played_audio(
1551 &self,
1552 _limit: Option<usize>,
1553 ) -> Result<Vec<MediaItem>, RepoError> {
1554 unimplemented!()
1555 }
1556
1557 async fn get_resume_movies(
1558 &self,
1559 _limit: Option<usize>,
1560 ) -> Result<Vec<MediaItem>, RepoError> {
1561 unimplemented!()
1562 }
1563
1564 async fn get_rediscover_albums(
1565 &self,
1566 _parent_id: Option<&str>,
1567 _limit: Option<usize>,
1568 ) -> Result<Vec<MediaItem>, RepoError> {
1569 unimplemented!()
1570 }
1571
1572 async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1573 unimplemented!()
1574 }
1575
1576 async fn search(
1577 &self,
1578 _query: &str,
1579 _options: Option<SearchOptions>,
1580 ) -> Result<SearchResult, RepoError> {
1581 unimplemented!()
1582 }
1583
1584 async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
1585 unimplemented!()
1586 }
1587
1588 async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
1589 unimplemented!()
1590 }
1591
1592 async fn get_audio_only_stream_url_for_video(
1593 &self,
1594 _item_id: &str,
1595 _media_source_id: Option<&str>,
1596 _start_time_seconds: Option<f64>,
1597 _audio_stream_index: Option<i32>,
1598 ) -> Result<String, RepoError> {
1599 unimplemented!()
1600 }
1601
1602 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
1603 unimplemented!()
1604 }
1605
1606 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
1607 unimplemented!()
1608 }
1609
1610 async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
1611 unimplemented!()
1612 }
1613
1614 async fn report_playback_start(
1615 &self,
1616 _item_id: &str,
1617 _position_ticks: i64,
1618 ) -> Result<(), RepoError> {
1619 unimplemented!()
1620 }
1621
1622 async fn report_playback_progress(
1623 &self,
1624 _item_id: &str,
1625 _position_ticks: i64,
1626 ) -> Result<(), RepoError> {
1627 unimplemented!()
1628 }
1629
1630 async fn report_playback_stopped(
1631 &self,
1632 _item_id: &str,
1633 _position_ticks: i64,
1634 ) -> Result<(), RepoError> {
1635 unimplemented!()
1636 }
1637
1638 fn get_image_url(
1639 &self,
1640 _item_id: &str,
1641 _image_type: ImageType,
1642 _options: Option<ImageOptions>,
1643 ) -> String {
1644 unimplemented!()
1645 }
1646
1647 fn get_subtitle_url(
1648 &self,
1649 _item_id: &str,
1650 _media_source_id: &str,
1651 _stream_index: i32,
1652 _format: &str,
1653 ) -> String {
1654 unimplemented!()
1655 }
1656
1657 fn get_video_download_url(
1658 &self,
1659 _item_id: &str,
1660 _quality: &str,
1661 _media_source_id: Option<&str>,
1662 _source_audio_codec: Option<&str>,
1663 ) -> String {
1664 unimplemented!()
1665 }
1666
1667 async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1668 unimplemented!()
1669 }
1670
1671 async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1672 unimplemented!()
1673 }
1674
1675 async fn get_favorites(
1676 &self,
1677 _scope: SearchScope,
1678 _options: Option<GetItemsOptions>,
1679 ) -> Result<SearchResult, RepoError> {
1680 unimplemented!()
1681 }
1682
1683 async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
1684 unimplemented!()
1685 }
1686
1687 async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
1688 unimplemented!()
1689 }
1690
1691 async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
1692 unimplemented!()
1693 }
1694
1695 async fn get_items_by_person(
1696 &self,
1697 _person_id: &str,
1698 _options: Option<GetItemsOptions>,
1699 ) -> Result<SearchResult, RepoError> {
1700 unimplemented!()
1701 }
1702
1703 async fn get_similar_items(
1704 &self,
1705 _item_id: &str,
1706 _limit: Option<usize>,
1707 ) -> Result<SearchResult, RepoError> {
1708 unimplemented!()
1709 }
1710
1711 async fn create_playlist(
1712 &self,
1713 _name: &str,
1714 _item_ids: &[String],
1715 ) -> Result<PlaylistCreatedResult, RepoError> {
1716 unimplemented!()
1717 }
1718
1719 async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
1720 unimplemented!()
1721 }
1722
1723 async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
1724 unimplemented!()
1725 }
1726
1727 async fn get_playlist_items(
1728 &self,
1729 _playlist_id: &str,
1730 ) -> Result<Vec<PlaylistEntry>, RepoError> {
1731 unimplemented!()
1732 }
1733
1734 async fn add_to_playlist(
1735 &self,
1736 _playlist_id: &str,
1737 _item_ids: &[String],
1738 ) -> Result<(), RepoError> {
1739 unimplemented!()
1740 }
1741
1742 async fn remove_from_playlist(
1743 &self,
1744 _playlist_id: &str,
1745 _entry_ids: &[String],
1746 ) -> Result<(), RepoError> {
1747 unimplemented!()
1748 }
1749
1750 async fn move_playlist_item(
1751 &self,
1752 _playlist_id: &str,
1753 _item_id: &str,
1754 _new_index: u32,
1755 ) -> Result<(), RepoError> {
1756 unimplemented!()
1757 }
1758 }
1759
1760 struct MockOnlineRepo {
1762 items: Vec<MediaItem>,
1763 query_count: Arc<Mutex<usize>>,
1764 }
1765
1766 impl MockOnlineRepo {
1767 fn new(items: Vec<MediaItem>) -> Self {
1768 Self {
1769 items,
1770 query_count: Arc::new(Mutex::new(0)),
1771 }
1772 }
1773
1774 fn get_query_count(&self) -> usize {
1775 *self.query_count.lock_safe()
1776 }
1777 }
1778
1779 #[async_trait]
1780 impl MediaRepository for MockOnlineRepo {
1781 async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
1782 unimplemented!()
1783 }
1784
1785 async fn get_items(
1786 &self,
1787 _parent_id: &str,
1788 _options: Option<GetItemsOptions>,
1789 ) -> Result<SearchResult, RepoError> {
1790 *self.query_count.lock_safe() += 1;
1791 Ok(SearchResult {
1792 items: self.items.clone(),
1793 total_record_count: self.items.len(),
1794 })
1795 }
1796
1797 async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
1798 unimplemented!()
1799 }
1800
1801 async fn get_latest_items(
1802 &self,
1803 _parent_id: &str,
1804 _limit: Option<usize>,
1805 ) -> Result<Vec<MediaItem>, RepoError> {
1806 unimplemented!()
1807 }
1808
1809 async fn get_resume_items(
1810 &self,
1811 _parent_id: Option<&str>,
1812 _limit: Option<usize>,
1813 ) -> Result<Vec<MediaItem>, RepoError> {
1814 unimplemented!()
1815 }
1816
1817 async fn get_next_up_episodes(
1818 &self,
1819 _series_id: Option<&str>,
1820 _limit: Option<usize>,
1821 ) -> Result<Vec<MediaItem>, RepoError> {
1822 unimplemented!()
1823 }
1824
1825 async fn get_recently_played_audio(
1826 &self,
1827 _limit: Option<usize>,
1828 ) -> Result<Vec<MediaItem>, RepoError> {
1829 unimplemented!()
1830 }
1831
1832 async fn get_resume_movies(
1833 &self,
1834 _limit: Option<usize>,
1835 ) -> Result<Vec<MediaItem>, RepoError> {
1836 unimplemented!()
1837 }
1838
1839 async fn get_rediscover_albums(
1840 &self,
1841 _parent_id: Option<&str>,
1842 _limit: Option<usize>,
1843 ) -> Result<Vec<MediaItem>, RepoError> {
1844 unimplemented!()
1845 }
1846
1847 async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
1848 unimplemented!()
1849 }
1850
1851 async fn search(
1852 &self,
1853 _query: &str,
1854 _options: Option<SearchOptions>,
1855 ) -> Result<SearchResult, RepoError> {
1856 unimplemented!()
1857 }
1858
1859 async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
1860 unimplemented!()
1861 }
1862
1863 async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
1864 unimplemented!()
1865 }
1866
1867 async fn get_audio_only_stream_url_for_video(
1868 &self,
1869 _item_id: &str,
1870 _media_source_id: Option<&str>,
1871 _start_time_seconds: Option<f64>,
1872 _audio_stream_index: Option<i32>,
1873 ) -> Result<String, RepoError> {
1874 unimplemented!()
1875 }
1876
1877 async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
1878 unimplemented!()
1879 }
1880
1881 async fn get_channels(&self) -> Result<SearchResult, RepoError> {
1882 unimplemented!()
1883 }
1884
1885 async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
1886 unimplemented!()
1887 }
1888
1889 async fn report_playback_start(
1890 &self,
1891 _item_id: &str,
1892 _position_ticks: i64,
1893 ) -> Result<(), RepoError> {
1894 unimplemented!()
1895 }
1896
1897 async fn report_playback_progress(
1898 &self,
1899 _item_id: &str,
1900 _position_ticks: i64,
1901 ) -> Result<(), RepoError> {
1902 unimplemented!()
1903 }
1904
1905 async fn report_playback_stopped(
1906 &self,
1907 _item_id: &str,
1908 _position_ticks: i64,
1909 ) -> Result<(), RepoError> {
1910 unimplemented!()
1911 }
1912
1913 fn get_image_url(
1914 &self,
1915 _item_id: &str,
1916 _image_type: ImageType,
1917 _options: Option<ImageOptions>,
1918 ) -> String {
1919 unimplemented!()
1920 }
1921
1922 fn get_subtitle_url(
1923 &self,
1924 _item_id: &str,
1925 _media_source_id: &str,
1926 _stream_index: i32,
1927 _format: &str,
1928 ) -> String {
1929 unimplemented!()
1930 }
1931
1932 fn get_video_download_url(
1933 &self,
1934 _item_id: &str,
1935 _quality: &str,
1936 _media_source_id: Option<&str>,
1937 _source_audio_codec: Option<&str>,
1938 ) -> String {
1939 unimplemented!()
1940 }
1941
1942 async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1943 unimplemented!()
1944 }
1945
1946 async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
1947 unimplemented!()
1948 }
1949
1950 async fn get_favorites(
1951 &self,
1952 _scope: SearchScope,
1953 _options: Option<GetItemsOptions>,
1954 ) -> Result<SearchResult, RepoError> {
1955 unimplemented!()
1956 }
1957
1958 async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
1959 unimplemented!()
1960 }
1961
1962 async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
1963 unimplemented!()
1964 }
1965
1966 async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
1967 unimplemented!()
1968 }
1969
1970 async fn get_items_by_person(
1971 &self,
1972 _person_id: &str,
1973 _options: Option<GetItemsOptions>,
1974 ) -> Result<SearchResult, RepoError> {
1975 unimplemented!()
1976 }
1977
1978 async fn get_similar_items(
1979 &self,
1980 _item_id: &str,
1981 _limit: Option<usize>,
1982 ) -> Result<SearchResult, RepoError> {
1983 unimplemented!()
1984 }
1985
1986 async fn create_playlist(
1987 &self,
1988 _name: &str,
1989 _item_ids: &[String],
1990 ) -> Result<PlaylistCreatedResult, RepoError> {
1991 unimplemented!()
1992 }
1993
1994 async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
1995 unimplemented!()
1996 }
1997
1998 async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
1999 unimplemented!()
2000 }
2001
2002 async fn get_playlist_items(
2003 &self,
2004 _playlist_id: &str,
2005 ) -> Result<Vec<PlaylistEntry>, RepoError> {
2006 unimplemented!()
2007 }
2008
2009 async fn add_to_playlist(
2010 &self,
2011 _playlist_id: &str,
2012 _item_ids: &[String],
2013 ) -> Result<(), RepoError> {
2014 unimplemented!()
2015 }
2016
2017 async fn remove_from_playlist(
2018 &self,
2019 _playlist_id: &str,
2020 _entry_ids: &[String],
2021 ) -> Result<(), RepoError> {
2022 unimplemented!()
2023 }
2024
2025 async fn move_playlist_item(
2026 &self,
2027 _playlist_id: &str,
2028 _item_id: &str,
2029 _new_index: u32,
2030 ) -> Result<(), RepoError> {
2031 unimplemented!()
2032 }
2033 }
2034
2035 fn create_test_item(id: &str, name: &str) -> MediaItem {
2036 MediaItem {
2037 id: id.to_string(),
2038 name: name.to_string(),
2039 item_type: "Movie".to_string(),
2040 kind: crate::domain::MediaKind::Movie,
2041 is_folder: false,
2042 server_id: "test-server".to_string(),
2043 parent_id: Some("parent-123".to_string()),
2044 library_id: Some("library-456".to_string()),
2045 overview: Some("Test overview".to_string()),
2046 genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
2047 runtime_ticks: Some(7200000000),
2048 duration_ms: Some(720000),
2049 production_year: Some(2024),
2050 premiere_date: None,
2051 community_rating: Some(8.5),
2052 official_rating: Some("PG-13".to_string()),
2053 primary_image_tag: Some("image-tag-123".to_string()),
2054 image_id: Some("image-tag-123".to_string()),
2055 backdrop_image_tags: Some(vec!["backdrop-1".to_string()]),
2056 parent_backdrop_image_tags: None,
2057 album_id: None,
2058 album_name: None,
2059 album_artist: None,
2060 artists: None,
2061 artist_items: None,
2062 index_number: None,
2063 series_id: None,
2064 series_name: None,
2065 season_id: None,
2066 season_name: None,
2067 parent_index_number: None,
2068 user_data: None,
2069 media_streams: None,
2070 media_sources: None,
2071 people: None,
2072 }
2073 }
2074
2075 struct TestHybridRepo {
2077 offline: Arc<MockOfflineRepo>,
2078 online: Arc<MockOnlineRepo>,
2079 }
2080
2081 impl TestHybridRepo {
2082 fn new(server_items: Vec<MediaItem>) -> Self {
2083 let offline = Arc::new(MockOfflineRepo::new());
2084 let online = Arc::new(MockOnlineRepo::new(server_items));
2085 Self { offline, online }
2086 }
2087
2088 async fn get_items(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
2090 let offline = Arc::clone(&self.offline);
2091 let offline_for_save = Arc::clone(&self.offline);
2092 let online = Arc::clone(&self.online);
2093 let parent_id = parent_id.to_string();
2094 let parent_id_clone = parent_id.clone();
2095 let parent_id_for_save = parent_id.clone();
2096
2097 let cache_future = async move { offline.get_items(&parent_id, None).await };
2099
2100 let server_future = async move { online.get_items(&parent_id_clone, None).await };
2101
2102 let (cache_result, server_result) = tokio::join!(cache_future, server_future);
2104
2105 let cache_had_content = cache_result
2107 .as_ref()
2108 .map(|data| data.has_content())
2109 .unwrap_or(false);
2110
2111 let result = if cache_had_content {
2113 cache_result?
2114 } else {
2115 let server_data = server_result?;
2117
2118 if !server_data.items.is_empty() {
2119 let items_clone = server_data.items.clone();
2120 offline_for_save
2121 .save_to_cache(&parent_id_for_save, &items_clone)
2122 .await?;
2123 }
2124
2125 server_data
2126 };
2127
2128 Ok(result)
2129 }
2130
2131 async fn get_items_gated(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
2137 if !crate::repository::offline::include_catalog_browse() {
2138 let items = self.offline.get_items(parent_id, None).await?;
2139 return Ok(items);
2141 }
2142 self.get_items(parent_id).await
2143 }
2144 }
2145
2146 static GATE_TEST_LOCK: Mutex<()> = Mutex::new(());
2149
2150 #[tokio::test]
2156 async fn test_get_items_gate_off_empty_does_not_query_server() {
2157 let _guard = GATE_TEST_LOCK.lock_safe();
2158 crate::repository::offline::set_include_catalog_browse(false);
2159
2160 let repo = TestHybridRepo::new(vec![
2162 create_test_item("s-1", "Server 1"),
2163 create_test_item("s-2", "Server 2"),
2164 ]);
2165
2166 let result = repo.get_items_gated("parent-123").await.unwrap();
2167
2168 assert_eq!(
2169 result.items.len(),
2170 0,
2171 "empty offline result is authoritative when the gate is off"
2172 );
2173 assert_eq!(
2174 repo.online.get_query_count(),
2175 0,
2176 "server must NOT be queried when the gate is off"
2177 );
2178
2179 crate::repository::offline::set_include_catalog_browse(true);
2180 }
2181
2182 #[tokio::test]
2188 async fn test_get_items_gate_on_empty_falls_through_to_server() {
2189 let _guard = GATE_TEST_LOCK.lock_safe();
2190 crate::repository::offline::set_include_catalog_browse(true);
2191
2192 let repo = TestHybridRepo::new(vec![
2193 create_test_item("s-1", "Server 1"),
2194 create_test_item("s-2", "Server 2"),
2195 ]);
2196
2197 let result = repo.get_items_gated("parent-123").await.unwrap();
2198
2199 assert_eq!(result.items.len(), 2, "server result used on empty cache");
2200 assert_eq!(
2201 repo.online.get_query_count(),
2202 1,
2203 "server IS queried when the gate is on and the cache is empty"
2204 );
2205 }
2206
2207 #[tokio::test]
2213 async fn test_cache_miss_saves_to_cache() {
2214 let server_items = vec![
2216 create_test_item("item-1", "Movie 1"),
2217 create_test_item("item-2", "Movie 2"),
2218 create_test_item("item-3", "Movie 3"),
2219 ];
2220
2221 let repo = TestHybridRepo::new(server_items.clone());
2222
2223 let result = repo.get_items("parent-123").await.unwrap();
2225
2226 assert_eq!(result.items.len(), 3);
2228 assert_eq!(result.items[0].id, "item-1");
2229
2230 assert_eq!(
2232 repo.offline.get_query_count(),
2233 1,
2234 "Cache should be queried once"
2235 );
2236 assert_eq!(
2237 repo.online.get_query_count(),
2238 1,
2239 "Server should be queried once"
2240 );
2241
2242 assert_eq!(
2244 repo.offline.get_save_count(),
2245 1,
2246 "Should save to cache on miss"
2247 );
2248 }
2249
2250 #[tokio::test]
2259 async fn test_cache_hit_no_save() {
2260 let server_items = vec![
2262 create_test_item("item-1", "Movie 1"),
2263 create_test_item("item-2", "Movie 2"),
2264 create_test_item("item-3", "Movie 3"),
2265 ];
2266
2267 let repo = TestHybridRepo::new(server_items.clone());
2268
2269 repo.offline
2271 .save_to_cache("parent-123", &server_items)
2272 .await
2273 .unwrap();
2274 assert_eq!(repo.offline.get_save_count(), 1);
2275
2276 let result = repo.get_items("parent-123").await.unwrap();
2278
2279 assert_eq!(result.items.len(), 3);
2281 assert_eq!(result.items[0].id, "item-1");
2282
2283 assert_eq!(repo.offline.get_query_count(), 1, "Cache should be queried");
2285 assert_eq!(
2286 repo.online.get_query_count(),
2287 1,
2288 "Server is queried in parallel"
2289 );
2290
2291 assert_eq!(
2293 repo.offline.get_save_count(),
2294 1,
2295 "Should NOT save when using cache"
2296 );
2297 }
2298
2299 #[tokio::test]
2304 async fn test_empty_cache_returns_empty_result() {
2305 let repo = TestHybridRepo::new(vec![]);
2307
2308 let result = repo.get_items("parent-123").await.unwrap();
2310
2311 assert_eq!(result.items.len(), 0);
2313
2314 assert_eq!(
2316 repo.offline.get_save_count(),
2317 0,
2318 "Should not save empty results"
2319 );
2320 }
2321
2322 #[tokio::test]
2326 async fn test_has_content_check() {
2327 let empty_result = SearchResult {
2329 items: vec![],
2330 total_record_count: 0,
2331 };
2332 assert!(
2333 !empty_result.has_content(),
2334 "Empty result should not have content"
2335 );
2336
2337 let result_with_items = SearchResult {
2338 items: vec![create_test_item("item-1", "Movie 1")],
2339 total_record_count: 1,
2340 };
2341 assert!(
2342 result_with_items.has_content(),
2343 "Result with items should have content"
2344 );
2345 }
2346
2347 #[test]
2348 fn test_merge_search_local_first_then_server_appended() {
2349 let cache = SearchResult {
2350 items: vec![
2351 create_test_item("a", "Cached A"),
2352 create_test_item("b", "Cached B"),
2353 ],
2354 total_record_count: 2,
2355 };
2356 let server = SearchResult {
2357 items: vec![
2358 create_test_item("c", "Server C"),
2359 create_test_item("d", "Server D"),
2360 ],
2361 total_record_count: 2,
2362 };
2363
2364 let merged = HybridRepository::merge_search_results(cache, server);
2365
2366 let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
2368 assert_eq!(ids, vec!["a", "b", "c", "d"]);
2369 assert_eq!(merged.total_record_count, 4);
2370 }
2371
2372 #[test]
2373 fn test_merge_search_dedupes_with_server_winning() {
2374 let cache = SearchResult {
2377 items: vec![
2378 create_test_item("a", "Cached A"),
2379 create_test_item("b", "Cached B"),
2380 ],
2381 total_record_count: 2,
2382 };
2383 let server = SearchResult {
2384 items: vec![
2385 create_test_item("b", "Server B (fresher)"),
2386 create_test_item("c", "Server C"),
2387 ],
2388 total_record_count: 2,
2389 };
2390
2391 let merged = HybridRepository::merge_search_results(cache, server);
2392
2393 let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
2394 assert_eq!(
2395 ids,
2396 vec!["a", "b", "c"],
2397 "no duplicate, local position kept"
2398 );
2399
2400 let b = merged.items.iter().find(|i| i.id == "b").unwrap();
2401 assert_eq!(
2402 b.name, "Server B (fresher)",
2403 "server metadata wins on conflict"
2404 );
2405 assert_eq!(merged.total_record_count, 3);
2406 }
2407
2408 #[test]
2409 fn test_merge_search_handles_empty_sides() {
2410 let only_server = HybridRepository::merge_search_results(
2411 SearchResult {
2412 items: vec![],
2413 total_record_count: 0,
2414 },
2415 SearchResult {
2416 items: vec![create_test_item("x", "X")],
2417 total_record_count: 1,
2418 },
2419 );
2420 assert_eq!(only_server.items.len(), 1);
2421 assert_eq!(only_server.items[0].id, "x");
2422
2423 let only_cache = HybridRepository::merge_search_results(
2424 SearchResult {
2425 items: vec![create_test_item("y", "Y")],
2426 total_record_count: 1,
2427 },
2428 SearchResult {
2429 items: vec![],
2430 total_record_count: 0,
2431 },
2432 );
2433 assert_eq!(only_cache.items.len(), 1);
2434 assert_eq!(only_cache.items[0].id, "y");
2435 }
2436}