mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
418b25914a
* chore(cover): scaffold cover module and rust cover_cache stub Wave 0: src/cover/ skeleton per contracts.md §12, stub IPC commands in cover_cache/mod.rs (no-op returns until phase B). * feat(cover): add unified cover module and tier resolver (phase A) Wave 1A: tiers, storage keys, resolveJs with cold/sibling races, useCoverArt, CoverArtImage, layoutSizes, playback scope helpers, coverSiblings tier ladder, deprecated shims on subsonicStreamUrl. * feat(cover): rust disk cache and tier-ready events (phase B) Wave 1B: cover_cache module with WebP tier encode, HTTP canonical 800 fetch, cover_cache_* commands, cover:tier-ready / cover:evicted events, disk layout tests. * feat(cover): prefetch hook, tier-ready handoff, library backfill IPC (phase B/C) Wave 2: useCoverArtPrefetch, cover:tier-ready/evicted bridge, one-time IDB cover key clear, prefetch registry drain, MainApp wiring. * feat(cover): migrate dense grids to CoverArtImage and prefetch (phase D) Wave 3A: dense surfaces use layout-native displayCssPx, surface=dense, coverPrefetchRegister on Home/Albums/search; AlbumCard cell width from grid. * feat(cover): migrate sparse surfaces and integrations (phase E sparse) Wave 3B: sparse CoverArtImage/useCoverArt, lightbox tier 2000, ArtistHeroCover, MPRIS/Discord/export integrations, playback chrome and detail heroes. * feat(cover): revalidation scheduler and disk pressure gate (phase E+) Wave 4: coverCacheMaxMb settings (en/ru), StorageTab disk usage, cover_cache_configure, useCoverRevalidateScheduler, playbackServer uses cover fetchUrl; pressure watermarks. * docs: CHANGELOG and credits for cover art pipeline PR #869 * fix(cover): stop webview getCoverArt storm on dense grids (429) Dense surfaces no longer put rotating getCoverArt URLs in img src; load disk via Rust ensure + convertFileSrc. Tier-ready notifies listeners instead of invalidating IDB. Throttle background prefetch and cap Home registry. * fix(cover): omit empty img src until cover URL is ready React 19 warns on src=""; CoverArtImage uses undefined until disk/IDB resolves; queue current track shows placeholder when src is still empty. * fix(cover): disk cache by host index key, parallel ensure, asset protocol Bind cover storage to serverIndexKey (library host), rename cover IPC/events, fix REST base URL and Tauri flat args, enable protocol-asset for disk paths, add prioritized ensure queue, and wipe legacy profile-UUID cache once. Limit Vite dep scan to index.html so research/target HTML is ignored. * fix(cover): WebP tiers, disk peek, home cache, asset URLs for mainstage Encode lossy WebP (~82), write only missing tiers, library cover backfill, and cover_cache_peek_batch for fast paint from disk. diskSrcCache + CSP asset protocol; no IDB fallback when server is up. Session Home feed cache with warm peek on return; BecauseYouLike deduped cover hook and high prefetch. * feat(cover): per-server cache strategy and native library backfill Move cover disk cache settings to Offline & cache with Lazy/Aggressive per server, per-server clear, and no size cap. Run full-catalog backfill on the Rust runtime (sync-idle wake, bounded HTTP, bulk 800px writes without flooding the webview). Drop global prefetch limits from auth store and waveform clear from the offline storage block. * fix(build): CSP connect-src for Subsonic API; quieter prod nix build Prod webview blocked axios ping after cover CSP (missing connect-src). Drop cargo tauri -v in flake build, raise Vite chunk limit, ignore tsbuildinfo. * fix(cover): complete WebP ladder in library bulk backfill Aggressive backfill now writes all derived tiers (128–800), skips IDs only when the full ladder exists (not 800 alone), avoids fetch-failed markers on bulk HTTP errors, and stops the pass when the active server changes. * fix(cover,home): navigation-priority backfill and Because You Like UX Pause library cover backfill while navigating; split peek/ensure traffic so grids and rails win over bulk work. Disk src lookup, grid warm hooks, and non-blocking mainstage prime for faster visible covers. Because You Like: session snapshot, staggered horizontal skeleton row, text hidden until cover is ready, and layout aligned with loaded cards. * feat(random-albums,library): local-first album fetch + cover art pipeline Random Albums теперь запрашивает локальный SQLite-индекс (ORDER BY RANDOM() LIMIT N) вместо сетевого запроса к серверу. При готовом индексе спиннер исчезает практически мгновенно; сеть используется только как фолбэк. - advanced_search.rs: добавляет `("random", _) => RANDOM()` в allowlist сортировок - browseTextSearch.ts: runLocalRandomAlbums — SQLite-рандом для Albums - RandomAlbums.tsx: doFetchRandomAlbums local-first для обоих путей (без жанра и с жанром через runLocalAlbumsByGenres + JS-shuffle); speculative reserve прогревает следующий батч в фоне после каждого Refresh Также: обновление пайплайна обложек (coverTraffic, peekQueue, ensureQueue, diskSrcLookup, warmDiskPeek, prefetchRegistry, useCoverArt, useWarmGridCovers, useCoverNavigationPriority, resolveIntersectionScrollRoot и сопутствующие компоненты/хуки). * fix(random-albums): prevent double-load on Zustand rehydration useEffect([selectedGenres, load]) fired twice on every visit: first with default store values, then again ~50 ms later when Zustand rehydrated mixMinRatingFilterEnabled/minAlbum/minArtist from localStorage. Previously this was invisible because the first network fetch took ~1.5 s, so loadingRef.current was still true on the second fire. With the new local-first SQLite path the first load completes in ~50 ms, leaving the guard cleared before rehydration triggers a second random batch. Fix: ref-pattern — keep loadRef.current fresh on every render, effect depends only on selectedGenres. Manual Refresh and genre-filter changes still call the latest closure correctly. * fix(random-albums): stop warmCoverDiskSrcBatch in fillReserve from causing visual flash fillReserve вызывал warmCoverDiskSrcBatch для обложек резервного батча, что вызывало bumpDiskSrcCache() для каждой новой обложки (~30+ вызовов). Это будило всех подписчиков useCoverArt на текущей странице, провоцируя видимую перерисовку примерно через ~1.5 с после загрузки (когда filterAlbumsByMixRatings делает сетевые запросы к рейтингам артистов). - fillReserve: убран warmCoverDiskSrcBatch — обложки прогреваются лениво при consume резерва через primeAlbumCoversForDisplay - reserve-путь в load(): добавлен primeAlbumCoversForDisplay перед setAlbums (аналогично non-reserve пути; при уже прогретом кэше — мгновенно) * feat(because-you-like): reserve-first pattern — instant display on return visits Каждый визит на Mainstage после первого теперь отдаёт готовую заготовку мгновенно, вместо spinner → сетевые запросы → контент. Архитектура: - resolvePicks / fetchBecauseYouLike вынесены на уровень модуля (выход из замыкания useEffect); читают текущий localStorage, возвращают { anchor, recs, nextAnchorHistory, nextPicksHistory } - fillBecauseReserve — fire-and-forget фоновая функция: запускается сразу после отображения результата, кладёт следующий батч в _becauseReserve. Covers намеренно не прогреваются (bumpDiskSrcCache на текущей странице не нужен); они прогреваются через primeAlbumCoversForDisplay при consume. - useLayoutEffect: если reserve готов — не сбрасывает стейт в skeleton (контент появляется без мигания) - useEffect: reserve-first path — consume → primeCovers → setState → fill; full-fetch path сохранён как fallback при первом визите или промахе Поведение: - Визит 1: full fetch (как раньше) → показ → fillReserve R1 - Визит 2+: consume R1 → мгновенный показ → fillReserve R2 - При сетевом сбое: restore из session cache (как раньше) * fix(because-you-like): initialise state from reserve — no skeleton flash on remount При ремаунте компонент стартовал с refreshing=true/anchor=null/recs=[] и показывал skeleton на один тик до того как useEffect отработает. Теперь useState() использует lazy initializers, которые читают _becauseReserve прямо в первом рендере: если reserve валиден — state сразу refreshing=false, anchor=X, recs=[...] и skeleton не показывается вообще. Covers уже в diskSrcCache (из предыдущего показа) и появляются без дополнительных запросов. useLayoutEffect упрощён: вызывает hasValidReserve() и сбрасывает в skeleton только если reserve отсутствует (для случая navigation без ремаунта). * fix(because-you-like): apply reserve in useLayoutEffect to handle async pool arrival Lazy initializers не могли применить reserve при первом рендере, потому что mostPlayed/recentlyPlayed/starred приходят из Home.tsx асинхронно — pool=[] на первом рендере, poolKey не совпадает с reserve. useLayoutEffect теперь активно ставит стейт из reserve (а не просто не сбрасывает): когда pool обновляется до реальных данных, useLayoutEffect срабатывает синхронно до paint, проверяет reserve и сразу применяет anchor/recs/refreshing=false. При отсутствии reserve — сбрасывает в skeleton как прежде. * fix(because-you-like): reserve > cache > skeleton — eliminate skeleton flash on mount Корневая причина: Home.tsx загружает mostPlayed асинхронно через useEffect, поэтому на первом рендере pool=[], poolKey=''. Reserve хранится с реальным poolKey → mismatch → lazy initializers запускали skeleton. Теперь двухуровневый fallback без зависимости от poolKey: 1. reserve (serverId + poolKey совпадают) → мгновенный новый батч 2. becauseYouLikeCache (только serverId) → stale-while-revalidate, контент доступен сразу с mount, обновляется тихо в фоне 3. skeleton → только при полном отсутствии данных (первый визит) Применяется одинаково в lazy useState initializers, useLayoutEffect и full-fetch path useEffect (не сбрасывать в skeleton пока есть cached контент). * fix(because-you-like): key reserve by serverId only; guard useEffect on empty pool Проблема: reserve хранился с poolKey, но на первом рендере pool=[] → poolKey='' → mismatch → показывался кэш (предыдущий набор) ~500ms пока Home.tsx не загружал mostPlayed. Исправления: - BecauseReserve: убран poolKey — reserve валиден для любого pool-состояния на том же сервере. Pool (топ-артисты) меняется медленно; один раз показать reserve с чуть устаревшим anchor лучше чем показывать предыдущий набор 500ms - hasValidReserve: проверяет только serverId - fillBecauseReserve: убран poolKey из сигнатуры и хранилища - useEffect: guard pool.length === 0 → возврат без fetch/consume; effect перезапустится когда pool заполнится (реальные deps изменятся) → reserve применяется из useLayoutEffect ещё до pool, без стале-флэша Итоговый порядок: reserve (instant, serverId) > cache (stale-while-revalidate) > skeleton (только первый визит) * fix(home): remove mix-rating deps from feed useEffect — prevent Zustand rehydration double-fetch Корень: useAuthStore(mixMinRatingFilterEnabled/Album/Artist) были в deps useEffect. Zustand persist реhydrates асинхронно — сначала activeServerId, потом mix-rating значения. Это вызывало двойной запуск эффекта: - Первый запуск: homeFeedCache hit → показывает набор предыдущего просмотра - Второй запуск (после rehydration): cache miss или повторный fetch с реальными mix-настройками → ~500ms → новый набор Итог: Hero, AlbumRow, BecauseYouLikeRail показывали предыдущий набор первые ~500ms при каждом возврате на Mainstage. Fix: убраны mixMinRatingFilterEnabled/Album/Artist из deps. getMixMinRatingsConfigFromAuth() читается внутри эффекта через getState() — всегда актуальные значения без пересоздания замыкания. Mix-настройки по-прежнему применяются при fetch, но не вызывают двойной запуск при rehydration. * feat(home): local-first discover songs via SQLite ORDER BY RANDOM() Добавлена runLocalRandomSongs (аналог runLocalRandomAlbums для треков) в browseTextSearch.ts — использует libraryAdvancedSearch с sort random, field уже поддерживается Rust-кодом через wildcarded ("random", _) ветку. В Home.tsx: discoverSongs теперь сначала пробует локальный индекс, и только при недоступности (индекс не готов, ошибка) падает обратно на getRandomSongs.view. Ускоряет первую загрузку Mainstage — треки берутся из SSD вместо сети. * fix(home): pre-populate state from cache at mount — eliminate empty-state flash on return visits Причина: Home.tsx размонтируется при навигации. При возврате первый рендер всегда с пустыми массивами (heroAlbums=[], mostPlayed=[] и т.д.), потом useEffect читает homeFeedCache и заполняет state. Даже один кадр с пустым состоянием вызывает перерисовку Hero и BecauseYouLikeRail (pool=[]). Решение: getInitialHomeFeed() читает homeFeedCache синхронно через useAuthStore.getState() (не hook) в lazy useState initializers. К моменту повторного визита store уже rehydrated — все state получают кэшированные данные до первого рендера. Дополнительно: wasPrePopulated предотвращает повторный applyFeedSnapshot в useEffect когда state уже заполнен — иначе новые ссылки на массивы вызывали бы ненужные ре-рендеры дочерних компонентов с теми же данными. * fix(mainstage): keep refresh without return flicker Keep Home and Because You Like visually stable during a single visit while still refreshing data for the next re-enter. Improve mainstage cover warmup by ensuring and pre-decoding above-the-fold artwork so hero and top rails appear instantly after navigation. * fix(mainstage): stabilize because rail and hero background framing Measure Because You Like layout before first paint to avoid width snap flicker, and render hero background as centered cover-fit images so the frame no longer jumps from top to middle on mount. * fix(now-playing): prewarm track data and prevent stale carry-over Warm Now Playing fetch caches and playback cover art on track change so entering the page no longer waits on first-load requests. Gate key-based sections (top songs, tour, Last.fm) by the active track/artist keys to avoid briefly rendering values from the previous track. * fix(cover,test): refresh playback scope and default tauri cover mocks Recompute playback cover scope when queue/server context changes so now-playing art resolves against the correct server after handoffs. Add default cover-cache invoke handlers to the shared Tauri test harness to prevent unhandled rejections in suites that mount cover-aware UI. * fix(cover,now-playing,test): align prewarm scopes and tighten tauri mocks Make cover-cache invoke defaults opt-in for tests, align radio prewarm scope with active rendering scope, and add targeted hook tests for prewarm + playback-scope reactivity. Also harden Rust cover URL building to avoid panic on malformed base URLs. * test(cover): hoist mocked useCoverArt and clean EOF whitespace Fix the new playback-scope hook test to use a hoisted vi.mock-safe stub and keep branch-wide diff checks clean by removing an accidental trailing blank line. * fix(cover): align playback ensure auth and harden backfill retry flow Use playback-server credentials for playback-scoped cover ensures, persist fetch-failed markers for bulk library backfill failures, and avoid advancing backfill cursor when UI-priority hold interrupts a batch. * fix(ci): resolve clippy lint and update frontend node runtime Move fetch helper before the test module to satisfy clippy's items-after-test-module rule, and modernize frontend CI to setup-node v6 with lts/* instead of pinned Node 20. * chore(settings): simplify cover and analytics strategy copy Move strategy summaries below tables, simplify Lazy/Aggressive wording, keep analytics warning always visible, and localize Russian texts to plain language without technical jargon.
793 lines
40 KiB
Rust
793 lines
40 KiB
Rust
// 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 lib_commands;
|
|
|
|
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<HashMap<String, String>>;
|
|
|
|
/// 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<Option<souvlaki::MediaControls>>;
|
|
|
|
/// Release builds only: focus or CLI-hand off when a second instance is launched.
|
|
#[cfg(not(debug_assertions))]
|
|
fn on_second_instance<R: tauri::Runtime>(
|
|
app: &tauri::AppHandle<R>,
|
|
argv: Vec<String>,
|
|
_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();
|
|
}
|
|
}
|
|
|
|
pub fn run() {
|
|
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let argv: Vec<String> = 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(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_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)");
|
|
}
|
|
|
|
// ── 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 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::<psysonic_library::LibraryRuntime>()
|
|
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 subsonic = psysonic_integration::subsonic::SubsonicClient::new(
|
|
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);
|
|
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::<crate::audio::AudioEngine>()
|
|
.is_some_and(|e| crate::audio::analysis_track_id_is_current_playback(&e, track_id))
|
|
},
|
|
move |track_id| {
|
|
app_defer
|
|
.try_state::<crate::audio::AudioEngine>()
|
|
.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::<psysonic_library::LibraryRuntime>()
|
|
{
|
|
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::<analysis_cache::AnalysisCache>()
|
|
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::<psysonic_library::LibraryRuntime>()
|
|
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::<psysonic_library::LibraryRuntime>()
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ── System tray ───────────────────────────────────────────────
|
|
// Always build on startup when possible; the frontend calls toggle_tray_icon(false)
|
|
// immediately after load if the user has disabled the tray icon.
|
|
// May be skipped if Ayatana/AppIndicator libraries are missing (no panic).
|
|
{
|
|
if let Some(tray) = try_build_tray_icon(app.handle()) {
|
|
*app.state::<TrayState>().lock().unwrap() = Some(tray);
|
|
}
|
|
}
|
|
|
|
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
|
|
// Release only: debug builds share the D-Bus name / SMTC slot with prod.
|
|
#[cfg(not(debug_assertions))]
|
|
{
|
|
use souvlaki::{MediaControlEvent, MediaControls, PlatformConfig};
|
|
|
|
// Collect pre-conditions and the platform-specific HWND.
|
|
// Returns None early (with a log) on any unrecoverable condition
|
|
// so app.manage() always executes exactly once at the bottom.
|
|
let maybe_controls: Option<MediaControls> = (|| {
|
|
// Linux: requires a live D-Bus session.
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let dbus_ok = std::env::var("DBUS_SESSION_BUS_ADDRESS")
|
|
.map(|v| !v.is_empty())
|
|
.unwrap_or(false);
|
|
if !dbus_ok {
|
|
crate::app_eprintln!("[Psysonic] No D-Bus session — MPRIS media controls disabled");
|
|
return None;
|
|
}
|
|
}
|
|
|
|
// Windows: souvlaki SMTC must hook into the existing Win32
|
|
// message loop rather than spinning up its own. Pass the
|
|
// main window's HWND so it can do so. If we can't get one,
|
|
// skip init (no crash, just no media overlay).
|
|
#[cfg(target_os = "windows")]
|
|
let hwnd = {
|
|
use tauri::Manager;
|
|
let h = app.get_webview_window("main")
|
|
.and_then(|w| w.hwnd().ok())
|
|
.map(|h| h.0);
|
|
if h.is_none() {
|
|
crate::app_eprintln!("[Psysonic] Could not get HWND — Windows media controls disabled");
|
|
return None;
|
|
}
|
|
h
|
|
};
|
|
#[cfg(not(target_os = "windows"))]
|
|
let hwnd: Option<*mut std::ffi::c_void> = None;
|
|
|
|
let config = PlatformConfig {
|
|
dbus_name: "psysonic",
|
|
display_name: "Psysonic",
|
|
hwnd,
|
|
};
|
|
|
|
match MediaControls::new(config) {
|
|
Ok(mut controls) => {
|
|
let app_handle = app.handle().clone();
|
|
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
|
|
match event {
|
|
MediaControlEvent::Toggle
|
|
| MediaControlEvent::Play
|
|
| MediaControlEvent::Pause => {
|
|
let _ = app_handle.emit("media:play-pause", ());
|
|
}
|
|
MediaControlEvent::Next => {
|
|
let _ = app_handle.emit("media:next", ());
|
|
}
|
|
MediaControlEvent::Previous => {
|
|
let _ = app_handle.emit("media:prev", ());
|
|
}
|
|
MediaControlEvent::Seek(direction) => {
|
|
use souvlaki::SeekDirection;
|
|
let delta: f64 = match direction {
|
|
SeekDirection::Forward => 5.0,
|
|
SeekDirection::Backward => -5.0,
|
|
};
|
|
let _ = app_handle.emit("media:seek-relative", delta);
|
|
}
|
|
MediaControlEvent::SetPosition(pos) => {
|
|
let secs = pos.0.as_secs_f64();
|
|
let _ = app_handle.emit("media:seek-absolute", secs);
|
|
}
|
|
_ => {}
|
|
}
|
|
}) {
|
|
crate::app_eprintln!("[Psysonic] Failed to attach media controls: {e:?}");
|
|
}
|
|
Some(controls)
|
|
}
|
|
Err(e) => {
|
|
crate::app_eprintln!("[Psysonic] Could not create media controls: {e:?}");
|
|
None
|
|
}
|
|
}
|
|
})();
|
|
|
|
app.manage(MprisControls::new(maybe_controls));
|
|
}
|
|
#[cfg(debug_assertions)]
|
|
{
|
|
app.manage(MprisControls::new(None));
|
|
}
|
|
|
|
// ── Windows Taskbar Thumbnail Toolbar ────────────────────────
|
|
#[cfg(all(target_os = "windows", not(debug_assertions)))]
|
|
{
|
|
use tauri::Manager;
|
|
if let Some(w) = app.get_webview_window("main") {
|
|
if let Ok(hwnd) = w.hwnd() {
|
|
taskbar_win::init(app.handle(), hwnd.0 as isize);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Audio device-change watcher ───────────────────────────────
|
|
{
|
|
use tauri::Manager;
|
|
let engine = app.state::<audio::AudioEngine>();
|
|
audio::start_device_watcher(&engine, app.handle().clone());
|
|
}
|
|
|
|
// ── Reopen output after system sleep/resume (WASAPI / PipeWire etc.)
|
|
audio::register_post_sleep_audio_recovery(app.handle().clone());
|
|
|
|
// ── Pre-create mini player window (Windows) ──────────────────
|
|
// Creating the second WebView2 webview lazily from an invoke
|
|
// handler on Windows reliably stalls the Tauri event loop —
|
|
// the mini shows a blank white window, neither main nor mini
|
|
// can be closed, and the user has to kill the process via
|
|
// Task Manager. Building it at startup (hidden) avoids the
|
|
// runtime-creation code path entirely; later `open_mini_player`
|
|
// calls are pure show/hide.
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
if let Err(e) = build_mini_player_window(app.handle(), false) {
|
|
crate::app_eprintln!("[psysonic] Failed to pre-create mini window: {e}");
|
|
}
|
|
}
|
|
|
|
// Cold start with `--player …`: defer emit so the webview can register listeners.
|
|
crate::cli::spawn_deferred_cli_argv_handler(app.handle());
|
|
|
|
Ok(())
|
|
})
|
|
.on_window_event(|window, event| {
|
|
// Persist mini player position whenever the user drags it.
|
|
if window.label() == "mini" {
|
|
if let tauri::WindowEvent::Moved(pos) = event {
|
|
persist_mini_pos_throttled(window.app_handle(), pos.x, pos.y);
|
|
}
|
|
}
|
|
|
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
|
if window.label() == "main" {
|
|
api.prevent_close();
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
// On macOS the red close button quits the app entirely.
|
|
// Route through JS so playback position + Orbit state get
|
|
// flushed; exit_app on the way back stops the audio engine.
|
|
let _ = window.emit("app:force-quit", ());
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
// Pause rendering before JS decides whether to hide to tray or exit.
|
|
if let Some(w) = window.app_handle().get_webview_window("main") {
|
|
let _ = w.eval(PAUSE_RENDERING_JS);
|
|
}
|
|
// Let JS decide: minimize to tray or exit, based on user setting.
|
|
let _ = window.emit("window:close-requested", ());
|
|
}
|
|
} else if window.label() == "mini" {
|
|
// Native close on the mini: hide instead of destroying so
|
|
// state is preserved, and restore the main window.
|
|
api.prevent_close();
|
|
if let Some(w) = window.app_handle().get_webview_window("mini") {
|
|
let _ = w.eval(PAUSE_RENDERING_JS);
|
|
}
|
|
let _ = window.hide();
|
|
if let Some(main) = window.app_handle().get_webview_window("main") {
|
|
let _ = main.unminimize();
|
|
let _ = main.show();
|
|
let _ = main.set_focus();
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.invoke_handler(tauri::generate_handler![
|
|
greet,
|
|
backup_export_library_db,
|
|
backup_import_library_db,
|
|
backup_export_full,
|
|
backup_import_full,
|
|
migration_inspect,
|
|
migration_run,
|
|
psysonic_syncfs::sync::batch::calculate_sync_payload,
|
|
exit_app,
|
|
cli_publish_player_snapshot,
|
|
cli_publish_library_list,
|
|
cli_publish_server_list,
|
|
cli_publish_search_results,
|
|
set_window_decorations,
|
|
set_linux_webkit_smooth_scrolling,
|
|
linux_wayland_gpu_font_tuning_active,
|
|
linux_wayland_text_render_settings_available,
|
|
set_linux_wayland_text_render_profile,
|
|
set_logging_mode,
|
|
export_runtime_logs,
|
|
frontend_debug_log,
|
|
performance_cpu_snapshot,
|
|
set_subsonic_wire_user_agent,
|
|
no_compositing_mode,
|
|
linux_xdg_session_type,
|
|
is_tiling_wm_cmd,
|
|
open_mini_player,
|
|
preload_mini_player,
|
|
close_mini_player,
|
|
set_mini_player_always_on_top,
|
|
resize_mini_player,
|
|
show_main_window,
|
|
pause_rendering,
|
|
resume_rendering,
|
|
register_global_shortcut,
|
|
unregister_global_shortcut,
|
|
mpris_set_metadata,
|
|
mpris_set_playback,
|
|
audio::commands::audio_play,
|
|
audio::transport_commands::audio_pause,
|
|
audio::transport_commands::audio_resume,
|
|
audio::transport_commands::audio_stop,
|
|
audio::transport_commands::audio_seek,
|
|
audio::mix_commands::audio_set_volume,
|
|
audio::mix_commands::audio_update_replay_gain,
|
|
audio::mix_commands::audio_set_eq,
|
|
audio::mix_commands::audio_set_playback_rate,
|
|
audio::autoeq_commands::autoeq_entries,
|
|
audio::autoeq_commands::autoeq_fetch_profile,
|
|
audio::preload_commands::audio_preload,
|
|
audio::radio_commands::audio_play_radio,
|
|
audio::preview::audio_preview_play,
|
|
audio::preview::audio_preview_stop,
|
|
audio::preview::audio_preview_stop_silent,
|
|
audio::preview::audio_preview_set_volume,
|
|
audio::mix_commands::audio_set_crossfade,
|
|
audio::mix_commands::audio_set_gapless,
|
|
audio::mix_commands::audio_set_normalization,
|
|
audio::device_commands::audio_list_devices,
|
|
audio::device_commands::audio_canonicalize_selected_device,
|
|
audio::device_commands::audio_default_output_device_name,
|
|
audio::device_commands::audio_set_device,
|
|
audio::commands::audio_chain_preload,
|
|
psysonic_integration::discord::discord_update_presence,
|
|
psysonic_integration::discord::discord_clear_presence,
|
|
psysonic_integration::remote::lastfm_request,
|
|
psysonic_integration::navidrome::covers::upload_playlist_cover,
|
|
psysonic_integration::navidrome::covers::upload_radio_cover,
|
|
psysonic_integration::navidrome::covers::upload_artist_image,
|
|
psysonic_integration::navidrome::covers::delete_radio_cover,
|
|
psysonic_integration::navidrome::users::navidrome_login,
|
|
psysonic_integration::navidrome::users::nd_list_users,
|
|
psysonic_integration::navidrome::users::nd_create_user,
|
|
psysonic_integration::navidrome::users::nd_update_user,
|
|
psysonic_integration::navidrome::users::nd_delete_user,
|
|
psysonic_integration::navidrome::queries::nd_list_libraries,
|
|
psysonic_integration::navidrome::queries::nd_list_songs,
|
|
psysonic_integration::navidrome::queries::nd_list_artists_by_role,
|
|
psysonic_integration::navidrome::queries::nd_list_albums_by_artist_role,
|
|
psysonic_integration::navidrome::queries::nd_set_user_libraries,
|
|
psysonic_integration::navidrome::playlists::nd_list_playlists,
|
|
psysonic_integration::navidrome::playlists::nd_create_playlist,
|
|
psysonic_integration::navidrome::playlists::nd_update_playlist,
|
|
psysonic_integration::navidrome::playlists::nd_get_playlist,
|
|
psysonic_integration::navidrome::playlists::nd_delete_playlist,
|
|
psysonic_integration::navidrome::queries::nd_get_song_path,
|
|
psysonic_integration::remote::search_radio_browser,
|
|
psysonic_integration::remote::get_top_radio_stations,
|
|
psysonic_integration::remote::fetch_url_bytes,
|
|
psysonic_integration::remote::fetch_json_url,
|
|
psysonic_integration::remote::fetch_icy_metadata,
|
|
psysonic_integration::remote::resolve_stream_url,
|
|
psysonic_analysis::commands::analysis_get_waveform,
|
|
psysonic_analysis::commands::analysis_get_waveform_for_track,
|
|
psysonic_analysis::commands::analysis_get_loudness_for_track,
|
|
psysonic_analysis::commands::analysis_delete_loudness_for_track,
|
|
psysonic_analysis::commands::analysis_delete_waveform_for_track,
|
|
psysonic_analysis::commands::analysis_delete_all_waveforms,
|
|
psysonic_analysis::commands::analysis_delete_all_for_server,
|
|
psysonic_analysis::commands::analysis_get_failed_track_count,
|
|
psysonic_analysis::commands::analysis_list_failed_tracks,
|
|
psysonic_analysis::commands::analysis_clear_failed_tracks,
|
|
psysonic_analysis::commands::analysis_migrate_server_index_keys,
|
|
psysonic_analysis::commands::analysis_enqueue_seed_from_url,
|
|
psysonic_analysis::commands::analysis_set_playback_priority_hints,
|
|
psysonic_analysis::commands::analysis_set_pipeline_parallelism,
|
|
psysonic_analysis::commands::analysis_get_pipeline_queue_stats,
|
|
psysonic_analysis::commands::analysis_get_backfill_queue_stats,
|
|
psysonic_analysis::commands::analysis_prune_pending_to_track_ids,
|
|
psysonic_library::commands::library_get_status,
|
|
psysonic_library::commands::library_search,
|
|
psysonic_library::commands::library_live_search,
|
|
psysonic_library::commands::library_advanced_search,
|
|
psysonic_library::commands::library_search_cross_server,
|
|
psysonic_library::commands::library_get_track,
|
|
psysonic_library::commands::library_get_tracks_batch,
|
|
psysonic_library::commands::library_get_tracks_by_album,
|
|
psysonic_library::commands::library_get_artifact,
|
|
psysonic_library::commands::library_get_facts,
|
|
psysonic_library::commands::library_get_offline_path,
|
|
psysonic_library::commands::library_analysis_progress,
|
|
psysonic_library::commands::library_count_live_tracks,
|
|
psysonic_library::commands::library_sync_bind_session,
|
|
psysonic_library::commands::library_sync_clear_session,
|
|
psysonic_library::commands::library_set_playback_hint,
|
|
psysonic_library::commands::library_get_playback_hint,
|
|
psysonic_library::commands::library_sync_start,
|
|
psysonic_library::commands::library_sync_verify_integrity,
|
|
psysonic_library::commands::library_sync_cancel,
|
|
psysonic_library::commands::library_patch_track,
|
|
psysonic_library::commands::library_put_artifact,
|
|
psysonic_library::commands::library_put_fact,
|
|
psysonic_library::commands::library_record_play_session,
|
|
psysonic_library::commands::library_get_player_stats_year_summary,
|
|
psysonic_library::commands::library_get_player_stats_heatmap,
|
|
psysonic_library::commands::library_get_player_stats_day_detail,
|
|
psysonic_library::commands::library_get_player_stats_year_bounds,
|
|
psysonic_library::commands::library_get_player_stats_recent_days,
|
|
psysonic_library::commands::library_purge_server,
|
|
psysonic_library::commands::library_migrate_server_index_keys,
|
|
psysonic_library::commands::library_delete_server_data,
|
|
psysonic_library::commands::library_analysis_backfill_batch,
|
|
cover_cache::cover_cache_peek_batch,
|
|
cover_cache::cover_cache_ensure,
|
|
cover_cache::cover_cache_ensure_batch,
|
|
cover_cache::cover_cache_stats,
|
|
cover_cache::cover_cache_evict_tick,
|
|
cover_cache::cover_cache_configure,
|
|
cover_cache::cover_cache_clear,
|
|
cover_cache::cover_cache_clear_server,
|
|
cover_cache::cover_cache_stats_server,
|
|
cover_cache::library_cover_backfill_batch,
|
|
cover_cache::library_cover_progress,
|
|
cover_cache::library_cover_catalog_size,
|
|
cover_cache::library_cover_clear_fetch_failures,
|
|
cover_cache::library_cover_backfill_configure,
|
|
cover_cache::library_cover_backfill_pulse,
|
|
cover_cache::library_cover_backfill_reset_cursor,
|
|
cover_cache::library_cover_backfill_set_ui_priority,
|
|
cover_cache::library_cover_backfill_run_full_pass,
|
|
cover_cache::cover_revalidate_enqueue,
|
|
cover_cache::cover_revalidate_tick,
|
|
cover_cache::cover_revalidate_batch,
|
|
psysonic_syncfs::cache::offline::download_track_offline,
|
|
psysonic_syncfs::cache::offline::cancel_offline_downloads,
|
|
psysonic_syncfs::cache::offline::clear_offline_cancel,
|
|
psysonic_syncfs::cache::offline::delete_offline_track,
|
|
psysonic_syncfs::cache::offline::get_offline_cache_size,
|
|
psysonic_syncfs::cache::hot::download_track_hot_cache,
|
|
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
|
|
psysonic_syncfs::cache::hot::get_hot_cache_size,
|
|
psysonic_syncfs::cache::hot::delete_hot_cache_track,
|
|
psysonic_syncfs::cache::hot::purge_hot_cache,
|
|
psysonic_syncfs::sync::device::sync_track_to_device,
|
|
psysonic_syncfs::sync::batch::sync_batch_to_device,
|
|
psysonic_syncfs::sync::batch::cancel_device_sync,
|
|
psysonic_syncfs::sync::device::compute_sync_paths,
|
|
psysonic_syncfs::sync::batch::list_device_dir_files,
|
|
psysonic_syncfs::sync::batch::delete_device_file,
|
|
psysonic_syncfs::sync::batch::delete_device_files,
|
|
psysonic_syncfs::sync::device::get_removable_drives,
|
|
psysonic_syncfs::sync::device::write_device_manifest,
|
|
psysonic_syncfs::sync::device::read_device_manifest,
|
|
psysonic_syncfs::sync::device::write_playlist_m3u8,
|
|
psysonic_syncfs::sync::device::rename_device_files,
|
|
toggle_tray_icon,
|
|
set_tray_tooltip,
|
|
set_tray_menu_labels,
|
|
check_dir_accessible,
|
|
psysonic_syncfs::cache::downloads::download_zip,
|
|
psysonic_syncfs::cache::downloads::check_arch_linux,
|
|
psysonic_syncfs::cache::downloads::download_update,
|
|
psysonic_syncfs::cache::downloads::open_folder,
|
|
psysonic_syncfs::cache::downloads::get_embedded_lyrics,
|
|
psysonic_syncfs::cache::downloads::fetch_netease_lyrics,
|
|
psysonic_integration::bandsintown::fetch_bandsintown_events,
|
|
#[cfg(target_os = "windows")]
|
|
taskbar_win::update_taskbar_icon,
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running Psysonic");
|
|
}
|