Skip to main content

jellytau_lib/commands/
repository.rs

1//! Tauri commands for repository access
2//! Uses handle-based system: UUID -> Arc<HybridRepository>
3//!
4//! TRACES: UR-007, UR-008, UR-023, UR-034, UR-035, UR-036 | IR-022, IR-024, JA-004, JA-005, JA-006, JA-029, JA-030, JA-031
5
6use crate::utils::lock::MutexSafe;
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9
10use log::{debug, error, info, warn};
11use serde::{Deserialize, Serialize};
12use tauri::{AppHandle, Emitter, State};
13use uuid::Uuid;
14
15use crate::domain::rank_search_results;
16use crate::jellyfin::HttpClient;
17use crate::repository::{
18    series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
19    OnlineRepository, StreamSelection,
20};
21
22/// Repository handle manager
23pub 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    /// Handles of every live repository.
45    ///
46    /// The background catalog indexer (DR-109) runs outside any command, so it
47    /// has no handle passed in and needs to discover one. In practice there is a
48    /// single signed-in repository; returning all of them avoids inventing an
49    /// "active" concept the rest of the code does not have.
50    ///
51    /// TRACES: UR-065 | DR-109
52    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
63/// Wrapper for Tauri state
64pub struct RepositoryManagerWrapper(pub RepositoryManager);
65
66/// Create a new repository instance
67/// Returns a handle (UUID) for accessing the repository
68#[tauri::command]
69#[specta::specta]
70// Four of the eight arguments are Tauri `State<'_, _>` injections, not caller
71// input. Folding the remaining four into a struct would change the IPC contract
72// and the generated TypeScript for no readability gain.
73#[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    // Create HTTP client for online repository
87    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    // Grab a connectivity reporter so the online repository's server outcomes
96    // drive the reachability state the UI observes (source of truth for the
97    // offline/online banner). See docs/architecture/07-connectivity.md.
98    let connectivity_reporter = {
99        let monitor = connectivity.0.lock().await;
100        monitor.reporter()
101    };
102
103    // Create online repository wired to connectivity reporting
104    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    // Create offline repository with async-safe database service
115    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    }; // Lock is released here
124    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    // Create hybrid repository
131    debug!("[REPO] Creating hybrid repository...");
132    let hybrid = HybridRepository::new(online, offline);
133    debug!("[REPO] Hybrid repository created");
134
135    // Generate handle and store repository
136    let uuid = Uuid::new_v4();
137    let handle = format!("{}", uuid);
138    info!("[REPO] Generated handle: {}", handle);
139
140    // Store repository synchronously
141    debug!("[REPO] Storing repository...");
142    manager.0.create(handle.clone(), hybrid);
143    info!("[REPO] Repository stored successfully");
144
145    // Give the player controller a repository for next-episode lookups. The
146    // Android playback-ended callback has no repository handle, so without
147    // this the episode autoplay countdown never triggers there.
148    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/// Destroy a repository instance
157#[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/// Get libraries
168#[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/// Get items in a container (library, folder, album, etc.)
187#[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/// Get a single item by ID
203#[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/// Downloaded-only browse: libraries that contain downloaded content.
218///
219/// Backs the Downloads "Downloaded" surface. Never merges server results and is
220/// authoritative — an empty list means nothing is downloaded.
221///
222/// TRACES: UR-055 | DR-082
223#[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/// Downloaded-only browse: items under a container that are on the device.
236///
237/// TRACES: UR-055 | DR-082, DR-083
238#[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/// On-disk usage of downloaded content (device total, per-item/container bytes).
253///
254/// TRACES: UR-056 | DR-085
255#[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/// Query the optional JRay plugin for the actors on screen at time `t`
268/// (seconds) in an item. Returns an empty list when JRay isn't installed or
269/// has no data for the item, so the caller can render nothing without error.
270#[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/// Get latest items in a library
286#[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/// Get resume items (continue watching/listening).
302///
303/// The home screen's Continue Watching row and every library's "pick up where
304/// you left off" hero come through here; each item carries its own resume
305/// position in `UserData`.
306///
307/// TRACES: UR-019, UR-023, UR-034 | IR-024, JA-013, JA-015 | DR-026, DR-038
308#[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/// Get next up episodes.
332///
333/// TRACES: UR-023, UR-034 | IR-024, JA-014 | DR-026
334#[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/// Every episode of a series, across all seasons, in series order.
350///
351/// Jellyfin hangs episodes off season folders — except for "flat" series whose
352/// children are episodes directly. Both shapes are provider vocabulary, so the
353/// fan-out and its fallback live in Rust rather than being reimplemented in the
354/// frontend (which is what it used to do).
355///
356/// TRACES: UR-062 | DR-101
357#[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/// The episode a viewer should land on when they open a series.
371///
372/// "Current" is domain policy, not layout: an episode in progress, else the
373/// server's Next Up for the series, else the first unwatched episode, else the
374/// first. The third rung is what makes this work offline, where Next Up is
375/// always empty. Returns `None` only when the series has no episodes at all.
376///
377/// TRACES: UR-062 | DR-101
378#[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/// Erase the viewer's watch history for an item.
392///
393/// Clears the played flag and the resume position; on a series or season the
394/// server applies it to everything inside. A series cleared this way is "never
395/// watched" again, so `repository_get_series_current_episode` returns its
396/// premiere. Requires the server — offline this fails rather than diverging
397/// local state the next sync would overwrite.
398///
399/// TRACES: UR-064 | DR-106
400#[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/// Get recently played audio
415#[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/// Get resume movies
430#[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/// Get albums the user hasn't listened to recently ("rediscover")
445#[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/// Get genres for a library
461#[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
475/// Tauri event name carrying the merged (cache + server) search results.
476pub const SEARCH_EVENT_NAME: &str = "search-event";
477
478/// Payload for the deferred, merged search results pushed to the frontend.
479///
480/// `request_id` matches the value the frontend passed to `repository_search`,
481/// letting it discard updates from queries that have since been superseded.
482#[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/// Search for items.
490///
491/// Resolves `SearchOptions::scope` into concrete Jellyfin item types before
492/// dispatching, so scope taxonomy stays in Rust.
493///
494/// TRACES: UR-049, UR-050 | DR-063
495#[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    // Expand the opaque scope into item types HERE — once, before the cache and
508    // server paths diverge — so both phases filter identically. Doing it later
509    // (or in only one path) makes offline results disagree with online ones.
510    // The frontend sends `scope` and never names a Jellyfin item type for
511    // search; see docs/specs/scoped-search-boundary.md.
512    let options = options.map(|mut o| {
513        o.resolve_scope();
514        o
515    });
516
517    // Phase 1: instant local results from the cache (downloaded content) so the
518    // UI can render immediately while the server is still being queried.
519    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    // Neither backend orders by *where* the query matched, so a mid-word hit
531    // ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation").
532    // Both phases are ranked with the same rules so the list does not reshuffle
533    // when the server results land.
534    rank_search_results(&mut cache_result.items, &query);
535
536    // Phase 2: query the live server in the background, merge with the cache,
537    // and push the union to the frontend via a `search-event`. Tagged with
538    // `request_id` so the frontend can discard results from superseded queries.
539    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 the union, not each half: a server-only prefix match must
547                // be able to outrank a cached mid-word one.
548                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                // Server failed — the cache results are already on screen, so
559                // just log. (Offline / unreachable server falls here.)
560                warn!(
561                    "[Search] Server search failed, keeping cache results: {:?}",
562                    e
563                );
564            }
565        }
566    });
567
568    Ok(cache_result)
569}
570
571/// Get playback info for an item
572#[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/// Get a video stream URL.
587///
588/// There is no start-position parameter on purpose: the URL is an HLS playlist
589/// covering the whole item, and a position on it makes the server reject every
590/// segment with `400` (DR-181). Callers resume by seeking after load.
591///
592/// TRACES: UR-004 | DR-181 | UT-182
593#[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/// Decide what stream to play for a video, and describe it.
610///
611/// Replaces `repository_get_video_stream_url` for playback. The returned
612/// [`StreamSelection`] carries the transport explicitly, so the frontend picks
613/// its loader from a tagged enum instead of testing the URL for `.m3u8`; and it
614/// carries the quality ladder as it applies to *this* source, so the picker can
615/// stop offering rungs that produce the same bytes as Original.
616///
617/// No start-position parameter, for the same reason as the URL builder: a
618/// position on an HLS playlist is copied onto every segment URI and the server
619/// rejects each with `400` (DR-181). Callers resume by seeking after load.
620///
621/// TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
622#[tauri::command]
623#[specta::specta]
624pub async fn repository_get_stream_selection(
625    manager: State<'_, RepositoryManagerWrapper>,
626    handle: String,
627    item_id: String,
628    media_source_id: Option<String>,
629    audio_stream_index: Option<i32>,
630) -> Result<StreamSelection, String> {
631    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
632    repo.as_ref()
633        .get_stream_selection(&item_id, media_source_id.as_deref(), audio_stream_index)
634        .await
635        .map_err(|e| format!("{:?}", e))
636}
637
638/// Get an audio-only stream URL for a *video* item (background-audio handoff).
639///
640/// TRACES: UR-040 | JA-032 | UT-061
641#[tauri::command]
642#[specta::specta]
643pub async fn repository_get_audio_only_stream_url_for_video(
644    manager: State<'_, RepositoryManagerWrapper>,
645    handle: String,
646    item_id: String,
647    media_source_id: Option<String>,
648    start_time_seconds: Option<f64>,
649    audio_stream_index: Option<i32>,
650) -> Result<String, String> {
651    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
652    repo.as_ref()
653        .get_audio_only_stream_url_for_video(
654            &item_id,
655            media_source_id.as_deref(),
656            start_time_seconds,
657            audio_stream_index,
658        )
659        .await
660        .map_err(|e| format!("{:?}", e))
661}
662
663/// Get audio stream URL for a track
664#[tauri::command]
665#[specta::specta]
666pub async fn repository_get_audio_stream_url(
667    manager: State<'_, RepositoryManagerWrapper>,
668    handle: String,
669    item_id: String,
670) -> Result<String, String> {
671    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
672    repo.as_ref()
673        .get_audio_stream_url(&item_id)
674        .await
675        .map_err(|e| format!("{:?}", e))
676}
677
678/// Get Live TV channels (broadcast / IPTV) for browsing
679#[tauri::command]
680#[specta::specta]
681pub async fn repository_get_live_tv_channels(
682    manager: State<'_, RepositoryManagerWrapper>,
683    handle: String,
684) -> Result<Vec<MediaItem>, String> {
685    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
686    repo.as_ref()
687        .get_live_tv_channels()
688        .await
689        .map_err(|e| format!("{:?}", e))
690}
691
692/// Get the root list of plugin "Channels"
693#[tauri::command]
694#[specta::specta]
695pub async fn repository_get_channels(
696    manager: State<'_, RepositoryManagerWrapper>,
697    handle: String,
698) -> Result<SearchResult, String> {
699    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
700    repo.as_ref()
701        .get_channels()
702        .await
703        .map_err(|e| format!("{:?}", e))
704}
705
706/// Open a live stream for a Live TV channel / live item
707#[tauri::command]
708#[specta::specta]
709pub async fn repository_open_live_stream(
710    manager: State<'_, RepositoryManagerWrapper>,
711    handle: String,
712    item_id: String,
713) -> Result<LiveStreamInfo, String> {
714    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
715    repo.as_ref()
716        .open_live_stream(&item_id)
717        .await
718        .map_err(|e| format!("{:?}", e))
719}
720
721/// Report playback start
722#[tauri::command]
723#[specta::specta]
724pub async fn repository_report_playback_start(
725    manager: State<'_, RepositoryManagerWrapper>,
726    handle: String,
727    item_id: String,
728    position_ms: i64,
729) -> Result<(), String> {
730    let position_ticks = position_ms * 10_000;
731    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
732    repo.as_ref()
733        .report_playback_start(&item_id, position_ticks)
734        .await
735        .map_err(|e| format!("{:?}", e))
736}
737
738/// Report playback progress
739#[tauri::command]
740#[specta::specta]
741pub async fn repository_report_playback_progress(
742    manager: State<'_, RepositoryManagerWrapper>,
743    handle: String,
744    item_id: String,
745    position_ms: i64,
746) -> Result<(), String> {
747    let position_ticks = position_ms * 10_000;
748    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
749    repo.as_ref()
750        .report_playback_progress(&item_id, position_ticks)
751        .await
752        .map_err(|e| format!("{:?}", e))
753}
754
755/// Report playback stopped
756///
757/// A stop-report that cannot reach the server is queued rather than dropped:
758/// this is the position the resume point is built from, and losing it is
759/// exactly the "it forgot where I was" the sync queue exists to prevent. The
760/// drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
761/// failing the command because the *queue* write failed would tell the caller
762/// the report was lost when the local position was already saved.
763///
764/// TRACES: UR-025 | DR-154 | UT-151
765#[tauri::command]
766#[specta::specta]
767pub async fn repository_report_playback_stopped(
768    db: State<'_, crate::commands::storage::DatabaseWrapper>,
769    manager: State<'_, RepositoryManagerWrapper>,
770    handle: String,
771    item_id: String,
772    position_ms: i64,
773) -> Result<(), String> {
774    // Milliseconds across the boundary; the Jellyfin API wants ticks.
775    let position_ticks = position_ms * 10_000;
776    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
777
778    let result = repo
779        .as_ref()
780        .report_playback_stopped(&item_id, position_ticks)
781        .await;
782
783    if let Err(e) = &result {
784        let db_service = {
785            let database = db.0.lock().map_err(|err| err.to_string())?;
786            Arc::new(database.service())
787        };
788        let user_id = repo.user_id().to_string();
789        if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
790            &db_service,
791            &user_id,
792            &item_id,
793            position_ticks,
794        )
795        .await
796        {
797            warn!(
798                "[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
799                item_id, e, queue_err
800            );
801        } else {
802            debug!(
803                "[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
804                item_id, e
805            );
806        }
807    }
808
809    result.map_err(|e| format!("{:?}", e))
810}
811
812/// Get image URL for an item
813#[tauri::command]
814#[specta::specta]
815pub fn repository_get_image_url(
816    manager: State<'_, RepositoryManagerWrapper>,
817    handle: String,
818    item_id: String,
819    image_type: ImageType,
820    options: Option<ImageOptions>,
821) -> Result<String, String> {
822    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
823    Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
824}
825
826/// Get subtitle URL for a media item
827#[tauri::command]
828#[specta::specta]
829#[allow(dead_code)]
830pub fn repository_get_subtitle_url(
831    manager: State<'_, RepositoryManagerWrapper>,
832    handle: String,
833    item_id: String,
834    media_source_id: String,
835    stream_index: i32,
836    format: String,
837) -> Result<String, String> {
838    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
839    Ok(repo
840        .as_ref()
841        .get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
842}
843
844/// Get video download URL with quality preset
845#[tauri::command]
846#[specta::specta]
847#[allow(dead_code)]
848pub async fn repository_get_video_download_url(
849    manager: State<'_, RepositoryManagerWrapper>,
850    handle: String,
851    item_id: String,
852    quality: String,
853    media_source_id: Option<String>,
854) -> Result<String, String> {
855    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
856    // Async because the audio-codec policy has to know what the source's audio
857    // is before it can decide whether the file may be copied verbatim (DR-171).
858    // The frontend calls this exactly as before — the decision stays in Rust.
859    Ok(crate::repository::resolve_video_download_url(
860        repo.as_ref(),
861        &item_id,
862        &quality,
863        media_source_id.as_deref(),
864    )
865    .await)
866}
867
868/// Mark an item as favorite
869#[tauri::command]
870#[specta::specta]
871pub async fn repository_mark_favorite(
872    manager: State<'_, RepositoryManagerWrapper>,
873    handle: String,
874    item_id: String,
875) -> Result<(), String> {
876    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
877    repo.as_ref()
878        .mark_favorite(&item_id)
879        .await
880        .map_err(|e| format!("{:?}", e))
881}
882
883/// Tauri event announcing that favourite state changed behind the UI's back —
884/// either because the server disagreed with the cache on a background refresh,
885/// or because pending offline toggles were pushed on reconnect.
886///
887/// TRACES: UR-069 | DR-120
888pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
889
890/// Payload for [`FAVORITES_CHANGED_EVENT`] — the ids whose favourite state
891/// actually flipped, so the frontend refreshes those rather than everything.
892///
893/// TRACES: UR-069 | DR-120 | UT-107
894#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
895#[serde(rename_all = "camelCase")]
896pub struct FavoritesChangedEvent {
897    pub item_ids: Vec<String>,
898}
899
900/// Ids whose favourite state differs between what we showed and what the server
901/// has — favourited elsewhere since the cache was written, or un-favourited
902/// elsewhere.
903///
904/// Pulled out of the command so the "emit nothing when nothing changed" rule is
905/// testable: an unchanged set must leave a quiet page quiet rather than
906/// triggering a refetch on every visit.
907///
908/// TRACES: UR-069 | DR-120 | UT-107
909fn changed_favorite_ids(
910    cached: &std::collections::HashSet<String>,
911    server: &std::collections::HashSet<String>,
912) -> Vec<String> {
913    let mut changed: Vec<String> = server.symmetric_difference(cached).cloned().collect();
914    // Deterministic order so the event payload does not depend on hash seeding.
915    changed.sort();
916    changed
917}
918
919/// Everything the viewer has favourited, across libraries, narrowed by scope.
920///
921/// Two-phase like `repository_search`: the local answer returns immediately and
922/// a background server pass emits `favorites-changed` when the server's set
923/// differs. Without the second phase a favourite marked in another client shows
924/// up only on the *second* visit to the page, since the cache-first read hands
925/// back local rows and the refresh is invisible to the frontend.
926///
927/// TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107
928#[tauri::command]
929#[specta::specta]
930pub async fn repository_get_favorites(
931    app: AppHandle,
932    manager: State<'_, RepositoryManagerWrapper>,
933    handle: String,
934    scope: SearchScope,
935    options: Option<GetItemsOptions>,
936) -> Result<SearchResult, String> {
937    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
938
939    let cache_result = repo
940        .get_favorites_cache_only(scope, options.clone())
941        .await
942        .unwrap_or_else(|e| {
943            debug!("[Favorites] Cache miss/timeout: {:?}", e);
944            SearchResult {
945                items: Vec::new(),
946                total_record_count: 0,
947            }
948        });
949
950    // With "Show all server media" off the local answer is authoritative
951    // (DR-080) — don't go behind the user's back to the server.
952    if !crate::repository::offline::include_catalog_browse() {
953        return Ok(cache_result);
954    }
955
956    // Nothing cached yet — a fresh install, or a viewer whose favourites were
957    // all marked on another client. Returning the empty result here paints
958    // "Nothing favourited yet — tap the heart on anything you like", which is a
959    // *wrong* answer, corrected a server round trip later when the background
960    // refresh fires `favorites-changed`. Ask the repository for a real answer
961    // instead: its `get_favorites` is exactly this read — cache first, server on
962    // a miss, saving through — and it applies the same DR-080 gate.
963    //
964    // TRACES: UR-067 | DR-115
965    if !cache_result.has_content() {
966        debug!("[Favorites] Nothing cached; answering from the server");
967        return repo
968            .get_favorites(scope, options)
969            .await
970            .map_err(|e| format!("{:?}", e));
971    }
972
973    let repo_bg = repo.clone();
974    let cached_ids: std::collections::HashSet<String> =
975        cache_result.items.iter().map(|i| i.id.clone()).collect();
976    tauri::async_runtime::spawn(async move {
977        match repo_bg.get_favorites_server_only(scope, options).await {
978            Ok(server_result) => {
979                let server_ids: std::collections::HashSet<String> =
980                    server_result.items.iter().map(|i| i.id.clone()).collect();
981                let changed = changed_favorite_ids(&cached_ids, &server_ids);
982
983                if !changed.is_empty() {
984                    let event = FavoritesChangedEvent { item_ids: changed };
985                    if let Err(e) = app.emit(FAVORITES_CHANGED_EVENT, &event) {
986                        error!("[Favorites] Failed to emit change event: {}", e);
987                    }
988                }
989            }
990            Err(e) => {
991                warn!(
992                    "[Favorites] Server refresh failed, keeping cached favourites: {:?}",
993                    e
994                );
995            }
996        }
997    });
998
999    Ok(cache_result)
1000}
1001
1002/// Unmark an item as favorite
1003#[tauri::command]
1004#[specta::specta]
1005pub async fn repository_unmark_favorite(
1006    manager: State<'_, RepositoryManagerWrapper>,
1007    handle: String,
1008    item_id: String,
1009) -> Result<(), String> {
1010    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1011    repo.as_ref()
1012        .unmark_favorite(&item_id)
1013        .await
1014        .map_err(|e| format!("{:?}", e))
1015}
1016
1017/// Get person details
1018#[tauri::command]
1019#[specta::specta]
1020pub async fn repository_get_person(
1021    manager: State<'_, RepositoryManagerWrapper>,
1022    handle: String,
1023    person_id: String,
1024) -> Result<MediaItem, String> {
1025    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1026    repo.as_ref()
1027        .get_person(&person_id)
1028        .await
1029        .map_err(|e| format!("{:?}", e))
1030}
1031
1032/// Get items by person (actor, director, etc.)
1033#[tauri::command]
1034#[specta::specta]
1035pub async fn repository_get_items_by_person(
1036    manager: State<'_, RepositoryManagerWrapper>,
1037    handle: String,
1038    person_id: String,
1039    options: Option<GetItemsOptions>,
1040) -> Result<SearchResult, String> {
1041    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1042    repo.as_ref()
1043        .get_items_by_person(&person_id, options)
1044        .await
1045        .map_err(|e| format!("{:?}", e))
1046}
1047
1048/// Get similar/related items for a media item
1049#[tauri::command]
1050#[specta::specta]
1051pub async fn repository_get_similar_items(
1052    manager: State<'_, RepositoryManagerWrapper>,
1053    handle: String,
1054    item_id: String,
1055    limit: Option<usize>,
1056) -> Result<SearchResult, String> {
1057    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1058    repo.as_ref()
1059        .get_similar_items(&item_id, limit)
1060        .await
1061        .map_err(|e| format!("{:?}", e))
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067
1068    #[test]
1069    fn test_repository_manager_creation() {
1070        let manager = RepositoryManager::new();
1071        // A freshly created manager holds no repositories
1072        assert!(manager.get("any-handle").is_none());
1073    }
1074
1075    fn ids(values: &[&str]) -> std::collections::HashSet<String> {
1076        values.iter().map(|v| v.to_string()).collect()
1077    }
1078
1079    /// UT-107 — the background refresh reports only what actually changed.
1080    ///
1081    /// TRACES: UR-069 | DR-120 | UT-107
1082    #[test]
1083    fn test_changed_favorite_ids_reports_both_directions() {
1084        // Favourited in another client since we cached.
1085        assert_eq!(
1086            changed_favorite_ids(&ids(&["a"]), &ids(&["a", "b"])),
1087            vec!["b".to_string()]
1088        );
1089
1090        // Un-favourited in another client.
1091        assert_eq!(
1092            changed_favorite_ids(&ids(&["a", "b"]), &ids(&["a"])),
1093            vec!["b".to_string()]
1094        );
1095
1096        // Both at once, in a stable order.
1097        assert_eq!(
1098            changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "c"])),
1099            vec!["a".to_string(), "c".to_string()]
1100        );
1101    }
1102
1103    /// An unchanged set emits nothing — otherwise every visit to the page would
1104    /// fire an event and trigger a pointless refetch.
1105    ///
1106    /// TRACES: UR-069 | DR-120 | UT-107
1107    #[test]
1108    fn test_changed_favorite_ids_is_empty_when_nothing_moved() {
1109        assert!(changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "a"])).is_empty());
1110        assert!(changed_favorite_ids(&ids(&[]), &ids(&[])).is_empty());
1111    }
1112
1113    #[test]
1114    fn test_repository_manager_wrapper_structure() {
1115        let manager = RepositoryManager::new();
1116        let wrapper = RepositoryManagerWrapper(manager);
1117        // The wrapper exposes the underlying manager, which starts empty
1118        assert!(wrapper.0.get("any-handle").is_none());
1119    }
1120
1121    #[test]
1122    fn test_repository_manager_get_nonexistent() {
1123        let manager = RepositoryManager::new();
1124        // Getting a non-existent repository should return None
1125        let result = manager.get("nonexistent-handle");
1126        assert!(result.is_none());
1127    }
1128
1129    #[test]
1130    fn test_uuid_handle_generation() {
1131        let uuid = Uuid::new_v4();
1132        let handle = format!("{}", uuid);
1133        // UUID should convert to a non-empty string
1134        assert!(!handle.is_empty());
1135    }
1136
1137    #[test]
1138    fn test_uuid_handles_are_unique() {
1139        let handle1 = format!("{}", Uuid::new_v4());
1140        let handle2 = format!("{}", Uuid::new_v4());
1141        // Two generated UUIDs should be different
1142        assert_ne!(handle1, handle2);
1143    }
1144
1145    #[test]
1146    fn test_uuid_handle_format() {
1147        let uuid = Uuid::new_v4();
1148        let handle = format!("{}", uuid);
1149        // UUID should have standard format with hyphens
1150        let parts: Vec<&str> = handle.split('-').collect();
1151        assert_eq!(parts.len(), 5);
1152    }
1153
1154    #[test]
1155    fn test_repository_manager_destroy_nonexistent() {
1156        let manager = RepositoryManager::new();
1157        // Destroying a non-existent repository should not panic
1158        manager.destroy("nonexistent-handle");
1159    }
1160
1161    #[test]
1162    fn test_repository_manager_is_send_sync() {
1163        // Verify RepositoryManager can be used in async contexts
1164        fn is_send_sync<T: Send + Sync>() {}
1165        is_send_sync::<RepositoryManager>();
1166    }
1167
1168    #[test]
1169    fn test_repository_manager_wrapper_is_send_sync() {
1170        // Verify RepositoryManagerWrapper is Send + Sync
1171        fn is_send_sync<T: Send + Sync>() {}
1172        is_send_sync::<RepositoryManagerWrapper>();
1173    }
1174
1175    #[test]
1176    fn test_multiple_manager_instances() {
1177        let manager1 = RepositoryManager::new();
1178        let manager2 = RepositoryManager::new();
1179
1180        // Multiple manager instances should be independent
1181        let handle1_nonexistent = manager1.get("test");
1182        let handle2_nonexistent = manager2.get("test");
1183
1184        assert!(handle1_nonexistent.is_none());
1185        assert!(handle2_nonexistent.is_none());
1186    }
1187
1188    #[test]
1189    fn test_handle_string_properties() {
1190        let uuid = Uuid::new_v4();
1191        let handle = format!("{}", uuid);
1192
1193        // Handle should be alphanumeric with hyphens
1194        for c in handle.chars() {
1195            assert!(c.is_alphanumeric() || c == '-');
1196        }
1197    }
1198
1199    #[test]
1200    fn test_repository_manager_concurrent_access() {
1201        let manager = Arc::new(RepositoryManager::new());
1202        let mut handles = vec![];
1203
1204        // Verify manager can be wrapped in Arc for concurrent access
1205        for _ in 0..3 {
1206            let mgr = Arc::clone(&manager);
1207            let handle = std::thread::spawn(move || {
1208                let result = mgr.get("test");
1209                assert!(result.is_none());
1210            });
1211            handles.push(handle);
1212        }
1213
1214        for h in handles {
1215            h.join().unwrap();
1216        }
1217    }
1218}