1use crate::utils::lock::MutexSafe;
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9
10use log::{debug, error, info, warn};
11use serde::{Deserialize, Serialize};
12use tauri::{AppHandle, Emitter, State};
13use uuid::Uuid;
14
15use crate::domain::rank_search_results;
16use crate::jellyfin::HttpClient;
17use crate::repository::{
18 series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
19 OnlineRepository, StreamSelection,
20};
21
22pub struct RepositoryManager {
24 repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
25}
26
27impl RepositoryManager {
28 pub fn new() -> Self {
29 Self {
30 repositories: Arc::new(Mutex::new(HashMap::new())),
31 }
32 }
33
34 pub fn create(&self, handle: String, repository: HybridRepository) {
35 let mut repos = self.repositories.lock_safe();
36 repos.insert(handle, Arc::new(repository));
37 }
38
39 pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
40 let repos = self.repositories.lock_safe();
41 repos.get(handle).cloned()
42 }
43
44 pub fn handles(&self) -> Vec<String> {
53 let repos = self.repositories.lock_safe();
54 repos.keys().cloned().collect()
55 }
56
57 pub fn destroy(&self, handle: &str) {
58 let mut repos = self.repositories.lock_safe();
59 repos.remove(handle);
60 }
61}
62
63pub struct RepositoryManagerWrapper(pub RepositoryManager);
65
66#[tauri::command]
69#[specta::specta]
70#[allow(clippy::too_many_arguments)]
74pub async fn repository_create(
75 manager: State<'_, RepositoryManagerWrapper>,
76 player: State<'_, crate::commands::player::PlayerStateWrapper>,
77 db: State<'_, crate::commands::storage::DatabaseWrapper>,
78 connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>,
79 server_url: String,
80 user_id: String,
81 access_token: String,
82 server_id: String,
83) -> Result<String, String> {
84 info!("[REPO] repository_create called for user: {}", user_id);
85
86 debug!("[REPO] Creating HTTP client...");
88 let http_config = crate::jellyfin::HttpConfig::default();
89 let http_client = HttpClient::new(http_config).map_err(|e| {
90 error!("[REPO] HTTP client creation failed: {}", e);
91 e.to_string()
92 })?;
93 debug!("[REPO] HTTP client created successfully");
94
95 let connectivity_reporter = {
99 let monitor = connectivity.0.lock().await;
100 monitor.reporter()
101 };
102
103 debug!("[REPO] Creating online repository...");
105 let online = OnlineRepository::new(
106 Arc::new(http_client),
107 server_url,
108 user_id.clone(),
109 access_token,
110 )
111 .with_connectivity(connectivity_reporter);
112 debug!("[REPO] Online repository created");
113
114 debug!("[REPO] Creating database service...");
116 let db_service = {
117 let database = db.0.lock().map_err(|e| {
118 error!("[REPO] Database lock failed: {}", e);
119 e.to_string()
120 })?;
121 debug!("[REPO] Database lock acquired, getting service...");
122 Arc::new(database.service())
123 }; debug!("[REPO] Database service created");
125
126 debug!("[REPO] Creating offline repository...");
127 let offline = OfflineRepository::new(db_service, server_id, user_id);
128 debug!("[REPO] Offline repository created");
129
130 debug!("[REPO] Creating hybrid repository...");
132 let hybrid = HybridRepository::new(online, offline);
133 debug!("[REPO] Hybrid repository created");
134
135 let uuid = Uuid::new_v4();
137 let handle = format!("{}", uuid);
138 info!("[REPO] Generated handle: {}", handle);
139
140 debug!("[REPO] Storing repository...");
142 manager.0.create(handle.clone(), hybrid);
143 info!("[REPO] Repository stored successfully");
144
145 if let Some(repo) = manager.0.get(&handle) {
149 let controller = player.0.lock().await;
150 controller.set_repository(repo);
151 }
152
153 Ok(handle)
154}
155
156#[tauri::command]
158#[specta::specta]
159pub async fn repository_destroy(
160 manager: State<'_, RepositoryManagerWrapper>,
161 handle: String,
162) -> Result<(), String> {
163 manager.0.destroy(&handle);
164 Ok(())
165}
166
167#[tauri::command]
169#[specta::specta]
170pub async fn repository_get_libraries(
171 manager: State<'_, RepositoryManagerWrapper>,
172 handle: String,
173) -> Result<Vec<Library>, String> {
174 debug!("[REPO] get_libraries called with handle: {}", handle);
175 let repo = manager.0.get(&handle).ok_or_else(|| {
176 error!("[REPO] Repository not found for handle: {}", handle);
177 "Repository not found".to_string()
178 })?;
179 debug!("[REPO] Repository found, fetching libraries...");
180 repo.as_ref().get_libraries().await.map_err(|e| {
181 error!("[REPO] Error fetching libraries: {:?}", e);
182 format!("{:?}", e)
183 })
184}
185
186#[tauri::command]
188#[specta::specta]
189pub async fn repository_get_items(
190 manager: State<'_, RepositoryManagerWrapper>,
191 handle: String,
192 parent_id: String,
193 options: Option<GetItemsOptions>,
194) -> Result<SearchResult, String> {
195 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
196 repo.as_ref()
197 .get_items(&parent_id, options)
198 .await
199 .map_err(|e| format!("{:?}", e))
200}
201
202#[tauri::command]
204#[specta::specta]
205pub async fn repository_get_item(
206 manager: State<'_, RepositoryManagerWrapper>,
207 handle: String,
208 item_id: String,
209) -> Result<MediaItem, String> {
210 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
211 repo.as_ref()
212 .get_item(&item_id)
213 .await
214 .map_err(|e| format!("{:?}", e))
215}
216
217#[tauri::command]
224#[specta::specta]
225pub async fn repository_get_downloaded_libraries(
226 manager: State<'_, RepositoryManagerWrapper>,
227 handle: String,
228) -> Result<Vec<Library>, String> {
229 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
230 repo.get_downloaded_libraries()
231 .await
232 .map_err(|e| format!("{:?}", e))
233}
234
235#[tauri::command]
239#[specta::specta]
240pub async fn repository_get_downloaded_items(
241 manager: State<'_, RepositoryManagerWrapper>,
242 handle: String,
243 parent_id: String,
244 options: Option<GetItemsOptions>,
245) -> Result<SearchResult, String> {
246 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
247 repo.get_downloaded_items(&parent_id, options)
248 .await
249 .map_err(|e| format!("{:?}", e))
250}
251
252#[tauri::command]
256#[specta::specta]
257pub async fn repository_get_download_disk_usage(
258 manager: State<'_, RepositoryManagerWrapper>,
259 handle: String,
260) -> Result<DownloadDiskUsage, String> {
261 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
262 repo.get_download_disk_usage()
263 .await
264 .map_err(|e| format!("{:?}", e))
265}
266
267#[tauri::command]
271#[specta::specta]
272pub async fn repository_jray_actors_at(
273 manager: State<'_, RepositoryManagerWrapper>,
274 handle: String,
275 item_id: String,
276 t: f64,
277) -> Result<Vec<crate::repository::JRayActor>, String> {
278 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
279 repo.as_ref()
280 .get_jray_actors(&item_id, t)
281 .await
282 .map_err(|e| format!("{:?}", e))
283}
284
285#[tauri::command]
287#[specta::specta]
288pub async fn repository_get_latest_items(
289 manager: State<'_, RepositoryManagerWrapper>,
290 handle: String,
291 parent_id: String,
292 limit: Option<usize>,
293) -> Result<Vec<MediaItem>, String> {
294 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
295 repo.as_ref()
296 .get_latest_items(&parent_id, limit)
297 .await
298 .map_err(|e| format!("{:?}", e))
299}
300
301#[tauri::command]
309#[specta::specta]
310pub async fn repository_get_resume_items(
311 manager: State<'_, RepositoryManagerWrapper>,
312 handle: String,
313 parent_id: Option<String>,
314 limit: Option<usize>,
315) -> Result<Vec<MediaItem>, String> {
316 debug!("[REPO] get_resume_items called with handle: {}", handle);
317 let repo = manager.0.get(&handle).ok_or_else(|| {
318 error!("[REPO] Repository not found for handle: {}", handle);
319 "Repository not found".to_string()
320 })?;
321 debug!("[REPO] Repository found, fetching resume items...");
322 repo.as_ref()
323 .get_resume_items(parent_id.as_deref(), limit)
324 .await
325 .map_err(|e| {
326 error!("[REPO] Error fetching resume items: {:?}", e);
327 format!("{:?}", e)
328 })
329}
330
331#[tauri::command]
335#[specta::specta]
336pub async fn repository_get_next_up_episodes(
337 manager: State<'_, RepositoryManagerWrapper>,
338 handle: String,
339 series_id: Option<String>,
340 limit: Option<usize>,
341) -> Result<Vec<MediaItem>, String> {
342 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
343 repo.as_ref()
344 .get_next_up_episodes(series_id.as_deref(), limit)
345 .await
346 .map_err(|e| format!("{:?}", e))
347}
348
349#[tauri::command]
358#[specta::specta]
359pub async fn repository_get_series_episodes(
360 manager: State<'_, RepositoryManagerWrapper>,
361 handle: String,
362 series_id: String,
363) -> Result<Vec<MediaItem>, String> {
364 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
365 series_progress::fetch_series_episodes(repo.as_ref(), &series_id)
366 .await
367 .map_err(|e| format!("{:?}", e))
368}
369
370#[tauri::command]
379#[specta::specta]
380pub async fn repository_get_series_current_episode(
381 manager: State<'_, RepositoryManagerWrapper>,
382 handle: String,
383 series_id: String,
384) -> Result<Option<MediaItem>, String> {
385 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
386 series_progress::resolve_current_episode(repo.as_ref(), &series_id)
387 .await
388 .map_err(|e| format!("{:?}", e))
389}
390
391#[tauri::command]
401#[specta::specta]
402pub async fn repository_clear_watch_history(
403 manager: State<'_, RepositoryManagerWrapper>,
404 handle: String,
405 item_id: String,
406) -> Result<(), String> {
407 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
408 repo.as_ref()
409 .clear_watch_history(&item_id)
410 .await
411 .map_err(|e| format!("{:?}", e))
412}
413
414#[tauri::command]
416#[specta::specta]
417pub async fn repository_get_recently_played_audio(
418 manager: State<'_, RepositoryManagerWrapper>,
419 handle: String,
420 limit: Option<usize>,
421) -> Result<Vec<MediaItem>, String> {
422 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
423 repo.as_ref()
424 .get_recently_played_audio(limit)
425 .await
426 .map_err(|e| format!("{:?}", e))
427}
428
429#[tauri::command]
431#[specta::specta]
432pub async fn repository_get_resume_movies(
433 manager: State<'_, RepositoryManagerWrapper>,
434 handle: String,
435 limit: Option<usize>,
436) -> Result<Vec<MediaItem>, String> {
437 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
438 repo.as_ref()
439 .get_resume_movies(limit)
440 .await
441 .map_err(|e| format!("{:?}", e))
442}
443
444#[tauri::command]
446#[specta::specta]
447pub async fn repository_get_rediscover_albums(
448 manager: State<'_, RepositoryManagerWrapper>,
449 handle: String,
450 parent_id: Option<String>,
451 limit: Option<usize>,
452) -> Result<Vec<MediaItem>, String> {
453 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
454 repo.as_ref()
455 .get_rediscover_albums(parent_id.as_deref(), limit)
456 .await
457 .map_err(|e| format!("{:?}", e))
458}
459
460#[tauri::command]
462#[specta::specta]
463pub async fn repository_get_genres(
464 manager: State<'_, RepositoryManagerWrapper>,
465 handle: String,
466 parent_id: Option<String>,
467) -> Result<Vec<Genre>, String> {
468 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
469 repo.as_ref()
470 .get_genres(parent_id.as_deref())
471 .await
472 .map_err(|e| format!("{:?}", e))
473}
474
475pub const SEARCH_EVENT_NAME: &str = "search-event";
477
478#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
483#[serde(rename_all = "camelCase")]
484pub struct SearchUpdateEvent {
485 pub request_id: u32,
486 pub result: SearchResult,
487}
488
489#[tauri::command]
496#[specta::specta]
497pub async fn repository_search(
498 app: AppHandle,
499 manager: State<'_, RepositoryManagerWrapper>,
500 handle: String,
501 query: String,
502 options: Option<SearchOptions>,
503 request_id: u32,
504) -> Result<SearchResult, String> {
505 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
506
507 let options = options.map(|mut o| {
513 o.resolve_scope();
514 o
515 });
516
517 let mut cache_result = repo
520 .search_cache_only(&query, options.clone())
521 .await
522 .unwrap_or_else(|e| {
523 debug!("[Search] Cache search miss/timeout: {:?}", e);
524 SearchResult {
525 items: Vec::new(),
526 total_record_count: 0,
527 }
528 });
529
530 rank_search_results(&mut cache_result.items, &query);
535
536 let repo_bg = repo.clone();
540 let cache_for_merge = cache_result.clone();
541 tauri::async_runtime::spawn(async move {
542 match repo_bg.search_server_only(&query, options).await {
543 Ok(server_result) => {
544 let mut merged =
545 HybridRepository::merge_search_results(cache_for_merge, server_result);
546 rank_search_results(&mut merged.items, &query);
549 let event = SearchUpdateEvent {
550 request_id,
551 result: merged,
552 };
553 if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) {
554 error!("[Search] Failed to emit search update: {}", e);
555 }
556 }
557 Err(e) => {
558 warn!(
561 "[Search] Server search failed, keeping cache results: {:?}",
562 e
563 );
564 }
565 }
566 });
567
568 Ok(cache_result)
569}
570
571#[tauri::command]
573#[specta::specta]
574pub async fn repository_get_playback_info(
575 manager: State<'_, RepositoryManagerWrapper>,
576 handle: String,
577 item_id: String,
578) -> Result<PlaybackInfo, String> {
579 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
580 repo.as_ref()
581 .get_playback_info(&item_id)
582 .await
583 .map_err(|e| format!("{:?}", e))
584}
585
586#[tauri::command]
594#[specta::specta]
595pub async fn repository_get_video_stream_url(
596 manager: State<'_, RepositoryManagerWrapper>,
597 handle: String,
598 item_id: String,
599 media_source_id: Option<String>,
600 audio_stream_index: Option<i32>,
601) -> Result<String, String> {
602 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
603 repo.as_ref()
604 .get_video_stream_url(&item_id, media_source_id.as_deref(), audio_stream_index)
605 .await
606 .map_err(|e| format!("{:?}", e))
607}
608
609#[tauri::command]
623#[specta::specta]
624pub async fn repository_get_stream_selection(
625 manager: State<'_, RepositoryManagerWrapper>,
626 handle: String,
627 item_id: String,
628 media_source_id: Option<String>,
629 audio_stream_index: Option<i32>,
630) -> Result<StreamSelection, String> {
631 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
632 repo.as_ref()
633 .get_stream_selection(&item_id, media_source_id.as_deref(), audio_stream_index)
634 .await
635 .map_err(|e| format!("{:?}", e))
636}
637
638#[tauri::command]
642#[specta::specta]
643pub async fn repository_get_audio_only_stream_url_for_video(
644 manager: State<'_, RepositoryManagerWrapper>,
645 handle: String,
646 item_id: String,
647 media_source_id: Option<String>,
648 start_time_seconds: Option<f64>,
649 audio_stream_index: Option<i32>,
650) -> Result<String, String> {
651 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
652 repo.as_ref()
653 .get_audio_only_stream_url_for_video(
654 &item_id,
655 media_source_id.as_deref(),
656 start_time_seconds,
657 audio_stream_index,
658 )
659 .await
660 .map_err(|e| format!("{:?}", e))
661}
662
663#[tauri::command]
665#[specta::specta]
666pub async fn repository_get_audio_stream_url(
667 manager: State<'_, RepositoryManagerWrapper>,
668 handle: String,
669 item_id: String,
670) -> Result<String, String> {
671 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
672 repo.as_ref()
673 .get_audio_stream_url(&item_id)
674 .await
675 .map_err(|e| format!("{:?}", e))
676}
677
678#[tauri::command]
680#[specta::specta]
681pub async fn repository_get_live_tv_channels(
682 manager: State<'_, RepositoryManagerWrapper>,
683 handle: String,
684) -> Result<Vec<MediaItem>, String> {
685 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
686 repo.as_ref()
687 .get_live_tv_channels()
688 .await
689 .map_err(|e| format!("{:?}", e))
690}
691
692#[tauri::command]
694#[specta::specta]
695pub async fn repository_get_channels(
696 manager: State<'_, RepositoryManagerWrapper>,
697 handle: String,
698) -> Result<SearchResult, String> {
699 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
700 repo.as_ref()
701 .get_channels()
702 .await
703 .map_err(|e| format!("{:?}", e))
704}
705
706#[tauri::command]
708#[specta::specta]
709pub async fn repository_open_live_stream(
710 manager: State<'_, RepositoryManagerWrapper>,
711 handle: String,
712 item_id: String,
713) -> Result<LiveStreamInfo, String> {
714 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
715 repo.as_ref()
716 .open_live_stream(&item_id)
717 .await
718 .map_err(|e| format!("{:?}", e))
719}
720
721#[tauri::command]
723#[specta::specta]
724pub async fn repository_report_playback_start(
725 manager: State<'_, RepositoryManagerWrapper>,
726 handle: String,
727 item_id: String,
728 position_ms: i64,
729) -> Result<(), String> {
730 let position_ticks = position_ms * 10_000;
731 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
732 repo.as_ref()
733 .report_playback_start(&item_id, position_ticks)
734 .await
735 .map_err(|e| format!("{:?}", e))
736}
737
738#[tauri::command]
740#[specta::specta]
741pub async fn repository_report_playback_progress(
742 manager: State<'_, RepositoryManagerWrapper>,
743 handle: String,
744 item_id: String,
745 position_ms: i64,
746) -> Result<(), String> {
747 let position_ticks = position_ms * 10_000;
748 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
749 repo.as_ref()
750 .report_playback_progress(&item_id, position_ticks)
751 .await
752 .map_err(|e| format!("{:?}", e))
753}
754
755#[tauri::command]
766#[specta::specta]
767pub async fn repository_report_playback_stopped(
768 db: State<'_, crate::commands::storage::DatabaseWrapper>,
769 manager: State<'_, RepositoryManagerWrapper>,
770 handle: String,
771 item_id: String,
772 position_ms: i64,
773) -> Result<(), String> {
774 let position_ticks = position_ms * 10_000;
776 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
777
778 let result = repo
779 .as_ref()
780 .report_playback_stopped(&item_id, position_ticks)
781 .await;
782
783 if let Err(e) = &result {
784 let db_service = {
785 let database = db.0.lock().map_err(|err| err.to_string())?;
786 Arc::new(database.service())
787 };
788 let user_id = repo.user_id().to_string();
789 if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
790 &db_service,
791 &user_id,
792 &item_id,
793 position_ticks,
794 )
795 .await
796 {
797 warn!(
798 "[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
799 item_id, e, queue_err
800 );
801 } else {
802 debug!(
803 "[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
804 item_id, e
805 );
806 }
807 }
808
809 result.map_err(|e| format!("{:?}", e))
810}
811
812#[tauri::command]
814#[specta::specta]
815pub fn repository_get_image_url(
816 manager: State<'_, RepositoryManagerWrapper>,
817 handle: String,
818 item_id: String,
819 image_type: ImageType,
820 options: Option<ImageOptions>,
821) -> Result<String, String> {
822 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
823 Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
824}
825
826#[tauri::command]
828#[specta::specta]
829#[allow(dead_code)]
830pub fn repository_get_subtitle_url(
831 manager: State<'_, RepositoryManagerWrapper>,
832 handle: String,
833 item_id: String,
834 media_source_id: String,
835 stream_index: i32,
836 format: String,
837) -> Result<String, String> {
838 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
839 Ok(repo
840 .as_ref()
841 .get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
842}
843
844#[tauri::command]
846#[specta::specta]
847#[allow(dead_code)]
848pub async fn repository_get_video_download_url(
849 manager: State<'_, RepositoryManagerWrapper>,
850 handle: String,
851 item_id: String,
852 quality: String,
853 media_source_id: Option<String>,
854) -> Result<String, String> {
855 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
856 Ok(crate::repository::resolve_video_download_url(
860 repo.as_ref(),
861 &item_id,
862 &quality,
863 media_source_id.as_deref(),
864 )
865 .await)
866}
867
868#[tauri::command]
870#[specta::specta]
871pub async fn repository_mark_favorite(
872 manager: State<'_, RepositoryManagerWrapper>,
873 handle: String,
874 item_id: String,
875) -> Result<(), String> {
876 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
877 repo.as_ref()
878 .mark_favorite(&item_id)
879 .await
880 .map_err(|e| format!("{:?}", e))
881}
882
883pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
889
890#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
895#[serde(rename_all = "camelCase")]
896pub struct FavoritesChangedEvent {
897 pub item_ids: Vec<String>,
898}
899
900fn changed_favorite_ids(
910 cached: &std::collections::HashSet<String>,
911 server: &std::collections::HashSet<String>,
912) -> Vec<String> {
913 let mut changed: Vec<String> = server.symmetric_difference(cached).cloned().collect();
914 changed.sort();
916 changed
917}
918
919#[tauri::command]
929#[specta::specta]
930pub async fn repository_get_favorites(
931 app: AppHandle,
932 manager: State<'_, RepositoryManagerWrapper>,
933 handle: String,
934 scope: SearchScope,
935 options: Option<GetItemsOptions>,
936) -> Result<SearchResult, String> {
937 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
938
939 let cache_result = repo
940 .get_favorites_cache_only(scope, options.clone())
941 .await
942 .unwrap_or_else(|e| {
943 debug!("[Favorites] Cache miss/timeout: {:?}", e);
944 SearchResult {
945 items: Vec::new(),
946 total_record_count: 0,
947 }
948 });
949
950 if !crate::repository::offline::include_catalog_browse() {
953 return Ok(cache_result);
954 }
955
956 if !cache_result.has_content() {
966 debug!("[Favorites] Nothing cached; answering from the server");
967 return repo
968 .get_favorites(scope, options)
969 .await
970 .map_err(|e| format!("{:?}", e));
971 }
972
973 let repo_bg = repo.clone();
974 let cached_ids: std::collections::HashSet<String> =
975 cache_result.items.iter().map(|i| i.id.clone()).collect();
976 tauri::async_runtime::spawn(async move {
977 match repo_bg.get_favorites_server_only(scope, options).await {
978 Ok(server_result) => {
979 let server_ids: std::collections::HashSet<String> =
980 server_result.items.iter().map(|i| i.id.clone()).collect();
981 let changed = changed_favorite_ids(&cached_ids, &server_ids);
982
983 if !changed.is_empty() {
984 let event = FavoritesChangedEvent { item_ids: changed };
985 if let Err(e) = app.emit(FAVORITES_CHANGED_EVENT, &event) {
986 error!("[Favorites] Failed to emit change event: {}", e);
987 }
988 }
989 }
990 Err(e) => {
991 warn!(
992 "[Favorites] Server refresh failed, keeping cached favourites: {:?}",
993 e
994 );
995 }
996 }
997 });
998
999 Ok(cache_result)
1000}
1001
1002#[tauri::command]
1004#[specta::specta]
1005pub async fn repository_unmark_favorite(
1006 manager: State<'_, RepositoryManagerWrapper>,
1007 handle: String,
1008 item_id: String,
1009) -> Result<(), String> {
1010 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1011 repo.as_ref()
1012 .unmark_favorite(&item_id)
1013 .await
1014 .map_err(|e| format!("{:?}", e))
1015}
1016
1017#[tauri::command]
1019#[specta::specta]
1020pub async fn repository_get_person(
1021 manager: State<'_, RepositoryManagerWrapper>,
1022 handle: String,
1023 person_id: String,
1024) -> Result<MediaItem, String> {
1025 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1026 repo.as_ref()
1027 .get_person(&person_id)
1028 .await
1029 .map_err(|e| format!("{:?}", e))
1030}
1031
1032#[tauri::command]
1034#[specta::specta]
1035pub async fn repository_get_items_by_person(
1036 manager: State<'_, RepositoryManagerWrapper>,
1037 handle: String,
1038 person_id: String,
1039 options: Option<GetItemsOptions>,
1040) -> Result<SearchResult, String> {
1041 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1042 repo.as_ref()
1043 .get_items_by_person(&person_id, options)
1044 .await
1045 .map_err(|e| format!("{:?}", e))
1046}
1047
1048#[tauri::command]
1050#[specta::specta]
1051pub async fn repository_get_similar_items(
1052 manager: State<'_, RepositoryManagerWrapper>,
1053 handle: String,
1054 item_id: String,
1055 limit: Option<usize>,
1056) -> Result<SearchResult, String> {
1057 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1058 repo.as_ref()
1059 .get_similar_items(&item_id, limit)
1060 .await
1061 .map_err(|e| format!("{:?}", e))
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066 use super::*;
1067
1068 #[test]
1069 fn test_repository_manager_creation() {
1070 let manager = RepositoryManager::new();
1071 assert!(manager.get("any-handle").is_none());
1073 }
1074
1075 fn ids(values: &[&str]) -> std::collections::HashSet<String> {
1076 values.iter().map(|v| v.to_string()).collect()
1077 }
1078
1079 #[test]
1083 fn test_changed_favorite_ids_reports_both_directions() {
1084 assert_eq!(
1086 changed_favorite_ids(&ids(&["a"]), &ids(&["a", "b"])),
1087 vec!["b".to_string()]
1088 );
1089
1090 assert_eq!(
1092 changed_favorite_ids(&ids(&["a", "b"]), &ids(&["a"])),
1093 vec!["b".to_string()]
1094 );
1095
1096 assert_eq!(
1098 changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "c"])),
1099 vec!["a".to_string(), "c".to_string()]
1100 );
1101 }
1102
1103 #[test]
1108 fn test_changed_favorite_ids_is_empty_when_nothing_moved() {
1109 assert!(changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "a"])).is_empty());
1110 assert!(changed_favorite_ids(&ids(&[]), &ids(&[])).is_empty());
1111 }
1112
1113 #[test]
1114 fn test_repository_manager_wrapper_structure() {
1115 let manager = RepositoryManager::new();
1116 let wrapper = RepositoryManagerWrapper(manager);
1117 assert!(wrapper.0.get("any-handle").is_none());
1119 }
1120
1121 #[test]
1122 fn test_repository_manager_get_nonexistent() {
1123 let manager = RepositoryManager::new();
1124 let result = manager.get("nonexistent-handle");
1126 assert!(result.is_none());
1127 }
1128
1129 #[test]
1130 fn test_uuid_handle_generation() {
1131 let uuid = Uuid::new_v4();
1132 let handle = format!("{}", uuid);
1133 assert!(!handle.is_empty());
1135 }
1136
1137 #[test]
1138 fn test_uuid_handles_are_unique() {
1139 let handle1 = format!("{}", Uuid::new_v4());
1140 let handle2 = format!("{}", Uuid::new_v4());
1141 assert_ne!(handle1, handle2);
1143 }
1144
1145 #[test]
1146 fn test_uuid_handle_format() {
1147 let uuid = Uuid::new_v4();
1148 let handle = format!("{}", uuid);
1149 let parts: Vec<&str> = handle.split('-').collect();
1151 assert_eq!(parts.len(), 5);
1152 }
1153
1154 #[test]
1155 fn test_repository_manager_destroy_nonexistent() {
1156 let manager = RepositoryManager::new();
1157 manager.destroy("nonexistent-handle");
1159 }
1160
1161 #[test]
1162 fn test_repository_manager_is_send_sync() {
1163 fn is_send_sync<T: Send + Sync>() {}
1165 is_send_sync::<RepositoryManager>();
1166 }
1167
1168 #[test]
1169 fn test_repository_manager_wrapper_is_send_sync() {
1170 fn is_send_sync<T: Send + Sync>() {}
1172 is_send_sync::<RepositoryManagerWrapper>();
1173 }
1174
1175 #[test]
1176 fn test_multiple_manager_instances() {
1177 let manager1 = RepositoryManager::new();
1178 let manager2 = RepositoryManager::new();
1179
1180 let handle1_nonexistent = manager1.get("test");
1182 let handle2_nonexistent = manager2.get("test");
1183
1184 assert!(handle1_nonexistent.is_none());
1185 assert!(handle2_nonexistent.is_none());
1186 }
1187
1188 #[test]
1189 fn test_handle_string_properties() {
1190 let uuid = Uuid::new_v4();
1191 let handle = format!("{}", uuid);
1192
1193 for c in handle.chars() {
1195 assert!(c.is_alphanumeric() || c == '-');
1196 }
1197 }
1198
1199 #[test]
1200 fn test_repository_manager_concurrent_access() {
1201 let manager = Arc::new(RepositoryManager::new());
1202 let mut handles = vec![];
1203
1204 for _ in 0..3 {
1206 let mgr = Arc::clone(&manager);
1207 let handle = std::thread::spawn(move || {
1208 let result = mgr.get("test");
1209 assert!(result.is_none());
1210 });
1211 handles.push(handle);
1212 }
1213
1214 for h in handles {
1215 h.join().unwrap();
1216 }
1217 }
1218}