All checks were successful
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m37s
Build & Release / Run Tests (push) Successful in 4m12s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 18m57s
Build & Release / Create Release (push) Successful in 13s
1055 lines
46 KiB
Rust
1055 lines
46 KiB
Rust
mod auth;
|
|
mod commands;
|
|
mod connectivity;
|
|
mod credentials;
|
|
mod download;
|
|
mod jellyfin;
|
|
mod playback_mode;
|
|
mod playback_reporting;
|
|
mod player;
|
|
mod repository;
|
|
mod session_poller;
|
|
pub mod settings;
|
|
mod storage;
|
|
mod thumbnail;
|
|
pub mod utils;
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio::sync::Mutex as TokioMutex;
|
|
use tauri::{Emitter, Manager};
|
|
use tauri_specta::Builder;
|
|
use log::{error, info};
|
|
#[cfg(target_os = "android")]
|
|
use log::warn;
|
|
|
|
use commands::{
|
|
cancel_download, clear_stale_downloads, delete_album_downloads, delete_all_downloads, delete_download,
|
|
download_album, download_item, download_item_and_start, download_video, download_series, download_season,
|
|
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
|
|
get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
|
|
get_album_affinity_status,
|
|
mark_download_completed, mark_download_failed, start_download, enqueue_download, enqueue_video_downloads,
|
|
pin_item, unpin_item, is_item_pinned,
|
|
offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
|
|
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
|
|
player_get_video_settings, player_next, player_pause, player_play, player_play_album_track,
|
|
player_play_item, player_play_queue, player_play_tracks, player_previous, player_seek, player_seek_video, player_set_audio_settings, player_set_audio_track, player_switch_audio_track,
|
|
player_set_subtitle_track, player_set_video_settings, player_set_volume, player_toggle_mute, player_stop, player_toggle,
|
|
player_toggle_shuffle,
|
|
// 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,
|
|
// HTML5 video state-report commands
|
|
player_report_state, player_report_position, player_report_media_loaded,
|
|
// Queue manipulation commands
|
|
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,
|
|
// Preload commands
|
|
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, SessionPollerWrapper,
|
|
// Playback mode commands
|
|
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
|
|
playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_set_transferring,
|
|
playback_mode_get_remote_status,
|
|
// Playback reporting commands
|
|
playback_reporter_init, playback_reporter_destroy,
|
|
playback_report_start, playback_report_progress, playback_report_stopped,
|
|
playback_mark_played, PlaybackReporterWrapper,
|
|
// 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_delete_server, storage_delete_user, storage_get_access_token,
|
|
storage_get_active_session, storage_get_active_user, storage_get_path,
|
|
storage_get_playback_progress, storage_get_security_status, storage_get_servers, storage_get_size,
|
|
storage_get_users, storage_init, storage_mark_played, storage_mark_synced, storage_save_server,
|
|
storage_save_user, storage_set_active_user, storage_toggle_favorite, storage_update_playback_progress,
|
|
storage_update_playback_context,
|
|
// 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_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_jray_actors_at, repository_get_latest_items, repository_get_resume_items,
|
|
repository_get_next_up_episodes, 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_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_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,
|
|
AuthManagerWrapper, SessionVerifierWrapper,
|
|
ConnectivityMonitorWrapper, CredentialStoreWrapper, DatabaseWrapper, PlayerStateWrapper,
|
|
MediaSessionManagerWrapper, VideoSettingsWrapper, ThumbnailCacheWrapper, SmartCacheWrapper,
|
|
PlaybackModeManagerWrapper, RepositoryManagerWrapper, DownloadManagerWrapper,
|
|
};
|
|
#[cfg(target_os = "android")]
|
|
use playback_mode::PlaybackModeManager;
|
|
use auth::AuthManager;
|
|
use connectivity::ConnectivityMonitor;
|
|
use credentials::CredentialStore;
|
|
use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
|
|
use download::DownloadManager;
|
|
use jellyfin::{HttpClient, HttpConfig};
|
|
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::{ThumbnailCache, CacheConfig as ThumbnailCacheConfig};
|
|
|
|
#[cfg(target_os = "android")]
|
|
use credentials::initialize_secure_storage;
|
|
|
|
#[cfg(target_os = "android")]
|
|
use player::ExoPlayerBackend;
|
|
|
|
#[cfg(target_os = "android")]
|
|
use player::{MediaCommandHandler, RemoteVolumeHandler, set_media_command_handler, set_remote_volume_handler};
|
|
|
|
/// 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) {
|
|
// 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(),
|
|
"next" => controller.next(),
|
|
"previous" => controller.previous(),
|
|
"stop" => controller.stop(),
|
|
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
|
|
Ok(pos) => controller.seek(pos),
|
|
Err(_) => {
|
|
warn!("[MediaSession] Bad seek command: {}", command);
|
|
Ok(())
|
|
}
|
|
},
|
|
_ => {
|
|
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.
|
|
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");
|
|
return 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());
|
|
return Box::new(NullBackend::new());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback for other platforms
|
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
|
{
|
|
warn!("WARNING: No audio backend available for this platform");
|
|
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_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_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_set_video_settings,
|
|
player_get_video_settings,
|
|
// 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_report_state,
|
|
player_report_position,
|
|
player_report_media_loaded,
|
|
// Preload commands
|
|
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_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,
|
|
clear_stale_downloads,
|
|
get_download_storage_stats,
|
|
mark_download_completed,
|
|
mark_download_failed,
|
|
start_download,
|
|
enqueue_download,
|
|
enqueue_video_downloads,
|
|
get_download_manager_stats,
|
|
set_max_concurrent_downloads,
|
|
get_smart_cache_stats,
|
|
update_smart_cache_config,
|
|
get_smart_cache_config,
|
|
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_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_jray_actors_at,
|
|
repository_get_latest_items,
|
|
repository_get_resume_items,
|
|
repository_get_next_up_episodes,
|
|
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_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_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);
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
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());
|
|
session_poller.start();
|
|
let session_poller_arc = Arc::new(session_poller);
|
|
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc);
|
|
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);
|
|
|
|
// 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);
|
|
|
|
// 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);
|
|
|
|
// 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());
|
|
|
|
// 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);
|
|
|
|
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");
|
|
}
|
|
} |