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