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