mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.
That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.
Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.
DR-238/246 a seek routed by the stream's container rather than by what the
engine could do with it - correct only while one player handled
those streams, silent the moment another did
DR-239 a property handled but never observed, so the play/pause button
waited for an event that could not arrive
DR-240 fullscreen expanding the document while the window stayed put
DR-241 a seek issued before the engine had a file, failed, and discarded
- which is why resume began at zero
DR-247 a Linux-only gate outliving the caller that made it Linux-only,
breaking the Android build outright
DR-250 a stop aimed at whichever renderer bookkeeping believed was in
charge, missing the one actually making sound
DR-251 a duration of zero believed, leaving the seek bar no scale
DR-252 a junk float converted to a Duration, panicking the backend the
instant a length-less stream appeared
So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.
Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.
Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.
Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.
Squashed from worktree-linux-native-video, which keeps the per-defect history.
1219 lines
40 KiB
Rust
1219 lines
40 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, StreamSelection,
|
|
};
|
|
|
|
/// 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))
|
|
}
|
|
|
|
/// Decide what stream to play for a video, and describe it.
|
|
///
|
|
/// Replaces `repository_get_video_stream_url` for playback. The returned
|
|
/// [`StreamSelection`] carries the transport explicitly, so the frontend picks
|
|
/// its loader from a tagged enum instead of testing the URL for `.m3u8`; and it
|
|
/// carries the quality ladder as it applies to *this* source, so the picker can
|
|
/// stop offering rungs that produce the same bytes as Original.
|
|
///
|
|
/// No start-position parameter, for the same reason as the URL builder: a
|
|
/// position on an HLS playlist is copied onto every segment URI and the server
|
|
/// rejects each with `400` (DR-181). Callers resume by seeking after load.
|
|
///
|
|
/// TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn repository_get_stream_selection(
|
|
manager: State<'_, RepositoryManagerWrapper>,
|
|
handle: String,
|
|
item_id: String,
|
|
media_source_id: Option<String>,
|
|
audio_stream_index: Option<i32>,
|
|
) -> Result<StreamSelection, String> {
|
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
repo.as_ref()
|
|
.get_stream_selection(&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();
|
|
}
|
|
}
|
|
}
|