// Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] pub mod cli; mod cover_cache; mod library_analysis_backfill; mod lib_commands; mod theme_import; pub mod theme_animation; pub use psysonic_integration::discord; pub use psysonic_core::logging; pub use psysonic_core::{app_eprintln, app_deprintln}; pub use psysonic_core::user_agent::{ default_subsonic_wire_user_agent, runtime_subsonic_wire_user_agent, subsonic_wire_user_agent, }; pub use psysonic_analysis::{analysis_cache, analysis_runtime}; pub use psysonic_audio as audio; pub use psysonic_syncfs::{sync_cancel_flags, DownloadSemaphore}; #[cfg(target_os = "windows")] mod taskbar_win; mod tray_runtime; pub(crate) use tray_runtime::*; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tauri::{Emitter, Manager}; use lib_commands::*; /// Tracks which user-configured shortcuts are currently registered (shortcut_str → action). /// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode). type ShortcutMap = Mutex>; /// Maximum number of offline track downloads that can run concurrently. /// The frontend queues more tasks than this; Rust is the real throttle. const MAX_DL_CONCURRENCY: usize = 4; /// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows). /// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux). type MprisControls = Mutex>; /// Release builds only: focus or CLI-hand off when a second instance is launched. #[cfg(not(debug_assertions))] fn on_second_instance( app: &tauri::AppHandle, argv: Vec, _cwd: String, ) { if !crate::cli::handle_cli_on_primary_instance(app, &argv) { let window = app.get_webview_window("main").expect("no main window"); // The window may have been hidden via the close-to-tray path, // which injects PAUSE_RENDERING_JS (sets `__psyHidden=true`, // pauses CSS animations). Tray-icon restore mirrors this with // RESUME_RENDERING_JS — second-launch restore must do the same, // otherwise the webview comes back with rendering still paused // and navigation looks blank (issue #497). let _ = window.eval(RESUME_RENDERING_JS); let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); } } /// Windows: associate this process with an explicit AppUserModelID. Windows uses /// it to name the app in taskbar grouping and the SMTC media controls; without it /// the media tile reads "Unknown application". Must match the AppUserModelID the /// installer sets on the Start-menu shortcut so the name/icon resolve. #[cfg(target_os = "windows")] fn set_app_user_model_id() { use windows::core::w; use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; // SAFETY: a Win32 call with a static wide string; errors are non-fatal. unsafe { let _ = SetCurrentProcessExplicitAppUserModelID(w!("dev.psysonic.player")); } } pub fn run() { // Windows: bind this process to an explicit AppUserModelID before any window // or the SMTC media controls are created, so the OS can resolve the app // name/icon for taskbar grouping and the media tile (#1102 follow-up: the // Quick-Settings / lock-screen media tile showed "Unknown application"). #[cfg(target_os = "windows")] set_app_user_model_id(); // Linux: second `psysonic --player …` forwards over D-Bus before heavy startup. #[cfg(target_os = "linux")] { let argv: Vec = std::env::args().collect(); if crate::cli::parse_cli_command(&argv).is_some() { match crate::cli::linux_try_forward_player_cli_secondary(&argv) { Ok(crate::cli::LinuxPlayerForwardResult::Forwarded) => std::process::exit(0), Ok(crate::cli::LinuxPlayerForwardResult::ContinueStartup) => {} Err(msg) => { crate::app_eprintln!("NOT OK: {msg}"); std::process::exit(1); } } } } let (audio_engine, _audio_thread) = audio::create_engine(); let builder = tauri::Builder::default() .manage(audio_engine) .manage(Arc::new(psysonic_core::server_http::ServerHttpRegistry::new())) .manage(ShortcutMap::default()) .manage(discord::DiscordState::new()) .manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore) .manage(TrayState::default()) .manage(TrayTooltip::default()) .manage(TrayPlaybackState::default()) .manage(TrayMenuItemsState::default()) .manage(TrayMenuLabelsState::default()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin( tauri_plugin_window_state::Builder::default() .with_state_flags( tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE, ) .with_denylist(&["mini"]) .build() ) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()); #[cfg(not(debug_assertions))] let builder = builder.plugin(tauri_plugin_single_instance::init(on_second_instance)); builder .setup(|app| { #[cfg(debug_assertions)] if let Some(window) = app.get_webview_window("main") { let _ = window.set_title("Psysonic (Dev)"); } // ── Dev: `--theme-watch ` live theme reload ───────── // Poll a local theme.css and push it into the running app on save, // so theme authors get a live loop without re-importing a zip. The // frontend (dev only) installs it under the id in its // `[data-theme='']` selector and applies it. Dev-builds only. #[cfg(debug_assertions)] { let args: Vec = std::env::args().collect(); if let Some(i) = args.iter().position(|a| a == "--theme-watch") { match args.get(i + 1).cloned() { Some(path) => { eprintln!("[theme-watch] watching {path}"); let handle = app.handle().clone(); std::thread::spawn(move || { let p = std::path::PathBuf::from(&path); let mut last_css = String::new(); loop { if let Ok(css) = std::fs::read_to_string(&p) { if css != last_css { last_css = css.clone(); let _ = handle.emit("theme-watch:css", css); } } std::thread::sleep(std::time::Duration::from_millis(300)); } }); } None => eprintln!("[theme-watch] usage: --theme-watch "), } } } // ── Analysis cache (SQLite) ─────────────────────────────────── { let cache = analysis_cache::AnalysisCache::init(app.handle()) .map_err(|e| format!("analysis cache init failed: {e}"))?; app.manage(cache); } cover_cache::init_cover_cache(app.handle()) .map_err(|e| format!("cover cache init failed: {e}"))?; library_analysis_backfill::init_library_analysis_backfill(app.handle()) .map_err(|e| format!("library analysis backfill init failed: {e}"))?; // ── Library track store (psysonic-library, PR-5a + PR-5b) ───── // PR-5a brought up the read-only Tauri surface + LibraryRuntime. // PR-5b adds the mutating commands, sync session map, current-job // tracker, and the 30-second background scheduler tick task below // — which sweeps every bound session through // `BackgroundScheduler::tick` while honouring the runtime's // `scheduler_cancel` flag. { let store = psysonic_library::store::LibraryStore::init(app.handle()) .map_err(|e| format!("library store init failed: {e}"))?; let runtime = psysonic_library::LibraryRuntime::new(std::sync::Arc::new(store)); app.manage(runtime); let app_for_sched = app.handle().clone(); tauri::async_runtime::spawn(async move { use std::sync::atomic::Ordering; use std::time::Duration; use tokio::time::MissedTickBehavior; let mut interval = tokio::time::interval(Duration::from_secs(30)); interval.set_missed_tick_behavior(MissedTickBehavior::Skip); loop { interval.tick().await; let Some(state) = app_for_sched .try_state::() else { break; }; if state.scheduler_cancel.load(Ordering::SeqCst) { break; } let sessions = state.snapshot_sessions(); if sessions.is_empty() { continue; } let hint = state.current_playback_hint(); let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis().min(i64::MAX as u128) as i64) .unwrap_or(0); for session in sessions { let scope = session.library_scope.clone().unwrap_or_default(); let flags_bits = psysonic_library::repos::SyncStateRepository::new( &state.store, ) .get_capability_flags(&session.server_id, &scope) .ok() .flatten() .unwrap_or(0); let flags = psysonic_library::sync::capability::CapabilityFlags::new( flags_bits, ); let registry = app_for_sched.state::>(); let subsonic = psysonic_integration::subsonic::subsonic_client_with_registry( Some(registry.as_ref()), &session.server_id, session.base_url.clone(), session.username.clone(), session.password.clone(), ); let mut sched = psysonic_library::sync::scheduler::BackgroundScheduler::new( &state.store, &subsonic, session.server_id.clone(), scope.clone(), flags, ) .with_playback_hint(hint) .with_http_registry(Some(Arc::clone(®istry))); if let Some(tok) = session.navidrome_token.clone() { sched = sched.with_navidrome_credentials( psysonic_library::sync::capability::NavidromeProbeCredentials { server_url: session.base_url.clone(), bearer_token: tok, }, ); } let foreground_job = state .current_job() .is_some_and(|j| j.server_id == session.server_id); if foreground_job { sched = sched.with_foreground_sync_job_active(true); } let _ = sched.tick(now_ms).await; // Background ticks stay silent in PR-5b — Tauri // emit for the scheduler path lands when the // Settings panel needs it (PR-5c). Manual // `library_sync_start` already emits via its // own orchestrator. } } }); } audio::cleanup_orphan_stream_spill_dir(app.handle()); // ── Playback-query port (analysis → audio back-edge) ────────── // Two closures, each capturing an AppHandle, so analysis_runtime // can ask AudioEngine playback questions without depending on the // audio crate. { let app_is_playing = app.handle().clone(); let app_defer = app.handle().clone(); let handle = psysonic_core::ports::PlaybackQueryHandle::new( move |track_id| { app_is_playing .try_state::() .is_some_and(|e| crate::audio::analysis_track_id_is_current_playback(&e, track_id)) }, move |track_id| { app_defer .try_state::() .is_some_and(|e| crate::audio::ranged_loudness_backfill_should_defer(&e, track_id)) }, ); app.manage(handle); } app.manage(psysonic_analysis::analysis_runtime::PlaybackPriorityHints::default()); // ── Content-hash sink (analysis → library E2 back-edge) ─────── // After a seed the analysis pipeline records the playback-derived // md5_16kb as `track.content_hash` so id-remap can rebind a track // when the server reassigns ids. Decoupled from psysonic-library // via a psysonic-core port; a no-op when the library has no row for // the (server_id, track_id) — i.e. the index is off for that server. { let app_for_hash = app.handle().clone(); let sink = psysonic_core::ports::ContentHashSink::new( move |server_id: &str, track_id: &str, md5: &str| { if let Some(runtime) = app_for_hash.try_state::() { let _ = psysonic_library::commands::patch_content_hash( &runtime, server_id, track_id, md5, ); } }, ); app.manage(sink); } // ── Analysis-readiness query (library → analysis E3 back-edge) ── // `library_get_track` enrichment asks whether waveform/loudness are // cached for (server_id, track_id, content_hash). Read-only probe: // exact key then legacy '' fallback, no re-tag. Decoupled from // psysonic-analysis via a psysonic-core port. { let app_for_readiness = app.handle().clone(); let query = psysonic_core::ports::AnalysisReadinessQuery::new( move |server_id: &str, track_id: &str, md5: &str| { let Some(cache) = app_for_readiness .try_state::() else { return (false, false); }; let probe = |sid: &str| { let key = analysis_cache::TrackKey { server_id: sid.to_string(), track_id: track_id.to_string(), md5_16kb: md5.to_string(), }; let wf = cache.get_waveform(&key).ok().flatten().is_some(); let ld = cache.loudness_row_exists_for_key(&key).unwrap_or(false); (wf, ld) }; let (wf, ld) = probe(server_id); // Legacy '' fallback for rows analysed before E1 wiring. let wf = wf || (!server_id.is_empty() && probe("").0); let ld = ld || (!server_id.is_empty() && probe("").1); (wf, ld) }, ); app.manage(query); } // ── Analysis needs-work probe (library → analysis batch scan) ── { use psysonic_core::ports::TrackAnalysisNeedsWorkQuery; let app_for_needs_work = app.handle().clone(); let needs_work = TrackAnalysisNeedsWorkQuery::new( move |server_id: &str, track_id: &str| { psysonic_analysis::analysis_runtime::track_analysis_needs_work( &app_for_needs_work, server_id, track_id, ) }, ); app.manage(needs_work); } // ── Track enrichment port (analysis → library facts) ─────────── { use psysonic_core::track_enrichment::{TrackEnrichmentPlan, TrackEnrichmentPort}; use std::time::{SystemTime, UNIX_EPOCH}; fn enrichment_now_unix_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or(0) } let app_for_enrichment_plan = app.handle().clone(); let app_for_enrichment_store = app.handle().clone(); let port = TrackEnrichmentPort::new( move |server_id: &str, track_id: &str, content_hash: &str| { let Some(runtime) = app_for_enrichment_plan.try_state::() else { return TrackEnrichmentPlan::default(); }; match psysonic_library::enrichment::plan_track_enrichment( &runtime.store, server_id, track_id, content_hash, enrichment_now_unix_ms(), ) { Ok(plan) => plan, Err(e) => { eprintln!( "[enrichment] plan failed server_id={server_id} track_id={track_id}: {e}" ); TrackEnrichmentPlan { need_bpm: true, need_valence: true, need_arousal: true, need_moods: true, } } } }, move |server_id: &str, track_id: &str, content_hash: &str, facts: &psysonic_core::track_enrichment::TrackEnrichmentFacts| { let Some(runtime) = app_for_enrichment_store.try_state::() else { return Err("library runtime unavailable".into()); }; psysonic_library::enrichment::store_track_enrichment_facts( &runtime.store, server_id, track_id, content_hash, facts, enrichment_now_unix_ms(), ) }, ); app.manage(port); } // Periodic analysis queue sizes (debug logging mode only). tauri::async_runtime::spawn(psysonic_analysis::analysis_runtime::analysis_queue_snapshot_loop()); // ── Custom title bar on Linux ───────────────────────────────── // Remove OS window decorations on all Linux so the React TitleBar // can take over. The frontend checks is_tiling_wm() to decide // whether to actually render the TitleBar (hidden on tiling WMs). #[cfg(target_os = "linux")] { use tauri::Manager; let handle = app.handle().clone(); sync_wayland_text_profile_cache_from_disk(&handle); if let Some(win) = app.get_webview_window("main") { let _ = win.set_decorations(false); let _ = linux_webkit_apply_wayland_gpu_font_tuning(&win); let _ = linux_webkit_reapply_cached_wayland_text_render_profile(&win); // Suppress WebKit's own MPRIS player so radio (HTML