Skip to main content

jellytau_lib/
lib.rs

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