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::capabilities::ServerCapabilities;
18use crate::repository::{
19 series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
20 OnlineRepository, StreamSelection,
21};
22
23pub struct RepositoryManager {
25 repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
26}
27
28impl RepositoryManager {
29 pub fn new() -> Self {
30 Self {
31 repositories: Arc::new(Mutex::new(HashMap::new())),
32 }
33 }
34
35 pub fn create(&self, handle: String, repository: HybridRepository) {
36 let mut repos = self.repositories.lock_safe();
37 repos.insert(handle, Arc::new(repository));
38 }
39
40 pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
41 let repos = self.repositories.lock_safe();
42 repos.get(handle).cloned()
43 }
44
45 pub fn handles(&self) -> Vec<String> {
54 let repos = self.repositories.lock_safe();
55 repos.keys().cloned().collect()
56 }
57
58 pub fn destroy(&self, handle: &str) {
59 let mut repos = self.repositories.lock_safe();
60 repos.remove(handle);
61 }
62}
63
64pub struct RepositoryManagerWrapper(pub RepositoryManager);
66
67async fn server_capabilities(
76 db: &Arc<crate::storage::db_service::RusqliteService>,
77 server_id: &str,
78) -> ServerCapabilities {
79 use crate::storage::db_service::{DatabaseService, Query, QueryParam};
80
81 let reported: Option<String> = db
82 .query_one(
83 Query::with_params(
84 "SELECT version FROM servers WHERE id = ?1",
85 vec![QueryParam::String(server_id.to_string())],
86 ),
87 |row| row.get::<_, Option<String>>(0),
88 )
89 .await
90 .ok()
91 .flatten();
92
93 match reported {
94 Some(version) => ServerCapabilities::from_reported(version.as_str()),
95 None => {
96 debug!("[REPO] No server version recorded for {server_id}; assuming current target");
97 ServerCapabilities::assumed()
98 }
99 }
100}
101
102async fn invalidate_cache_on_generation_change(
113 db: &Arc<crate::storage::db_service::RusqliteService>,
114 server_id: &str,
115 generation: crate::repository::capabilities::ServerGeneration,
116) -> bool {
117 use crate::storage::db_service::{DatabaseService, Query, QueryParam};
118
119 let current = format!("{generation:?}");
120
121 let previous: Option<String> = db
122 .query_one(
123 Query::with_params(
124 "SELECT catalog_generation FROM servers WHERE id = ?1",
125 vec![QueryParam::String(server_id.to_string())],
126 ),
127 |row| row.get::<_, Option<String>>(0),
128 )
129 .await
130 .ok()
131 .flatten();
132
133 let changed = matches!(previous.as_deref(), Some(prev) if prev != current);
134
135 if changed {
136 warn!(
137 "[REPO] Server generation changed ({:?} -> {}); dropping the cached catalog so it \
138 is re-fetched under the new generation's shapes",
139 previous, current
140 );
141 if let Err(e) = db
142 .execute(Query::with_params(
143 "UPDATE items SET synced_at = NULL WHERE server_id = ?1",
144 vec![QueryParam::String(server_id.to_string())],
145 ))
146 .await
147 {
148 error!("[REPO] Failed to invalidate cached catalog: {e}");
151 }
152 }
153
154 if previous.as_deref() != Some(current.as_str()) {
155 if let Err(e) = db
156 .execute(Query::with_params(
157 "UPDATE servers SET catalog_generation = ?1 WHERE id = ?2",
158 vec![
159 QueryParam::String(current),
160 QueryParam::String(server_id.to_string()),
161 ],
162 ))
163 .await
164 {
165 error!("[REPO] Failed to record server generation: {e}");
166 }
167 }
168
169 changed
170}
171
172#[tauri::command]
175#[specta::specta]
176#[allow(clippy::too_many_arguments)]
180pub async fn repository_create(
181 manager: State<'_, RepositoryManagerWrapper>,
182 player: State<'_, crate::commands::player::PlayerStateWrapper>,
183 db: State<'_, crate::commands::storage::DatabaseWrapper>,
184 connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>,
185 server_url: String,
186 user_id: String,
187 access_token: String,
188 server_id: String,
189) -> Result<String, String> {
190 info!("[REPO] repository_create called for user: {}", user_id);
191
192 debug!("[REPO] Creating HTTP client...");
194 let http_config = crate::jellyfin::HttpConfig::default();
195 let http_client = HttpClient::new(http_config).map_err(|e| {
196 error!("[REPO] HTTP client creation failed: {}", e);
197 e.to_string()
198 })?;
199 debug!("[REPO] HTTP client created successfully");
200
201 let connectivity_reporter = {
205 let monitor = connectivity.0.lock().await;
206 monitor.reporter()
207 };
208
209 debug!("[REPO] Creating database service...");
211 let db_service = {
212 let database = db.0.lock().map_err(|e| {
213 error!("[REPO] Database lock failed: {}", e);
214 e.to_string()
215 })?;
216 debug!("[REPO] Database lock acquired, getting service...");
217 Arc::new(database.service())
218 }; debug!("[REPO] Database service created");
220
221 let capabilities = server_capabilities(&db_service, &server_id).await;
230 info!(
231 "[REPO] Server generation: {:?} (reported {:?})",
232 capabilities.generation,
233 capabilities.version.as_ref().map(|v| v.raw.as_str())
234 );
235
236 invalidate_cache_on_generation_change(&db_service, &server_id, capabilities.generation).await;
239
240 debug!("[REPO] Creating online repository...");
242 let online = OnlineRepository::new(
243 Arc::new(http_client),
244 server_url,
245 user_id.clone(),
246 access_token,
247 )
248 .with_connectivity(connectivity_reporter)
249 .with_capabilities(capabilities);
250 debug!("[REPO] Online repository created");
251
252 debug!("[REPO] Creating offline repository...");
253 let offline = OfflineRepository::new(db_service, server_id, user_id);
254 debug!("[REPO] Offline repository created");
255
256 debug!("[REPO] Creating hybrid repository...");
258 let hybrid = HybridRepository::new(online, offline);
259 debug!("[REPO] Hybrid repository created");
260
261 let uuid = Uuid::new_v4();
263 let handle = format!("{}", uuid);
264 info!("[REPO] Generated handle: {}", handle);
265
266 debug!("[REPO] Storing repository...");
268 manager.0.create(handle.clone(), hybrid);
269 info!("[REPO] Repository stored successfully");
270
271 if let Some(repo) = manager.0.get(&handle) {
275 let controller = player.0.lock().await;
276 controller.set_repository(repo);
277 }
278
279 Ok(handle)
280}
281
282#[tauri::command]
284#[specta::specta]
285pub async fn repository_destroy(
286 manager: State<'_, RepositoryManagerWrapper>,
287 handle: String,
288) -> Result<(), String> {
289 manager.0.destroy(&handle);
290 Ok(())
291}
292
293#[tauri::command]
295#[specta::specta]
296pub async fn repository_get_libraries(
297 manager: State<'_, RepositoryManagerWrapper>,
298 handle: String,
299) -> Result<Vec<Library>, String> {
300 debug!("[REPO] get_libraries called with handle: {}", handle);
301 let repo = manager.0.get(&handle).ok_or_else(|| {
302 error!("[REPO] Repository not found for handle: {}", handle);
303 "Repository not found".to_string()
304 })?;
305 debug!("[REPO] Repository found, fetching libraries...");
306 repo.as_ref().get_libraries().await.map_err(|e| {
307 error!("[REPO] Error fetching libraries: {:?}", e);
308 format!("{:?}", e)
309 })
310}
311
312#[tauri::command]
314#[specta::specta]
315pub async fn repository_get_items(
316 manager: State<'_, RepositoryManagerWrapper>,
317 handle: String,
318 parent_id: String,
319 options: Option<GetItemsOptions>,
320) -> Result<SearchResult, String> {
321 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
322 repo.as_ref()
323 .get_items(&parent_id, options)
324 .await
325 .map_err(|e| format!("{:?}", e))
326}
327
328#[tauri::command]
330#[specta::specta]
331pub async fn repository_get_item(
332 manager: State<'_, RepositoryManagerWrapper>,
333 handle: String,
334 item_id: String,
335) -> Result<MediaItem, String> {
336 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
337 repo.as_ref()
338 .get_item(&item_id)
339 .await
340 .map_err(|e| format!("{:?}", e))
341}
342
343#[tauri::command]
350#[specta::specta]
351pub async fn repository_get_downloaded_libraries(
352 manager: State<'_, RepositoryManagerWrapper>,
353 handle: String,
354) -> Result<Vec<Library>, String> {
355 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
356 repo.get_downloaded_libraries()
357 .await
358 .map_err(|e| format!("{:?}", e))
359}
360
361#[tauri::command]
365#[specta::specta]
366pub async fn repository_get_downloaded_items(
367 manager: State<'_, RepositoryManagerWrapper>,
368 handle: String,
369 parent_id: String,
370 options: Option<GetItemsOptions>,
371) -> Result<SearchResult, String> {
372 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
373 repo.get_downloaded_items(&parent_id, options)
374 .await
375 .map_err(|e| format!("{:?}", e))
376}
377
378#[tauri::command]
382#[specta::specta]
383pub async fn repository_get_download_disk_usage(
384 manager: State<'_, RepositoryManagerWrapper>,
385 handle: String,
386) -> Result<DownloadDiskUsage, String> {
387 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
388 repo.get_download_disk_usage()
389 .await
390 .map_err(|e| format!("{:?}", e))
391}
392
393#[tauri::command]
397#[specta::specta]
398pub async fn repository_jray_actors_at(
399 manager: State<'_, RepositoryManagerWrapper>,
400 handle: String,
401 item_id: String,
402 t: f64,
403) -> Result<Vec<crate::repository::JRayActor>, String> {
404 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
405 repo.as_ref()
406 .get_jray_actors(&item_id, t)
407 .await
408 .map_err(|e| format!("{:?}", e))
409}
410
411#[tauri::command]
413#[specta::specta]
414pub async fn repository_get_latest_items(
415 manager: State<'_, RepositoryManagerWrapper>,
416 handle: String,
417 parent_id: String,
418 limit: Option<usize>,
419) -> Result<Vec<MediaItem>, String> {
420 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
421 repo.as_ref()
422 .get_latest_items(&parent_id, limit)
423 .await
424 .map_err(|e| format!("{:?}", e))
425}
426
427#[tauri::command]
435#[specta::specta]
436pub async fn repository_get_resume_items(
437 manager: State<'_, RepositoryManagerWrapper>,
438 handle: String,
439 parent_id: Option<String>,
440 limit: Option<usize>,
441) -> Result<Vec<MediaItem>, String> {
442 debug!("[REPO] get_resume_items called with handle: {}", handle);
443 let repo = manager.0.get(&handle).ok_or_else(|| {
444 error!("[REPO] Repository not found for handle: {}", handle);
445 "Repository not found".to_string()
446 })?;
447 debug!("[REPO] Repository found, fetching resume items...");
448 repo.as_ref()
449 .get_resume_items(parent_id.as_deref(), limit)
450 .await
451 .map_err(|e| {
452 error!("[REPO] Error fetching resume items: {:?}", e);
453 format!("{:?}", e)
454 })
455}
456
457#[tauri::command]
461#[specta::specta]
462pub async fn repository_get_next_up_episodes(
463 manager: State<'_, RepositoryManagerWrapper>,
464 handle: String,
465 series_id: Option<String>,
466 limit: Option<usize>,
467) -> Result<Vec<MediaItem>, String> {
468 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
469 repo.as_ref()
470 .get_next_up_episodes(series_id.as_deref(), limit)
471 .await
472 .map_err(|e| format!("{:?}", e))
473}
474
475#[tauri::command]
484#[specta::specta]
485pub async fn repository_get_series_episodes(
486 manager: State<'_, RepositoryManagerWrapper>,
487 handle: String,
488 series_id: String,
489) -> Result<Vec<MediaItem>, String> {
490 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
491 series_progress::fetch_series_episodes(repo.as_ref(), &series_id)
492 .await
493 .map_err(|e| format!("{:?}", e))
494}
495
496#[tauri::command]
505#[specta::specta]
506pub async fn repository_get_series_current_episode(
507 manager: State<'_, RepositoryManagerWrapper>,
508 handle: String,
509 series_id: String,
510) -> Result<Option<MediaItem>, String> {
511 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
512 series_progress::resolve_current_episode(repo.as_ref(), &series_id)
513 .await
514 .map_err(|e| format!("{:?}", e))
515}
516
517#[tauri::command]
527#[specta::specta]
528pub async fn repository_clear_watch_history(
529 manager: State<'_, RepositoryManagerWrapper>,
530 handle: String,
531 item_id: String,
532) -> Result<(), String> {
533 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
534 repo.as_ref()
535 .clear_watch_history(&item_id)
536 .await
537 .map_err(|e| format!("{:?}", e))
538}
539
540#[tauri::command]
542#[specta::specta]
543pub async fn repository_get_recently_played_audio(
544 manager: State<'_, RepositoryManagerWrapper>,
545 handle: String,
546 limit: Option<usize>,
547) -> Result<Vec<MediaItem>, String> {
548 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
549 repo.as_ref()
550 .get_recently_played_audio(limit)
551 .await
552 .map_err(|e| format!("{:?}", e))
553}
554
555#[tauri::command]
557#[specta::specta]
558pub async fn repository_get_resume_movies(
559 manager: State<'_, RepositoryManagerWrapper>,
560 handle: String,
561 limit: Option<usize>,
562) -> Result<Vec<MediaItem>, String> {
563 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
564 repo.as_ref()
565 .get_resume_movies(limit)
566 .await
567 .map_err(|e| format!("{:?}", e))
568}
569
570#[tauri::command]
572#[specta::specta]
573pub async fn repository_get_rediscover_albums(
574 manager: State<'_, RepositoryManagerWrapper>,
575 handle: String,
576 parent_id: Option<String>,
577 limit: Option<usize>,
578) -> Result<Vec<MediaItem>, String> {
579 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
580 repo.as_ref()
581 .get_rediscover_albums(parent_id.as_deref(), limit)
582 .await
583 .map_err(|e| format!("{:?}", e))
584}
585
586#[tauri::command]
588#[specta::specta]
589pub async fn repository_get_genres(
590 manager: State<'_, RepositoryManagerWrapper>,
591 handle: String,
592 parent_id: Option<String>,
593) -> Result<Vec<Genre>, String> {
594 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
595 repo.as_ref()
596 .get_genres(parent_id.as_deref())
597 .await
598 .map_err(|e| format!("{:?}", e))
599}
600
601pub const SEARCH_EVENT_NAME: &str = "search-event";
603
604#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
609#[serde(rename_all = "camelCase")]
610pub struct SearchUpdateEvent {
611 pub request_id: u32,
612 pub result: SearchResult,
613}
614
615#[tauri::command]
622#[specta::specta]
623pub async fn repository_search(
624 app: AppHandle,
625 manager: State<'_, RepositoryManagerWrapper>,
626 handle: String,
627 query: String,
628 options: Option<SearchOptions>,
629 request_id: u32,
630) -> Result<SearchResult, String> {
631 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
632
633 let options = options.map(|mut o| {
639 o.resolve_scope();
640 o
641 });
642
643 let mut cache_result = repo
646 .search_cache_only(&query, options.clone())
647 .await
648 .unwrap_or_else(|e| {
649 debug!("[Search] Cache search miss/timeout: {:?}", e);
650 SearchResult {
651 items: Vec::new(),
652 total_record_count: 0,
653 }
654 });
655
656 rank_search_results(&mut cache_result.items, &query);
661
662 let repo_bg = repo.clone();
666 let cache_for_merge = cache_result.clone();
667 tauri::async_runtime::spawn(async move {
668 match repo_bg.search_server_only(&query, options).await {
669 Ok(server_result) => {
670 let mut merged =
671 HybridRepository::merge_search_results(cache_for_merge, server_result);
672 rank_search_results(&mut merged.items, &query);
675 let event = SearchUpdateEvent {
676 request_id,
677 result: merged,
678 };
679 if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) {
680 error!("[Search] Failed to emit search update: {}", e);
681 }
682 }
683 Err(e) => {
684 warn!(
687 "[Search] Server search failed, keeping cache results: {:?}",
688 e
689 );
690 }
691 }
692 });
693
694 Ok(cache_result)
695}
696
697#[tauri::command]
699#[specta::specta]
700pub async fn repository_get_playback_info(
701 manager: State<'_, RepositoryManagerWrapper>,
702 handle: String,
703 item_id: String,
704) -> Result<PlaybackInfo, String> {
705 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
706 repo.as_ref()
707 .get_playback_info(&item_id)
708 .await
709 .map_err(|e| format!("{:?}", e))
710}
711
712#[tauri::command]
720#[specta::specta]
721pub async fn repository_get_video_stream_url(
722 manager: State<'_, RepositoryManagerWrapper>,
723 handle: String,
724 item_id: String,
725 media_source_id: Option<String>,
726 audio_stream_index: Option<i32>,
727) -> Result<String, String> {
728 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
729 repo.as_ref()
730 .get_video_stream_url(&item_id, media_source_id.as_deref(), audio_stream_index)
731 .await
732 .map_err(|e| format!("{:?}", e))
733}
734
735#[tauri::command]
749#[specta::specta]
750pub async fn repository_get_stream_selection(
751 manager: State<'_, RepositoryManagerWrapper>,
752 handle: String,
753 item_id: String,
754 media_source_id: Option<String>,
755 audio_stream_index: Option<i32>,
756) -> Result<StreamSelection, String> {
757 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
758 repo.as_ref()
759 .get_stream_selection(&item_id, media_source_id.as_deref(), audio_stream_index)
760 .await
761 .map_err(|e| format!("{:?}", e))
762}
763
764#[tauri::command]
768#[specta::specta]
769pub async fn repository_get_audio_only_stream_url_for_video(
770 manager: State<'_, RepositoryManagerWrapper>,
771 handle: String,
772 item_id: String,
773 media_source_id: Option<String>,
774 start_time_seconds: Option<f64>,
775 audio_stream_index: Option<i32>,
776) -> Result<String, String> {
777 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
778 repo.as_ref()
779 .get_audio_only_stream_url_for_video(
780 &item_id,
781 media_source_id.as_deref(),
782 start_time_seconds,
783 audio_stream_index,
784 )
785 .await
786 .map_err(|e| format!("{:?}", e))
787}
788
789#[tauri::command]
791#[specta::specta]
792pub async fn repository_get_audio_stream_url(
793 manager: State<'_, RepositoryManagerWrapper>,
794 handle: String,
795 item_id: String,
796) -> Result<String, String> {
797 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
798 repo.as_ref()
799 .get_audio_stream_url(&item_id)
800 .await
801 .map_err(|e| format!("{:?}", e))
802}
803
804#[tauri::command]
806#[specta::specta]
807pub async fn repository_get_live_tv_channels(
808 manager: State<'_, RepositoryManagerWrapper>,
809 handle: String,
810) -> Result<Vec<MediaItem>, String> {
811 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
812 repo.as_ref()
813 .get_live_tv_channels()
814 .await
815 .map_err(|e| format!("{:?}", e))
816}
817
818#[tauri::command]
820#[specta::specta]
821pub async fn repository_get_channels(
822 manager: State<'_, RepositoryManagerWrapper>,
823 handle: String,
824) -> Result<SearchResult, String> {
825 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
826 repo.as_ref()
827 .get_channels()
828 .await
829 .map_err(|e| format!("{:?}", e))
830}
831
832#[tauri::command]
834#[specta::specta]
835pub async fn repository_open_live_stream(
836 manager: State<'_, RepositoryManagerWrapper>,
837 handle: String,
838 item_id: String,
839) -> Result<LiveStreamInfo, String> {
840 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
841 repo.as_ref()
842 .open_live_stream(&item_id)
843 .await
844 .map_err(|e| format!("{:?}", e))
845}
846
847#[tauri::command]
849#[specta::specta]
850pub async fn repository_report_playback_start(
851 manager: State<'_, RepositoryManagerWrapper>,
852 handle: String,
853 item_id: String,
854 position_ms: i64,
855) -> Result<(), String> {
856 let position_ticks = position_ms * 10_000;
857 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
858 repo.as_ref()
859 .report_playback_start(&item_id, position_ticks)
860 .await
861 .map_err(|e| format!("{:?}", e))
862}
863
864#[tauri::command]
866#[specta::specta]
867pub async fn repository_report_playback_progress(
868 manager: State<'_, RepositoryManagerWrapper>,
869 handle: String,
870 item_id: String,
871 position_ms: i64,
872) -> Result<(), String> {
873 let position_ticks = position_ms * 10_000;
874 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
875 repo.as_ref()
876 .report_playback_progress(&item_id, position_ticks)
877 .await
878 .map_err(|e| format!("{:?}", e))
879}
880
881#[tauri::command]
892#[specta::specta]
893pub async fn repository_report_playback_stopped(
894 db: State<'_, crate::commands::storage::DatabaseWrapper>,
895 manager: State<'_, RepositoryManagerWrapper>,
896 handle: String,
897 item_id: String,
898 position_ms: i64,
899) -> Result<(), String> {
900 let position_ticks = position_ms * 10_000;
902 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
903
904 let result = repo
905 .as_ref()
906 .report_playback_stopped(&item_id, position_ticks)
907 .await;
908
909 if let Err(e) = &result {
910 let db_service = {
911 let database = db.0.lock().map_err(|err| err.to_string())?;
912 Arc::new(database.service())
913 };
914 let user_id = repo.user_id().to_string();
915 if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
916 &db_service,
917 &user_id,
918 &item_id,
919 position_ticks,
920 )
921 .await
922 {
923 warn!(
924 "[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
925 item_id, e, queue_err
926 );
927 } else {
928 debug!(
929 "[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
930 item_id, e
931 );
932 }
933 }
934
935 result.map_err(|e| format!("{:?}", e))
936}
937
938#[tauri::command]
940#[specta::specta]
941pub fn repository_get_image_url(
942 manager: State<'_, RepositoryManagerWrapper>,
943 handle: String,
944 item_id: String,
945 image_type: ImageType,
946 options: Option<ImageOptions>,
947) -> Result<String, String> {
948 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
949 Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
950}
951
952#[tauri::command]
954#[specta::specta]
955#[allow(dead_code)]
956pub fn repository_get_subtitle_url(
957 manager: State<'_, RepositoryManagerWrapper>,
958 handle: String,
959 item_id: String,
960 media_source_id: String,
961 stream_index: i32,
962 format: String,
963) -> Result<String, String> {
964 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
965 Ok(repo
966 .as_ref()
967 .get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
968}
969
970#[tauri::command]
972#[specta::specta]
973#[allow(dead_code)]
974pub async fn repository_get_video_download_url(
975 manager: State<'_, RepositoryManagerWrapper>,
976 handle: String,
977 item_id: String,
978 quality: String,
979 media_source_id: Option<String>,
980) -> Result<String, String> {
981 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
982 Ok(crate::repository::resolve_video_download_url(
986 repo.as_ref(),
987 &item_id,
988 &quality,
989 media_source_id.as_deref(),
990 )
991 .await)
992}
993
994#[tauri::command]
996#[specta::specta]
997pub async fn repository_mark_favorite(
998 manager: State<'_, RepositoryManagerWrapper>,
999 handle: String,
1000 item_id: String,
1001) -> Result<(), String> {
1002 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1003 repo.as_ref()
1004 .mark_favorite(&item_id)
1005 .await
1006 .map_err(|e| format!("{:?}", e))
1007}
1008
1009pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
1015
1016#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
1021#[serde(rename_all = "camelCase")]
1022pub struct FavoritesChangedEvent {
1023 pub item_ids: Vec<String>,
1024}
1025
1026fn changed_favorite_ids(
1036 cached: &std::collections::HashSet<String>,
1037 server: &std::collections::HashSet<String>,
1038) -> Vec<String> {
1039 let mut changed: Vec<String> = server.symmetric_difference(cached).cloned().collect();
1040 changed.sort();
1042 changed
1043}
1044
1045#[tauri::command]
1055#[specta::specta]
1056pub async fn repository_get_favorites(
1057 app: AppHandle,
1058 manager: State<'_, RepositoryManagerWrapper>,
1059 handle: String,
1060 scope: SearchScope,
1061 options: Option<GetItemsOptions>,
1062) -> Result<SearchResult, String> {
1063 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1064
1065 let cache_result = repo
1066 .get_favorites_cache_only(scope, options.clone())
1067 .await
1068 .unwrap_or_else(|e| {
1069 debug!("[Favorites] Cache miss/timeout: {:?}", e);
1070 SearchResult {
1071 items: Vec::new(),
1072 total_record_count: 0,
1073 }
1074 });
1075
1076 if !crate::repository::offline::include_catalog_browse() {
1079 return Ok(cache_result);
1080 }
1081
1082 if !cache_result.has_content() {
1092 debug!("[Favorites] Nothing cached; answering from the server");
1093 return repo
1094 .get_favorites(scope, options)
1095 .await
1096 .map_err(|e| format!("{:?}", e));
1097 }
1098
1099 let repo_bg = repo.clone();
1100 let cached_ids: std::collections::HashSet<String> =
1101 cache_result.items.iter().map(|i| i.id.clone()).collect();
1102 tauri::async_runtime::spawn(async move {
1103 match repo_bg.get_favorites_server_only(scope, options).await {
1104 Ok(server_result) => {
1105 let server_ids: std::collections::HashSet<String> =
1106 server_result.items.iter().map(|i| i.id.clone()).collect();
1107 let changed = changed_favorite_ids(&cached_ids, &server_ids);
1108
1109 if !changed.is_empty() {
1110 let event = FavoritesChangedEvent { item_ids: changed };
1111 if let Err(e) = app.emit(FAVORITES_CHANGED_EVENT, &event) {
1112 error!("[Favorites] Failed to emit change event: {}", e);
1113 }
1114 }
1115 }
1116 Err(e) => {
1117 warn!(
1118 "[Favorites] Server refresh failed, keeping cached favourites: {:?}",
1119 e
1120 );
1121 }
1122 }
1123 });
1124
1125 Ok(cache_result)
1126}
1127
1128#[tauri::command]
1130#[specta::specta]
1131pub async fn repository_unmark_favorite(
1132 manager: State<'_, RepositoryManagerWrapper>,
1133 handle: String,
1134 item_id: String,
1135) -> Result<(), String> {
1136 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1137 repo.as_ref()
1138 .unmark_favorite(&item_id)
1139 .await
1140 .map_err(|e| format!("{:?}", e))
1141}
1142
1143#[tauri::command]
1145#[specta::specta]
1146pub async fn repository_get_person(
1147 manager: State<'_, RepositoryManagerWrapper>,
1148 handle: String,
1149 person_id: String,
1150) -> Result<MediaItem, String> {
1151 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1152 repo.as_ref()
1153 .get_person(&person_id)
1154 .await
1155 .map_err(|e| format!("{:?}", e))
1156}
1157
1158#[tauri::command]
1160#[specta::specta]
1161pub async fn repository_get_items_by_person(
1162 manager: State<'_, RepositoryManagerWrapper>,
1163 handle: String,
1164 person_id: String,
1165 options: Option<GetItemsOptions>,
1166) -> Result<SearchResult, String> {
1167 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1168 repo.as_ref()
1169 .get_items_by_person(&person_id, options)
1170 .await
1171 .map_err(|e| format!("{:?}", e))
1172}
1173
1174#[tauri::command]
1176#[specta::specta]
1177pub async fn repository_get_similar_items(
1178 manager: State<'_, RepositoryManagerWrapper>,
1179 handle: String,
1180 item_id: String,
1181 limit: Option<usize>,
1182) -> Result<SearchResult, String> {
1183 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1184 repo.as_ref()
1185 .get_similar_items(&item_id, limit)
1186 .await
1187 .map_err(|e| format!("{:?}", e))
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192 use super::*;
1193
1194 #[test]
1195 fn test_repository_manager_creation() {
1196 let manager = RepositoryManager::new();
1197 assert!(manager.get("any-handle").is_none());
1199 }
1200
1201 fn ids(values: &[&str]) -> std::collections::HashSet<String> {
1202 values.iter().map(|v| v.to_string()).collect()
1203 }
1204
1205 #[test]
1209 fn test_changed_favorite_ids_reports_both_directions() {
1210 assert_eq!(
1212 changed_favorite_ids(&ids(&["a"]), &ids(&["a", "b"])),
1213 vec!["b".to_string()]
1214 );
1215
1216 assert_eq!(
1218 changed_favorite_ids(&ids(&["a", "b"]), &ids(&["a"])),
1219 vec!["b".to_string()]
1220 );
1221
1222 assert_eq!(
1224 changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "c"])),
1225 vec!["a".to_string(), "c".to_string()]
1226 );
1227 }
1228
1229 #[test]
1234 fn test_changed_favorite_ids_is_empty_when_nothing_moved() {
1235 assert!(changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "a"])).is_empty());
1236 assert!(changed_favorite_ids(&ids(&[]), &ids(&[])).is_empty());
1237 }
1238
1239 #[test]
1240 fn test_repository_manager_wrapper_structure() {
1241 let manager = RepositoryManager::new();
1242 let wrapper = RepositoryManagerWrapper(manager);
1243 assert!(wrapper.0.get("any-handle").is_none());
1245 }
1246
1247 #[test]
1248 fn test_repository_manager_get_nonexistent() {
1249 let manager = RepositoryManager::new();
1250 let result = manager.get("nonexistent-handle");
1252 assert!(result.is_none());
1253 }
1254
1255 #[test]
1256 fn test_uuid_handle_generation() {
1257 let uuid = Uuid::new_v4();
1258 let handle = format!("{}", uuid);
1259 assert!(!handle.is_empty());
1261 }
1262
1263 #[test]
1264 fn test_uuid_handles_are_unique() {
1265 let handle1 = format!("{}", Uuid::new_v4());
1266 let handle2 = format!("{}", Uuid::new_v4());
1267 assert_ne!(handle1, handle2);
1269 }
1270
1271 #[test]
1272 fn test_uuid_handle_format() {
1273 let uuid = Uuid::new_v4();
1274 let handle = format!("{}", uuid);
1275 let parts: Vec<&str> = handle.split('-').collect();
1277 assert_eq!(parts.len(), 5);
1278 }
1279
1280 #[test]
1281 fn test_repository_manager_destroy_nonexistent() {
1282 let manager = RepositoryManager::new();
1283 manager.destroy("nonexistent-handle");
1285 }
1286
1287 #[test]
1288 fn test_repository_manager_is_send_sync() {
1289 fn is_send_sync<T: Send + Sync>() {}
1291 is_send_sync::<RepositoryManager>();
1292 }
1293
1294 #[test]
1295 fn test_repository_manager_wrapper_is_send_sync() {
1296 fn is_send_sync<T: Send + Sync>() {}
1298 is_send_sync::<RepositoryManagerWrapper>();
1299 }
1300
1301 #[test]
1302 fn test_multiple_manager_instances() {
1303 let manager1 = RepositoryManager::new();
1304 let manager2 = RepositoryManager::new();
1305
1306 let handle1_nonexistent = manager1.get("test");
1308 let handle2_nonexistent = manager2.get("test");
1309
1310 assert!(handle1_nonexistent.is_none());
1311 assert!(handle2_nonexistent.is_none());
1312 }
1313
1314 #[test]
1315 fn test_handle_string_properties() {
1316 let uuid = Uuid::new_v4();
1317 let handle = format!("{}", uuid);
1318
1319 for c in handle.chars() {
1321 assert!(c.is_alphanumeric() || c == '-');
1322 }
1323 }
1324
1325 #[test]
1326 fn test_repository_manager_concurrent_access() {
1327 let manager = Arc::new(RepositoryManager::new());
1328 let mut handles = vec![];
1329
1330 for _ in 0..3 {
1332 let mgr = Arc::clone(&manager);
1333 let handle = std::thread::spawn(move || {
1334 let result = mgr.get("test");
1335 assert!(result.is_none());
1336 });
1337 handles.push(handle);
1338 }
1339
1340 for h in handles {
1341 h.join().unwrap();
1342 }
1343 }
1344}
1345
1346#[cfg(test)]
1347mod generation_change_tests {
1348 use super::*;
1349 use crate::repository::capabilities::ServerGeneration;
1350 use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
1351
1352 async fn db_with_server() -> Arc<RusqliteService> {
1353 let conn = rusqlite::Connection::open_in_memory().expect("in-memory db");
1354 for (_, sql) in crate::storage::schema::MIGRATIONS {
1355 conn.execute_batch(sql).expect("migration");
1356 }
1357 let db = Arc::new(RusqliteService::new(Arc::new(std::sync::Mutex::new(conn))));
1358 db.execute(Query::with_params(
1359 "INSERT INTO servers (id, name, url, version) VALUES (?1, ?2, ?3, ?4)",
1360 vec![
1361 QueryParam::String("srv-1".into()),
1362 QueryParam::String("Home".into()),
1363 QueryParam::String("https://example.test".into()),
1364 QueryParam::String("10.11.5".into()),
1365 ],
1366 ))
1367 .await
1368 .expect("seed server");
1369 db
1370 }
1371
1372 async fn recorded(db: &Arc<RusqliteService>) -> Option<String> {
1373 db.query_one(
1374 Query::new("SELECT catalog_generation FROM servers WHERE id = 'srv-1'"),
1375 |row| row.get::<_, Option<String>>(0),
1376 )
1377 .await
1378 .ok()
1379 .flatten()
1380 }
1381
1382 #[tokio::test]
1388 async fn the_first_look_records_without_invalidating() {
1389 let db = db_with_server().await;
1390 let invalidated =
1391 invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1392
1393 assert!(!invalidated, "a first sighting is not a change");
1394 assert_eq!(recorded(&db).await.as_deref(), Some("V10_11"));
1395 }
1396
1397 #[tokio::test]
1401 async fn an_unchanged_generation_does_not_invalidate() {
1402 let db = db_with_server().await;
1403 invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1404 let invalidated =
1405 invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1406
1407 assert!(!invalidated);
1408 assert_eq!(recorded(&db).await.as_deref(), Some("V10_11"));
1409 }
1410
1411 #[tokio::test]
1416 async fn a_real_upgrade_invalidates_and_records() {
1417 let db = db_with_server().await;
1418 invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1419
1420 let invalidated =
1421 invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V12Plus).await;
1422
1423 assert!(invalidated, "10.11 -> 12.x is a generation change");
1424 assert_eq!(recorded(&db).await.as_deref(), Some("V12Plus"));
1425 }
1426
1427 #[tokio::test]
1431 async fn an_unknown_server_is_harmless() {
1432 let db = db_with_server().await;
1433 let invalidated =
1434 invalidate_cache_on_generation_change(&db, "no-such-server", ServerGeneration::V12Plus)
1435 .await;
1436 assert!(!invalidated);
1437 }
1438}