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,
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]
613#[specta::specta]
614pub async fn repository_get_audio_only_stream_url_for_video(
615 manager: State<'_, RepositoryManagerWrapper>,
616 handle: String,
617 item_id: String,
618 media_source_id: Option<String>,
619 start_time_seconds: Option<f64>,
620 audio_stream_index: Option<i32>,
621) -> Result<String, String> {
622 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
623 repo.as_ref()
624 .get_audio_only_stream_url_for_video(
625 &item_id,
626 media_source_id.as_deref(),
627 start_time_seconds,
628 audio_stream_index,
629 )
630 .await
631 .map_err(|e| format!("{:?}", e))
632}
633
634#[tauri::command]
636#[specta::specta]
637pub async fn repository_get_audio_stream_url(
638 manager: State<'_, RepositoryManagerWrapper>,
639 handle: String,
640 item_id: String,
641) -> Result<String, String> {
642 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
643 repo.as_ref()
644 .get_audio_stream_url(&item_id)
645 .await
646 .map_err(|e| format!("{:?}", e))
647}
648
649#[tauri::command]
651#[specta::specta]
652pub async fn repository_get_live_tv_channels(
653 manager: State<'_, RepositoryManagerWrapper>,
654 handle: String,
655) -> Result<Vec<MediaItem>, String> {
656 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
657 repo.as_ref()
658 .get_live_tv_channels()
659 .await
660 .map_err(|e| format!("{:?}", e))
661}
662
663#[tauri::command]
665#[specta::specta]
666pub async fn repository_get_channels(
667 manager: State<'_, RepositoryManagerWrapper>,
668 handle: String,
669) -> Result<SearchResult, String> {
670 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
671 repo.as_ref()
672 .get_channels()
673 .await
674 .map_err(|e| format!("{:?}", e))
675}
676
677#[tauri::command]
679#[specta::specta]
680pub async fn repository_open_live_stream(
681 manager: State<'_, RepositoryManagerWrapper>,
682 handle: String,
683 item_id: String,
684) -> Result<LiveStreamInfo, String> {
685 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
686 repo.as_ref()
687 .open_live_stream(&item_id)
688 .await
689 .map_err(|e| format!("{:?}", e))
690}
691
692#[tauri::command]
694#[specta::specta]
695pub async fn repository_report_playback_start(
696 manager: State<'_, RepositoryManagerWrapper>,
697 handle: String,
698 item_id: String,
699 position_ms: i64,
700) -> Result<(), String> {
701 let position_ticks = position_ms * 10_000;
702 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
703 repo.as_ref()
704 .report_playback_start(&item_id, position_ticks)
705 .await
706 .map_err(|e| format!("{:?}", e))
707}
708
709#[tauri::command]
711#[specta::specta]
712pub async fn repository_report_playback_progress(
713 manager: State<'_, RepositoryManagerWrapper>,
714 handle: String,
715 item_id: String,
716 position_ms: i64,
717) -> Result<(), String> {
718 let position_ticks = position_ms * 10_000;
719 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
720 repo.as_ref()
721 .report_playback_progress(&item_id, position_ticks)
722 .await
723 .map_err(|e| format!("{:?}", e))
724}
725
726#[tauri::command]
737#[specta::specta]
738pub async fn repository_report_playback_stopped(
739 db: State<'_, crate::commands::storage::DatabaseWrapper>,
740 manager: State<'_, RepositoryManagerWrapper>,
741 handle: String,
742 item_id: String,
743 position_ms: i64,
744) -> Result<(), String> {
745 let position_ticks = position_ms * 10_000;
747 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
748
749 let result = repo
750 .as_ref()
751 .report_playback_stopped(&item_id, position_ticks)
752 .await;
753
754 if let Err(e) = &result {
755 let db_service = {
756 let database = db.0.lock().map_err(|err| err.to_string())?;
757 Arc::new(database.service())
758 };
759 let user_id = repo.user_id().to_string();
760 if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
761 &db_service,
762 &user_id,
763 &item_id,
764 position_ticks,
765 )
766 .await
767 {
768 warn!(
769 "[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
770 item_id, e, queue_err
771 );
772 } else {
773 debug!(
774 "[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
775 item_id, e
776 );
777 }
778 }
779
780 result.map_err(|e| format!("{:?}", e))
781}
782
783#[tauri::command]
785#[specta::specta]
786pub fn repository_get_image_url(
787 manager: State<'_, RepositoryManagerWrapper>,
788 handle: String,
789 item_id: String,
790 image_type: ImageType,
791 options: Option<ImageOptions>,
792) -> Result<String, String> {
793 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
794 Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
795}
796
797#[tauri::command]
799#[specta::specta]
800#[allow(dead_code)]
801pub fn repository_get_subtitle_url(
802 manager: State<'_, RepositoryManagerWrapper>,
803 handle: String,
804 item_id: String,
805 media_source_id: String,
806 stream_index: i32,
807 format: String,
808) -> Result<String, String> {
809 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
810 Ok(repo
811 .as_ref()
812 .get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
813}
814
815#[tauri::command]
817#[specta::specta]
818#[allow(dead_code)]
819pub async fn repository_get_video_download_url(
820 manager: State<'_, RepositoryManagerWrapper>,
821 handle: String,
822 item_id: String,
823 quality: String,
824 media_source_id: Option<String>,
825) -> Result<String, String> {
826 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
827 Ok(crate::repository::resolve_video_download_url(
831 repo.as_ref(),
832 &item_id,
833 &quality,
834 media_source_id.as_deref(),
835 )
836 .await)
837}
838
839#[tauri::command]
841#[specta::specta]
842pub async fn repository_mark_favorite(
843 manager: State<'_, RepositoryManagerWrapper>,
844 handle: String,
845 item_id: String,
846) -> Result<(), String> {
847 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
848 repo.as_ref()
849 .mark_favorite(&item_id)
850 .await
851 .map_err(|e| format!("{:?}", e))
852}
853
854pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
860
861#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
866#[serde(rename_all = "camelCase")]
867pub struct FavoritesChangedEvent {
868 pub item_ids: Vec<String>,
869}
870
871fn changed_favorite_ids(
881 cached: &std::collections::HashSet<String>,
882 server: &std::collections::HashSet<String>,
883) -> Vec<String> {
884 let mut changed: Vec<String> = server.symmetric_difference(cached).cloned().collect();
885 changed.sort();
887 changed
888}
889
890#[tauri::command]
900#[specta::specta]
901pub async fn repository_get_favorites(
902 app: AppHandle,
903 manager: State<'_, RepositoryManagerWrapper>,
904 handle: String,
905 scope: SearchScope,
906 options: Option<GetItemsOptions>,
907) -> Result<SearchResult, String> {
908 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
909
910 let cache_result = repo
911 .get_favorites_cache_only(scope, options.clone())
912 .await
913 .unwrap_or_else(|e| {
914 debug!("[Favorites] Cache miss/timeout: {:?}", e);
915 SearchResult {
916 items: Vec::new(),
917 total_record_count: 0,
918 }
919 });
920
921 if !crate::repository::offline::include_catalog_browse() {
924 return Ok(cache_result);
925 }
926
927 if !cache_result.has_content() {
937 debug!("[Favorites] Nothing cached; answering from the server");
938 return repo
939 .get_favorites(scope, options)
940 .await
941 .map_err(|e| format!("{:?}", e));
942 }
943
944 let repo_bg = repo.clone();
945 let cached_ids: std::collections::HashSet<String> =
946 cache_result.items.iter().map(|i| i.id.clone()).collect();
947 tauri::async_runtime::spawn(async move {
948 match repo_bg.get_favorites_server_only(scope, options).await {
949 Ok(server_result) => {
950 let server_ids: std::collections::HashSet<String> =
951 server_result.items.iter().map(|i| i.id.clone()).collect();
952 let changed = changed_favorite_ids(&cached_ids, &server_ids);
953
954 if !changed.is_empty() {
955 let event = FavoritesChangedEvent { item_ids: changed };
956 if let Err(e) = app.emit(FAVORITES_CHANGED_EVENT, &event) {
957 error!("[Favorites] Failed to emit change event: {}", e);
958 }
959 }
960 }
961 Err(e) => {
962 warn!(
963 "[Favorites] Server refresh failed, keeping cached favourites: {:?}",
964 e
965 );
966 }
967 }
968 });
969
970 Ok(cache_result)
971}
972
973#[tauri::command]
975#[specta::specta]
976pub async fn repository_unmark_favorite(
977 manager: State<'_, RepositoryManagerWrapper>,
978 handle: String,
979 item_id: String,
980) -> Result<(), String> {
981 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
982 repo.as_ref()
983 .unmark_favorite(&item_id)
984 .await
985 .map_err(|e| format!("{:?}", e))
986}
987
988#[tauri::command]
990#[specta::specta]
991pub async fn repository_get_person(
992 manager: State<'_, RepositoryManagerWrapper>,
993 handle: String,
994 person_id: String,
995) -> Result<MediaItem, String> {
996 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
997 repo.as_ref()
998 .get_person(&person_id)
999 .await
1000 .map_err(|e| format!("{:?}", e))
1001}
1002
1003#[tauri::command]
1005#[specta::specta]
1006pub async fn repository_get_items_by_person(
1007 manager: State<'_, RepositoryManagerWrapper>,
1008 handle: String,
1009 person_id: String,
1010 options: Option<GetItemsOptions>,
1011) -> Result<SearchResult, String> {
1012 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1013 repo.as_ref()
1014 .get_items_by_person(&person_id, options)
1015 .await
1016 .map_err(|e| format!("{:?}", e))
1017}
1018
1019#[tauri::command]
1021#[specta::specta]
1022pub async fn repository_get_similar_items(
1023 manager: State<'_, RepositoryManagerWrapper>,
1024 handle: String,
1025 item_id: String,
1026 limit: Option<usize>,
1027) -> Result<SearchResult, String> {
1028 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1029 repo.as_ref()
1030 .get_similar_items(&item_id, limit)
1031 .await
1032 .map_err(|e| format!("{:?}", e))
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037 use super::*;
1038
1039 #[test]
1040 fn test_repository_manager_creation() {
1041 let manager = RepositoryManager::new();
1042 assert!(manager.get("any-handle").is_none());
1044 }
1045
1046 fn ids(values: &[&str]) -> std::collections::HashSet<String> {
1047 values.iter().map(|v| v.to_string()).collect()
1048 }
1049
1050 #[test]
1054 fn test_changed_favorite_ids_reports_both_directions() {
1055 assert_eq!(
1057 changed_favorite_ids(&ids(&["a"]), &ids(&["a", "b"])),
1058 vec!["b".to_string()]
1059 );
1060
1061 assert_eq!(
1063 changed_favorite_ids(&ids(&["a", "b"]), &ids(&["a"])),
1064 vec!["b".to_string()]
1065 );
1066
1067 assert_eq!(
1069 changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "c"])),
1070 vec!["a".to_string(), "c".to_string()]
1071 );
1072 }
1073
1074 #[test]
1079 fn test_changed_favorite_ids_is_empty_when_nothing_moved() {
1080 assert!(changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "a"])).is_empty());
1081 assert!(changed_favorite_ids(&ids(&[]), &ids(&[])).is_empty());
1082 }
1083
1084 #[test]
1085 fn test_repository_manager_wrapper_structure() {
1086 let manager = RepositoryManager::new();
1087 let wrapper = RepositoryManagerWrapper(manager);
1088 assert!(wrapper.0.get("any-handle").is_none());
1090 }
1091
1092 #[test]
1093 fn test_repository_manager_get_nonexistent() {
1094 let manager = RepositoryManager::new();
1095 let result = manager.get("nonexistent-handle");
1097 assert!(result.is_none());
1098 }
1099
1100 #[test]
1101 fn test_uuid_handle_generation() {
1102 let uuid = Uuid::new_v4();
1103 let handle = format!("{}", uuid);
1104 assert!(!handle.is_empty());
1106 }
1107
1108 #[test]
1109 fn test_uuid_handles_are_unique() {
1110 let handle1 = format!("{}", Uuid::new_v4());
1111 let handle2 = format!("{}", Uuid::new_v4());
1112 assert_ne!(handle1, handle2);
1114 }
1115
1116 #[test]
1117 fn test_uuid_handle_format() {
1118 let uuid = Uuid::new_v4();
1119 let handle = format!("{}", uuid);
1120 let parts: Vec<&str> = handle.split('-').collect();
1122 assert_eq!(parts.len(), 5);
1123 }
1124
1125 #[test]
1126 fn test_repository_manager_destroy_nonexistent() {
1127 let manager = RepositoryManager::new();
1128 manager.destroy("nonexistent-handle");
1130 }
1131
1132 #[test]
1133 fn test_repository_manager_is_send_sync() {
1134 fn is_send_sync<T: Send + Sync>() {}
1136 is_send_sync::<RepositoryManager>();
1137 }
1138
1139 #[test]
1140 fn test_repository_manager_wrapper_is_send_sync() {
1141 fn is_send_sync<T: Send + Sync>() {}
1143 is_send_sync::<RepositoryManagerWrapper>();
1144 }
1145
1146 #[test]
1147 fn test_multiple_manager_instances() {
1148 let manager1 = RepositoryManager::new();
1149 let manager2 = RepositoryManager::new();
1150
1151 let handle1_nonexistent = manager1.get("test");
1153 let handle2_nonexistent = manager2.get("test");
1154
1155 assert!(handle1_nonexistent.is_none());
1156 assert!(handle2_nonexistent.is_none());
1157 }
1158
1159 #[test]
1160 fn test_handle_string_properties() {
1161 let uuid = Uuid::new_v4();
1162 let handle = format!("{}", uuid);
1163
1164 for c in handle.chars() {
1166 assert!(c.is_alphanumeric() || c == '-');
1167 }
1168 }
1169
1170 #[test]
1171 fn test_repository_manager_concurrent_access() {
1172 let manager = Arc::new(RepositoryManager::new());
1173 let mut handles = vec![];
1174
1175 for _ in 0..3 {
1177 let mgr = Arc::clone(&manager);
1178 let handle = std::thread::spawn(move || {
1179 let result = mgr.get("test");
1180 assert!(result.is_none());
1181 });
1182 handles.push(handle);
1183 }
1184
1185 for h in handles {
1186 h.join().unwrap();
1187 }
1188 }
1189}