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}