Skip to main content

jellytau_lib/
lib.rs

1#[cfg(target_os = "android")]
2mod android_context;
3mod auth;
4mod commands;
5mod connectivity;
6mod credentials;
7mod domain;
8mod download;
9mod jellyfin;
10mod media_server;
11mod playback_mode;
12mod playback_reporting;
13mod player;
14mod repository;
15mod session_poller;
16pub mod settings;
17mod storage;
18mod thumbnail;
19pub mod utils;
20
21#[cfg(target_os = "android")]
22use log::warn;
23use log::{error, info};
24use std::sync::{Arc, Mutex};
25use tauri::{Emitter, Manager};
26use tauri_specta::Builder;
27use tokio::sync::Mutex as TokioMutex;
28
29use auth::AuthManager;
30use commands::{
31    auth_connect_to_server,
32    auth_get_session,
33    // Auth commands
34    auth_initialize,
35    auth_login,
36    auth_logout,
37    auth_reauthenticate,
38    auth_set_session,
39    auth_start_verification,
40    auth_stop_verification,
41    auth_verify_session,
42    calc_progress,
43    cancel_download,
44    catalog_sync_status,
45    clear_stale_downloads,
46    // Connectivity commands
47    connectivity_check_server,
48    connectivity_get_status,
49    connectivity_mark_reachable,
50    connectivity_mark_unreachable,
51    connectivity_set_server_url,
52    connectivity_start_monitoring,
53    connectivity_stop_monitoring,
54    convert_percent_to_volume,
55    convert_ticks_to_seconds,
56    delete_album_downloads,
57    delete_all_downloads,
58    delete_download,
59    delete_downloads_under,
60    // Device commands
61    device_get_id,
62    device_set_id,
63    // Diagnostics commands
64    diagnostics_export,
65    diagnostics_get_info,
66    diagnostics_set_level,
67    download_album,
68    download_item,
69    download_item_and_start,
70    download_season,
71    download_series,
72    download_video,
73    enqueue_download,
74    enqueue_video_downloads,
75    // Conversion commands
76    format_time_seconds,
77    format_time_seconds_long,
78    get_album_affinity_status,
79    get_album_recommendations,
80    get_download_manager_stats,
81    get_download_storage_stats,
82    get_downloads,
83    get_downloads_allowed,
84    get_smart_cache_config,
85    get_smart_cache_stats,
86    image_get_url,
87    is_item_pinned,
88    // Library browsing preferences (hidden folders)
89    library_get_exclusion_candidates,
90    library_get_settings,
91    library_set_settings,
92    lms_create_sync_group,
93    lms_dissolve_sync_group,
94    // LMS multi-room sync group commands
95    lms_get_sync_groups,
96    lms_unsync_player,
97    mark_download_completed,
98    mark_download_failed,
99    media_local_url,
100    offline_get_items,
101    offline_is_available,
102    offline_search,
103    pause_download,
104    pin_item,
105    playback_mark_played,
106    // Playback mode commands
107    playback_mode_get_current,
108    playback_mode_get_remote_status,
109    playback_mode_is_transferring,
110    playback_mode_set,
111    playback_mode_set_transferring,
112    playback_mode_transfer_to_local,
113    playback_mode_transfer_to_remote,
114    playback_report_progress,
115    playback_report_start,
116    playback_report_stopped,
117    playback_reporter_destroy,
118    // Playback reporting commands
119    playback_reporter_init,
120    // Queue manipulation commands
121    player_add_to_queue,
122    player_add_track_by_id,
123    player_add_tracks_by_ids,
124    player_background_action,
125    player_cancel_autoplay_countdown,
126    player_cancel_sleep_timer,
127    // Jellyfin reporting commands
128    player_configure_jellyfin,
129    player_cycle_repeat,
130    player_disable_jellyfin,
131    player_dismiss_session,
132    player_enter_background_audio,
133    player_exit_background_audio,
134    player_get_audio_settings,
135    player_get_autoplay_settings,
136    player_get_cache_config,
137    player_get_capabilities,
138    player_get_eq_presets,
139    player_get_queue,
140    // Session management commands
141    player_get_session,
142    player_get_sleep_timer,
143    player_get_status,
144    player_get_streaming_qualities,
145    player_get_video_settings,
146    // Preload commands
147    player_local_media_path,
148    player_move_in_queue,
149    player_next,
150    player_on_playback_ended,
151    player_pause,
152    player_play,
153    player_play_album_track,
154    player_play_item,
155    player_play_next_episode,
156    player_play_queue,
157    player_play_tracks,
158    player_preload_upcoming,
159    player_previous,
160    player_recover_stream,
161    player_remove_from_queue,
162    player_report_media_loaded,
163    player_report_position,
164    // HTML5 video state-report commands
165    player_report_state,
166    player_seek,
167    player_seek_video,
168    player_set_audio_settings,
169    player_set_audio_track,
170    player_set_autoplay_settings,
171    player_set_cache_config,
172    // Sleep timer and autoplay commands
173    player_set_sleep_timer,
174    player_set_stream_quality,
175    player_set_subtitle_track,
176    player_set_video_settings,
177    player_set_volume,
178    player_skip_to,
179    player_stop,
180    player_switch_audio_track,
181    player_toggle,
182    player_toggle_mute,
183    player_toggle_shuffle,
184    playlist_add_items,
185    // Playlist commands
186    playlist_create,
187    playlist_delete,
188    playlist_get_items,
189    playlist_move_item,
190    playlist_remove_items,
191    playlist_rename,
192    // Remote session control commands
193    remote_play_on_session,
194    remote_send_command,
195    remote_session_seek,
196    remote_session_set_volume,
197    remote_session_toggle_mute,
198    // Repository commands
199    repository_clear_watch_history,
200    repository_create,
201    repository_destroy,
202    repository_get_audio_only_stream_url_for_video,
203    repository_get_audio_stream_url,
204    repository_get_channels,
205    repository_get_download_disk_usage,
206    repository_get_downloaded_items,
207    repository_get_downloaded_libraries,
208    repository_get_favorites,
209    repository_get_genres,
210    repository_get_image_url,
211    repository_get_item,
212    repository_get_items,
213    repository_get_items_by_person,
214    repository_get_latest_items,
215    repository_get_libraries,
216    repository_get_live_tv_channels,
217    repository_get_next_up_episodes,
218    repository_get_person,
219    repository_get_playback_info,
220    repository_get_recently_played_audio,
221    repository_get_rediscover_albums,
222    repository_get_resume_items,
223    repository_get_resume_movies,
224    repository_get_series_current_episode,
225    repository_get_series_episodes,
226    repository_get_similar_items,
227    repository_get_subtitle_url,
228    repository_get_video_download_url,
229    repository_get_video_stream_url,
230    repository_jray_actors_at,
231    repository_mark_favorite,
232    repository_open_live_stream,
233    repository_report_playback_progress,
234    repository_report_playback_start,
235    repository_report_playback_stopped,
236    repository_search,
237    repository_unmark_favorite,
238    resume_download,
239    resume_queued_downloads,
240    sessions_poll_now,
241    // Session polling commands
242    sessions_set_polling_hint,
243    set_max_concurrent_downloads,
244    set_network_state,
245    set_show_server_catalog,
246    start_download,
247    // Storage commands
248    storage_delete_server,
249    storage_delete_user,
250    storage_get_access_token,
251    storage_get_active_session,
252    storage_get_active_user,
253    storage_get_item,
254    storage_get_item_people,
255    storage_get_items,
256    // Offline cache commands
257    storage_get_libraries,
258    storage_get_path,
259    storage_get_pending_sync_count,
260    storage_get_person,
261    storage_get_playback_progress,
262    storage_get_security_status,
263    storage_get_series_audio_preference,
264    storage_get_servers,
265    storage_get_size,
266    storage_get_users,
267    storage_init,
268    storage_mark_played,
269    storage_mark_synced,
270    storage_save_item,
271    storage_save_item_people,
272    storage_save_library,
273    // People cache commands
274    storage_save_person,
275    // Series audio preferences
276    storage_save_series_audio_preference,
277    storage_save_server,
278    storage_save_user,
279    storage_search_items,
280    storage_set_active_user,
281    storage_set_watched,
282    storage_toggle_favorite,
283    storage_update_playback_context,
284    storage_update_playback_progress,
285    sync_cleanup_completed,
286    sync_clear_user,
287    sync_full_catalog,
288    sync_get_pending,
289    sync_get_pending_count,
290    sync_mark_completed,
291    sync_mark_failed,
292    sync_mark_processing,
293    sync_process_pending,
294    // Sync queue commands
295    sync_queue_mutation,
296    thumbnail_clear_cache,
297    thumbnail_delete_item,
298    // Thumbnail cache and image commands
299    thumbnail_get_cached,
300    thumbnail_get_stats,
301    thumbnail_save,
302    thumbnail_set_limit,
303    unpin_item,
304    update_smart_cache_config,
305    AuthManagerWrapper,
306    ConnectivityMonitorWrapper,
307    CredentialStoreWrapper,
308    DatabaseWrapper,
309    DownloadManagerWrapper,
310    MediaSessionManagerWrapper,
311    PlaybackModeManagerWrapper,
312    PlaybackReporterWrapper,
313    PlayerStateWrapper,
314    RepositoryManagerWrapper,
315    SessionPollerWrapper,
316    SessionVerifierWrapper,
317    SmartCacheWrapper,
318    ThumbnailCacheWrapper,
319    VideoSettingsWrapper,
320};
321use connectivity::ConnectivityMonitor;
322use credentials::CredentialStore;
323use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
324use download::DownloadManager;
325use jellyfin::{HttpClient, HttpConfig};
326#[cfg(target_os = "android")]
327use playback_mode::PlaybackModeManager;
328// Only the Android MediaSessionHandler resolves lockscreen skips; on other
329// targets this would be an unused import.
330#[cfg(target_os = "android")]
331use player::seek::{resolve_skip_action, SkipAction};
332use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
333// NullBackend is used both for platforms without a native backend AND as a graceful
334// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
335// still launch (browse library, manage downloads, see an error) instead of crashing.
336use player::NullBackend;
337
338#[cfg(target_os = "linux")]
339use player::MpvBackend;
340use settings::VideoSettings;
341use storage::Database;
342use thumbnail::{CacheConfig as ThumbnailCacheConfig, ThumbnailCache};
343
344#[cfg(target_os = "android")]
345use credentials::initialize_secure_storage;
346
347#[cfg(target_os = "android")]
348use player::ExoPlayerBackend;
349
350#[cfg(target_os = "android")]
351use player::{
352    set_media_command_handler, set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
353};
354
355/// Handler for media commands from Android MediaSession (lockscreen/notification controls).
356///
357/// Routes commands from the system media controls to the right place depending on
358/// playback mode: in local mode it drives the local `PlayerController`; in remote
359/// (cast) mode it forwards transport commands to the remote Jellyfin session so
360/// the lockscreen can control whatever is casting. Stop while casting requests a
361/// disconnect back to local playback.
362#[cfg(target_os = "android")]
363struct MediaSessionHandler {
364    player: Arc<TokioMutex<PlayerController>>,
365    playback_mode: Arc<PlaybackModeManager>,
366    event_emitter: Arc<TauriEventEmitter>,
367}
368
369#[cfg(target_os = "android")]
370impl MediaSessionHandler {
371    /// Forward a transport command to the active remote Jellyfin session.
372    ///
373    /// Runs async on the Tauri runtime because JNI callbacks arrive on arbitrary
374    /// threads without a Tokio context.
375    fn handle_remote_command(&self, command: &str, session_id: String) {
376        use crate::player::{PlayerEventEmitter, PlayerStatusEvent};
377
378        // Stop while casting means "disconnect and resume locally". The frontend
379        // owns the remote->local transfer (it reloads the item locally), so we
380        // just signal intent.
381        if command == "stop" {
382            self.event_emitter
383                .emit(PlayerStatusEvent::RemoteDisconnectRequested);
384            return;
385        }
386
387        let jellyfin_client = {
388            let player = self.player.blocking_lock();
389            player.jellyfin_client()
390        };
391        let command = command.to_string();
392
393        tauri::async_runtime::spawn(async move {
394            let client = {
395                let guard = match jellyfin_client.lock() {
396                    Ok(g) => g,
397                    Err(e) => {
398                        error!("[MediaSession] Failed to lock Jellyfin client: {}", e);
399                        return;
400                    }
401                };
402                match guard.as_ref() {
403                    Some(c) => c.clone(),
404                    None => {
405                        warn!("[MediaSession] No Jellyfin client for remote command");
406                        return;
407                    }
408                }
409            };
410
411            // Map lockscreen transport commands onto Jellyfin session commands.
412            let result = match command.as_str() {
413                "play" => client.send_session_command(session_id, "Unpause").await,
414                "pause" => client.send_session_command(session_id, "Pause").await,
415                "next" => client.send_session_command(session_id, "NextTrack").await,
416                "previous" => {
417                    client
418                        .send_session_command(session_id, "PreviousTrack")
419                        .await
420                }
421                cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
422                    Ok(seconds) => {
423                        let ticks = (seconds * 10_000_000.0) as i64;
424                        client.session_seek(session_id, ticks).await
425                    }
426                    Err(_) => {
427                        warn!("[MediaSession] Bad seek command: {}", command);
428                        Ok(())
429                    }
430                },
431                _ => {
432                    warn!("[MediaSession] Unknown remote command: {}", command);
433                    Ok(())
434                }
435            };
436
437            if let Err(e) = result {
438                error!("[MediaSession] Remote command '{}' failed: {}", command, e);
439            }
440        });
441    }
442
443    /// Drive the local player for a transport command.
444    fn handle_local_command(&self, command: &str) {
445        // A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
446        // whole episode — and resolving it during a background-audio handoff means
447        // re-opening the stream, which is async. So it runs on the runtime and,
448        // critically, is handled *before* the blocking lock below: taking that
449        // guard and then spawning a task that waits for the same mutex would
450        // deadlock the media session. (DR-159)
451        if let Some(raw) = command.strip_prefix("seek:") {
452            match raw.parse::<f64>() {
453                Ok(position) => {
454                    let player = self.player.clone();
455                    tokio::spawn(async move {
456                        let controller = player.lock().await;
457                        if let Err(e) = controller.seek_absolute(position).await {
458                            error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
459                        }
460                    });
461                }
462                Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
463            }
464            return;
465        }
466
467        // Skip means different things depending on what is actually playing, so
468        // the decision belongs here rather than in the Kotlin that drew the
469        // button: music advances the queue, while a video whose audio is running
470        // through a background-audio handoff scrubs instead (UR-040). Routed
471        // through the same spawn-and-seek path as "seek:" above, because
472        // `seek_absolute` rebuilds the stream during a handoff and must not run
473        // under the blocking lock (DR-159).
474        //
475        // TRACES: UR-040, UR-006 | DR-201
476        if command == "next" || command == "previous" {
477            let is_next = command == "next";
478            let player = self.player.clone();
479            tokio::spawn(async move {
480                let controller = player.lock().await;
481                let action = resolve_skip_action(
482                    is_next,
483                    controller.is_background_audio_active(),
484                    controller.position(),
485                    controller.duration(),
486                );
487                let label = if is_next { "next" } else { "previous" };
488                let result: Result<(), String> = match action {
489                    SkipAction::Advance => if is_next {
490                        controller.next()
491                    } else {
492                        controller.previous()
493                    }
494                    .map_err(|e| e.to_string()),
495                    SkipAction::SeekTo(position) => {
496                        info!(
497                            "[MediaSession] Background audio: '{}' scrubs to {:.1}s",
498                            label, position
499                        );
500                        controller.seek_absolute(position).await
501                    }
502                };
503                if let Err(e) = result {
504                    error!("[MediaSession] Skip '{}' failed: {}", label, e);
505                }
506            });
507            return;
508        }
509
510        // Use blocking_lock since this is called from a non-async JNI callback
511        let controller = self.player.blocking_lock();
512
513        let result = match command {
514            "play" => controller.play(),
515            "pause" => controller.pause(),
516            "stop" => controller.stop(),
517            _ => {
518                warn!("[MediaSession] Unknown command: {}", command);
519                Ok(())
520            }
521        };
522
523        if let Err(e) = result {
524            error!("[MediaSession] Command '{}' failed: {}", command, e);
525        }
526    }
527}
528
529#[cfg(target_os = "android")]
530impl MediaCommandHandler for MediaSessionHandler {
531    fn on_command(&self, command: &str) {
532        match self.playback_mode.get_mode() {
533            playback_mode::PlaybackMode::Remote { session_id } => {
534                self.handle_remote_command(command, session_id);
535            }
536            _ => self.handle_local_command(command),
537        }
538    }
539}
540
541/// Handler for remote volume changes from Android volume buttons when in remote playback mode.
542///
543/// Routes volume commands to the Jellyfin session via the playback mode manager.
544#[cfg(target_os = "android")]
545struct RemoteVolumeSessionHandler {
546    playback_mode: Arc<PlaybackModeManager>,
547}
548
549#[cfg(target_os = "android")]
550impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
551    fn on_remote_volume_change(&self, command: &str, volume: i32) {
552        log::info!("[RemoteVolume] Command: {}, Volume: {}", command, volume);
553
554        // Send the volume command to the remote session asynchronously
555        let playback_mode = Arc::clone(&self.playback_mode);
556        let command_str = command.to_string();
557
558        // Use tauri::async_runtime::spawn instead of tokio::spawn
559        // JNI callbacks happen on arbitrary threads without a Tokio runtime
560        log::info!("[RemoteVolume] Spawning async task to send volume command...");
561        tauri::async_runtime::spawn(async move {
562            log::info!("[RemoteVolume] Async task started, calling send_remote_volume_command...");
563            match playback_mode
564                .send_remote_volume_command(&command_str, volume)
565                .await
566            {
567                Ok(_) => log::info!("[RemoteVolume] Volume command completed successfully"),
568                Err(e) => log::error!("[RemoteVolume] Failed to send volume command: {}", e),
569            }
570            log::info!("[RemoteVolume] Async task completed");
571        });
572        log::info!("[RemoteVolume] Async task spawned, returning from JNI callback");
573    }
574}
575
576/// Payload emitted to the frontend when a native player backend fails to
577/// initialize and the app falls back to a no-op backend.
578#[derive(Clone, serde::Serialize)]
579struct BackendInitError {
580    platform: &'static str,
581    backend: &'static str,
582    message: String,
583}
584
585/// Log a backend-initialization failure and notify the frontend, so the UI can
586/// surface "playback unavailable" instead of the app hard-crashing.
587fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str, message: String) {
588    error!(
589        "[INIT] Player backend '{}' failed to initialize: {}. Falling back to NullBackend (playback disabled).",
590        backend, message
591    );
592    let _ = app_handle.emit(
593        "backend-init-failed",
594        BackendInitError {
595            platform: std::env::consts::OS,
596            backend,
597            message,
598        },
599    );
600}
601
602/// Create the appropriate player backend for the current platform.
603// playback_reporter/position_throttler are consumed only by the native audio
604// backends (mpv/exo); on platforms using the webview audio backend they're unused.
605#[cfg_attr(
606    not(any(target_os = "linux", target_os = "android")),
607    allow(unused_variables)
608)]
609fn create_player_backend(
610    app_handle: tauri::AppHandle,
611    playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
612    position_throttler: Arc<playback_reporting::EventThrottler>,
613) -> Box<dyn PlayerBackend> {
614    let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle.clone()));
615
616    #[cfg(target_os = "android")]
617    {
618        info!("Android platform detected - initializing ExoPlayer backend");
619
620        // Same precondition as the credential path: ndk_context must be
621        // populated before it is read, and nothing outside this crate populates
622        // it any more. Idempotent, so it does not matter which of the two runs
623        // first. TRACES: UR-012 | DR-223
624        if let Err(e) = crate::android_context::ensure_initialized() {
625            log::error!("[INIT] Android context unavailable for the player: {e}");
626        }
627
628        // Get the Android context via ndk-context
629        let ctx = ndk_context::android_context();
630
631        // Get JavaVM and create JNI environment
632        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
633
634        match vm {
635            Ok(java_vm) => {
636                match java_vm.attach_current_thread() {
637                    Ok(mut env) => {
638                        let context_obj =
639                            unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
640
641                        match ExoPlayerBackend::new(
642                            &mut env,
643                            &context_obj,
644                            _event_emitter.clone(),
645                            playback_reporter.clone(),
646                            position_throttler.clone(),
647                        ) {
648                            Ok(backend) => {
649                                info!("Successfully initialized ExoPlayer backend for Android");
650                                return Box::new(backend);
651                            }
652                            Err(e) => {
653                                // Degrade gracefully instead of crashing the app.
654                                emit_backend_init_failed(&app_handle, "exoplayer", e.to_string());
655                                return Box::new(NullBackend::new());
656                            }
657                        }
658                    }
659                    Err(e) => {
660                        emit_backend_init_failed(
661                            &app_handle,
662                            "exoplayer",
663                            format!("attach JNI thread failed: {}", e),
664                        );
665                        return Box::new(NullBackend::new());
666                    }
667                }
668            }
669            Err(e) => {
670                emit_backend_init_failed(
671                    &app_handle,
672                    "exoplayer",
673                    format!("create JavaVM failed: {}", e),
674                );
675                return Box::new(NullBackend::new());
676            }
677        }
678    }
679
680    // For Linux, use MPV backend for audio playback
681    #[cfg(target_os = "linux")]
682    {
683        info!("Linux platform detected - initializing MPV backend for audio");
684        match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
685            Ok(backend) => {
686                info!("Successfully initialized MPV backend for Linux");
687                Box::new(backend)
688            }
689            Err(e) => {
690                error!("\n========================================");
691                error!("FATAL ERROR: Failed to initialize MPV backend");
692                error!("========================================");
693                error!("Error: {}", e);
694                error!("\nCommon causes:");
695                error!("  1. MPV is not installed");
696                error!("     Solution: Install MPV using your package manager");
697                error!("     - Arch/CachyOS: sudo pacman -S mpv");
698                error!("     - Ubuntu/Debian: sudo apt install mpv libmpv-dev");
699                error!("     - Fedora: sudo dnf install mpv mpv-libs-devel");
700                error!("\n  2. MPV version mismatch (app was built with different libmpv version)");
701                error!("     Solution: Rebuild the application");
702                error!("     - cd src-tauri && cargo clean && cargo build --release");
703                error!("\n  3. Audio system not working");
704                error!("     Solution: Verify audio works with: pactl info");
705                error!("\nAudio playback will NOT work until this is fixed.");
706                error!("========================================\n");
707
708                // Degrade gracefully: launch with a no-op backend so the user can
709                // still browse the library and manage downloads, and the frontend
710                // can show a "playback unavailable" notice via this event.
711                emit_backend_init_failed(&app_handle, "mpv", e.to_string());
712                Box::new(NullBackend::new())
713            }
714        }
715    }
716
717    // Platforms with no native audio backend (e.g. Windows): render audio-only
718    // playback through a webview <audio> element (all video already renders in
719    // the webview). Falls back to NullBackend only if the backend can't init.
720    #[cfg(not(any(target_os = "linux", target_os = "android")))]
721    {
722        info!("No native audio backend for this platform - using webview <audio> backend");
723        match player::WebviewAudioBackend::new(_event_emitter) {
724            Ok(backend) => Box::new(backend),
725            Err(e) => {
726                emit_backend_init_failed(&app_handle, "webview-audio", e.to_string());
727                Box::new(NullBackend::new())
728            }
729        }
730    }
731}
732
733/// Construct the tauri-specta command builder. Shared by `run()` and the
734/// bindings-export test so the TypeScript bindings always match the handler.
735fn specta_builder() -> Builder<tauri::Wry> {
736    Builder::<tauri::Wry>::new()
737        // Throw on error so generated `commands.*` return Promise<T> and throw,
738        // matching the existing frontend's invoke() try/catch convention.
739        .error_handling(tauri_specta::ErrorHandlingMode::Throw)
740        .events(tauri_specta::collect_events![
741            crate::player::events::PlayerStatusEvent
742        ])
743        .commands(tauri_specta::collect_commands![
744            // Player commands
745            player_play_item,
746            player_background_action,
747            player_enter_background_audio,
748            player_exit_background_audio,
749            player_play_queue,
750            player_play_album_track,
751            player_play_tracks,
752            player_play,
753            player_pause,
754            player_toggle,
755            player_stop,
756            player_next,
757            player_previous,
758            player_seek,
759            player_seek_video,
760            player_set_volume,
761            player_toggle_mute,
762            player_set_audio_track,
763            player_switch_audio_track,
764            player_set_subtitle_track,
765            player_toggle_shuffle,
766            player_cycle_repeat,
767            player_get_status,
768            player_get_queue,
769            player_get_capabilities,
770            player_add_to_queue,
771            player_add_track_by_id,
772            player_add_tracks_by_ids,
773            player_remove_from_queue,
774            player_move_in_queue,
775            player_skip_to,
776            player_set_audio_settings,
777            player_get_audio_settings,
778            player_get_eq_presets,
779            player_set_video_settings,
780            player_get_video_settings,
781            player_get_streaming_qualities,
782            player_set_stream_quality,
783            // Sleep timer and autoplay commands
784            player_set_sleep_timer,
785            player_cancel_sleep_timer,
786            player_get_sleep_timer,
787            player_get_autoplay_settings,
788            player_set_autoplay_settings,
789            player_cancel_autoplay_countdown,
790            player_play_next_episode,
791            player_on_playback_ended,
792            player_recover_stream,
793            player_report_state,
794            player_report_position,
795            player_report_media_loaded,
796            // Preload commands
797            player_local_media_path,
798            player_preload_upcoming,
799            player_set_cache_config,
800            player_get_cache_config,
801            // Jellyfin reporting commands
802            player_configure_jellyfin,
803            player_disable_jellyfin,
804            // Session management commands
805            player_get_session,
806            player_dismiss_session,
807            // Remote session control commands
808            remote_play_on_session,
809            remote_send_command,
810            remote_session_seek,
811            remote_session_set_volume,
812            remote_session_toggle_mute,
813            // LMS multi-room sync group commands
814            lms_get_sync_groups,
815            lms_create_sync_group,
816            lms_unsync_player,
817            lms_dissolve_sync_group,
818            // Session polling commands
819            sessions_set_polling_hint,
820            sessions_poll_now,
821            // Playback mode commands
822            playback_mode_get_current,
823            playback_mode_set,
824            playback_mode_is_transferring,
825            playback_mode_transfer_to_remote,
826            playback_mode_get_remote_status,
827            playback_mode_transfer_to_local,
828            playback_mode_set_transferring,
829            // Playback reporting commands
830            playback_reporter_init,
831            playback_reporter_destroy,
832            playback_report_start,
833            playback_report_progress,
834            playback_report_stopped,
835            playback_mark_played,
836            // Auth commands
837            auth_initialize,
838            auth_connect_to_server,
839            auth_login,
840            auth_verify_session,
841            auth_logout,
842            auth_get_session,
843            auth_set_session,
844            auth_start_verification,
845            auth_stop_verification,
846            auth_reauthenticate,
847            // Device commands
848            device_get_id,
849            device_set_id,
850            // Connectivity commands
851            connectivity_check_server,
852            connectivity_set_server_url,
853            connectivity_get_status,
854            connectivity_start_monitoring,
855            connectivity_stop_monitoring,
856            connectivity_mark_reachable,
857            connectivity_mark_unreachable,
858            // Storage commands
859            storage_init,
860            storage_get_path,
861            storage_get_size,
862            storage_get_security_status,
863            storage_save_server,
864            storage_get_servers,
865            storage_delete_server,
866            storage_save_user,
867            storage_get_users,
868            storage_set_active_user,
869            storage_get_active_user,
870            storage_get_active_session,
871            storage_get_access_token,
872            storage_delete_user,
873            // Playback progress commands
874            storage_update_playback_progress,
875            storage_update_playback_context,
876            storage_mark_played,
877            storage_set_watched,
878            storage_get_playback_progress,
879            storage_mark_synced,
880            storage_toggle_favorite,
881            // Download commands
882            download_item,
883            download_item_and_start,
884            download_album,
885            download_video,
886            download_series,
887            download_season,
888            get_downloads,
889            pause_download,
890            resume_download,
891            cancel_download,
892            delete_download,
893            delete_all_downloads,
894            delete_album_downloads,
895            delete_downloads_under,
896            clear_stale_downloads,
897            get_download_storage_stats,
898            mark_download_completed,
899            mark_download_failed,
900            media_local_url,
901            start_download,
902            enqueue_download,
903            enqueue_video_downloads,
904            sync_full_catalog,
905            catalog_sync_status,
906            set_show_server_catalog,
907            // Library browsing preferences (UR-076 / DR-209)
908            library_get_settings,
909            library_set_settings,
910            library_get_exclusion_candidates,
911            resume_queued_downloads,
912            get_download_manager_stats,
913            set_max_concurrent_downloads,
914            get_smart_cache_stats,
915            update_smart_cache_config,
916            get_smart_cache_config,
917            // WiFi-only download gate (UR-053)
918            set_network_state,
919            get_downloads_allowed,
920            get_album_recommendations,
921            get_album_affinity_status,
922            // Pinning commands
923            pin_item,
924            unpin_item,
925            is_item_pinned,
926            // Offline commands
927            offline_is_available,
928            offline_get_items,
929            offline_search,
930            // Offline cache commands
931            storage_get_libraries,
932            storage_get_items,
933            storage_get_item,
934            storage_search_items,
935            storage_save_library,
936            storage_save_item,
937            storage_get_pending_sync_count,
938            // Sync queue commands
939            sync_queue_mutation,
940            sync_get_pending,
941            sync_mark_processing,
942            sync_mark_completed,
943            sync_mark_failed,
944            sync_get_pending_count,
945            sync_process_pending,
946            sync_cleanup_completed,
947            sync_clear_user,
948            // Thumbnail cache and image commands
949            thumbnail_get_cached,
950            thumbnail_save,
951            thumbnail_get_stats,
952            thumbnail_set_limit,
953            thumbnail_clear_cache,
954            thumbnail_delete_item,
955            image_get_url,
956            // People cache commands
957            storage_save_person,
958            storage_get_person,
959            storage_save_item_people,
960            storage_get_item_people,
961            // Series audio preferences
962            storage_save_series_audio_preference,
963            storage_get_series_audio_preference,
964            // Repository commands
965            repository_create,
966            repository_destroy,
967            repository_get_libraries,
968            repository_get_items,
969            repository_get_item,
970            repository_get_downloaded_libraries,
971            repository_get_downloaded_items,
972            repository_get_download_disk_usage,
973            repository_jray_actors_at,
974            repository_get_latest_items,
975            repository_get_resume_items,
976            repository_get_next_up_episodes,
977            repository_get_series_episodes,
978            repository_get_series_current_episode,
979            repository_clear_watch_history,
980            repository_get_recently_played_audio,
981            repository_get_resume_movies,
982            repository_get_rediscover_albums,
983            repository_get_genres,
984            repository_search,
985            repository_get_playback_info,
986            repository_get_video_stream_url,
987            repository_get_audio_stream_url,
988            repository_get_audio_only_stream_url_for_video,
989            repository_get_live_tv_channels,
990            repository_get_channels,
991            repository_open_live_stream,
992            repository_report_playback_start,
993            repository_report_playback_progress,
994            repository_report_playback_stopped,
995            repository_get_image_url,
996            repository_mark_favorite,
997            repository_unmark_favorite,
998            repository_get_favorites,
999            repository_get_person,
1000            repository_get_items_by_person,
1001            repository_get_similar_items,
1002            repository_get_subtitle_url,
1003            repository_get_video_download_url,
1004            // Playlist commands
1005            playlist_create,
1006            playlist_delete,
1007            playlist_rename,
1008            playlist_get_items,
1009            playlist_add_items,
1010            playlist_remove_items,
1011            playlist_move_item,
1012            // Diagnostics commands
1013            // TRACES: UR-078 | DR-218
1014            diagnostics_get_info,
1015            diagnostics_set_level,
1016            diagnostics_export,
1017            // Conversion commands
1018            format_time_seconds,
1019            format_time_seconds_long,
1020            convert_ticks_to_seconds,
1021            calc_progress,
1022            convert_percent_to_volume,
1023        ])
1024}
1025
1026/// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `<video>`
1027/// element on Linux) to prefer hardware-accelerated VAAPI decoding when the
1028/// host provides it, falling back to software decoding otherwise.
1029///
1030/// All variables are only set if the user has not already exported them, so an
1031/// explicit override (e.g. forcing software decode for debugging) is respected.
1032/// They must be applied before WebKitGTK builds its GStreamer pipeline, hence the
1033/// call at the very top of `run()`.
1034#[cfg(target_os = "linux")]
1035fn enable_linux_hardware_video_decoding() {
1036    // Boost the rank of the modern stateless VAAPI decoders (gst-plugins-bad
1037    // `va` plugin) so GStreamer selects them ahead of the software decoders. The
1038    // `MAX` rank wins decoder autoplugging when the hardware/driver supports the
1039    // codec; unsupported codecs simply fall through to software.
1040    let rank_overrides = "vah264dec:MAX,vah265dec:MAX,vavp9dec:MAX,vaav1dec:MAX,\
1041                          vampeg2dec:MAX,vavp8dec:MAX";
1042
1043    set_env_if_unset("GST_PLUGIN_FEATURE_RANK", rank_overrides);
1044
1045    // Ensure WebKit keeps GStreamer's hardware/DMABUF video path enabled. Setting
1046    // this to "0" would force software decoding, so only default it to "1".
1047    set_env_if_unset("WEBKIT_GST_ENABLE_HW_VIDEO_DECODER", "1");
1048
1049    info!("[INIT] Linux hardware video decoding (VAAPI) enabled where supported");
1050
1051    log_available_vaapi_decoders();
1052}
1053
1054/// Probe (via `gst-inspect-1.0`, which ships with GStreamer) which VAAPI hardware
1055/// video decoders GStreamer can actually load on this host, and log the result so
1056/// it is clear at startup whether hardware decoding is genuinely available or
1057/// whether playback will fall back to software.
1058#[cfg(target_os = "linux")]
1059fn log_available_vaapi_decoders() {
1060    const HW_DECODERS: &[&str] = &[
1061        "vah264dec",
1062        "vah265dec",
1063        "vavp9dec",
1064        "vaav1dec",
1065        "vampeg2dec",
1066        "vavp8dec",
1067    ];
1068
1069    let available: Vec<&str> = HW_DECODERS
1070        .iter()
1071        .copied()
1072        .filter(|name| {
1073            std::process::Command::new("gst-inspect-1.0")
1074                .arg(name)
1075                .stdout(std::process::Stdio::null())
1076                .stderr(std::process::Stdio::null())
1077                .status()
1078                .map(|s| s.success())
1079                .unwrap_or(false)
1080        })
1081        .collect();
1082
1083    if available.is_empty() {
1084        log::warn!(
1085            "[INIT] No VAAPI hardware video decoders found via gst-inspect-1.0; \
1086             video will use software decoding. Install the GStreamer 'va' plugin \
1087             (gst-plugins-bad) and a VAAPI driver to enable hardware decoding."
1088        );
1089    } else {
1090        info!(
1091            "[INIT] VAAPI hardware video decoders available to GStreamer: {}",
1092            available.join(", ")
1093        );
1094    }
1095}
1096
1097#[cfg(target_os = "linux")]
1098fn set_env_if_unset(key: &str, value: &str) {
1099    if std::env::var_os(key).is_none() {
1100        // SAFETY: called once at startup before any threads that read the
1101        // environment (WebKitGTK/GStreamer) are spawned.
1102        std::env::set_var(key, value);
1103    }
1104}
1105
1106/// Cached thumbnails are handed to the webview as asset-protocol URLs by
1107/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
1108/// `http://asset.localhost/…` on Windows/Android). Tauri only answers that
1109/// origin when the `protocol-asset` cargo feature is compiled in *and*
1110/// `app.security.assetProtocol.enable` is set in `tauri.conf.json`. Both are
1111/// required together: with either missing the URL resolves to nothing and the
1112/// webview reports `NETWORK_NO_SOURCE`, which is how offline video came to fail
1113/// silently.
1114///
1115/// The scope is `$APPDATA/thumbnails/**`, not the storage root: downloaded media
1116/// moved to the loopback media server in DR-137, so `imageCache` is the only
1117/// remaining `convertFileSrc` caller and the database and the encrypted-token
1118/// fallback file — which share that root — never need to be readable by the
1119/// webview. Widen it only if something other than thumbnails starts resolving
1120/// through `convertFileSrc` again.
1121///
1122/// TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198
1123/// Build the logging plugin.
1124///
1125/// Replaces the previous `env_logger` init, which wrote to **stdout only**. That
1126/// was invisible to anyone who launched from a desktop icon, and worse than
1127/// useless on Android: stdout is not logcat, so the Rust backend produced no
1128/// visible output at all on the platform carrying the hardest bugs in this
1129/// project's history (the autoplay deadlock, the truncated-stream restart, the
1130/// background-audio stall). tauri-plugin-log routes to logcat there for free.
1131///
1132/// Three decisions worth keeping:
1133///
1134/// * **Every line goes through `redact` first.** A credential must never reach
1135///   disk, not merely be stripped later when a bundle is exported — a file on
1136///   the device is already the disclosure.
1137/// * **The size cap is deliberate.** `RotationStrategy::KeepAll` would let a
1138///   long-running session fill a phone. One rotation keeps yesterday's evidence
1139///   without unbounded growth.
1140/// * **The level is read from disk.** Someone reproducing a bug needs debug
1141///   logging to survive the restart that reproduces it.
1142///
1143/// TRACES: UR-078 | DR-218
1144fn build_log_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
1145    use tauri_plugin_log::{Target, TargetKind};
1146
1147    let mut targets = vec![
1148        Target::new(TargetKind::Stdout),
1149        Target::new(TargetKind::LogDir {
1150            file_name: Some("jellytau".to_string()),
1151        }),
1152    ];
1153
1154    // Rust lines in the webview console, so a developer sees both halves of the
1155    // app in one place. Dev only -- in a release build this would ship backend
1156    // logging into a console the user can open.
1157    if cfg!(debug_assertions) {
1158        targets.push(Target::new(TargetKind::Webview));
1159    }
1160
1161    tauri_plugin_log::Builder::new()
1162        .targets(targets)
1163        .level(log::LevelFilter::Info)
1164        .max_file_size(5 * 1024 * 1024)
1165        .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
1166        .format(|out, message, record| {
1167            out.finish(format_args!(
1168                "[{}][{}] {}",
1169                record.level(),
1170                record.target(),
1171                crate::utils::diagnostics::redact(&message.to_string())
1172            ))
1173        })
1174        .build()
1175}
1176
1177#[cfg_attr(mobile, tauri::mobile_entry_point)]
1178pub fn run() {
1179    // Crash capture before anything else, so a panic during startup is recorded
1180    // rather than vanishing with the process.
1181    //
1182    // TRACES: UR-078 | DR-218
1183    crate::utils::diagnostics::install_panic_hook();
1184
1185    // On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
1186    // GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
1187    // when available so video transcoding/decoding does not fall back to the CPU.
1188    // These must be set before WebKitGTK initializes its GStreamer pipeline.
1189    #[cfg(target_os = "linux")]
1190    enable_linux_hardware_video_decoding();
1191
1192    // NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
1193    // test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
1194    // `.export()` here would try to write `../src/lib/api/bindings.ts` at app
1195    // startup, which panics on devices (e.g. Android) where that path doesn't exist.
1196    let builder = specta_builder();
1197    let invoke_handler = builder.invoke_handler();
1198
1199    tauri::Builder::default()
1200        .plugin(build_log_plugin())
1201        .plugin(tauri_plugin_opener::init())
1202        .plugin(tauri_plugin_os::init())
1203        .invoke_handler(invoke_handler)
1204        .setup(move |app| {
1205            // Mount tauri-specta events so PlayerStatusEvent can be emitted to and
1206            // listened for on the frontend via the generated bindings.
1207            builder.mount_events(app);
1208
1209            // In-app update, desktop only.
1210            //
1211            // Registered here rather than in the builder chain above because a
1212            // `#[cfg]` attribute cannot be attached to one link of a method
1213            // chain -- this block is the shape Tauri's own docs use.
1214            //
1215            // Android is excluded on purpose: tauri-plugin-updater cannot
1216            // replace an installed APK, and the frontend offers the releases
1217            // page there instead.
1218            //
1219            // Re-apply the log level the user last chose. Without this the
1220            // picker would only affect the running session -- and the whole
1221            // point of a persisted level is that somebody reproducing a bug
1222            // keeps debug logging across the restart that reproduces it.
1223            //
1224            // TRACES: UR-078 | DR-218
1225            if let Ok(config_dir) = app.path().app_config_dir() {
1226                if let Some(level) = crate::commands::diagnostics::stored_level(&config_dir) {
1227                    log::set_max_level(level);
1228                    log::info!("[DIAG] restored log level from settings: {level}");
1229                }
1230            }
1231
1232            // TRACES: UR-077 | DR-217
1233            #[cfg(desktop)]
1234            {
1235                app.handle()
1236                    .plugin(tauri_plugin_updater::Builder::new().build())?;
1237                app.handle().plugin(tauri_plugin_process::init())?;
1238            }
1239
1240            // Initialize database with proper app data directory
1241            // Check for test mode environment variable first
1242            let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
1243                let test_path = std::path::PathBuf::from(test_data_dir);
1244                info!("[INIT] Using test data directory: {:?}", test_path);
1245                test_path.join("jellytau.db")
1246            } else {
1247                app
1248                    .path()
1249                    .app_data_dir()
1250                    .expect("Failed to get app data directory")
1251                    .join("jellytau.db")
1252            };
1253
1254            info!("[INIT] Initializing database at: {:?}", db_path);
1255
1256            // Create the directory if it doesn't exist
1257            if let Some(parent) = db_path.parent() {
1258                info!("[INIT] Creating database directory: {:?}", parent);
1259                match std::fs::create_dir_all(parent) {
1260                    Ok(_) => info!("[INIT] Database directory ready"),
1261                    Err(e) => {
1262                        error!("[INIT ERROR] Failed to create database directory: {}", e);
1263                        panic!("Failed to create database directory: {}", e);
1264                    }
1265                }
1266            }
1267
1268            info!("[INIT] Opening database...");
1269            let database = match Database::open(&db_path) {
1270                Ok(db) => {
1271                    info!("[INIT] Database initialized successfully");
1272                    db
1273                }
1274                Err(e) => {
1275                    error!("[INIT ERROR] Failed to initialize database: {}", e);
1276                    panic!("Failed to initialize database: {}", e);
1277                }
1278            };
1279            let db_wrapper = DatabaseWrapper(Mutex::new(database));
1280            app.manage(db_wrapper);
1281
1282            // On Android, initialize SecureStorage BEFORE creating CredentialStore
1283            #[cfg(target_os = "android")]
1284            {
1285                // Publish the JavaVM and Application into ndk_context first.
1286                //
1287                // Everything below reads that global. tao used to populate it
1288                // and stopped doing so in 0.35 (Tauri 2.11), at which point the
1289                // first read here aborted the process on launch. See
1290                // android_context.rs. A failure is logged rather than fatal:
1291                // credentials then fall back to the encrypted-file path, which
1292                // is a supported degraded mode -- unlike aborting.
1293                //
1294                // TRACES: UR-012 | DR-223
1295                if let Err(e) = crate::android_context::ensure_initialized() {
1296                    log::error!("[INIT] Android context unavailable: {e}");
1297                    log::error!("[INIT] Secure credential storage will fall back to the encrypted file.");
1298                }
1299
1300                info!("[INIT] Initializing Android SecureStorage for credentials...");
1301                let ctx = ndk_context::android_context();
1302                let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
1303
1304                match vm {
1305                    Ok(java_vm) => {
1306                        match java_vm.attach_current_thread() {
1307                            Ok(mut env) => {
1308                                let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
1309
1310                                match initialize_secure_storage(&mut env, &context_obj) {
1311                                    Ok(()) => {
1312                                        info!("[INIT] Android SecureStorage initialized successfully");
1313                                    }
1314                                    Err(e) => {
1315                                        warn!("[INIT WARNING] Failed to initialize SecureStorage: {}. Credentials will use encrypted file fallback.", e);
1316                                    }
1317                                }
1318                            }
1319                            Err(e) => {
1320                                warn!("[INIT WARNING] Failed to attach JNI thread: {}. Credentials will use encrypted file fallback.", e);
1321                            }
1322                        }
1323                    }
1324                    Err(e) => {
1325                        warn!("[INIT WARNING] Failed to create JavaVM: {}. Credentials will use encrypted file fallback.", e);
1326                    }
1327                }
1328            }
1329
1330            // Initialize credential store (keyring with encrypted file fallback)
1331            info!("[INIT] Initializing credential store...");
1332            let credential_store = CredentialStore::new();
1333            let creds_wrapper = CredentialStoreWrapper(Mutex::new(credential_store));
1334            app.manage(creds_wrapper);
1335
1336            // Create shared reporter and throttler Arc wrappers before backend/controller
1337            info!("[INIT] Creating shared playback reporting infrastructure...");
1338            let playback_reporter = Arc::new(tokio::sync::Mutex::new(None));
1339            let position_throttler = Arc::new(playback_reporting::EventThrottler::new());
1340
1341            // Create player backend with access to AppHandle for event emission
1342            info!("[INIT] Creating player backend...");
1343            let backend = create_player_backend(
1344                app.handle().clone(),
1345                playback_reporter.clone(),
1346                position_throttler.clone(),
1347            );
1348            let player_controller = PlayerController::new(
1349                backend,
1350                playback_reporter.clone(),
1351                position_throttler.clone(),
1352            );
1353
1354            // Wire up event emitter for sleep timer and autoplay notifications
1355            let event_emitter = Arc::new(TauriEventEmitter::new(app.handle().clone()));
1356            player_controller.set_event_emitter(event_emitter.clone());
1357
1358            let player_arc = Arc::new(TokioMutex::new(player_controller));
1359
1360            // On Android, register the player controller for autoplay decisions.
1361            // The MediaSession (lockscreen) handler is set up later, once the
1362            // playback mode manager exists, so it can route to remote sessions.
1363            #[cfg(target_os = "android")]
1364            {
1365                player::android::set_player_controller(player_arc.clone());
1366            }
1367
1368            let player_state = PlayerStateWrapper(player_arc.clone());
1369            app.manage(player_state);
1370
1371            // Initialize media session manager
1372            info!("[INIT] Initializing media session manager...");
1373            let session_manager = MediaSessionManager::new();
1374            let session_wrapper = MediaSessionManagerWrapper(Mutex::new(session_manager));
1375            app.manage(session_wrapper);
1376
1377            // Initialize playback mode manager
1378            info!("[INIT] Initializing playback mode manager...");
1379            let jellyfin_client = {
1380                let player = player_arc.blocking_lock();
1381                player.jellyfin_client()
1382            };
1383            let playback_mode_manager = playback_mode::PlaybackModeManager::new(
1384                jellyfin_client.clone(),
1385                player_arc.clone(),
1386            );
1387            let playback_mode_arc = Arc::new(playback_mode_manager);
1388            // Broadcast mode changes so the frontend's mirror store reconciles to
1389            // this authoritative one (prevents remote/local control desync).
1390            playback_mode_arc.set_event_emitter(event_emitter.clone());
1391            let playback_mode_wrapper = PlaybackModeManagerWrapper(playback_mode_arc.clone());
1392            app.manage(playback_mode_wrapper);
1393
1394            // Initialize session poller manager for remote session polling
1395            info!("[INIT] Initializing session poller manager...");
1396            let session_poller = session_poller::SessionPollerManager::new(
1397                jellyfin_client,
1398                playback_mode_arc.clone(),
1399            );
1400            session_poller.set_event_emitter(event_emitter.clone());
1401            // Note: start() is deferred until after the connectivity monitor is
1402            // created below, so the poller can report reachability from its first
1403            // poll (it drives offline detection + recovery while the user is idle).
1404            let session_poller_arc = Arc::new(session_poller);
1405            let session_poller_wrapper = SessionPollerWrapper(session_poller_arc.clone());
1406            app.manage(session_poller_wrapper);
1407
1408            // On Android, set up the MediaSession (lockscreen) handler and the
1409            // remote volume handler. Both need the playback mode manager so they
1410            // can route to the active remote session while casting.
1411            #[cfg(target_os = "android")]
1412            {
1413                info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
1414                let media_handler = Arc::new(MediaSessionHandler {
1415                    player: player_arc.clone(),
1416                    playback_mode: playback_mode_arc.clone(),
1417                    event_emitter: event_emitter.clone(),
1418                });
1419                set_media_command_handler(media_handler);
1420
1421                info!("[INIT] Setting up remote volume handler for Android...");
1422                let handler = Arc::new(RemoteVolumeSessionHandler {
1423                    playback_mode: playback_mode_arc.clone(),
1424                });
1425                set_remote_volume_handler(handler);
1426            }
1427
1428            // Initialize video settings with defaults
1429            let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
1430            app.manage(video_settings);
1431
1432            // Restore the persisted streaming bandwidth ceiling. Deferred to the
1433            // async runtime because the read is async, and ordered after the
1434            // wrapper above because it writes into it. Until it lands, streams
1435            // are uncapped — the pre-existing behaviour — and no playback can
1436            // have started this early anyway (login happens after setup).
1437            //
1438            // TRACES: UR-074 | DR-162
1439            {
1440                let handle = app.handle().clone();
1441                tauri::async_runtime::spawn(async move {
1442                    crate::commands::restore_streaming_quality(&handle).await;
1443                });
1444            }
1445
1446            // Restore the folders the user hid from browsing, for the same
1447            // reason and in the same way. Until it lands nothing is hidden —
1448            // the pre-existing behaviour — and no query can have run this early.
1449            //
1450            // TRACES: UR-076 | DR-209
1451            {
1452                let handle = app.handle().clone();
1453                tauri::async_runtime::spawn(async move {
1454                    crate::commands::restore_library_settings(&handle).await;
1455                });
1456            }
1457
1458            // Initialize thumbnail cache
1459            info!("[INIT] Initializing thumbnail cache...");
1460            let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
1461                std::path::PathBuf::from(test_data_dir)
1462            } else {
1463                app
1464                    .path()
1465                    .app_data_dir()
1466                    .expect("Failed to get app data directory")
1467            };
1468            let thumbnail_cache = ThumbnailCache::new(app_data_dir.clone(), ThumbnailCacheConfig::default());
1469            let thumbnail_wrapper = ThumbnailCacheWrapper(Arc::new(thumbnail_cache));
1470            app.manage(thumbnail_wrapper);
1471
1472            // Initialize smart cache for preloading
1473            info!("[INIT] Initializing smart cache...");
1474            let smart_cache = SmartCache::new(SmartCacheConfig::default());
1475            let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
1476            app.manage(smart_cache_wrapper);
1477
1478            // Serve downloaded media over loopback HTTP. The webview cannot
1479            // stream a large file through the asset protocol (see media_server),
1480            // so local playback resolves its URL from here instead.
1481            // TRACES: UR-071 | DR-137
1482            info!("[INIT] Starting local media server...");
1483            let media_server = match media_server::start(app_data_dir.clone()) {
1484                Ok(s) => Some(s),
1485                Err(e) => {
1486                    // Not fatal: streaming still works, and the command reports
1487                    // a clear error if local playback is attempted.
1488                    error!("[INIT ERROR] Local media server failed to start: {}", e);
1489                    None
1490                }
1491            };
1492            app.manage(media_server::MediaServerWrapper(media_server));
1493
1494            // Initialize download manager
1495            info!("[INIT] Initializing download manager...");
1496            let download_dir = app_data_dir.join("downloads");
1497            let download_manager = DownloadManager::new(download_dir);
1498            let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
1499            app.manage(download_manager_wrapper);
1500
1501            // Current network transport, for the WiFi-only download gate (UR-053).
1502            // Defaults to unmetered ethernet so desktop is never gated; Android
1503            // overwrites it via set_network_state as soon as the UI starts.
1504            app.manage(commands::download::NetworkStateWrapper(
1505                download::network::NetworkStateHandle::new(),
1506            ));
1507
1508            // Initialize connectivity monitor
1509            info!("[INIT] Initializing connectivity monitor...");
1510            let http_config = HttpConfig::default();
1511            let http_client = HttpClient::new(http_config)
1512                .expect("Failed to create HTTP client");
1513            let mut connectivity_monitor = ConnectivityMonitor::new(http_client);
1514            connectivity_monitor.set_app_handle(app.handle().clone());
1515
1516            // Wire the connectivity reporter into the session poller so its
1517            // continuous background polls drive reachability (offline detection
1518            // + recovery) even when the user isn't browsing, then start it.
1519            session_poller_arc.set_connectivity_reporter(connectivity_monitor.reporter());
1520            session_poller_arc.start();
1521
1522            // Wrap in Arc for sharing with AuthManager
1523            let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor));
1524            let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone());
1525            app.manage(connectivity_wrapper);
1526
1527            // Initialize auth manager
1528            info!("[INIT] Initializing auth manager...");
1529            let auth_http_config = HttpConfig::default();
1530            let auth_http_client = HttpClient::new(auth_http_config)
1531                .expect("Failed to create HTTP client for auth");
1532            let mut auth_manager = AuthManager::new(auth_http_client);
1533
1534            // Give auth manager a reference to connectivity monitor
1535            auth_manager.set_connectivity_monitor(connectivity_arc.clone());
1536
1537            let auth_manager_wrapper = AuthManagerWrapper(Arc::new(auth_manager));
1538            app.manage(auth_manager_wrapper);
1539
1540            // Initialize session verifier wrapper (initially empty)
1541            info!("[INIT] Initializing session verifier wrapper...");
1542            let session_verifier_wrapper = SessionVerifierWrapper(Arc::new(tokio::sync::Mutex::new(None)));
1543            app.manage(session_verifier_wrapper);
1544
1545            // Initialize repository manager
1546            info!("[INIT] Initializing repository manager...");
1547            let repository_manager = commands::RepositoryManager::new();
1548            let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
1549            app.manage(repository_manager_wrapper);
1550
1551            // Initialize playback reporter wrapper. This MUST share the same Arc
1552            // the player controller and MPV progress loop report through (created
1553            // above at `playback_reporter`), otherwise `playback_reporter_init`
1554            // would populate a dead, parallel Option and no Start/Progress/Stopped
1555            // would ever reach Jellyfin.
1556            info!("[INIT] Initializing playback reporter wrapper...");
1557            let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
1558            app.manage(playback_reporter_wrapper);
1559
1560            // Keep the local search index fresh. Ownership of *when* to re-index
1561            // sits here rather than in the frontend: it is sync policy over
1562            // domain data, and a startup-only trigger left a long session
1563            // searching a stale catalog.
1564            // TRACES: UR-065 | DR-109, IR-030
1565            info!("[INIT] Starting background catalog indexer...");
1566            commands::catalog::spawn_catalog_indexer(app.handle().clone());
1567
1568            // Push favourite toggles made while the server was unreachable, on
1569            // every reconnect. In Rust rather than the frontend so it runs
1570            // whether or not the screen that made the change is still mounted.
1571            // TRACES: UR-069 | DR-120
1572            info!("[INIT] Starting favourites drain...");
1573            commands::favorites::spawn_favorites_drain(app.handle().clone());
1574
1575            // Push playback reports queued while the server was unreachable.
1576            // Without this the `sync_queue` rows the reporter writes offline
1577            // are never sent and the offline banner's count only grows.
1578            // TRACES: UR-025, UR-002 | DR-131
1579            info!("[INIT] Starting sync-queue drain...");
1580            commands::sync_drain::spawn_sync_queue_drain(app.handle().clone());
1581
1582            info!("[INIT] Application setup completed successfully");
1583            Ok(())
1584        })
1585        .run(tauri::generate_context!())
1586        .expect("error while running tauri application");
1587}
1588
1589#[cfg(test)]
1590mod specta_bindings {
1591    /// Generates `src/lib/api/bindings.ts`. Run with `cargo test export_typescript_bindings`.
1592    #[test]
1593    fn export_typescript_bindings() {
1594        super::specta_builder()
1595            .export(
1596                specta_typescript::Typescript::default()
1597                    .bigint(specta_typescript::BigIntExportBehavior::Number),
1598                "../src/lib/api/bindings.ts",
1599            )
1600            .expect("failed to export typescript bindings");
1601    }
1602}