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]
523#[specta::specta]
524pub async fn repository_get_series_view(
525 manager: State<'_, RepositoryManagerWrapper>,
526 handle: String,
527 series_id: String,
528) -> Result<series_progress::SeriesView, String> {
529 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
530 series_progress::resolve_series_view(repo.as_ref(), &series_id)
531 .await
532 .map_err(|e| format!("{:?}", e))
533}
534
535#[tauri::command]
545#[specta::specta]
546pub async fn repository_clear_watch_history(
547 manager: State<'_, RepositoryManagerWrapper>,
548 handle: String,
549 item_id: String,
550) -> Result<(), String> {
551 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
552 repo.as_ref()
553 .clear_watch_history(&item_id)
554 .await
555 .map_err(|e| format!("{:?}", e))
556}
557
558#[tauri::command]
560#[specta::specta]
561pub async fn repository_get_recently_played_audio(
562 manager: State<'_, RepositoryManagerWrapper>,
563 handle: String,
564 limit: Option<usize>,
565) -> Result<Vec<MediaItem>, String> {
566 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
567 repo.as_ref()
568 .get_recently_played_audio(limit)
569 .await
570 .map_err(|e| format!("{:?}", e))
571}
572
573#[tauri::command]
575#[specta::specta]
576pub async fn repository_get_resume_movies(
577 manager: State<'_, RepositoryManagerWrapper>,
578 handle: String,
579 limit: Option<usize>,
580) -> Result<Vec<MediaItem>, String> {
581 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
582 repo.as_ref()
583 .get_resume_movies(limit)
584 .await
585 .map_err(|e| format!("{:?}", e))
586}
587
588#[tauri::command]
590#[specta::specta]
591pub async fn repository_get_rediscover_albums(
592 manager: State<'_, RepositoryManagerWrapper>,
593 handle: String,
594 parent_id: Option<String>,
595 limit: Option<usize>,
596) -> Result<Vec<MediaItem>, String> {
597 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
598 repo.as_ref()
599 .get_rediscover_albums(parent_id.as_deref(), limit)
600 .await
601 .map_err(|e| format!("{:?}", e))
602}
603
604#[tauri::command]
606#[specta::specta]
607pub async fn repository_get_genres(
608 manager: State<'_, RepositoryManagerWrapper>,
609 handle: String,
610 parent_id: Option<String>,
611) -> Result<Vec<Genre>, String> {
612 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
613 repo.as_ref()
614 .get_genres(parent_id.as_deref())
615 .await
616 .map_err(|e| format!("{:?}", e))
617}
618
619pub const SEARCH_EVENT_NAME: &str = "search-event";
621
622#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
627#[serde(rename_all = "camelCase")]
628pub struct SearchUpdateEvent {
629 pub request_id: u32,
630 pub result: SearchResult,
631}
632
633#[tauri::command]
640#[specta::specta]
641pub async fn repository_search(
642 app: AppHandle,
643 manager: State<'_, RepositoryManagerWrapper>,
644 handle: String,
645 query: String,
646 options: Option<SearchOptions>,
647 request_id: u32,
648) -> Result<SearchResult, String> {
649 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
650
651 let options = options.map(|mut o| {
657 o.resolve_scope();
658 o
659 });
660
661 let mut cache_result = repo
664 .search_cache_only(&query, options.clone())
665 .await
666 .unwrap_or_else(|e| {
667 debug!("[Search] Cache search miss/timeout: {:?}", e);
668 SearchResult {
669 items: Vec::new(),
670 total_record_count: 0,
671 }
672 });
673
674 rank_search_results(&mut cache_result.items, &query);
679
680 let repo_bg = repo.clone();
684 let cache_for_merge = cache_result.clone();
685 tauri::async_runtime::spawn(async move {
686 match repo_bg.search_server_only(&query, options).await {
687 Ok(server_result) => {
688 let mut merged =
689 HybridRepository::merge_search_results(cache_for_merge, server_result);
690 rank_search_results(&mut merged.items, &query);
693 let event = SearchUpdateEvent {
694 request_id,
695 result: merged,
696 };
697 if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) {
698 error!("[Search] Failed to emit search update: {}", e);
699 }
700 }
701 Err(e) => {
702 warn!(
705 "[Search] Server search failed, keeping cache results: {:?}",
706 e
707 );
708 }
709 }
710 });
711
712 Ok(cache_result)
713}
714
715#[tauri::command]
717#[specta::specta]
718pub async fn repository_get_playback_info(
719 manager: State<'_, RepositoryManagerWrapper>,
720 handle: String,
721 item_id: String,
722) -> Result<PlaybackInfo, String> {
723 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
724 repo.as_ref()
725 .get_playback_info(&item_id)
726 .await
727 .map_err(|e| format!("{:?}", e))
728}
729
730#[tauri::command]
738#[specta::specta]
739pub async fn repository_get_video_stream_url(
740 manager: State<'_, RepositoryManagerWrapper>,
741 handle: String,
742 item_id: String,
743 media_source_id: Option<String>,
744 audio_stream_index: Option<i32>,
745) -> Result<String, String> {
746 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
747 repo.as_ref()
748 .get_video_stream_url(&item_id, media_source_id.as_deref(), audio_stream_index)
749 .await
750 .map_err(|e| format!("{:?}", e))
751}
752
753#[tauri::command]
767#[specta::specta]
768pub async fn repository_get_stream_selection(
769 manager: State<'_, RepositoryManagerWrapper>,
770 handle: String,
771 item_id: String,
772 media_source_id: Option<String>,
773 audio_stream_index: Option<i32>,
774) -> Result<StreamSelection, String> {
775 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
776 repo.as_ref()
777 .get_stream_selection(&item_id, media_source_id.as_deref(), audio_stream_index)
778 .await
779 .map_err(|e| format!("{:?}", e))
780}
781
782#[tauri::command]
786#[specta::specta]
787pub async fn repository_get_audio_only_stream_url_for_video(
788 manager: State<'_, RepositoryManagerWrapper>,
789 handle: String,
790 item_id: String,
791 media_source_id: Option<String>,
792 start_time_seconds: Option<f64>,
793 audio_stream_index: Option<i32>,
794) -> Result<String, String> {
795 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
796 repo.as_ref()
797 .get_audio_only_stream_url_for_video(
798 &item_id,
799 media_source_id.as_deref(),
800 start_time_seconds,
801 audio_stream_index,
802 )
803 .await
804 .map_err(|e| format!("{:?}", e))
805}
806
807#[tauri::command]
809#[specta::specta]
810pub async fn repository_get_audio_stream_url(
811 manager: State<'_, RepositoryManagerWrapper>,
812 handle: String,
813 item_id: String,
814) -> Result<String, String> {
815 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
816 repo.as_ref()
817 .get_audio_stream_url(&item_id)
818 .await
819 .map_err(|e| format!("{:?}", e))
820}
821
822#[tauri::command]
824#[specta::specta]
825pub async fn repository_get_live_tv_channels(
826 manager: State<'_, RepositoryManagerWrapper>,
827 handle: String,
828) -> Result<Vec<MediaItem>, String> {
829 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
830 repo.as_ref()
831 .get_live_tv_channels()
832 .await
833 .map_err(|e| format!("{:?}", e))
834}
835
836#[tauri::command]
838#[specta::specta]
839pub async fn repository_get_channels(
840 manager: State<'_, RepositoryManagerWrapper>,
841 handle: String,
842) -> Result<SearchResult, String> {
843 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
844 repo.as_ref()
845 .get_channels()
846 .await
847 .map_err(|e| format!("{:?}", e))
848}
849
850#[tauri::command]
852#[specta::specta]
853pub async fn repository_open_live_stream(
854 manager: State<'_, RepositoryManagerWrapper>,
855 handle: String,
856 item_id: String,
857) -> Result<LiveStreamInfo, String> {
858 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
859 repo.as_ref()
860 .open_live_stream(&item_id)
861 .await
862 .map_err(|e| format!("{:?}", e))
863}
864
865#[tauri::command]
867#[specta::specta]
868pub async fn repository_report_playback_start(
869 manager: State<'_, RepositoryManagerWrapper>,
870 handle: String,
871 item_id: String,
872 position_ms: i64,
873) -> Result<(), String> {
874 let position_ticks = position_ms * 10_000;
875 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
876 repo.as_ref()
877 .report_playback_start(&item_id, position_ticks)
878 .await
879 .map_err(|e| format!("{:?}", e))
880}
881
882#[tauri::command]
884#[specta::specta]
885pub async fn repository_report_playback_progress(
886 manager: State<'_, RepositoryManagerWrapper>,
887 handle: String,
888 item_id: String,
889 position_ms: i64,
890) -> Result<(), String> {
891 let position_ticks = position_ms * 10_000;
892 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
893 repo.as_ref()
894 .report_playback_progress(&item_id, position_ticks)
895 .await
896 .map_err(|e| format!("{:?}", e))
897}
898
899#[tauri::command]
910#[specta::specta]
911pub async fn repository_report_playback_stopped(
912 db: State<'_, crate::commands::storage::DatabaseWrapper>,
913 manager: State<'_, RepositoryManagerWrapper>,
914 handle: String,
915 item_id: String,
916 position_ms: i64,
917) -> Result<(), String> {
918 let position_ticks = position_ms * 10_000;
920 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
921
922 let result = repo
923 .as_ref()
924 .report_playback_stopped(&item_id, position_ticks)
925 .await;
926
927 if let Err(e) = &result {
928 let db_service = {
929 let database = db.0.lock().map_err(|err| err.to_string())?;
930 Arc::new(database.service())
931 };
932 let user_id = repo.user_id().to_string();
933 if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
934 &db_service,
935 &user_id,
936 &item_id,
937 position_ticks,
938 )
939 .await
940 {
941 warn!(
942 "[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
943 item_id, e, queue_err
944 );
945 } else {
946 debug!(
947 "[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
948 item_id, e
949 );
950 }
951 }
952
953 result.map_err(|e| format!("{:?}", e))
954}
955
956#[tauri::command]
958#[specta::specta]
959pub fn repository_get_image_url(
960 manager: State<'_, RepositoryManagerWrapper>,
961 handle: String,
962 item_id: String,
963 image_type: ImageType,
964 options: Option<ImageOptions>,
965) -> Result<String, String> {
966 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
967 Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
968}
969
970#[tauri::command]
972#[specta::specta]
973#[allow(dead_code)]
974pub fn repository_get_subtitle_url(
975 manager: State<'_, RepositoryManagerWrapper>,
976 handle: String,
977 item_id: String,
978 media_source_id: String,
979 stream_index: i32,
980 format: String,
981) -> Result<String, String> {
982 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
983 Ok(repo
984 .as_ref()
985 .get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
986}
987
988#[tauri::command]
990#[specta::specta]
991#[allow(dead_code)]
992pub async fn repository_get_video_download_url(
993 manager: State<'_, RepositoryManagerWrapper>,
994 handle: String,
995 item_id: String,
996 quality: String,
997 media_source_id: Option<String>,
998) -> Result<String, String> {
999 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1000 Ok(crate::repository::resolve_video_download_url(
1004 repo.as_ref(),
1005 &item_id,
1006 &quality,
1007 media_source_id.as_deref(),
1008 )
1009 .await)
1010}
1011
1012#[tauri::command]
1014#[specta::specta]
1015pub async fn repository_mark_favorite(
1016 manager: State<'_, RepositoryManagerWrapper>,
1017 handle: String,
1018 item_id: String,
1019) -> Result<(), String> {
1020 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1021 repo.as_ref()
1022 .mark_favorite(&item_id)
1023 .await
1024 .map_err(|e| format!("{:?}", e))
1025}
1026
1027pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
1033
1034#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
1039#[serde(rename_all = "camelCase")]
1040pub struct FavoritesChangedEvent {
1041 pub item_ids: Vec<String>,
1042}
1043
1044fn changed_favorite_ids(
1054 cached: &std::collections::HashSet<String>,
1055 server: &std::collections::HashSet<String>,
1056) -> Vec<String> {
1057 let mut changed: Vec<String> = server.symmetric_difference(cached).cloned().collect();
1058 changed.sort();
1060 changed
1061}
1062
1063#[tauri::command]
1073#[specta::specta]
1074pub async fn repository_get_favorites(
1075 app: AppHandle,
1076 manager: State<'_, RepositoryManagerWrapper>,
1077 handle: String,
1078 scope: SearchScope,
1079 options: Option<GetItemsOptions>,
1080) -> Result<SearchResult, String> {
1081 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1082
1083 let cache_result = repo
1084 .get_favorites_cache_only(scope, options.clone())
1085 .await
1086 .unwrap_or_else(|e| {
1087 debug!("[Favorites] Cache miss/timeout: {:?}", e);
1088 SearchResult {
1089 items: Vec::new(),
1090 total_record_count: 0,
1091 }
1092 });
1093
1094 if !crate::repository::offline::include_catalog_browse() {
1097 return Ok(cache_result);
1098 }
1099
1100 if !cache_result.has_content() {
1110 debug!("[Favorites] Nothing cached; answering from the server");
1111 return repo
1112 .get_favorites(scope, options)
1113 .await
1114 .map_err(|e| format!("{:?}", e));
1115 }
1116
1117 let repo_bg = repo.clone();
1118 let cached_ids: std::collections::HashSet<String> =
1119 cache_result.items.iter().map(|i| i.id.clone()).collect();
1120 tauri::async_runtime::spawn(async move {
1121 match repo_bg.get_favorites_server_only(scope, options).await {
1122 Ok(server_result) => {
1123 let server_ids: std::collections::HashSet<String> =
1124 server_result.items.iter().map(|i| i.id.clone()).collect();
1125 let changed = changed_favorite_ids(&cached_ids, &server_ids);
1126
1127 if !changed.is_empty() {
1128 let event = FavoritesChangedEvent { item_ids: changed };
1129 if let Err(e) = app.emit(FAVORITES_CHANGED_EVENT, &event) {
1130 error!("[Favorites] Failed to emit change event: {}", e);
1131 }
1132 }
1133 }
1134 Err(e) => {
1135 warn!(
1136 "[Favorites] Server refresh failed, keeping cached favourites: {:?}",
1137 e
1138 );
1139 }
1140 }
1141 });
1142
1143 Ok(cache_result)
1144}
1145
1146#[tauri::command]
1148#[specta::specta]
1149pub async fn repository_unmark_favorite(
1150 manager: State<'_, RepositoryManagerWrapper>,
1151 handle: String,
1152 item_id: String,
1153) -> Result<(), String> {
1154 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1155 repo.as_ref()
1156 .unmark_favorite(&item_id)
1157 .await
1158 .map_err(|e| format!("{:?}", e))
1159}
1160
1161#[tauri::command]
1163#[specta::specta]
1164pub async fn repository_get_person(
1165 manager: State<'_, RepositoryManagerWrapper>,
1166 handle: String,
1167 person_id: String,
1168) -> Result<MediaItem, String> {
1169 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1170 repo.as_ref()
1171 .get_person(&person_id)
1172 .await
1173 .map_err(|e| format!("{:?}", e))
1174}
1175
1176#[tauri::command]
1178#[specta::specta]
1179pub async fn repository_get_items_by_person(
1180 manager: State<'_, RepositoryManagerWrapper>,
1181 handle: String,
1182 person_id: String,
1183 options: Option<GetItemsOptions>,
1184) -> Result<SearchResult, String> {
1185 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1186 repo.as_ref()
1187 .get_items_by_person(&person_id, options)
1188 .await
1189 .map_err(|e| format!("{:?}", e))
1190}
1191
1192#[tauri::command]
1194#[specta::specta]
1195pub async fn repository_get_similar_items(
1196 manager: State<'_, RepositoryManagerWrapper>,
1197 handle: String,
1198 item_id: String,
1199 limit: Option<usize>,
1200) -> Result<SearchResult, String> {
1201 let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1202 repo.as_ref()
1203 .get_similar_items(&item_id, limit)
1204 .await
1205 .map_err(|e| format!("{:?}", e))
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210 use super::*;
1211
1212 #[test]
1213 fn test_repository_manager_creation() {
1214 let manager = RepositoryManager::new();
1215 assert!(manager.get("any-handle").is_none());
1217 }
1218
1219 fn ids(values: &[&str]) -> std::collections::HashSet<String> {
1220 values.iter().map(|v| v.to_string()).collect()
1221 }
1222
1223 #[test]
1227 fn test_changed_favorite_ids_reports_both_directions() {
1228 assert_eq!(
1230 changed_favorite_ids(&ids(&["a"]), &ids(&["a", "b"])),
1231 vec!["b".to_string()]
1232 );
1233
1234 assert_eq!(
1236 changed_favorite_ids(&ids(&["a", "b"]), &ids(&["a"])),
1237 vec!["b".to_string()]
1238 );
1239
1240 assert_eq!(
1242 changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "c"])),
1243 vec!["a".to_string(), "c".to_string()]
1244 );
1245 }
1246
1247 #[test]
1252 fn test_changed_favorite_ids_is_empty_when_nothing_moved() {
1253 assert!(changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "a"])).is_empty());
1254 assert!(changed_favorite_ids(&ids(&[]), &ids(&[])).is_empty());
1255 }
1256
1257 #[test]
1258 fn test_repository_manager_wrapper_structure() {
1259 let manager = RepositoryManager::new();
1260 let wrapper = RepositoryManagerWrapper(manager);
1261 assert!(wrapper.0.get("any-handle").is_none());
1263 }
1264
1265 #[test]
1266 fn test_repository_manager_get_nonexistent() {
1267 let manager = RepositoryManager::new();
1268 let result = manager.get("nonexistent-handle");
1270 assert!(result.is_none());
1271 }
1272
1273 #[test]
1274 fn test_uuid_handle_generation() {
1275 let uuid = Uuid::new_v4();
1276 let handle = format!("{}", uuid);
1277 assert!(!handle.is_empty());
1279 }
1280
1281 #[test]
1282 fn test_uuid_handles_are_unique() {
1283 let handle1 = format!("{}", Uuid::new_v4());
1284 let handle2 = format!("{}", Uuid::new_v4());
1285 assert_ne!(handle1, handle2);
1287 }
1288
1289 #[test]
1290 fn test_uuid_handle_format() {
1291 let uuid = Uuid::new_v4();
1292 let handle = format!("{}", uuid);
1293 let parts: Vec<&str> = handle.split('-').collect();
1295 assert_eq!(parts.len(), 5);
1296 }
1297
1298 #[test]
1299 fn test_repository_manager_destroy_nonexistent() {
1300 let manager = RepositoryManager::new();
1301 manager.destroy("nonexistent-handle");
1303 }
1304
1305 #[test]
1306 fn test_repository_manager_is_send_sync() {
1307 fn is_send_sync<T: Send + Sync>() {}
1309 is_send_sync::<RepositoryManager>();
1310 }
1311
1312 #[test]
1313 fn test_repository_manager_wrapper_is_send_sync() {
1314 fn is_send_sync<T: Send + Sync>() {}
1316 is_send_sync::<RepositoryManagerWrapper>();
1317 }
1318
1319 #[test]
1320 fn test_multiple_manager_instances() {
1321 let manager1 = RepositoryManager::new();
1322 let manager2 = RepositoryManager::new();
1323
1324 let handle1_nonexistent = manager1.get("test");
1326 let handle2_nonexistent = manager2.get("test");
1327
1328 assert!(handle1_nonexistent.is_none());
1329 assert!(handle2_nonexistent.is_none());
1330 }
1331
1332 #[test]
1333 fn test_handle_string_properties() {
1334 let uuid = Uuid::new_v4();
1335 let handle = format!("{}", uuid);
1336
1337 for c in handle.chars() {
1339 assert!(c.is_alphanumeric() || c == '-');
1340 }
1341 }
1342
1343 #[test]
1344 fn test_repository_manager_concurrent_access() {
1345 let manager = Arc::new(RepositoryManager::new());
1346 let mut handles = vec![];
1347
1348 for _ in 0..3 {
1350 let mgr = Arc::clone(&manager);
1351 let handle = std::thread::spawn(move || {
1352 let result = mgr.get("test");
1353 assert!(result.is_none());
1354 });
1355 handles.push(handle);
1356 }
1357
1358 for h in handles {
1359 h.join().unwrap();
1360 }
1361 }
1362}
1363
1364#[cfg(test)]
1365mod generation_change_tests {
1366 use super::*;
1367 use crate::repository::capabilities::ServerGeneration;
1368 use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
1369
1370 async fn db_with_server() -> Arc<RusqliteService> {
1371 let conn = rusqlite::Connection::open_in_memory().expect("in-memory db");
1372 for (_, sql) in crate::storage::schema::MIGRATIONS {
1373 conn.execute_batch(sql).expect("migration");
1374 }
1375 let db = Arc::new(RusqliteService::new(Arc::new(std::sync::Mutex::new(conn))));
1376 db.execute(Query::with_params(
1377 "INSERT INTO servers (id, name, url, version) VALUES (?1, ?2, ?3, ?4)",
1378 vec![
1379 QueryParam::String("srv-1".into()),
1380 QueryParam::String("Home".into()),
1381 QueryParam::String("https://example.test".into()),
1382 QueryParam::String("10.11.5".into()),
1383 ],
1384 ))
1385 .await
1386 .expect("seed server");
1387 db
1388 }
1389
1390 async fn recorded(db: &Arc<RusqliteService>) -> Option<String> {
1391 db.query_one(
1392 Query::new("SELECT catalog_generation FROM servers WHERE id = 'srv-1'"),
1393 |row| row.get::<_, Option<String>>(0),
1394 )
1395 .await
1396 .ok()
1397 .flatten()
1398 }
1399
1400 #[tokio::test]
1406 async fn the_first_look_records_without_invalidating() {
1407 let db = db_with_server().await;
1408 let invalidated =
1409 invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1410
1411 assert!(!invalidated, "a first sighting is not a change");
1412 assert_eq!(recorded(&db).await.as_deref(), Some("V10_11"));
1413 }
1414
1415 #[tokio::test]
1419 async fn an_unchanged_generation_does_not_invalidate() {
1420 let db = db_with_server().await;
1421 invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1422 let invalidated =
1423 invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1424
1425 assert!(!invalidated);
1426 assert_eq!(recorded(&db).await.as_deref(), Some("V10_11"));
1427 }
1428
1429 #[tokio::test]
1434 async fn a_real_upgrade_invalidates_and_records() {
1435 let db = db_with_server().await;
1436 invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1437
1438 let invalidated =
1439 invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V12Plus).await;
1440
1441 assert!(invalidated, "10.11 -> 12.x is a generation change");
1442 assert_eq!(recorded(&db).await.as_deref(), Some("V12Plus"));
1443 }
1444
1445 #[tokio::test]
1449 async fn an_unknown_server_is_harmless() {
1450 let db = db_with_server().await;
1451 let invalidated =
1452 invalidate_cache_on_generation_change(&db, "no-such-server", ServerGeneration::V12Plus)
1453 .await;
1454 assert!(!invalidated);
1455 }
1456}