51 clippy warnings -> 0, with 8 justified #[allow]s (IPC arity, specta wire types, and the 9 test-only await-holding-lock sites). 27 raw lock calls moved to the poison-tolerant helpers - all of them test code; production was already clean. Caught a non-neutral clippy --fix: removing the redundant 'use hostname;' in credentials.rs orphaned its #[cfg(target_os = "linux")] onto SERVICE_NAME, which would have cfg'd the constant out of every non-Linux build. Compiles clean on Linux, so only Windows/macOS CI would have caught it.
1190 lines
39 KiB
Rust
1190 lines
39 KiB
Rust
//! Tauri commands for repository access
|
|
//! Uses handle-based system: UUID -> Arc<HybridRepository>
|
|
//!
|
|
//! 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
|
|
|
|
use crate::utils::lock::MutexSafe;
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use log::{debug, error, info, warn};
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::{AppHandle, Emitter, State};
|
|
use uuid::Uuid;
|
|
|
|
use crate::domain::rank_search_results;
|
|
use crate::jellyfin::HttpClient;
|
|
use crate::repository::{
|
|
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
|
|
OnlineRepository,
|
|
};
|
|
|
|
/// Repository handle manager
|
|
pub struct RepositoryManager {
|
|
repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
|
|
}
|
|
|
|
impl RepositoryManager {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
repositories: Arc::new(Mutex::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
pub fn create(&self, handle: String, repository: HybridRepository) {
|
|
let mut repos = self.repositories.lock_safe();
|
|
repos.insert(handle, Arc::new(repository));
|
|
}
|
|
|
|
pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
|
|
let repos = self.repositories.lock_safe();
|
|
repos.get(handle).cloned()
|
|
}
|
|
|
|
/// Handles of every live repository.
|
|
///
|
|
/// The background catalog indexer (DR-109) runs outside any command, so it
|
|
/// has no handle passed in and needs to discover one. In practice there is a
|
|
/// single signed-in repository; returning all of them avoids inventing an
|
|
/// "active" concept the rest of the code does not have.
|
|
///
|
|
/// TRACES: UR-065 | DR-109
|
|
pub fn handles(&self) -> Vec<String> {
|
|
let repos = self.repositories.lock_safe();
|
|
repos.keys().cloned().collect()
|
|
}
|
|
|
|
pub fn destroy(&self, handle: &str) {
|
|
let mut repos = self.repositories.lock_safe();
|
|
repos.remove(handle);
|
|
}
|
|
}
|
|
|
|
/// Wrapper for Tauri state
|
|
pub struct RepositoryManagerWrapper(pub RepositoryManager);
|
|
|
|
/// Create a new repository instance
|
|
/// Returns a handle (UUID) for accessing the repository
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
// Four of the eight arguments are Tauri `State<'_, _>` injections, not caller
|
|
// input. Folding the remaining four into a struct would change the IPC contract
|
|
// and the generated TypeScript for no readability gain.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn repository_create(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
player: State<'_, crate::commands::player::PlayerStateWrapper>,
|
|
db: State<'_, crate::commands::storage::DatabaseWrapper>,
|
|
connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>,
|
|
server_url: String,
|
|
user_id: String,
|
|
access_token: String,
|
|
server_id: String,
|
|
) -> Result<String, String> {
|
|
info!("[REPO] repository_create called for user: {}", user_id);
|
|
|
|
// Create HTTP client for online repository
|
|
debug!("[REPO] Creating HTTP client...");
|
|
let http_config = crate::jellyfin::HttpConfig::default();
|
|
let http_client = HttpClient::new(http_config).map_err(|e| {
|
|
error!("[REPO] HTTP client creation failed: {}", e);
|
|
e.to_string()
|
|
})?;
|
|
debug!("[REPO] HTTP client created successfully");
|
|
|
|
// Grab a connectivity reporter so the online repository's server outcomes
|
|
// drive the reachability state the UI observes (source of truth for the
|
|
// offline/online banner). See docs/architecture/07-connectivity.md.
|
|
let connectivity_reporter = {
|
|
let monitor = connectivity.0.lock().await;
|
|
monitor.reporter()
|
|
};
|
|
|
|
// Create online repository wired to connectivity reporting
|
|
debug!("[REPO] Creating online repository...");
|
|
let online = OnlineRepository::new(
|
|
Arc::new(http_client),
|
|
server_url,
|
|
user_id.clone(),
|
|
access_token,
|
|
)
|
|
.with_connectivity(connectivity_reporter);
|
|
debug!("[REPO] Online repository created");
|
|
|
|
// Create offline repository with async-safe database service
|
|
debug!("[REPO] Creating database service...");
|
|
let db_service = {
|
|
let database = db.0.lock().map_err(|e| {
|
|
error!("[REPO] Database lock failed: {}", e);
|
|
e.to_string()
|
|
})?;
|
|
debug!("[REPO] Database lock acquired, getting service...");
|
|
Arc::new(database.service())
|
|
}; // Lock is released here
|
|
debug!("[REPO] Database service created");
|
|
|
|
debug!("[REPO] Creating offline repository...");
|
|
let offline = OfflineRepository::new(db_service, server_id, user_id);
|
|
debug!("[REPO] Offline repository created");
|
|
|
|
// Create hybrid repository
|
|
debug!("[REPO] Creating hybrid repository...");
|
|
let hybrid = HybridRepository::new(online, offline);
|
|
debug!("[REPO] Hybrid repository created");
|
|
|
|
// Generate handle and store repository
|
|
let uuid = Uuid::new_v4();
|
|
let handle = format!("{}", uuid);
|
|
info!("[REPO] Generated handle: {}", handle);
|
|
|
|
// Store repository synchronously
|
|
debug!("[REPO] Storing repository...");
|
|
manager.0.create(handle.clone(), hybrid);
|
|
info!("[REPO] Repository stored successfully");
|
|
|
|
// Give the player controller a repository for next-episode lookups. The
|
|
// Android playback-ended callback has no repository handle, so without
|
|
// this the episode autoplay countdown never triggers there.
|
|
if let Some(repo) = manager.0.get(&handle) {
|
|
let controller = player.0.lock().await;
|
|
controller.set_repository(repo);
|
|
}
|
|
|
|
Ok(handle)
|
|
}
|
|
|
|
/// Destroy a repository instance
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_destroy(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
) -> Result<(), String> {
|
|
manager.0.destroy(&handle);
|
|
Ok(())
|
|
}
|
|
|
|
/// Get libraries
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_libraries(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
) -> Result<Vec<Library>, String> {
|
|
debug!("[REPO] get_libraries called with handle: {}", handle);
|
|
let repo = manager.0.get(&handle).ok_or_else(|| {
|
|
error!("[REPO] Repository not found for handle: {}", handle);
|
|
"Repository not found".to_string()
|
|
})?;
|
|
debug!("[REPO] Repository found, fetching libraries...");
|
|
repo.as_ref().get_libraries().await.map_err(|e| {
|
|
error!("[REPO] Error fetching libraries: {:?}", e);
|
|
format!("{:?}", e)
|
|
})
|
|
}
|
|
|
|
/// Get items in a container (library, folder, album, etc.)
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_items(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
parent_id: String,
|
|
options: Option<GetItemsOptions>,
|
|
) -> Result<SearchResult, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_items(&parent_id, options)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get a single item by ID
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_item(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
) -> Result<MediaItem, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_item(&item_id)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Downloaded-only browse: libraries that contain downloaded content.
|
|
///
|
|
/// Backs the Downloads "Downloaded" surface. Never merges server results and is
|
|
/// authoritative — an empty list means nothing is downloaded.
|
|
///
|
|
/// TRACES: UR-055 | DR-082
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_downloaded_libraries(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
) -> Result<Vec<Library>, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.get_downloaded_libraries()
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Downloaded-only browse: items under a container that are on the device.
|
|
///
|
|
/// TRACES: UR-055 | DR-082, DR-083
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_downloaded_items(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
parent_id: String,
|
|
options: Option<GetItemsOptions>,
|
|
) -> Result<SearchResult, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.get_downloaded_items(&parent_id, options)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// On-disk usage of downloaded content (device total, per-item/container bytes).
|
|
///
|
|
/// TRACES: UR-056 | DR-085
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_download_disk_usage(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
) -> Result<DownloadDiskUsage, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.get_download_disk_usage()
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Query the optional JRay plugin for the actors on screen at time `t`
|
|
/// (seconds) in an item. Returns an empty list when JRay isn't installed or
|
|
/// has no data for the item, so the caller can render nothing without error.
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_jray_actors_at(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
t: f64,
|
|
) -> Result<Vec<crate::repository::JRayActor>, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_jray_actors(&item_id, t)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get latest items in a library
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_latest_items(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
parent_id: String,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_latest_items(&parent_id, limit)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get resume items (continue watching/listening).
|
|
///
|
|
/// The home screen's Continue Watching row and every library's "pick up where
|
|
/// you left off" hero come through here; each item carries its own resume
|
|
/// position in `UserData`.
|
|
///
|
|
/// TRACES: UR-019, UR-023, UR-034 | IR-024, JA-013, JA-015 | DR-026, DR-038
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_resume_items(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
parent_id: Option<String>,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, String> {
|
|
debug!("[REPO] get_resume_items called with handle: {}", handle);
|
|
let repo = manager.0.get(&handle).ok_or_else(|| {
|
|
error!("[REPO] Repository not found for handle: {}", handle);
|
|
"Repository not found".to_string()
|
|
})?;
|
|
debug!("[REPO] Repository found, fetching resume items...");
|
|
repo.as_ref()
|
|
.get_resume_items(parent_id.as_deref(), limit)
|
|
.await
|
|
.map_err(|e| {
|
|
error!("[REPO] Error fetching resume items: {:?}", e);
|
|
format!("{:?}", e)
|
|
})
|
|
}
|
|
|
|
/// Get next up episodes.
|
|
///
|
|
/// TRACES: UR-023, UR-034 | IR-024, JA-014 | DR-026
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_next_up_episodes(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
series_id: Option<String>,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_next_up_episodes(series_id.as_deref(), limit)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Every episode of a series, across all seasons, in series order.
|
|
///
|
|
/// Jellyfin hangs episodes off season folders — except for "flat" series whose
|
|
/// children are episodes directly. Both shapes are provider vocabulary, so the
|
|
/// fan-out and its fallback live in Rust rather than being reimplemented in the
|
|
/// frontend (which is what it used to do).
|
|
///
|
|
/// TRACES: UR-062 | DR-101
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_series_episodes(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
series_id: String,
|
|
) -> Result<Vec<MediaItem>, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
series_progress::fetch_series_episodes(repo.as_ref(), &series_id)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// The episode a viewer should land on when they open a series.
|
|
///
|
|
/// "Current" is domain policy, not layout: an episode in progress, else the
|
|
/// server's Next Up for the series, else the first unwatched episode, else the
|
|
/// first. The third rung is what makes this work offline, where Next Up is
|
|
/// always empty. Returns `None` only when the series has no episodes at all.
|
|
///
|
|
/// TRACES: UR-062 | DR-101
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_series_current_episode(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
series_id: String,
|
|
) -> Result<Option<MediaItem>, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
series_progress::resolve_current_episode(repo.as_ref(), &series_id)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Erase the viewer's watch history for an item.
|
|
///
|
|
/// Clears the played flag and the resume position; on a series or season the
|
|
/// server applies it to everything inside. A series cleared this way is "never
|
|
/// watched" again, so `repository_get_series_current_episode` returns its
|
|
/// premiere. Requires the server — offline this fails rather than diverging
|
|
/// local state the next sync would overwrite.
|
|
///
|
|
/// TRACES: UR-064 | DR-106
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_clear_watch_history(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
) -> Result<(), String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.clear_watch_history(&item_id)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get recently played audio
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_recently_played_audio(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_recently_played_audio(limit)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get resume movies
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_resume_movies(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_resume_movies(limit)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get albums the user hasn't listened to recently ("rediscover")
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_rediscover_albums(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
parent_id: Option<String>,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_rediscover_albums(parent_id.as_deref(), limit)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get genres for a library
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_genres(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
parent_id: Option<String>,
|
|
) -> Result<Vec<Genre>, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_genres(parent_id.as_deref())
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Tauri event name carrying the merged (cache + server) search results.
|
|
pub const SEARCH_EVENT_NAME: &str = "search-event";
|
|
|
|
/// Payload for the deferred, merged search results pushed to the frontend.
|
|
///
|
|
/// `request_id` matches the value the frontend passed to `repository_search`,
|
|
/// letting it discard updates from queries that have since been superseded.
|
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SearchUpdateEvent {
|
|
pub request_id: u32,
|
|
pub result: SearchResult,
|
|
}
|
|
|
|
/// Search for items.
|
|
///
|
|
/// Resolves `SearchOptions::scope` into concrete Jellyfin item types before
|
|
/// dispatching, so scope taxonomy stays in Rust.
|
|
///
|
|
/// TRACES: UR-049, UR-050 | DR-063
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_search(
|
|
app: AppHandle,
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
query: String,
|
|
options: Option<SearchOptions>,
|
|
request_id: u32,
|
|
) -> Result<SearchResult, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
|
|
// Expand the opaque scope into item types HERE — once, before the cache and
|
|
// server paths diverge — so both phases filter identically. Doing it later
|
|
// (or in only one path) makes offline results disagree with online ones.
|
|
// The frontend sends `scope` and never names a Jellyfin item type for
|
|
// search; see docs/specs/scoped-search-boundary.md.
|
|
let options = options.map(|mut o| {
|
|
o.resolve_scope();
|
|
o
|
|
});
|
|
|
|
// Phase 1: instant local results from the cache (downloaded content) so the
|
|
// UI can render immediately while the server is still being queried.
|
|
let mut cache_result = repo
|
|
.search_cache_only(&query, options.clone())
|
|
.await
|
|
.unwrap_or_else(|e| {
|
|
debug!("[Search] Cache search miss/timeout: {:?}", e);
|
|
SearchResult {
|
|
items: Vec::new(),
|
|
total_record_count: 0,
|
|
}
|
|
});
|
|
|
|
// Neither backend orders by *where* the query matched, so a mid-word hit
|
|
// ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation").
|
|
// Both phases are ranked with the same rules so the list does not reshuffle
|
|
// when the server results land.
|
|
rank_search_results(&mut cache_result.items, &query);
|
|
|
|
// Phase 2: query the live server in the background, merge with the cache,
|
|
// and push the union to the frontend via a `search-event`. Tagged with
|
|
// `request_id` so the frontend can discard results from superseded queries.
|
|
let repo_bg = repo.clone();
|
|
let cache_for_merge = cache_result.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
match repo_bg.search_server_only(&query, options).await {
|
|
Ok(server_result) => {
|
|
let mut merged =
|
|
HybridRepository::merge_search_results(cache_for_merge, server_result);
|
|
// Rank the union, not each half: a server-only prefix match must
|
|
// be able to outrank a cached mid-word one.
|
|
rank_search_results(&mut merged.items, &query);
|
|
let event = SearchUpdateEvent {
|
|
request_id,
|
|
result: merged,
|
|
};
|
|
if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) {
|
|
error!("[Search] Failed to emit search update: {}", e);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
// Server failed — the cache results are already on screen, so
|
|
// just log. (Offline / unreachable server falls here.)
|
|
warn!(
|
|
"[Search] Server search failed, keeping cache results: {:?}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(cache_result)
|
|
}
|
|
|
|
/// Get playback info for an item
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_playback_info(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
) -> Result<PlaybackInfo, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_playback_info(&item_id)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get a video stream URL.
|
|
///
|
|
/// There is no start-position parameter on purpose: the URL is an HLS playlist
|
|
/// covering the whole item, and a position on it makes the server reject every
|
|
/// segment with `400` (DR-181). Callers resume by seeking after load.
|
|
///
|
|
/// TRACES: UR-004 | DR-181 | UT-182
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_video_stream_url(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
media_source_id: Option<String>,
|
|
audio_stream_index: Option<i32>,
|
|
) -> Result<String, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_video_stream_url(&item_id, media_source_id.as_deref(), audio_stream_index)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
|
|
///
|
|
/// TRACES: UR-040 | JA-032 | UT-061
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_audio_only_stream_url_for_video(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
media_source_id: Option<String>,
|
|
start_time_seconds: Option<f64>,
|
|
audio_stream_index: Option<i32>,
|
|
) -> Result<String, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_audio_only_stream_url_for_video(
|
|
&item_id,
|
|
media_source_id.as_deref(),
|
|
start_time_seconds,
|
|
audio_stream_index,
|
|
)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get audio stream URL for a track
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_audio_stream_url(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
) -> Result<String, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_audio_stream_url(&item_id)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get Live TV channels (broadcast / IPTV) for browsing
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_live_tv_channels(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
) -> Result<Vec<MediaItem>, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_live_tv_channels()
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get the root list of plugin "Channels"
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_channels(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
) -> Result<SearchResult, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_channels()
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Open a live stream for a Live TV channel / live item
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_open_live_stream(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
) -> Result<LiveStreamInfo, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.open_live_stream(&item_id)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Report playback start
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_report_playback_start(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
position_ms: i64,
|
|
) -> Result<(), String> {
|
|
let position_ticks = position_ms * 10_000;
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.report_playback_start(&item_id, position_ticks)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Report playback progress
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_report_playback_progress(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
position_ms: i64,
|
|
) -> Result<(), String> {
|
|
let position_ticks = position_ms * 10_000;
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.report_playback_progress(&item_id, position_ticks)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Report playback stopped
|
|
///
|
|
/// A stop-report that cannot reach the server is queued rather than dropped:
|
|
/// this is the position the resume point is built from, and losing it is
|
|
/// exactly the "it forgot where I was" the sync queue exists to prevent. The
|
|
/// drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
|
|
/// failing the command because the *queue* write failed would tell the caller
|
|
/// the report was lost when the local position was already saved.
|
|
///
|
|
/// TRACES: UR-025 | DR-154 | UT-151
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_report_playback_stopped(
|
|
db: State<'_, crate::commands::storage::DatabaseWrapper>,
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
position_ms: i64,
|
|
) -> Result<(), String> {
|
|
// Milliseconds across the boundary; the Jellyfin API wants ticks.
|
|
let position_ticks = position_ms * 10_000;
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
|
|
let result = repo
|
|
.as_ref()
|
|
.report_playback_stopped(&item_id, position_ticks)
|
|
.await;
|
|
|
|
if let Err(e) = &result {
|
|
let db_service = {
|
|
let database = db.0.lock().map_err(|err| err.to_string())?;
|
|
Arc::new(database.service())
|
|
};
|
|
let user_id = repo.user_id().to_string();
|
|
if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
|
|
&db_service,
|
|
&user_id,
|
|
&item_id,
|
|
position_ticks,
|
|
)
|
|
.await
|
|
{
|
|
warn!(
|
|
"[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
|
|
item_id, e, queue_err
|
|
);
|
|
} else {
|
|
debug!(
|
|
"[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
|
|
item_id, e
|
|
);
|
|
}
|
|
}
|
|
|
|
result.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get image URL for an item
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub fn repository_get_image_url(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
image_type: ImageType,
|
|
options: Option<ImageOptions>,
|
|
) -> Result<String, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
|
|
}
|
|
|
|
/// Get subtitle URL for a media item
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
#[allow(dead_code)]
|
|
pub fn repository_get_subtitle_url(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
media_source_id: String,
|
|
stream_index: i32,
|
|
format: String,
|
|
) -> Result<String, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
Ok(repo
|
|
.as_ref()
|
|
.get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
|
|
}
|
|
|
|
/// Get video download URL with quality preset
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
#[allow(dead_code)]
|
|
pub async fn repository_get_video_download_url(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
quality: String,
|
|
media_source_id: Option<String>,
|
|
) -> Result<String, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
// Async because the audio-codec policy has to know what the source's audio
|
|
// is before it can decide whether the file may be copied verbatim (DR-171).
|
|
// The frontend calls this exactly as before — the decision stays in Rust.
|
|
Ok(crate::repository::resolve_video_download_url(
|
|
repo.as_ref(),
|
|
&item_id,
|
|
&quality,
|
|
media_source_id.as_deref(),
|
|
)
|
|
.await)
|
|
}
|
|
|
|
/// Mark an item as favorite
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_mark_favorite(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
) -> Result<(), String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.mark_favorite(&item_id)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Tauri event announcing that favourite state changed behind the UI's back —
|
|
/// either because the server disagreed with the cache on a background refresh,
|
|
/// or because pending offline toggles were pushed on reconnect.
|
|
///
|
|
/// TRACES: UR-069 | DR-120
|
|
pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
|
|
|
|
/// Payload for [`FAVORITES_CHANGED_EVENT`] — the ids whose favourite state
|
|
/// actually flipped, so the frontend refreshes those rather than everything.
|
|
///
|
|
/// TRACES: UR-069 | DR-120 | UT-107
|
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct FavoritesChangedEvent {
|
|
pub item_ids: Vec<String>,
|
|
}
|
|
|
|
/// Ids whose favourite state differs between what we showed and what the server
|
|
/// has — favourited elsewhere since the cache was written, or un-favourited
|
|
/// elsewhere.
|
|
///
|
|
/// Pulled out of the command so the "emit nothing when nothing changed" rule is
|
|
/// testable: an unchanged set must leave a quiet page quiet rather than
|
|
/// triggering a refetch on every visit.
|
|
///
|
|
/// TRACES: UR-069 | DR-120 | UT-107
|
|
fn changed_favorite_ids(
|
|
cached: &std::collections::HashSet<String>,
|
|
server: &std::collections::HashSet<String>,
|
|
) -> Vec<String> {
|
|
let mut changed: Vec<String> = server.symmetric_difference(cached).cloned().collect();
|
|
// Deterministic order so the event payload does not depend on hash seeding.
|
|
changed.sort();
|
|
changed
|
|
}
|
|
|
|
/// Everything the viewer has favourited, across libraries, narrowed by scope.
|
|
///
|
|
/// Two-phase like `repository_search`: the local answer returns immediately and
|
|
/// a background server pass emits `favorites-changed` when the server's set
|
|
/// differs. Without the second phase a favourite marked in another client shows
|
|
/// up only on the *second* visit to the page, since the cache-first read hands
|
|
/// back local rows and the refresh is invisible to the frontend.
|
|
///
|
|
/// TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_favorites(
|
|
app: AppHandle,
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
scope: SearchScope,
|
|
options: Option<GetItemsOptions>,
|
|
) -> Result<SearchResult, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
|
|
let cache_result = repo
|
|
.get_favorites_cache_only(scope, options.clone())
|
|
.await
|
|
.unwrap_or_else(|e| {
|
|
debug!("[Favorites] Cache miss/timeout: {:?}", e);
|
|
SearchResult {
|
|
items: Vec::new(),
|
|
total_record_count: 0,
|
|
}
|
|
});
|
|
|
|
// With "Show all server media" off the local answer is authoritative
|
|
// (DR-080) — don't go behind the user's back to the server.
|
|
if !crate::repository::offline::include_catalog_browse() {
|
|
return Ok(cache_result);
|
|
}
|
|
|
|
// Nothing cached yet — a fresh install, or a viewer whose favourites were
|
|
// all marked on another client. Returning the empty result here paints
|
|
// "Nothing favourited yet — tap the heart on anything you like", which is a
|
|
// *wrong* answer, corrected a server round trip later when the background
|
|
// refresh fires `favorites-changed`. Ask the repository for a real answer
|
|
// instead: its `get_favorites` is exactly this read — cache first, server on
|
|
// a miss, saving through — and it applies the same DR-080 gate.
|
|
//
|
|
// TRACES: UR-067 | DR-115
|
|
if !cache_result.has_content() {
|
|
debug!("[Favorites] Nothing cached; answering from the server");
|
|
return repo
|
|
.get_favorites(scope, options)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e));
|
|
}
|
|
|
|
let repo_bg = repo.clone();
|
|
let cached_ids: std::collections::HashSet<String> =
|
|
cache_result.items.iter().map(|i| i.id.clone()).collect();
|
|
tauri::async_runtime::spawn(async move {
|
|
match repo_bg.get_favorites_server_only(scope, options).await {
|
|
Ok(server_result) => {
|
|
let server_ids: std::collections::HashSet<String> =
|
|
server_result.items.iter().map(|i| i.id.clone()).collect();
|
|
let changed = changed_favorite_ids(&cached_ids, &server_ids);
|
|
|
|
if !changed.is_empty() {
|
|
let event = FavoritesChangedEvent { item_ids: changed };
|
|
if let Err(e) = app.emit(FAVORITES_CHANGED_EVENT, &event) {
|
|
error!("[Favorites] Failed to emit change event: {}", e);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!(
|
|
"[Favorites] Server refresh failed, keeping cached favourites: {:?}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(cache_result)
|
|
}
|
|
|
|
/// Unmark an item as favorite
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_unmark_favorite(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
) -> Result<(), String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.unmark_favorite(&item_id)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get person details
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_person(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
person_id: String,
|
|
) -> Result<MediaItem, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_person(&person_id)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get items by person (actor, director, etc.)
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_items_by_person(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
person_id: String,
|
|
options: Option<GetItemsOptions>,
|
|
) -> Result<SearchResult, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_items_by_person(&person_id, options)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
/// Get similar/related items for a media item
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_similar_items(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
limit: Option<usize>,
|
|
) -> Result<SearchResult, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_similar_items(&item_id, limit)
|
|
.await
|
|
.map_err(|e| format!("{:?}", e))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_repository_manager_creation() {
|
|
let manager = RepositoryManager::new();
|
|
// A freshly created manager holds no repositories
|
|
assert!(manager.get("any-handle").is_none());
|
|
}
|
|
|
|
fn ids(values: &[&str]) -> std::collections::HashSet<String> {
|
|
values.iter().map(|v| v.to_string()).collect()
|
|
}
|
|
|
|
/// UT-107 — the background refresh reports only what actually changed.
|
|
///
|
|
/// TRACES: UR-069 | DR-120 | UT-107
|
|
#[test]
|
|
fn test_changed_favorite_ids_reports_both_directions() {
|
|
// Favourited in another client since we cached.
|
|
assert_eq!(
|
|
changed_favorite_ids(&ids(&["a"]), &ids(&["a", "b"])),
|
|
vec!["b".to_string()]
|
|
);
|
|
|
|
// Un-favourited in another client.
|
|
assert_eq!(
|
|
changed_favorite_ids(&ids(&["a", "b"]), &ids(&["a"])),
|
|
vec!["b".to_string()]
|
|
);
|
|
|
|
// Both at once, in a stable order.
|
|
assert_eq!(
|
|
changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "c"])),
|
|
vec!["a".to_string(), "c".to_string()]
|
|
);
|
|
}
|
|
|
|
/// An unchanged set emits nothing — otherwise every visit to the page would
|
|
/// fire an event and trigger a pointless refetch.
|
|
///
|
|
/// TRACES: UR-069 | DR-120 | UT-107
|
|
#[test]
|
|
fn test_changed_favorite_ids_is_empty_when_nothing_moved() {
|
|
assert!(changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "a"])).is_empty());
|
|
assert!(changed_favorite_ids(&ids(&[]), &ids(&[])).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_repository_manager_wrapper_structure() {
|
|
let manager = RepositoryManager::new();
|
|
let wrapper = RepositoryManagerWrapper(manager);
|
|
// The wrapper exposes the underlying manager, which starts empty
|
|
assert!(wrapper.0.get("any-handle").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_repository_manager_get_nonexistent() {
|
|
let manager = RepositoryManager::new();
|
|
// Getting a non-existent repository should return None
|
|
let result = manager.get("nonexistent-handle");
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_uuid_handle_generation() {
|
|
let uuid = Uuid::new_v4();
|
|
let handle = format!("{}", uuid);
|
|
// UUID should convert to a non-empty string
|
|
assert!(!handle.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_uuid_handles_are_unique() {
|
|
let handle1 = format!("{}", Uuid::new_v4());
|
|
let handle2 = format!("{}", Uuid::new_v4());
|
|
// Two generated UUIDs should be different
|
|
assert_ne!(handle1, handle2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_uuid_handle_format() {
|
|
let uuid = Uuid::new_v4();
|
|
let handle = format!("{}", uuid);
|
|
// UUID should have standard format with hyphens
|
|
let parts: Vec<&str> = handle.split('-').collect();
|
|
assert_eq!(parts.len(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_repository_manager_destroy_nonexistent() {
|
|
let manager = RepositoryManager::new();
|
|
// Destroying a non-existent repository should not panic
|
|
manager.destroy("nonexistent-handle");
|
|
}
|
|
|
|
#[test]
|
|
fn test_repository_manager_is_send_sync() {
|
|
// Verify RepositoryManager can be used in async contexts
|
|
fn is_send_sync<T: Send + Sync>() {}
|
|
is_send_sync::<RepositoryManager>();
|
|
}
|
|
|
|
#[test]
|
|
fn test_repository_manager_wrapper_is_send_sync() {
|
|
// Verify RepositoryManagerWrapper is Send + Sync
|
|
fn is_send_sync<T: Send + Sync>() {}
|
|
is_send_sync::<RepositoryManagerWrapper>();
|
|
}
|
|
|
|
#[test]
|
|
fn test_multiple_manager_instances() {
|
|
let manager1 = RepositoryManager::new();
|
|
let manager2 = RepositoryManager::new();
|
|
|
|
// Multiple manager instances should be independent
|
|
let handle1_nonexistent = manager1.get("test");
|
|
let handle2_nonexistent = manager2.get("test");
|
|
|
|
assert!(handle1_nonexistent.is_none());
|
|
assert!(handle2_nonexistent.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_handle_string_properties() {
|
|
let uuid = Uuid::new_v4();
|
|
let handle = format!("{}", uuid);
|
|
|
|
// Handle should be alphanumeric with hyphens
|
|
for c in handle.chars() {
|
|
assert!(c.is_alphanumeric() || c == '-');
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_repository_manager_concurrent_access() {
|
|
let manager = Arc::new(RepositoryManager::new());
|
|
let mut handles = vec![];
|
|
|
|
// Verify manager can be wrapped in Arc for concurrent access
|
|
for _ in 0..3 {
|
|
let mgr = Arc::clone(&manager);
|
|
let handle = std::thread::spawn(move || {
|
|
let result = mgr.get("test");
|
|
assert!(result.is_none());
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
for h in handles {
|
|
h.join().unwrap();
|
|
}
|
|
}
|
|
}
|