Stage 1 of scoped-search-boundary-implementation.md — the query side.
scoped-search-boundary.md diagnosed this leak, specified the fix in
detail, and became the justification for the boundary rule in CLAUDE.md,
the check:boundary tripwire, and the spec-review checklist. The fix was
never built: SCOPE_ITEM_TYPES was still live in searchScope.ts, called by
library.ts, and no SearchScope existed anywhere in src-tauri/. The rule's
own founding violation was still shipping.
Rust now owns the taxonomy:
pub enum SearchScope { All, Music, Movies, Tv }
impl SearchScope { pub fn item_types(self) -> Option<Vec<String>> }
- SearchOptions gains `scope`, resolved by resolve_scope(). Scope wins
over include_item_types, which stays for the non-search get_items
callers that legitimately request one concrete type.
- repository_search resolves the scope ONCE, before the cache/server
paths diverge, so online and offline filter identically — the failure
mode most likely to go unnoticed.
- All expands to None (no filter), not the union of the other scopes:
an explicit includeItemTypes list would silently drop People, folders,
and any type nobody enumerated.
- searchScope.ts re-exports SearchScope from generated bindings instead
of a hand-written union, and no longer names an item type for search.
- library.ts sends { scope }.
8 Rust tests written first, confirmed failing on "use of undeclared type
SearchScope" before the implementation existed.
The frontend tests that asserted includeItemTypes contents were rewritten
to assert the opaque scope is sent and includeItemTypes is absent —
keeping the old assertions would require the frontend to know the
taxonomy again, defeating the fix. The expansion is now asserted in Rust.
Verified the spec's headline criterion by hashing every src/ file, adding
"AudioBook" to the Music scope in Rust, and re-hashing: zero frontend
files change. That criterion failed before this commit.
Stage 2 (result-side grouping: GROUP_ITEM_TYPES, GroupedSearchResult on
both search payloads) remains open.
897 lines
28 KiB
Rust
897 lines
28 KiB
Rust
//! Tauri commands for repository access
|
|
//! Uses handle-based system: UUID -> Arc<HybridRepository>
|
|
//!
|
|
//! TRACES: UR-007, UR-035, UR-036 | JA-004, JA-005, 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::{
|
|
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()
|
|
}
|
|
|
|
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]
|
|
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)
|
|
#[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
|
|
#[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))
|
|
}
|
|
|
|
/// 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 video stream URL with optional seeking support
|
|
#[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>,
|
|
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_video_stream_url(
|
|
&item_id,
|
|
media_source_id.as_deref(),
|
|
start_time_seconds,
|
|
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
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_report_playback_stopped(
|
|
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")?;
|
|
repo.as_ref()
|
|
.report_playback_stopped(&item_id, position_ticks)
|
|
.await
|
|
.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 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")?;
|
|
Ok(repo
|
|
.as_ref()
|
|
.get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
|
|
}
|
|
|
|
/// 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))
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
|
|
#[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());
|
|
assert!(handle.len() > 0);
|
|
}
|
|
|
|
#[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();
|
|
}
|
|
}
|
|
}
|