Files
jellytau/src-tauri/src/lib.rs
T
dtourolle 2f637d4775 feat(video): let mpv decode video at all, behind one shared flag
mpv has never decoded a video frame in this app: the backend sets `video: no`
unconditionally, because Linux video has always been the webview's job and
decoding it twice would burn a core for a picture nobody sees. The render path
built in the previous commit therefore had nothing to draw.

With native video on, mpv is configured for video *and* `vo=libmpv` — the render
API only works through that output, and the default would try to open a window
of its own. Set at construction, because mpv resolves its video output when it
initialises and flipping the property later does not re-open one.

The flag lives in `player::native_video`, read by all three things that must
agree: the backend (configured before anything plays), the surface (nothing to
draw otherwise), and `get_player_status` (which tells the frontend whether to
use a `<video>` element — two decoders on one stream would fight over the
audio). A function rather than three `env::var` checks, because a capability
answered in several places is a capability whose answers drift: four separate
bugs this cycle came from exactly that shape.

Also fixes an ordering bug the first run exposed. The surface was attached in
`setup` before the player backend was constructed, and the mpv handle is
registered *during* that construction — so it found nothing every time and
logged "no mpv handle". Attaching after the backend exists is the whole fix.

Confirmed on a real run: mpv accepts `vo=libmpv`, the GL context comes up on
Tauri's vbox, and `mpv_render_context_create` succeeds — which also proves the
libepoxy data-symbol handling is right, since a wrong `get_proc_address` would
have taken SIGSEGV on the first GL call rather than returning cleanly.

No frame has reached the screen yet. The webview is still opaque, so it will
paint over anything drawn beneath it until transparency is set up.

Security: quick-xml 0.38.4 carried RUSTSEC-2026-0194 (quadratic parse on
duplicate attribute names) and RUSTSEC-2026-0195 (unbounded namespace
allocation, memory-exhaustion DoS). `cargo deny` gates CI on advisories, so this
would have failed the next release. Fixed by plist 1.8 -> 1.10, which pulls
quick-xml 0.41. Licences, bans and sources still pass.

UT-216 pins the flag's parsing: absent, empty, `0`, `no` and anything
unrecognised all mean off. A half-set variable that half-enabled the renderer
would configure mpv for video with nothing drawing it — audio over a black
rectangle.

Also removes a wall-clock timer from the waitForRepository late-arrival test,
which failed once under load. The assertion is about ordering, so it now
publishes on a microtask and cannot race.
2026-08-22 13:45:04 +02:00

1663 lines
66 KiB
Rust

#[cfg(target_os = "android")]
mod android_context;
mod auth;
mod commands;
mod connectivity;
mod credentials;
mod domain;
mod download;
mod jellyfin;
mod media_server;
mod playback_mode;
mod playback_reporting;
mod player;
mod repository;
mod session_poller;
pub mod settings;
mod storage;
mod thumbnail;
pub mod utils;
#[cfg(target_os = "android")]
use log::warn;
use log::{error, info};
use std::sync::{Arc, Mutex};
use tauri::{Emitter, Manager};
use tauri_specta::Builder;
use tokio::sync::Mutex as TokioMutex;
use auth::AuthManager;
use commands::{
auth_connect_to_server,
auth_get_session,
// Auth commands
auth_initialize,
auth_login,
auth_logout,
auth_reauthenticate,
auth_set_session,
auth_start_verification,
auth_stop_verification,
auth_verify_session,
calc_progress,
cancel_download,
catalog_sync_status,
clear_stale_downloads,
// Connectivity commands
connectivity_check_server,
connectivity_get_status,
connectivity_mark_reachable,
connectivity_mark_unreachable,
connectivity_set_server_url,
connectivity_start_monitoring,
connectivity_stop_monitoring,
convert_percent_to_volume,
convert_ticks_to_seconds,
delete_album_downloads,
delete_all_downloads,
delete_download,
delete_downloads_under,
// Device commands
device_get_id,
device_set_id,
// Diagnostics commands
diagnostics_export,
diagnostics_get_info,
diagnostics_set_level,
download_album,
download_item,
download_item_and_start,
download_season,
download_series,
download_video,
enqueue_download,
enqueue_video_downloads,
// Conversion commands
format_time_seconds,
format_time_seconds_long,
get_album_affinity_status,
get_album_recommendations,
get_download_manager_stats,
get_download_storage_stats,
get_downloads,
get_downloads_allowed,
get_smart_cache_config,
get_smart_cache_stats,
image_get_url,
is_item_pinned,
// Library browsing preferences (hidden folders)
library_get_exclusion_candidates,
library_get_settings,
library_set_settings,
lms_create_sync_group,
lms_dissolve_sync_group,
// LMS multi-room sync group commands
lms_get_sync_groups,
lms_unsync_player,
mark_download_completed,
mark_download_failed,
media_local_selection,
media_local_url,
offline_get_items,
offline_is_available,
offline_search,
pause_download,
pin_item,
playback_mark_played,
// Playback mode commands
playback_mode_get_current,
playback_mode_get_remote_status,
playback_mode_is_transferring,
playback_mode_set,
playback_mode_set_transferring,
playback_mode_transfer_to_local,
playback_mode_transfer_to_remote,
playback_report_progress,
playback_report_start,
playback_report_stopped,
playback_reporter_destroy,
// Playback reporting commands
playback_reporter_init,
// Queue manipulation commands
player_add_to_queue,
player_add_track_by_id,
player_add_tracks_by_ids,
player_background_action,
player_cancel_autoplay_countdown,
player_cancel_sleep_timer,
// Jellyfin reporting commands
player_configure_jellyfin,
player_cycle_repeat,
player_disable_jellyfin,
player_dismiss_session,
player_enter_background_audio,
player_exit_background_audio,
player_get_audio_settings,
player_get_autoplay_settings,
player_get_cache_config,
player_get_capabilities,
player_get_eq_presets,
player_get_queue,
// Session management commands
player_get_session,
player_get_sleep_timer,
player_get_status,
player_get_streaming_qualities,
player_get_video_settings,
// Preload commands
player_local_media_path,
player_move_in_queue,
player_next,
player_on_playback_ended,
player_pause,
player_play,
player_play_album_track,
player_play_item,
player_play_next_episode,
player_play_queue,
player_play_tracks,
player_preload_upcoming,
player_previous,
player_recover_stream,
player_remove_from_queue,
player_report_media_loaded,
player_report_position,
// HTML5 video state-report commands
player_report_state,
player_seek,
player_seek_video,
player_set_audio_settings,
player_set_audio_track,
player_set_autoplay_settings,
player_set_cache_config,
// Sleep timer and autoplay commands
player_set_sleep_timer,
player_set_stream_quality,
player_set_subtitle_track,
player_set_video_settings,
player_set_volume,
player_skip_to,
player_stop,
player_switch_audio_track,
player_toggle,
player_toggle_mute,
player_toggle_shuffle,
playlist_add_items,
// Playlist commands
playlist_create,
playlist_delete,
playlist_get_items,
playlist_move_item,
playlist_remove_items,
playlist_rename,
// Remote session control commands
remote_play_on_session,
remote_send_command,
remote_session_seek,
remote_session_set_volume,
remote_session_toggle_mute,
// Repository commands
repository_clear_watch_history,
repository_create,
repository_destroy,
repository_get_audio_only_stream_url_for_video,
repository_get_audio_stream_url,
repository_get_channels,
repository_get_download_disk_usage,
repository_get_downloaded_items,
repository_get_downloaded_libraries,
repository_get_favorites,
repository_get_genres,
repository_get_image_url,
repository_get_item,
repository_get_items,
repository_get_items_by_person,
repository_get_latest_items,
repository_get_libraries,
repository_get_live_tv_channels,
repository_get_next_up_episodes,
repository_get_person,
repository_get_playback_info,
repository_get_recently_played_audio,
repository_get_rediscover_albums,
repository_get_resume_items,
repository_get_resume_movies,
repository_get_series_current_episode,
repository_get_series_episodes,
repository_get_similar_items,
repository_get_stream_selection,
repository_get_subtitle_url,
repository_get_video_download_url,
repository_get_video_stream_url,
repository_jray_actors_at,
repository_mark_favorite,
repository_open_live_stream,
repository_report_playback_progress,
repository_report_playback_start,
repository_report_playback_stopped,
repository_search,
repository_unmark_favorite,
resume_download,
resume_queued_downloads,
sessions_poll_now,
// Session polling commands
sessions_set_polling_hint,
set_max_concurrent_downloads,
set_network_state,
set_show_server_catalog,
start_download,
// Storage commands
storage_delete_server,
storage_delete_user,
storage_get_access_token,
storage_get_active_session,
storage_get_active_user,
storage_get_item,
storage_get_item_people,
storage_get_items,
// Offline cache commands
storage_get_libraries,
storage_get_path,
storage_get_pending_sync_count,
storage_get_person,
storage_get_playback_progress,
storage_get_security_status,
storage_get_series_audio_preference,
storage_get_servers,
storage_get_size,
storage_get_users,
storage_init,
storage_mark_played,
storage_mark_synced,
storage_save_item,
storage_save_item_people,
storage_save_library,
// People cache commands
storage_save_person,
// Series audio preferences
storage_save_series_audio_preference,
storage_save_server,
storage_save_user,
storage_search_items,
storage_set_active_user,
storage_set_watched,
storage_toggle_favorite,
storage_update_playback_context,
storage_update_playback_progress,
sync_cleanup_completed,
sync_clear_user,
sync_full_catalog,
sync_get_pending,
sync_get_pending_count,
sync_mark_completed,
sync_mark_failed,
sync_mark_processing,
sync_process_pending,
// Sync queue commands
sync_queue_mutation,
thumbnail_clear_cache,
thumbnail_delete_item,
// Thumbnail cache and image commands
thumbnail_get_cached,
thumbnail_get_stats,
thumbnail_save,
thumbnail_set_limit,
unpin_item,
update_smart_cache_config,
AuthManagerWrapper,
ConnectivityMonitorWrapper,
CredentialStoreWrapper,
DatabaseWrapper,
DownloadManagerWrapper,
MediaSessionManagerWrapper,
PlaybackModeManagerWrapper,
PlaybackReporterWrapper,
PlayerStateWrapper,
RepositoryManagerWrapper,
SessionPollerWrapper,
SessionVerifierWrapper,
SmartCacheWrapper,
ThumbnailCacheWrapper,
VideoSettingsWrapper,
};
use connectivity::ConnectivityMonitor;
use credentials::CredentialStore;
use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
use download::DownloadManager;
use jellyfin::{HttpClient, HttpConfig};
#[cfg(target_os = "android")]
use playback_mode::PlaybackModeManager;
// Only the Android MediaSessionHandler resolves lockscreen skips; on other
// targets this would be an unused import.
#[cfg(target_os = "android")]
use player::seek::{resolve_skip_action, SkipAction};
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
// NullBackend is used both for platforms without a native backend AND as a graceful
// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
// still launch (browse library, manage downloads, see an error) instead of crashing.
use player::NullBackend;
#[cfg(target_os = "linux")]
use player::MpvBackend;
use settings::VideoSettings;
use storage::Database;
use thumbnail::{CacheConfig as ThumbnailCacheConfig, ThumbnailCache};
#[cfg(target_os = "android")]
use credentials::initialize_secure_storage;
#[cfg(target_os = "android")]
use player::ExoPlayerBackend;
#[cfg(target_os = "android")]
use player::{
set_media_command_handler, set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
};
/// Handler for media commands from Android MediaSession (lockscreen/notification controls).
///
/// Routes commands from the system media controls to the right place depending on
/// playback mode: in local mode it drives the local `PlayerController`; in remote
/// (cast) mode it forwards transport commands to the remote Jellyfin session so
/// the lockscreen can control whatever is casting. Stop while casting requests a
/// disconnect back to local playback.
#[cfg(target_os = "android")]
struct MediaSessionHandler {
player: Arc<TokioMutex<PlayerController>>,
playback_mode: Arc<PlaybackModeManager>,
event_emitter: Arc<TauriEventEmitter>,
}
#[cfg(target_os = "android")]
impl MediaSessionHandler {
/// Forward a transport command to the active remote Jellyfin session.
///
/// Runs async on the Tauri runtime because JNI callbacks arrive on arbitrary
/// threads without a Tokio context.
fn handle_remote_command(&self, command: &str, session_id: String) {
use crate::player::{PlayerEventEmitter, PlayerStatusEvent};
// Stop while casting means "disconnect and resume locally". The frontend
// owns the remote->local transfer (it reloads the item locally), so we
// just signal intent.
if command == "stop" {
self.event_emitter
.emit(PlayerStatusEvent::RemoteDisconnectRequested);
return;
}
let jellyfin_client = {
let player = self.player.blocking_lock();
player.jellyfin_client()
};
let command = command.to_string();
tauri::async_runtime::spawn(async move {
let client = {
let guard = match jellyfin_client.lock() {
Ok(g) => g,
Err(e) => {
error!("[MediaSession] Failed to lock Jellyfin client: {}", e);
return;
}
};
match guard.as_ref() {
Some(c) => c.clone(),
None => {
warn!("[MediaSession] No Jellyfin client for remote command");
return;
}
}
};
// Map lockscreen transport commands onto Jellyfin session commands.
let result = match command.as_str() {
"play" => client.send_session_command(session_id, "Unpause").await,
"pause" => client.send_session_command(session_id, "Pause").await,
"next" => client.send_session_command(session_id, "NextTrack").await,
"previous" => {
client
.send_session_command(session_id, "PreviousTrack")
.await
}
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
Ok(seconds) => {
let ticks = (seconds * 10_000_000.0) as i64;
client.session_seek(session_id, ticks).await
}
Err(_) => {
warn!("[MediaSession] Bad seek command: {}", command);
Ok(())
}
},
_ => {
warn!("[MediaSession] Unknown remote command: {}", command);
Ok(())
}
};
if let Err(e) = result {
error!("[MediaSession] Remote command '{}' failed: {}", command, e);
}
});
}
/// Drive the local player for a transport command.
fn handle_local_command(&self, command: &str) {
// A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
// whole episode — and resolving it during a background-audio handoff means
// re-opening the stream, which is async. So it runs on the runtime and,
// critically, is handled *before* the blocking lock below: taking that
// guard and then spawning a task that waits for the same mutex would
// deadlock the media session. (DR-159)
if let Some(raw) = command.strip_prefix("seek:") {
match raw.parse::<f64>() {
Ok(position) => {
let player = self.player.clone();
tokio::spawn(async move {
let controller = player.lock().await;
if let Err(e) = controller.seek_absolute(position).await {
error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
}
});
}
Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
}
return;
}
// Skip means different things depending on what is actually playing, so
// the decision belongs here rather than in the Kotlin that drew the
// button: music advances the queue, while a video whose audio is running
// through a background-audio handoff scrubs instead (UR-040). Routed
// through the same spawn-and-seek path as "seek:" above, because
// `seek_absolute` rebuilds the stream during a handoff and must not run
// under the blocking lock (DR-159).
//
// TRACES: UR-040, UR-006 | DR-201
if command == "next" || command == "previous" {
let is_next = command == "next";
let player = self.player.clone();
tokio::spawn(async move {
let controller = player.lock().await;
let action = resolve_skip_action(
is_next,
controller.is_background_audio_active(),
controller.position(),
controller.duration(),
);
let label = if is_next { "next" } else { "previous" };
let result: Result<(), String> = match action {
SkipAction::Advance => if is_next {
controller.next()
} else {
controller.previous()
}
.map_err(|e| e.to_string()),
SkipAction::SeekTo(position) => {
info!(
"[MediaSession] Background audio: '{}' scrubs to {:.1}s",
label, position
);
controller.seek_absolute(position).await
}
};
if let Err(e) = result {
error!("[MediaSession] Skip '{}' failed: {}", label, e);
}
});
return;
}
// Use blocking_lock since this is called from a non-async JNI callback
let controller = self.player.blocking_lock();
let result = match command {
"play" => controller.play(),
"pause" => controller.pause(),
"stop" => controller.stop(),
_ => {
warn!("[MediaSession] Unknown command: {}", command);
Ok(())
}
};
if let Err(e) = result {
error!("[MediaSession] Command '{}' failed: {}", command, e);
}
}
}
#[cfg(target_os = "android")]
impl MediaCommandHandler for MediaSessionHandler {
fn on_command(&self, command: &str) {
match self.playback_mode.get_mode() {
playback_mode::PlaybackMode::Remote { session_id } => {
self.handle_remote_command(command, session_id);
}
_ => self.handle_local_command(command),
}
}
}
/// Handler for remote volume changes from Android volume buttons when in remote playback mode.
///
/// Routes volume commands to the Jellyfin session via the playback mode manager.
#[cfg(target_os = "android")]
struct RemoteVolumeSessionHandler {
playback_mode: Arc<PlaybackModeManager>,
}
#[cfg(target_os = "android")]
impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
fn on_remote_volume_change(&self, command: &str, volume: i32) {
log::info!("[RemoteVolume] Command: {}, Volume: {}", command, volume);
// Send the volume command to the remote session asynchronously
let playback_mode = Arc::clone(&self.playback_mode);
let command_str = command.to_string();
// Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime
log::info!("[RemoteVolume] Spawning async task to send volume command...");
tauri::async_runtime::spawn(async move {
log::info!("[RemoteVolume] Async task started, calling send_remote_volume_command...");
match playback_mode
.send_remote_volume_command(&command_str, volume)
.await
{
Ok(_) => log::info!("[RemoteVolume] Volume command completed successfully"),
Err(e) => log::error!("[RemoteVolume] Failed to send volume command: {}", e),
}
log::info!("[RemoteVolume] Async task completed");
});
log::info!("[RemoteVolume] Async task spawned, returning from JNI callback");
}
}
/// Payload emitted to the frontend when a native player backend fails to
/// initialize and the app falls back to a no-op backend.
#[derive(Clone, serde::Serialize)]
struct BackendInitError {
platform: &'static str,
backend: &'static str,
message: String,
}
/// Log a backend-initialization failure and notify the frontend, so the UI can
/// surface "playback unavailable" instead of the app hard-crashing.
fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str, message: String) {
error!(
"[INIT] Player backend '{}' failed to initialize: {}. Falling back to NullBackend (playback disabled).",
backend, message
);
let _ = app_handle.emit(
"backend-init-failed",
BackendInitError {
platform: std::env::consts::OS,
backend,
message,
},
);
}
/// Create the appropriate player backend for the current platform.
// playback_reporter/position_throttler are consumed only by the native audio
// backends (mpv/exo); on platforms using the webview audio backend they're unused.
#[cfg_attr(
not(any(target_os = "linux", target_os = "android")),
allow(unused_variables)
)]
fn create_player_backend(
app_handle: tauri::AppHandle,
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
position_throttler: Arc<playback_reporting::EventThrottler>,
) -> Box<dyn PlayerBackend> {
let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle.clone()));
#[cfg(target_os = "android")]
{
info!("Android platform detected - initializing ExoPlayer backend");
// Same precondition as the credential path: ndk_context must be
// populated before it is read, and nothing outside this crate populates
// it any more. Idempotent, so it does not matter which of the two runs
// first. TRACES: UR-012 | DR-223
if let Err(e) = crate::android_context::ensure_initialized() {
log::error!("[INIT] Android context unavailable for the player: {e}");
}
// Get the Android context via ndk-context
let ctx = ndk_context::android_context();
// Get JavaVM and create JNI environment
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
match vm {
Ok(java_vm) => {
match java_vm.attach_current_thread() {
Ok(mut env) => {
let context_obj =
unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
match ExoPlayerBackend::new(
&mut env,
&context_obj,
_event_emitter.clone(),
playback_reporter.clone(),
position_throttler.clone(),
) {
Ok(backend) => {
info!("Successfully initialized ExoPlayer backend for Android");
return Box::new(backend);
}
Err(e) => {
// Degrade gracefully instead of crashing the app.
emit_backend_init_failed(&app_handle, "exoplayer", e.to_string());
return Box::new(NullBackend::new());
}
}
}
Err(e) => {
emit_backend_init_failed(
&app_handle,
"exoplayer",
format!("attach JNI thread failed: {}", e),
);
return Box::new(NullBackend::new());
}
}
}
Err(e) => {
emit_backend_init_failed(
&app_handle,
"exoplayer",
format!("create JavaVM failed: {}", e),
);
return Box::new(NullBackend::new());
}
}
}
// For Linux, use MPV backend for audio playback
#[cfg(target_os = "linux")]
{
info!("Linux platform detected - initializing MPV backend for audio");
match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
Ok(backend) => {
info!("Successfully initialized MPV backend for Linux");
Box::new(backend)
}
Err(e) => {
error!("\n========================================");
error!("FATAL ERROR: Failed to initialize MPV backend");
error!("========================================");
error!("Error: {}", e);
error!("\nCommon causes:");
error!(" 1. MPV is not installed");
error!(" Solution: Install MPV using your package manager");
error!(" - Arch/CachyOS: sudo pacman -S mpv");
error!(" - Ubuntu/Debian: sudo apt install mpv libmpv-dev");
error!(" - Fedora: sudo dnf install mpv mpv-libs-devel");
error!("\n 2. MPV version mismatch (app was built with different libmpv version)");
error!(" Solution: Rebuild the application");
error!(" - cd src-tauri && cargo clean && cargo build --release");
error!("\n 3. Audio system not working");
error!(" Solution: Verify audio works with: pactl info");
error!("\nAudio playback will NOT work until this is fixed.");
error!("========================================\n");
// Degrade gracefully: launch with a no-op backend so the user can
// still browse the library and manage downloads, and the frontend
// can show a "playback unavailable" notice via this event.
emit_backend_init_failed(&app_handle, "mpv", e.to_string());
Box::new(NullBackend::new())
}
}
}
// Platforms with no native audio backend (e.g. Windows): render audio-only
// playback through a webview <audio> element (all video already renders in
// the webview). Falls back to NullBackend only if the backend can't init.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
{
info!("No native audio backend for this platform - using webview <audio> backend");
match player::WebviewAudioBackend::new(_event_emitter) {
Ok(backend) => Box::new(backend),
Err(e) => {
emit_backend_init_failed(&app_handle, "webview-audio", e.to_string());
Box::new(NullBackend::new())
}
}
}
}
/// Construct the tauri-specta command builder. Shared by `run()` and the
/// bindings-export test so the TypeScript bindings always match the handler.
fn specta_builder() -> Builder<tauri::Wry> {
Builder::<tauri::Wry>::new()
// Throw on error so generated `commands.*` return Promise<T> and throw,
// matching the existing frontend's invoke() try/catch convention.
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
.events(tauri_specta::collect_events![
crate::player::events::PlayerStatusEvent
])
.commands(tauri_specta::collect_commands![
// Player commands
player_play_item,
player_background_action,
player_enter_background_audio,
player_exit_background_audio,
player_play_queue,
player_play_album_track,
player_play_tracks,
player_play,
player_pause,
player_toggle,
player_stop,
player_next,
player_previous,
player_seek,
player_seek_video,
player_set_volume,
player_toggle_mute,
player_set_audio_track,
player_switch_audio_track,
player_set_subtitle_track,
player_toggle_shuffle,
player_cycle_repeat,
player_get_status,
player_get_queue,
player_get_capabilities,
player_add_to_queue,
player_add_track_by_id,
player_add_tracks_by_ids,
player_remove_from_queue,
player_move_in_queue,
player_skip_to,
player_set_audio_settings,
player_get_audio_settings,
player_get_eq_presets,
player_set_video_settings,
player_get_video_settings,
player_get_streaming_qualities,
player_set_stream_quality,
// Sleep timer and autoplay commands
player_set_sleep_timer,
player_cancel_sleep_timer,
player_get_sleep_timer,
player_get_autoplay_settings,
player_set_autoplay_settings,
player_cancel_autoplay_countdown,
player_play_next_episode,
player_on_playback_ended,
player_recover_stream,
player_report_state,
player_report_position,
player_report_media_loaded,
// Preload commands
player_local_media_path,
player_preload_upcoming,
player_set_cache_config,
player_get_cache_config,
// Jellyfin reporting commands
player_configure_jellyfin,
player_disable_jellyfin,
// Session management commands
player_get_session,
player_dismiss_session,
// Remote session control commands
remote_play_on_session,
remote_send_command,
remote_session_seek,
remote_session_set_volume,
remote_session_toggle_mute,
// LMS multi-room sync group commands
lms_get_sync_groups,
lms_create_sync_group,
lms_unsync_player,
lms_dissolve_sync_group,
// Session polling commands
sessions_set_polling_hint,
sessions_poll_now,
// Playback mode commands
playback_mode_get_current,
playback_mode_set,
playback_mode_is_transferring,
playback_mode_transfer_to_remote,
playback_mode_get_remote_status,
playback_mode_transfer_to_local,
playback_mode_set_transferring,
// Playback reporting commands
playback_reporter_init,
playback_reporter_destroy,
playback_report_start,
playback_report_progress,
playback_report_stopped,
playback_mark_played,
// Auth commands
auth_initialize,
auth_connect_to_server,
auth_login,
auth_verify_session,
auth_logout,
auth_get_session,
auth_set_session,
auth_start_verification,
auth_stop_verification,
auth_reauthenticate,
// Device commands
device_get_id,
device_set_id,
// Connectivity commands
connectivity_check_server,
connectivity_set_server_url,
connectivity_get_status,
connectivity_start_monitoring,
connectivity_stop_monitoring,
connectivity_mark_reachable,
connectivity_mark_unreachable,
// Storage commands
storage_init,
storage_get_path,
storage_get_size,
storage_get_security_status,
storage_save_server,
storage_get_servers,
storage_delete_server,
storage_save_user,
storage_get_users,
storage_set_active_user,
storage_get_active_user,
storage_get_active_session,
storage_get_access_token,
storage_delete_user,
// Playback progress commands
storage_update_playback_progress,
storage_update_playback_context,
storage_mark_played,
storage_set_watched,
storage_get_playback_progress,
storage_mark_synced,
storage_toggle_favorite,
// Download commands
download_item,
download_item_and_start,
download_album,
download_video,
download_series,
download_season,
get_downloads,
pause_download,
resume_download,
cancel_download,
delete_download,
delete_all_downloads,
delete_album_downloads,
delete_downloads_under,
clear_stale_downloads,
get_download_storage_stats,
mark_download_completed,
mark_download_failed,
media_local_url,
media_local_selection,
start_download,
enqueue_download,
enqueue_video_downloads,
sync_full_catalog,
catalog_sync_status,
set_show_server_catalog,
// Library browsing preferences (UR-076 / DR-209)
library_get_settings,
library_set_settings,
library_get_exclusion_candidates,
resume_queued_downloads,
get_download_manager_stats,
set_max_concurrent_downloads,
get_smart_cache_stats,
update_smart_cache_config,
get_smart_cache_config,
// WiFi-only download gate (UR-053)
set_network_state,
get_downloads_allowed,
get_album_recommendations,
get_album_affinity_status,
// Pinning commands
pin_item,
unpin_item,
is_item_pinned,
// Offline commands
offline_is_available,
offline_get_items,
offline_search,
// Offline cache commands
storage_get_libraries,
storage_get_items,
storage_get_item,
storage_search_items,
storage_save_library,
storage_save_item,
storage_get_pending_sync_count,
// Sync queue commands
sync_queue_mutation,
sync_get_pending,
sync_mark_processing,
sync_mark_completed,
sync_mark_failed,
sync_get_pending_count,
sync_process_pending,
sync_cleanup_completed,
sync_clear_user,
// Thumbnail cache and image commands
thumbnail_get_cached,
thumbnail_save,
thumbnail_get_stats,
thumbnail_set_limit,
thumbnail_clear_cache,
thumbnail_delete_item,
image_get_url,
// People cache commands
storage_save_person,
storage_get_person,
storage_save_item_people,
storage_get_item_people,
// Series audio preferences
storage_save_series_audio_preference,
storage_get_series_audio_preference,
// Repository commands
repository_create,
repository_destroy,
repository_get_libraries,
repository_get_items,
repository_get_item,
repository_get_downloaded_libraries,
repository_get_downloaded_items,
repository_get_download_disk_usage,
repository_jray_actors_at,
repository_get_latest_items,
repository_get_resume_items,
repository_get_next_up_episodes,
repository_get_series_episodes,
repository_get_series_current_episode,
repository_clear_watch_history,
repository_get_recently_played_audio,
repository_get_resume_movies,
repository_get_rediscover_albums,
repository_get_genres,
repository_search,
repository_get_playback_info,
repository_get_video_stream_url,
repository_get_stream_selection,
repository_get_audio_stream_url,
repository_get_audio_only_stream_url_for_video,
repository_get_live_tv_channels,
repository_get_channels,
repository_open_live_stream,
repository_report_playback_start,
repository_report_playback_progress,
repository_report_playback_stopped,
repository_get_image_url,
repository_mark_favorite,
repository_unmark_favorite,
repository_get_favorites,
repository_get_person,
repository_get_items_by_person,
repository_get_similar_items,
repository_get_subtitle_url,
repository_get_video_download_url,
// Playlist commands
playlist_create,
playlist_delete,
playlist_rename,
playlist_get_items,
playlist_add_items,
playlist_remove_items,
playlist_move_item,
// Diagnostics commands
// TRACES: UR-078 | DR-218
diagnostics_get_info,
diagnostics_set_level,
diagnostics_export,
// Conversion commands
format_time_seconds,
format_time_seconds_long,
convert_ticks_to_seconds,
calc_progress,
convert_percent_to_volume,
])
}
/// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `<video>`
/// element on Linux) to prefer hardware-accelerated VAAPI decoding when the
/// host provides it, falling back to software decoding otherwise.
///
/// All variables are only set if the user has not already exported them, so an
/// explicit override (e.g. forcing software decode for debugging) is respected.
/// They must be applied before WebKitGTK builds its GStreamer pipeline, hence the
/// call at the very top of `run()`.
#[cfg(target_os = "linux")]
fn enable_linux_hardware_video_decoding() {
// Boost the rank of the modern stateless VAAPI decoders (gst-plugins-bad
// `va` plugin) so GStreamer selects them ahead of the software decoders. The
// `MAX` rank wins decoder autoplugging when the hardware/driver supports the
// codec; unsupported codecs simply fall through to software.
let rank_overrides = "vah264dec:MAX,vah265dec:MAX,vavp9dec:MAX,vaav1dec:MAX,\
vampeg2dec:MAX,vavp8dec:MAX";
set_env_if_unset("GST_PLUGIN_FEATURE_RANK", rank_overrides);
// Ensure WebKit keeps GStreamer's hardware/DMABUF video path enabled. Setting
// this to "0" would force software decoding, so only default it to "1".
set_env_if_unset("WEBKIT_GST_ENABLE_HW_VIDEO_DECODER", "1");
info!("[INIT] Linux hardware video decoding (VAAPI) enabled where supported");
log_available_vaapi_decoders();
}
/// Probe (via `gst-inspect-1.0`, which ships with GStreamer) which VAAPI hardware
/// video decoders GStreamer can actually load on this host, and log the result so
/// it is clear at startup whether hardware decoding is genuinely available or
/// whether playback will fall back to software.
#[cfg(target_os = "linux")]
fn log_available_vaapi_decoders() {
const HW_DECODERS: &[&str] = &[
"vah264dec",
"vah265dec",
"vavp9dec",
"vaav1dec",
"vampeg2dec",
"vavp8dec",
];
let available: Vec<&str> = HW_DECODERS
.iter()
.copied()
.filter(|name| {
std::process::Command::new("gst-inspect-1.0")
.arg(name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
})
.collect();
if available.is_empty() {
log::warn!(
"[INIT] No VAAPI hardware video decoders found via gst-inspect-1.0; \
video will use software decoding. Install the GStreamer 'va' plugin \
(gst-plugins-bad) and a VAAPI driver to enable hardware decoding."
);
} else {
info!(
"[INIT] VAAPI hardware video decoders available to GStreamer: {}",
available.join(", ")
);
}
}
#[cfg(target_os = "linux")]
fn set_env_if_unset(key: &str, value: &str) {
if std::env::var_os(key).is_none() {
// SAFETY: called once at startup before any threads that read the
// environment (WebKitGTK/GStreamer) are spawned.
std::env::set_var(key, value);
}
}
/// Cached thumbnails are handed to the webview as asset-protocol URLs by
/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
/// `http://asset.localhost/…` on Windows/Android). Tauri only answers that
/// origin when the `protocol-asset` cargo feature is compiled in *and*
/// `app.security.assetProtocol.enable` is set in `tauri.conf.json`. Both are
/// required together: with either missing the URL resolves to nothing and the
/// webview reports `NETWORK_NO_SOURCE`, which is how offline video came to fail
/// silently.
///
/// The scope is `$APPDATA/thumbnails/**`, not the storage root: downloaded media
/// moved to the loopback media server in DR-137, so `imageCache` is the only
/// remaining `convertFileSrc` caller and the database and the encrypted-token
/// fallback file — which share that root — never need to be readable by the
/// webview. Widen it only if something other than thumbnails starts resolving
/// through `convertFileSrc` again.
///
/// TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198
/// Build the logging plugin.
///
/// Replaces the previous `env_logger` init, which wrote to **stdout only**. That
/// was invisible to anyone who launched from a desktop icon, and worse than
/// useless on Android: stdout is not logcat, so the Rust backend produced no
/// visible output at all on the platform carrying the hardest bugs in this
/// project's history (the autoplay deadlock, the truncated-stream restart, the
/// background-audio stall). tauri-plugin-log routes to logcat there for free.
///
/// Three decisions worth keeping:
///
/// * **Every line goes through `redact` first.** A credential must never reach
/// disk, not merely be stripped later when a bundle is exported — a file on
/// the device is already the disclosure.
/// * **The size cap is deliberate.** `RotationStrategy::KeepAll` would let a
/// long-running session fill a phone. One rotation keeps yesterday's evidence
/// without unbounded growth.
/// * **The level is read from disk.** Someone reproducing a bug needs debug
/// logging to survive the restart that reproduces it.
///
/// TRACES: UR-078 | DR-218
fn build_log_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
use tauri_plugin_log::{Target, TargetKind};
let mut targets = vec![
Target::new(TargetKind::Stdout),
Target::new(TargetKind::LogDir {
file_name: Some("jellytau".to_string()),
}),
];
// Rust lines in the webview console, so a developer sees both halves of the
// app in one place. Dev only -- in a release build this would ship backend
// logging into a console the user can open.
if cfg!(debug_assertions) {
targets.push(Target::new(TargetKind::Webview));
}
tauri_plugin_log::Builder::new()
.targets(targets)
.level(log::LevelFilter::Info)
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
.format(|out, message, record| {
out.finish(format_args!(
"[{}][{}] {}",
record.level(),
record.target(),
crate::utils::diagnostics::redact(&message.to_string())
))
})
.build()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Crash capture before anything else, so a panic during startup is recorded
// rather than vanishing with the process.
//
// TRACES: UR-078 | DR-218
crate::utils::diagnostics::install_panic_hook();
// On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
// GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
// when available so video transcoding/decoding does not fall back to the CPU.
// These must be set before WebKitGTK initializes its GStreamer pipeline.
#[cfg(target_os = "linux")]
enable_linux_hardware_video_decoding();
// NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
// startup, which panics on devices (e.g. Android) where that path doesn't exist.
let builder = specta_builder();
let invoke_handler = builder.invoke_handler();
tauri::Builder::default()
.plugin(build_log_plugin())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::init())
.invoke_handler(invoke_handler)
.setup(move |app| {
// Mount tauri-specta events so PlayerStatusEvent can be emitted to and
// listened for on the frontend via the generated bindings.
builder.mount_events(app);
// In-app update, desktop only.
//
// Registered here rather than in the builder chain above because a
// `#[cfg]` attribute cannot be attached to one link of a method
// chain -- this block is the shape Tauri's own docs use.
//
// Android is excluded on purpose: tauri-plugin-updater cannot
// replace an installed APK, and the frontend offers the releases
// page there instead.
//
// Re-apply the log level the user last chose. Without this the
// picker would only affect the running session -- and the whole
// point of a persisted level is that somebody reproducing a bug
// keeps debug logging across the restart that reproduces it.
//
// TRACES: UR-078 | DR-218
if let Ok(config_dir) = app.path().app_config_dir() {
if let Some(level) = crate::commands::diagnostics::stored_level(&config_dir) {
log::set_max_level(level);
log::info!("[DIAG] restored log level from settings: {level}");
}
}
// TRACES: UR-077 | DR-217
#[cfg(desktop)]
{
app.handle()
.plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
}
// Initialize database with proper app data directory
// Check for test mode environment variable first
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
let test_path = std::path::PathBuf::from(test_data_dir);
info!("[INIT] Using test data directory: {:?}", test_path);
test_path.join("jellytau.db")
} else {
app
.path()
.app_data_dir()
.expect("Failed to get app data directory")
.join("jellytau.db")
};
info!("[INIT] Initializing database at: {:?}", db_path);
// Create the directory if it doesn't exist
if let Some(parent) = db_path.parent() {
info!("[INIT] Creating database directory: {:?}", parent);
match std::fs::create_dir_all(parent) {
Ok(_) => info!("[INIT] Database directory ready"),
Err(e) => {
error!("[INIT ERROR] Failed to create database directory: {}", e);
panic!("Failed to create database directory: {}", e);
}
}
}
info!("[INIT] Opening database...");
let database = match Database::open(&db_path) {
Ok(db) => {
info!("[INIT] Database initialized successfully");
db
}
Err(e) => {
error!("[INIT ERROR] Failed to initialize database: {}", e);
panic!("Failed to initialize database: {}", e);
}
};
let db_wrapper = DatabaseWrapper(Mutex::new(database));
app.manage(db_wrapper);
// On Android, initialize SecureStorage BEFORE creating CredentialStore
#[cfg(target_os = "android")]
{
// Publish the JavaVM and Application into ndk_context first.
//
// Everything below reads that global. tao used to populate it
// and stopped doing so in 0.35 (Tauri 2.11), at which point the
// first read here aborted the process on launch. See
// android_context.rs. A failure is logged rather than fatal:
// credentials then fall back to the encrypted-file path, which
// is a supported degraded mode -- unlike aborting.
//
// TRACES: UR-012 | DR-223
if let Err(e) = crate::android_context::ensure_initialized() {
log::error!("[INIT] Android context unavailable: {e}");
log::error!("[INIT] Secure credential storage will fall back to the encrypted file.");
}
info!("[INIT] Initializing Android SecureStorage for credentials...");
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
match vm {
Ok(java_vm) => {
match java_vm.attach_current_thread() {
Ok(mut env) => {
let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
match initialize_secure_storage(&mut env, &context_obj) {
Ok(()) => {
info!("[INIT] Android SecureStorage initialized successfully");
}
Err(e) => {
warn!("[INIT WARNING] Failed to initialize SecureStorage: {}. Credentials will use encrypted file fallback.", e);
}
}
}
Err(e) => {
warn!("[INIT WARNING] Failed to attach JNI thread: {}. Credentials will use encrypted file fallback.", e);
}
}
}
Err(e) => {
warn!("[INIT WARNING] Failed to create JavaVM: {}. Credentials will use encrypted file fallback.", e);
}
}
}
// Initialize credential store (keyring with encrypted file fallback)
info!("[INIT] Initializing credential store...");
let credential_store = CredentialStore::new();
let creds_wrapper = CredentialStoreWrapper(Mutex::new(credential_store));
app.manage(creds_wrapper);
// Create shared reporter and throttler Arc wrappers before backend/controller
info!("[INIT] Creating shared playback reporting infrastructure...");
let playback_reporter = Arc::new(tokio::sync::Mutex::new(None));
let position_throttler = Arc::new(playback_reporting::EventThrottler::new());
// Create player backend with access to AppHandle for event emission
info!("[INIT] Creating player backend...");
let backend = create_player_backend(
app.handle().clone(),
playback_reporter.clone(),
position_throttler.clone(),
);
// Attached *after* the backend exists: the mpv handle is registered
// during its construction, and doing this in the order the code
// used to read produced "no mpv handle" every time — the surface was
// built before there was anything to draw from.
// Native video surface: put a GL area under Tauri's webview so mpv
// can draw beneath the controls (UR-080 / DR-231).
//
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
// walks a hard-coded two-hop path on every button press in the
// webview:
//
// webview.parent() // "This one should be GtkBox"
// .parent() // ...and this one the GtkWindow
// .downcast::<gtk::Window>().unwrap()
//
// Wrapping the webview in a GtkOverlay makes that chain
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
// panic is non-unwinding it aborts the process. The decoration check
// that would otherwise make this handler inert runs *after* the
// unwrap, so no window configuration avoids it.
//
// This is the "only place Tauri-specific behaviour could still bite"
// that the spike named as the untested half of G1. It bites. The
// surface attaches perfectly and then dies on interaction, so
// "attached successfully" in the log is not the gate — a click is.
//
// Kept behind an env var rather than deleted so the next attempt has
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
//
// TRACES: UR-080 | DR-231
#[cfg(target_os = "linux")]
if crate::player::native_video::enabled() {
use tauri::Manager;
log::warn!(
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
video surface (mpv drawn behind the webview, no reparenting)"
);
if let Some(window) = app.get_webview_window("main") {
match window.default_vbox() {
Ok(vbox) => {
let handle = crate::player::mpv_backend::registered_handle();
if crate::player::video_surface::attach(&vbox, handle) {
info!("[INIT] Native video surface attached");
} else {
log::warn!("[INIT] Native video surface unavailable");
}
}
Err(e) => {
log::warn!("[INIT] No GTK vbox for the main window: {e}")
}
}
}
}
let player_controller = PlayerController::new(
backend,
playback_reporter.clone(),
position_throttler.clone(),
);
// Wire up event emitter for sleep timer and autoplay notifications
let event_emitter = Arc::new(TauriEventEmitter::new(app.handle().clone()));
player_controller.set_event_emitter(event_emitter.clone());
let player_arc = Arc::new(TokioMutex::new(player_controller));
// On Android, register the player controller for autoplay decisions.
// The MediaSession (lockscreen) handler is set up later, once the
// playback mode manager exists, so it can route to remote sessions.
#[cfg(target_os = "android")]
{
player::android::set_player_controller(player_arc.clone());
}
let player_state = PlayerStateWrapper(player_arc.clone());
app.manage(player_state);
// Initialize media session manager
info!("[INIT] Initializing media session manager...");
let session_manager = MediaSessionManager::new();
let session_wrapper = MediaSessionManagerWrapper(Mutex::new(session_manager));
app.manage(session_wrapper);
// Initialize playback mode manager
info!("[INIT] Initializing playback mode manager...");
let jellyfin_client = {
let player = player_arc.blocking_lock();
player.jellyfin_client()
};
let playback_mode_manager = playback_mode::PlaybackModeManager::new(
jellyfin_client.clone(),
player_arc.clone(),
);
let playback_mode_arc = Arc::new(playback_mode_manager);
// Broadcast mode changes so the frontend's mirror store reconciles to
// this authoritative one (prevents remote/local control desync).
playback_mode_arc.set_event_emitter(event_emitter.clone());
let playback_mode_wrapper = PlaybackModeManagerWrapper(playback_mode_arc.clone());
app.manage(playback_mode_wrapper);
// Initialize session poller manager for remote session polling
info!("[INIT] Initializing session poller manager...");
let session_poller = session_poller::SessionPollerManager::new(
jellyfin_client,
playback_mode_arc.clone(),
);
session_poller.set_event_emitter(event_emitter.clone());
// Note: start() is deferred until after the connectivity monitor is
// created below, so the poller can report reachability from its first
// poll (it drives offline detection + recovery while the user is idle).
let session_poller_arc = Arc::new(session_poller);
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc.clone());
app.manage(session_poller_wrapper);
// On Android, set up the MediaSession (lockscreen) handler and the
// remote volume handler. Both need the playback mode manager so they
// can route to the active remote session while casting.
#[cfg(target_os = "android")]
{
info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
let media_handler = Arc::new(MediaSessionHandler {
player: player_arc.clone(),
playback_mode: playback_mode_arc.clone(),
event_emitter: event_emitter.clone(),
});
set_media_command_handler(media_handler);
info!("[INIT] Setting up remote volume handler for Android...");
let handler = Arc::new(RemoteVolumeSessionHandler {
playback_mode: playback_mode_arc.clone(),
});
set_remote_volume_handler(handler);
}
// Initialize video settings with defaults
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
app.manage(video_settings);
// Restore the persisted streaming bandwidth ceiling. Deferred to the
// async runtime because the read is async, and ordered after the
// wrapper above because it writes into it. Until it lands, streams
// are uncapped — the pre-existing behaviour — and no playback can
// have started this early anyway (login happens after setup).
//
// TRACES: UR-074 | DR-162
{
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
crate::commands::restore_streaming_quality(&handle).await;
});
}
// Restore the folders the user hid from browsing, for the same
// reason and in the same way. Until it lands nothing is hidden —
// the pre-existing behaviour — and no query can have run this early.
//
// TRACES: UR-076 | DR-209
{
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
crate::commands::restore_library_settings(&handle).await;
});
}
// Initialize thumbnail cache
info!("[INIT] Initializing thumbnail cache...");
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
std::path::PathBuf::from(test_data_dir)
} else {
app
.path()
.app_data_dir()
.expect("Failed to get app data directory")
};
let thumbnail_cache = ThumbnailCache::new(app_data_dir.clone(), ThumbnailCacheConfig::default());
let thumbnail_wrapper = ThumbnailCacheWrapper(Arc::new(thumbnail_cache));
app.manage(thumbnail_wrapper);
// Initialize smart cache for preloading
info!("[INIT] Initializing smart cache...");
let smart_cache = SmartCache::new(SmartCacheConfig::default());
let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
app.manage(smart_cache_wrapper);
// Serve downloaded media over loopback HTTP. The webview cannot
// stream a large file through the asset protocol (see media_server),
// so local playback resolves its URL from here instead.
// TRACES: UR-071 | DR-137
info!("[INIT] Starting local media server...");
let media_server = match media_server::start(app_data_dir.clone()) {
Ok(s) => Some(s),
Err(e) => {
// Not fatal: streaming still works, and the command reports
// a clear error if local playback is attempted.
error!("[INIT ERROR] Local media server failed to start: {}", e);
None
}
};
app.manage(media_server::MediaServerWrapper(media_server));
// Initialize download manager
info!("[INIT] Initializing download manager...");
let download_dir = app_data_dir.join("downloads");
let download_manager = DownloadManager::new(download_dir);
let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
app.manage(download_manager_wrapper);
// Current network transport, for the WiFi-only download gate (UR-053).
// Defaults to unmetered ethernet so desktop is never gated; Android
// overwrites it via set_network_state as soon as the UI starts.
app.manage(commands::download::NetworkStateWrapper(
download::network::NetworkStateHandle::new(),
));
// Initialize connectivity monitor
info!("[INIT] Initializing connectivity monitor...");
let http_config = HttpConfig::default();
let http_client = HttpClient::new(http_config)
.expect("Failed to create HTTP client");
let mut connectivity_monitor = ConnectivityMonitor::new(http_client);
connectivity_monitor.set_app_handle(app.handle().clone());
// Wire the connectivity reporter into the session poller so its
// continuous background polls drive reachability (offline detection
// + recovery) even when the user isn't browsing, then start it.
session_poller_arc.set_connectivity_reporter(connectivity_monitor.reporter());
session_poller_arc.start();
// Wrap in Arc for sharing with AuthManager
let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor));
let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone());
app.manage(connectivity_wrapper);
// Initialize auth manager
info!("[INIT] Initializing auth manager...");
let auth_http_config = HttpConfig::default();
let auth_http_client = HttpClient::new(auth_http_config)
.expect("Failed to create HTTP client for auth");
let mut auth_manager = AuthManager::new(auth_http_client);
// Give auth manager a reference to connectivity monitor
auth_manager.set_connectivity_monitor(connectivity_arc.clone());
let auth_manager_wrapper = AuthManagerWrapper(Arc::new(auth_manager));
app.manage(auth_manager_wrapper);
// Initialize session verifier wrapper (initially empty)
info!("[INIT] Initializing session verifier wrapper...");
let session_verifier_wrapper = SessionVerifierWrapper(Arc::new(tokio::sync::Mutex::new(None)));
app.manage(session_verifier_wrapper);
// Initialize repository manager
info!("[INIT] Initializing repository manager...");
let repository_manager = commands::RepositoryManager::new();
let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
app.manage(repository_manager_wrapper);
// Initialize playback reporter wrapper. This MUST share the same Arc
// the player controller and MPV progress loop report through (created
// above at `playback_reporter`), otherwise `playback_reporter_init`
// would populate a dead, parallel Option and no Start/Progress/Stopped
// would ever reach Jellyfin.
info!("[INIT] Initializing playback reporter wrapper...");
let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
app.manage(playback_reporter_wrapper);
// Keep the local search index fresh. Ownership of *when* to re-index
// sits here rather than in the frontend: it is sync policy over
// domain data, and a startup-only trigger left a long session
// searching a stale catalog.
// TRACES: UR-065 | DR-109, IR-030
info!("[INIT] Starting background catalog indexer...");
commands::catalog::spawn_catalog_indexer(app.handle().clone());
// Push favourite toggles made while the server was unreachable, on
// every reconnect. In Rust rather than the frontend so it runs
// whether or not the screen that made the change is still mounted.
// TRACES: UR-069 | DR-120
info!("[INIT] Starting favourites drain...");
commands::favorites::spawn_favorites_drain(app.handle().clone());
// Push playback reports queued while the server was unreachable.
// Without this the `sync_queue` rows the reporter writes offline
// are never sent and the offline banner's count only grows.
// TRACES: UR-025, UR-002 | DR-131
info!("[INIT] Starting sync-queue drain...");
commands::sync_drain::spawn_sync_queue_drain(app.handle().clone());
info!("[INIT] Application setup completed successfully");
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod specta_bindings {
/// Generates `src/lib/api/bindings.ts`. Run with `cargo test export_typescript_bindings`.
#[test]
fn export_typescript_bindings() {
super::specta_builder()
.export(
specta_typescript::Typescript::default()
.bigint(specta_typescript::BigIntExportBehavior::Number),
"../src/lib/api/bindings.ts",
)
.expect("failed to export typescript bindings");
}
}