Files
jellytau/src-tauri/src/lib.rs
T
dtourolle 6dfc6b259a fix(player): lockscreen skip scrubs instead of advancing in background audio
onSkipToNext/onSkipToPrevious forwarded a bare next/previous to Rust, which
always advanced the queue. Correct for music, wrong for a video whose audio is
running through a background-audio handoff (UR-040): pressing skip to re-hear a
line jumped to the next episode instead of scrubbing.

resolve_skip_action in player/seek.rs maps the command to Advance or SeekTo, and
is_background_audio_active() is the whole test — the handoff exists only for
video, and an episode played through it reports MediaType::Audio, so media type
cannot distinguish the case. Forward 30s, back 10s, both clamped to [0, duration]
so a skip near either end cannot seek negative or read as EOF and advance.

Routed through the same spawn-then-seek_absolute path as the scrubber, because a
handoff seek re-opens the stream and must not run under the blocking lock
(DR-159). Kotlin keeps sending the opaque command; it only gains FAST_FORWARD/
REWIND in the PlaybackStateCompat so the system stops drawing skip arrows for a
control that scrubs. The remote-volume action block is deliberately untouched:
the handoff never applies to cast sessions, where skip really does mean advance.

Tests written first and watched fail (left: Advance, right: SeekTo). 706 Rust
tests pass, clippy 0, coverage 90%.
2026-08-16 23:54:43 +02:00

1460 lines
57 KiB
Rust

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,
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,
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_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_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_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");
// 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_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,
start_download,
enqueue_download,
enqueue_video_downloads,
sync_full_catalog,
catalog_sync_status,
set_show_server_catalog,
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_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,
// 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
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Initialize logger
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
// 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(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);
// 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")]
{
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(),
);
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;
});
}
// 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");
}
}