refactor(ipc): complete tauri-specta typed-IPC cutover, contract guards, and layering cleanup (#1230)

This commit is contained in:
Psychotoxical
2026-07-04 16:00:31 +02:00
committed by GitHub
parent fdbb9deac6
commit 77ab95170d
199 changed files with 3691 additions and 1252 deletions
-525
View File
@@ -498,23 +498,6 @@
"name": "lib-is-the-floor"
}
},
{
"type": "dependency",
"from": "src/lib/cache/ephemeralTierReconcile.ts",
"to": "src/store/localPlaybackStore.ts",
"unresolvedTo": "@/store/localPlaybackStore",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
],
"rule": {
"severity": "error",
"name": "lib-is-the-floor"
}
},
{
"type": "dependency",
"from": "src/lib/format/formatClockTime.ts",
@@ -13313,301 +13296,6 @@
}
]
},
{
"type": "cycle",
"from": "src/features/album/components/AlbumCard.tsx",
"to": "src/features/artist/index.ts",
"unresolvedTo": "@/features/artist",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
],
"rule": {
"severity": "error",
"name": "no-circular"
},
"cycle": [
{
"name": "src/features/artist/index.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/artist/components/ArtistDetailHero.tsx",
"dependencyTypes": [
"local",
"export"
]
},
{
"name": "src/features/album/index.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/album/components/AlbumCard.tsx",
"dependencyTypes": [
"local",
"export"
]
}
]
},
{
"type": "cycle",
"from": "src/features/album/components/AlbumHeader.tsx",
"to": "src/features/artist/index.ts",
"unresolvedTo": "@/features/artist",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
],
"rule": {
"severity": "error",
"name": "no-circular"
},
"cycle": [
{
"name": "src/features/artist/index.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/artist/components/ArtistDetailHero.tsx",
"dependencyTypes": [
"local",
"export"
]
},
{
"name": "src/features/album/index.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/album/components/AlbumHeader.tsx",
"dependencyTypes": [
"local",
"export"
]
}
]
},
{
"type": "cycle",
"from": "src/features/album/components/AlbumRow.tsx",
"to": "src/features/album/components/AlbumCard.tsx",
"unresolvedTo": "@/features/album/components/AlbumCard",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
],
"rule": {
"severity": "error",
"name": "no-circular"
},
"cycle": [
{
"name": "src/features/album/components/AlbumCard.tsx",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/artist/index.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/artist/components/ArtistDetailHero.tsx",
"dependencyTypes": [
"local",
"export"
]
},
{
"name": "src/features/album/index.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/album/components/AlbumRow.tsx",
"dependencyTypes": [
"local",
"export"
]
}
]
},
{
"type": "cycle",
"from": "src/features/album/components/LosslessAlbumsRail.tsx",
"to": "src/features/album/components/AlbumRow.tsx",
"unresolvedTo": "@/features/album/components/AlbumRow",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
],
"rule": {
"severity": "error",
"name": "no-circular"
},
"cycle": [
{
"name": "src/features/album/components/AlbumRow.tsx",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/album/components/AlbumCard.tsx",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/artist/index.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/artist/components/ArtistDetailHero.tsx",
"dependencyTypes": [
"local",
"export"
]
},
{
"name": "src/features/album/index.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/album/components/LosslessAlbumsRail.tsx",
"dependencyTypes": [
"local",
"export"
]
}
]
},
{
"type": "cycle",
"from": "src/features/artist/hooks/useArtistsBrowseFilters.ts",
"to": "src/features/album/index.ts",
"unresolvedTo": "@/features/album",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
],
"rule": {
"severity": "error",
"name": "no-circular"
},
"cycle": [
{
"name": "src/features/album/index.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/album/components/AlbumCard.tsx",
"dependencyTypes": [
"local",
"export"
]
},
{
"name": "src/features/artist/index.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/artist/hooks/useArtistsBrowseFilters.ts",
"dependencyTypes": [
"local",
"export"
]
}
]
},
{
"type": "cycle",
"from": "src/features/lyrics/hooks/useLyrics.ts",
@@ -46947,180 +46635,6 @@
}
]
},
{
"type": "cycle",
"from": "src/hotCachePrefetch.ts",
"to": "src/lib/cache/ephemeralTierReconcile.ts",
"unresolvedTo": "@/lib/cache/ephemeralTierReconcile",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
],
"rule": {
"severity": "error",
"name": "no-circular"
},
"cycle": [
{
"name": "src/lib/cache/ephemeralTierReconcile.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/store/localPlaybackStore.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/store/localPlaybackMigration.ts",
"dependencyTypes": [
"local",
"import"
]
},
{
"name": "src/features/offline/index.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"type-only",
"import"
]
},
{
"name": "src/features/offline/hooks/useOfflineBrowseContext.ts",
"dependencyTypes": [
"local",
"export"
]
},
{
"name": "src/features/offline/store/offlineStore.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/offline/utils/offlineLibraryHelpers.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/offline/utils/libraryTierReconcile.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"dynamic-import"
]
},
{
"name": "src/lib/api/coverCache.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/cover/storageKeys.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/playback/utils/playback/playbackServer.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/playback/store/playerStore.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/playback/store/miscActions.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/playback/store/applyServerPlayQueue.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/features/playback/store/pausedRestorePrepare.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/hotCachePrefetch.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
}
]
},
{
"type": "cycle",
"from": "src/hotCachePrefetch.ts",
@@ -49632,45 +49146,6 @@
}
]
},
{
"type": "cycle",
"from": "src/lib/cache/ephemeralTierReconcile.ts",
"to": "src/store/localPlaybackStore.ts",
"unresolvedTo": "@/store/localPlaybackStore",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
],
"rule": {
"severity": "error",
"name": "no-circular"
},
"cycle": [
{
"name": "src/store/localPlaybackStore.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
},
{
"name": "src/lib/cache/ephemeralTierReconcile.ts",
"dependencyTypes": [
"aliased",
"aliased-tsconfig",
"aliased-tsconfig-paths",
"local",
"import"
]
}
]
},
{
"type": "cycle",
"from": "src/lib/library/advancedSearchLocal.ts",
+6
View File
@@ -34,6 +34,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Reorganised the frontend into a feature-folder architecture with a CI-enforced layering guard, added unit + behavior-scenario + boot-smoke test coverage, and introduced a compile-time frontend/backend IPC contract via tauri-specta. Internal only — no change to how the app looks or behaves.
### Typed-IPC contract — completed the tauri-specta cutover
**By [@Psychotoxical](https://github.com/Psychotoxical), with additional architecture by [@cucadmuh](https://github.com/cucadmuh), PR [#1230](https://github.com/Psychotoxical/psysonic/pull/1230)**
* Completed the frontend/backend typed-IPC contract: the frontend now calls the generated tauri-specta command surface, with CI guards keeping the bindings fresh and every command registered in the handler. Internal only — no change to how the app looks or behaves.
## Fixed
### Playback — ReplayGain prefetch, gapless playbar sync, and library peak index
+5
View File
@@ -4187,6 +4187,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"specta",
"symphonia",
"symphonia-adapter-libopus",
"tauri",
@@ -4212,6 +4213,7 @@ dependencies = [
"rodio",
"serde",
"serde_json",
"specta",
"symphonia",
"symphonia-adapter-libopus",
"tauri",
@@ -4230,6 +4232,7 @@ dependencies = [
"libc",
"reqwest",
"serde",
"specta",
"tauri",
"url",
]
@@ -4245,6 +4248,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"specta",
"tauri",
"tokio",
"url",
@@ -4282,6 +4286,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"specta",
"sysinfo",
"tauri",
"tauri-plugin-process",
@@ -12,6 +12,7 @@ psysonic-core = { path = "../psysonic-core" }
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
tokio = { version = "1", features = ["rt", "time", "sync"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "rustls", "gzip", "brotli"] }
futures-util = "0.3"
@@ -37,7 +37,7 @@ impl AnalysisTierCounts {
}
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPipelineQueueStatsDto {
pub pipeline_workers: u32,
@@ -11,7 +11,7 @@ use crate::analysis_runtime::{
prune_analysis_queues, AnalysisBackfillPriority, PlaybackPriorityHints,
};
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct WaveformCachePayload {
pub bins: Vec<u8>,
@@ -35,7 +35,7 @@ impl From<analysis_cache::WaveformEntry> for WaveformCachePayload {
}
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LoudnessCachePayload {
pub integrated_lufs: f64,
@@ -45,7 +45,7 @@ pub struct LoudnessCachePayload {
pub updated_at: i64,
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisDeleteServerReportDto {
pub analysis_tracks: u64,
@@ -53,7 +53,7 @@ pub struct AnalysisDeleteServerReportDto {
pub loudness: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisFailedTrackDto {
pub track_id: String,
@@ -81,7 +81,7 @@ impl From<analysis_cache::FailedTrackEntry> for AnalysisFailedTrackDto {
}
}
#[derive(serde::Deserialize)]
#[derive(serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisServerKeyMigrationDto {
pub legacy_id: String,
@@ -148,6 +148,7 @@ pub fn get_loudness_payload_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_waveform(
track_id: String,
md5_16kb: String,
@@ -172,6 +173,7 @@ pub fn analysis_get_waveform(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_waveform_for_track(
track_id: String,
server_id: Option<String>,
@@ -192,6 +194,7 @@ pub fn analysis_get_waveform_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_loudness_for_track(
track_id: String,
target_lufs: Option<f64>,
@@ -203,6 +206,7 @@ pub fn analysis_get_loudness_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_delete_loudness_for_track(
track_id: String,
server_id: Option<String>,
@@ -212,6 +216,7 @@ pub fn analysis_delete_loudness_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_delete_waveform_for_track(
track_id: String,
server_id: Option<String>,
@@ -221,6 +226,7 @@ pub fn analysis_delete_waveform_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_delete_all_waveforms(
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
@@ -228,6 +234,7 @@ pub fn analysis_delete_all_waveforms(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_delete_all_for_server(
server_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
@@ -240,6 +247,7 @@ pub fn analysis_delete_all_for_server(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_failed_track_count(
server_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
@@ -252,6 +260,7 @@ pub fn analysis_get_failed_track_count(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_list_failed_tracks(
server_id: String,
limit: Option<u32>,
@@ -269,6 +278,7 @@ pub fn analysis_list_failed_tracks(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_clear_failed_tracks(
server_id: String,
track_ids: Option<Vec<String>>,
@@ -288,6 +298,7 @@ pub fn analysis_clear_failed_tracks(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_migrate_server_index_keys(
mappings: Vec<AnalysisServerKeyMigrationDto>,
_cache: tauri::State<'_, analysis_cache::AnalysisCache>,
@@ -299,6 +310,7 @@ pub fn analysis_migrate_server_index_keys(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_enqueue_seed_from_url(
track_id: String,
url: String,
@@ -318,7 +330,7 @@ pub fn analysis_enqueue_seed_from_url(
)
}
#[derive(Debug, Clone, serde::Deserialize)]
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPriorityHintDto {
pub server_id: String,
@@ -326,6 +338,7 @@ pub struct AnalysisPriorityHintDto {
}
#[tauri::command]
#[specta::specta]
pub fn analysis_set_playback_priority_hints(
middle_track_refs: Vec<AnalysisPriorityHintDto>,
hints: tauri::State<'_, PlaybackPriorityHints>,
@@ -337,7 +350,7 @@ pub fn analysis_set_playback_priority_hints(
Ok(())
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisBackfillQueueStatsDto {
pub queued: usize,
@@ -346,17 +359,20 @@ pub struct AnalysisBackfillQueueStatsDto {
}
#[tauri::command]
#[specta::specta]
pub fn analysis_set_pipeline_parallelism(workers: u32) -> Result<(), String> {
crate::analysis_runtime::analysis_set_pipeline_parallelism(workers as usize);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_pipeline_queue_stats() -> Result<crate::analysis_runtime::AnalysisPipelineQueueStatsDto, String> {
Ok(analysis_pipeline_queue_stats())
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsDto, String> {
let (queued, in_progress_count, in_progress_track_id) =
analysis_backfill_queue_stats();
@@ -367,7 +383,7 @@ pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsD
})
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPrunePendingResult {
pub keep_count: usize,
@@ -380,6 +396,7 @@ pub struct AnalysisPrunePendingResult {
///
/// Keeps currently-running jobs untouched; only queued (not-yet-started) jobs are removed.
#[tauri::command]
#[specta::specta]
pub fn analysis_prune_pending_to_track_ids(
track_ids: Vec<String>,
server_id: String,
@@ -11,6 +11,7 @@ psysonic-core = { path = "../psysonic-core" }
psysonic-analysis = { path = "../psysonic-analysis" }
tauri = { version = "2" }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
@@ -40,6 +40,7 @@ pub(crate) fn autoeq_profile_url_candidates(
/// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions.
#[tauri::command]
#[specta::specta]
pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, String> {
audio_http_client(&state)
.get("https://autoeq.app/entries")
@@ -49,6 +50,7 @@ pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, Str
/// Fetches the AutoEQ FixedBandEQ profile for a specific headphone from GitHub raw content.
#[tauri::command]
#[specta::specta]
pub async fn autoeq_fetch_profile(
name: String,
source: String,
@@ -40,6 +40,10 @@ use super::state::{ChainedInfo, PreloadedTrack};
/// `stream_format_suffix`: Subsonic `song.suffix` (e.g. m4a); `stream.view` URLs have no
/// file extension, so this helps pick a Symphonia `format_hint` for ranged HTTP.
#[tauri::command]
// NOTE: excluded from tauri-specta collect_commands! — specta's SpectaFn is only
// implemented up to 10 args and this has 24. Typing it needs the args bundled into
// a struct (a behaviour/contract change), tracked for the D4 flip; stays on
// generate_handler! for now.
#[allow(clippy::too_many_arguments)]
pub async fn audio_play(
url: String,
@@ -602,6 +606,9 @@ pub async fn audio_play(
/// audio_play() checks chained_info.url on arrival: if it matches, it returns
/// immediately without touching the Sink (pure no-op on the audio path).
#[tauri::command]
// NOTE: excluded from tauri-specta collect_commands! — 13 args exceed specta's
// 10-arg SpectaFn limit; needs arg-bundling for the D4 flip. Stays on
// generate_handler! for now.
#[allow(clippy::too_many_arguments)]
pub async fn audio_chain_preload(
url: String,
@@ -15,6 +15,7 @@ use super::engine::AudioEngine;
/// When the saved `selected_device` no longer literally matches any listed
/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form.
#[tauri::command]
#[specta::specta]
pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option<String> {
let pinned = state.selected_device.lock().unwrap().clone()?;
if pinned.is_empty() {
@@ -50,12 +51,14 @@ pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<String> {
/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
#[tauri::command]
#[specta::specta]
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
audio_list_devices_for_engine(&state)
}
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
#[tauri::command]
#[specta::specta]
pub fn audio_default_output_device_name() -> Option<String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
with_suppressed_alsa_stderr(|| {
@@ -68,6 +71,7 @@ pub fn audio_default_output_device_name() -> Option<String> {
/// Switch the audio output device. `device_name = null` → follow system default.
/// Reopens the stream immediately; frontend must restart playback via audio:device-changed.
#[tauri::command]
#[specta::specta]
pub async fn audio_set_device(
device_name: Option<String>,
state: State<'_, AudioEngine>,
@@ -11,6 +11,7 @@ use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
#[tauri::command]
#[specta::specta]
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
cur.base_volume = volume.clamp(0.0, 1.0);
@@ -22,6 +23,7 @@ pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
}
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub fn audio_update_replay_gain(
volume: f32,
@@ -132,6 +134,7 @@ pub fn audio_update_replay_gain(
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State<'_, AudioEngine>) {
state.eq_enabled.store(enabled, Ordering::Relaxed);
state.eq_pre_gain.store(pre_gain.clamp(-30.0, 6.0).to_bits(), Ordering::Relaxed);
@@ -141,12 +144,14 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
state.crossfade_secs.store(secs.clamp(0.1, 12.0).to_bits(), Ordering::Relaxed);
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
state.gapless_enabled.store(enabled, Ordering::Relaxed);
}
@@ -154,6 +159,7 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
/// Duck the current sink over `fade_secs` without exhausting its source (which
/// would spuriously emit `audio:ended` before the interrupt handoff).
#[tauri::command]
#[specta::specta]
pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>) {
let fade_secs = fade_secs.clamp(0.1, 12.0);
let cur = state.current.lock().unwrap();
@@ -173,6 +179,7 @@ pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>)
/// (only when the next track is actually playable). When `false`, the engine's
/// normal early crossfade trigger is restored (plain crossfade / loud→loud).
#[tauri::command]
#[specta::specta]
pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
state
.autodj_suppress_autocrossfade
@@ -180,6 +187,7 @@ pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_playback_rate(
enabled: bool,
strategy: String,
@@ -263,6 +271,7 @@ pub fn audio_set_playback_rate(
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_normalization(
engine: String,
target_lufs: f32,
@@ -114,6 +114,7 @@ fn emit_preload_cancelled(app: &AppHandle, url: String, track_id: Option<String>
}
#[tauri::command]
#[specta::specta]
pub async fn audio_preload(
url: String,
duration_hint: f64,
@@ -336,6 +336,7 @@ async fn open_preview_decoder(
}
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
pub async fn audio_preview_play(
id: String,
@@ -532,6 +533,7 @@ mod tests {
}
#[tauri::command]
#[specta::specta]
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, true);
}
@@ -542,6 +544,7 @@ pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
/// auto-resume main playback the moment the preview ends and the user perceives
/// the click as having no effect.
#[tauri::command]
#[specta::specta]
pub fn audio_preview_stop_silent(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, false);
}
@@ -552,6 +555,7 @@ pub fn audio_preview_stop_silent(app: AppHandle, state: State<'_, AudioEngine>)
/// start, so the engine just clamps and applies the master headroom. No-op
/// when no preview is active.
#[tauri::command]
#[specta::specta]
pub fn audio_preview_set_volume(volume: f32, state: State<'_, AudioEngine>) {
if let Some(sink) = state.preview_sink.lock().unwrap().as_ref() {
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
@@ -31,6 +31,7 @@ use super::stream::{
/// Emits `audio:playing` with `duration = 0.0` (sentinel for live stream)
/// and `radio:metadata` whenever the StreamTitle changes.
#[tauri::command]
#[specta::specta]
pub async fn audio_play_radio(
url: String,
volume: f32,
@@ -18,6 +18,7 @@ use super::preview::preview_clear_for_new_main_playback;
use super::stream::{radio_download_task, RADIO_BUF_CAPACITY};
#[tauri::command]
#[specta::specta]
pub fn audio_pause(state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
if let Some(sink) = &cur.sink {
@@ -49,6 +50,7 @@ pub fn audio_pause(state: State<'_, AudioEngine>) {
/// ring buffer is created, its consumer is sent to `AudioStreamReader` (which
/// swaps it in on the next `read()`), and a new download task is spawned.
#[tauri::command]
#[specta::specta]
pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Result<(), String> {
// If a preview is running, cancel it first — otherwise sink.play() on the
// main sink would mix on top of the preview sink.
@@ -112,6 +114,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu
}
#[tauri::command]
#[specta::specta]
pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
preview_clear_for_new_main_playback(&state, &app);
state.generation.fetch_add(1, Ordering::SeqCst);
@@ -135,6 +138,7 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
}
#[tauri::command]
#[specta::specta]
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
let state = state.inner();
const AUDIO_SEEK_TIMEOUT_MS: u64 = 700;
@@ -8,6 +8,7 @@ publish = false
[dependencies]
tauri = { version = "2" }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls"] }
url = "2"
@@ -8,14 +8,14 @@ use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::RequestBuilder;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "lowercase")]
pub enum EndpointKind {
Local,
Public,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "lowercase")]
pub enum CustomHeadersApplyTo {
Local,
@@ -24,19 +24,19 @@ pub enum CustomHeadersApplyTo {
Both,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
pub struct ServerHttpEndpointWire {
pub url: String,
pub kind: EndpointKind,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
pub struct CustomHeaderEntryWire {
pub name: String,
pub value: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
pub struct ServerHttpContextSyncWire {
#[serde(rename = "serverId")]
pub server_id: String,
@@ -10,6 +10,7 @@ publish = false
psysonic-core = { path = "../psysonic-core" }
tauri = { version = "2" }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
@@ -4,7 +4,7 @@
// `js_app_id` is the ID their own embeddable widget uses and is broadly accepted.
pub const BANDSINTOWN_APP_ID: &str = "js_app_id";
#[derive(serde::Serialize, Default)]
#[derive(serde::Serialize, Default, specta::Type)]
pub struct BandsintownEvent {
datetime: String, // ISO 8601 (e.g. "2026-04-23T20:30:00")
venue_name: String,
@@ -20,6 +20,7 @@ pub struct BandsintownEvent {
/// Returns an empty list on any failure (404, network, parse) — the UI
/// just hides the section in that case.
#[tauri::command]
#[specta::specta]
pub async fn fetch_bandsintown_events(artist_name: String) -> Result<Vec<BandsintownEvent>, String> {
let trimmed = artist_name.trim();
if trimmed.is_empty() {
@@ -326,6 +326,7 @@ pub(crate) fn compute_discord_start_timestamp(elapsed_secs: f64, now_unix_secs:
/// user list (e.g. "🎵 Bohemian Rhapsody" instead of "🎵 Psysonic"). Default: "{title}".
/// Empty string falls back to the registered Discord application name.
/// Supported placeholders: {title}, {artist}, {album}
// NOT specta-collected: >10 total params exceed specta's SpectaFn arg cap. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn discord_update_presence(
@@ -448,6 +449,7 @@ pub async fn discord_update_presence(
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
#[tauri::command]
#[specta::specta]
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
let mut guard = state.client.lock().unwrap();
if let Some(client) = guard.as_mut() {
@@ -44,7 +44,7 @@ pub fn nd_apply_request(
}
/// Payload returned by Navidrome's `/auth/login`.
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
pub struct NdLoginResult {
pub(super) token: String,
#[serde(rename = "userId")]
@@ -10,6 +10,7 @@ use tauri::State;
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_http_client};
#[tauri::command]
#[specta::specta]
pub async fn upload_playlist_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -45,6 +46,7 @@ pub async fn upload_playlist_cover(
}
#[tauri::command]
#[specta::specta]
pub async fn upload_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -80,6 +82,7 @@ pub async fn upload_radio_cover(
}
#[tauri::command]
#[specta::specta]
pub async fn upload_artist_image(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -115,6 +118,7 @@ pub async fn upload_artist_image(
}
#[tauri::command]
#[specta::specta]
pub async fn delete_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -10,6 +10,7 @@ use tauri::State;
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry};
/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_list_playlists(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -46,6 +47,7 @@ pub async fn nd_list_playlists(
}
/// POST `/api/playlist` — create playlist (supports smart rules payload).
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_create_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -84,6 +86,7 @@ pub async fn nd_create_playlist(
}
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_update_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -123,6 +126,7 @@ pub async fn nd_update_playlist(
}
/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_get_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -160,6 +164,7 @@ pub async fn nd_get_playlist(
/// DELETE `/api/playlist/{id}` — delete playlist.
#[tauri::command]
#[specta::specta]
pub async fn nd_delete_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -54,6 +54,7 @@ pub async fn nd_list_songs_internal(
/// Tauri-visible variant — owned-String arguments to keep the IPC
/// surface unchanged for existing call sites in the WebView.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_list_songs(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -98,6 +99,7 @@ fn nd_build_filters(seed: serde_json::Map<String, serde_json::Value>, library_id
/// — paginated list of artists that have at least one credit in the given role.
/// Navidrome 0.55.0+ (uses `library_artist.stats` JSON aggregate). Available to any
/// authenticated user. Returns raw JSON array.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_artists_by_role(
@@ -158,6 +160,7 @@ pub async fn nd_list_artists_by_role(
/// Subsonic `getArtist.view` only walks AlbumArtist relations, so composer-only
/// (or conductor-only, lyricist-only, …) credits are unreachable there. Navidrome
/// generates `role_<role>_id` filters dynamically from `model.AllRoles`.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_albums_by_artist_role(
@@ -216,6 +219,7 @@ pub async fn nd_list_albums_by_artist_role(
}
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_list_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -249,6 +253,7 @@ pub async fn nd_list_libraries(
/// PUT `/api/user/{id}/library` — assign libraries to a non-admin user.
/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
#[tauri::command]
#[specta::specta]
pub async fn nd_set_user_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -299,6 +304,7 @@ pub async fn nd_set_user_libraries(
/// Returns `Ok(None)` when the response has no `path` field — Navidrome can omit
/// it for non-admin users on some configurations.
#[tauri::command]
#[specta::specta]
pub async fn nd_get_song_path(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -11,6 +11,7 @@ use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry, NdLoginR
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
#[tauri::command]
#[specta::specta]
pub async fn navidrome_login(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -50,6 +51,7 @@ pub async fn navidrome_login(
}
/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_list_users(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -83,6 +85,7 @@ pub async fn nd_list_users(
}
/// POST `/api/user` — create a user.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_create_user(
@@ -133,6 +136,7 @@ pub async fn nd_create_user(
}
/// PUT `/api/user/{id}` — update a user. Pass an empty `password` to leave it unchanged.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_update_user(
@@ -188,6 +192,7 @@ pub async fn nd_update_user(
/// DELETE `/api/user/{id}`.
#[tauri::command]
#[specta::specta]
pub async fn nd_delete_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -3,6 +3,7 @@ use psysonic_core::user_agent::subsonic_wire_user_agent;
pub const RADIO_PAGE_SIZE: u32 = 25;
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
@@ -26,6 +27,7 @@ pub async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serd
}
/// Fetch top-voted stations from radio-browser.info for initial suggestions.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
@@ -46,6 +48,7 @@ pub async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value
/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS.
/// Returns (bytes, content_type).
#[tauri::command]
#[specta::specta]
pub async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
@@ -75,6 +78,7 @@ pub async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
/// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions.
/// Returns the response body as a UTF-8 string for parsing on the JS side.
// NOT specta-collected: raw-JSON passthrough (FE JSON.parses the string) — kept hand-written on generate_handler! with the scrobbler/fetch family.
#[tauri::command]
pub async fn fetch_json_url(url: String) -> Result<String, String> {
let client = reqwest::Client::builder()
@@ -95,7 +99,7 @@ pub async fn fetch_json_url(url: String) -> Result<String, String> {
}
/// ICY metadata response returned to the frontend.
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
pub struct IcyMetadata {
/// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`).
stream_title: Option<String>,
@@ -173,6 +177,7 @@ pub async fn resolve_playlist_url(client: &reqwest::Client, url: &str) -> Option
/// If `url` is a PLS or M3U playlist file it is resolved to the first direct
/// stream URL before the ICY request is made.
#[tauri::command]
#[specta::specta]
pub async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
use futures_util::StreamExt;
@@ -277,6 +282,7 @@ pub async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
/// Returns the original URL unchanged if it is not a recognised playlist format
/// or if the playlist cannot be fetched/parsed.
#[tauri::command]
#[specta::specta]
pub async fn resolve_stream_url(url: String) -> String {
let Ok(client) = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
@@ -309,6 +315,7 @@ fn provider_http_client() -> Result<reqwest::Client, String> {
/// `params` is a list of [key, value] pairs (method must be included). If `sign`
/// is true an `api_sig` is computed (MD5 of sorted params + secret). If `get` is
/// true a GET request is made, otherwise a form POST.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn audioscrobbler_request(
base_url: String,
@@ -371,6 +378,7 @@ pub async fn audioscrobbler_request(
///
/// `path` is appended to `base_url` (e.g. `/1/submit-listens`). When `json_body`
/// is present the request is a POST with that body; otherwise a GET.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn listenbrainz_request(
base_url: String,
@@ -410,6 +418,7 @@ pub async fn listenbrainz_request(
///
/// `path` is appended to `base_url`. When `json_body` is present the request is a
/// POST with that body; otherwise a GET with `query` pairs.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn maloja_request(
base_url: String,
@@ -13,7 +13,7 @@ const DEFAULT_BATCH: u32 = 20;
const MAX_BATCH: u32 = 50;
const PROGRESS_SCAN_CHUNK: usize = 1000;
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAnalysisBackfillBatchDto {
pub track_ids: Vec<String>,
@@ -21,7 +21,7 @@ pub struct LibraryAnalysisBackfillBatchDto {
pub exhausted: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAnalysisProgressDto {
pub total_tracks: i64,
@@ -9,7 +9,7 @@ use crate::runtime::LibraryRuntime;
use crate::search::library_scope_equals_sql;
use crate::store::LibraryStore;
#[derive(Debug, Clone, serde::Deserialize)]
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct StarredAlbumReconcileItem {
pub id: String,
@@ -19,6 +19,7 @@ pub struct StarredAlbumReconcileItem {
/// Align `album.starred_at` with server favorites: UPDATE existing rows only
/// (no INSERT / stub rows). Clears local stars absent from `starred_albums`.
#[tauri::command]
#[specta::specta]
pub fn library_reconcile_album_stars(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -57,7 +57,7 @@ where
const TRACKS_BATCH_LIMIT: usize = 100;
const ANALYSIS_PROGRESS_CACHE_TTL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, serde::Deserialize)]
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryServerKeyMigrationDto {
pub legacy_id: String,
@@ -66,6 +66,7 @@ pub struct LibraryServerKeyMigrationDto {
/// Resolve cover disk + fetch ids from the local library (`album` | `artist` | `track`).
#[tauri::command]
#[specta::specta]
pub fn library_resolve_cover_entry(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -87,6 +88,7 @@ pub fn library_resolve_cover_entry(
}
#[tauri::command]
#[specta::specta]
pub fn library_analysis_backfill_batch(
app: AppHandle,
runtime: State<'_, LibraryRuntime>,
@@ -106,6 +108,7 @@ pub fn library_analysis_backfill_batch(
}
#[tauri::command]
#[specta::specta]
pub fn library_analysis_progress(
app: AppHandle,
runtime: State<'_, LibraryRuntime>,
@@ -156,6 +159,7 @@ pub fn library_analysis_progress(
}
#[tauri::command]
#[specta::specta]
pub fn library_count_live_tracks(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -169,6 +173,7 @@ pub fn library_count_live_tracks(
}
#[tauri::command]
#[specta::specta]
pub async fn library_get_status(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -302,6 +307,7 @@ fn resolve_local_track_count(
}
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_search(
runtime: State<'_, LibraryRuntime>,
@@ -335,6 +341,7 @@ pub async fn library_search(
Ok(LibraryTracksEnvelope { tracks, total })
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_get_track(
runtime: State<'_, LibraryRuntime>,
@@ -374,6 +381,7 @@ pub async fn library_get_track(
Ok(Some(dto))
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_get_tracks_batch(
runtime: State<'_, LibraryRuntime>,
@@ -389,6 +397,7 @@ pub async fn library_get_tracks_batch(
hydrate_refs(&runtime, &refs)
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_get_tracks_by_album(
runtime: State<'_, LibraryRuntime>,
@@ -401,6 +410,7 @@ pub async fn library_get_tracks_by_album(
/// Upsert Subsonic API song payloads into the library index so pin/download can
/// build `media/library/…` paths before a full sync has ingested the rows.
// NOT specta-collected: takes a serde_json::Value arg — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub fn library_upsert_songs_from_api(
runtime: State<'_, LibraryRuntime>,
@@ -434,6 +444,7 @@ pub fn library_upsert_songs_from_api(
}
#[tauri::command]
#[specta::specta]
pub async fn library_get_artifact(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -456,6 +467,7 @@ pub async fn library_get_artifact(
}
#[tauri::command]
#[specta::specta]
pub async fn library_get_facts(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -472,6 +484,7 @@ pub async fn library_get_facts(
}
#[tauri::command]
#[specta::specta]
pub async fn library_get_offline_path(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -501,6 +514,7 @@ pub async fn library_get_offline_path(
// PR-5d — Advanced Search (§5.13) + cross-server search (§5.5B)
// ──────────────────────────────────────────────────────────────────────
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_advanced_search(
runtime: State<'_, LibraryRuntime>,
@@ -510,6 +524,7 @@ pub async fn library_advanced_search(
library_spawn_blocking(move || advanced_search::run_advanced_search(&store, &request)).await
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_list_lossless_albums(
runtime: State<'_, LibraryRuntime>,
@@ -519,6 +534,7 @@ pub async fn library_list_lossless_albums(
library_spawn_blocking(move || crate::lossless_albums::list_lossless_albums(&store, &request)).await
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_list_albums_by_genre(
runtime: State<'_, LibraryRuntime>,
@@ -530,6 +546,7 @@ pub async fn library_list_albums_by_genre(
}
#[tauri::command]
#[specta::specta]
pub fn library_genre_tags_inspect(
runtime: State<'_, LibraryRuntime>,
) -> Result<crate::genre_tags_backfill::GenreTagsInspectDto, String> {
@@ -537,6 +554,7 @@ pub fn library_genre_tags_inspect(
}
#[tauri::command]
#[specta::specta]
pub async fn library_genre_tags_run(
app: tauri::AppHandle,
runtime: State<'_, LibraryRuntime>,
@@ -546,6 +564,7 @@ pub async fn library_genre_tags_run(
.await
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_get_artist_lossless_browse(
runtime: State<'_, LibraryRuntime>,
@@ -554,6 +573,7 @@ pub async fn library_get_artist_lossless_browse(
crate::artist_lossless_browse::get_artist_lossless_browse(&runtime.store, &request)
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_live_search(
runtime: State<'_, LibraryRuntime>,
@@ -589,6 +609,7 @@ pub async fn library_live_search(
Ok(result)
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_search_cross_server(
runtime: State<'_, LibraryRuntime>,
@@ -677,6 +698,7 @@ async fn navidrome_token_with_retry(
}
#[tauri::command]
#[specta::specta]
pub async fn library_sync_bind_session(
runtime: State<'_, LibraryRuntime>,
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -750,6 +772,7 @@ fn subsonic_base_url_from(runtime: &LibraryRuntime, server_id: &str) -> String {
}
#[tauri::command]
#[specta::specta]
pub fn library_sync_clear_session(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -759,6 +782,7 @@ pub fn library_sync_clear_session(
}
#[tauri::command]
#[specta::specta]
pub fn library_set_playback_hint(
runtime: State<'_, LibraryRuntime>,
hint: String,
@@ -774,6 +798,7 @@ pub fn library_set_playback_hint(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_playback_hint(runtime: State<'_, LibraryRuntime>) -> Result<String, String> {
Ok(match runtime.current_playback_hint() {
PlaybackHint::Idle => "idle".to_string(),
@@ -783,6 +808,7 @@ pub fn library_get_playback_hint(runtime: State<'_, LibraryRuntime>) -> Result<S
}
#[tauri::command]
#[specta::specta]
pub async fn library_sync_start(
app: AppHandle,
runtime: State<'_, LibraryRuntime>,
@@ -1017,6 +1043,7 @@ async fn library_sync_start_inner(
/// Mode A user-initiated full reconcile bypasses the threshold
/// check.
#[tauri::command]
#[specta::specta]
pub async fn library_sync_verify_integrity(
app: AppHandle,
runtime: State<'_, LibraryRuntime>,
@@ -1035,6 +1062,7 @@ pub async fn library_sync_verify_integrity(
}
#[tauri::command]
#[specta::specta]
pub fn library_sync_cancel(
runtime: State<'_, LibraryRuntime>,
job_id: Option<String>,
@@ -1080,6 +1108,7 @@ pub fn patch_content_hash(
.map_err(|e| e.to_string())
}
// NOT specta-collected: takes a serde_json::Value arg — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub fn library_patch_track(
runtime: State<'_, LibraryRuntime>,
@@ -1160,6 +1189,7 @@ pub(crate) fn apply_track_patch(
}
#[tauri::command]
#[specta::specta]
pub fn library_put_artifact(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -1176,6 +1206,7 @@ pub fn library_put_artifact(
}
#[tauri::command]
#[specta::specta]
pub fn library_put_fact(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -1188,6 +1219,7 @@ pub fn library_put_fact(
}
#[tauri::command]
#[specta::specta]
pub fn library_record_play_session(
runtime: State<'_, LibraryRuntime>,
input: PlaySessionInputDto,
@@ -1196,6 +1228,7 @@ pub fn library_record_play_session(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_player_stats_year_summary(
runtime: State<'_, LibraryRuntime>,
year: i32,
@@ -1204,6 +1237,7 @@ pub fn library_get_player_stats_year_summary(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_player_stats_heatmap(
runtime: State<'_, LibraryRuntime>,
year: i32,
@@ -1212,6 +1246,7 @@ pub fn library_get_player_stats_heatmap(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_player_stats_day_detail(
runtime: State<'_, LibraryRuntime>,
date_iso: String,
@@ -1220,6 +1255,7 @@ pub fn library_get_player_stats_day_detail(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_player_stats_year_bounds(
runtime: State<'_, LibraryRuntime>,
) -> Result<PlaySessionYearBoundsDto, String> {
@@ -1227,6 +1263,7 @@ pub fn library_get_player_stats_year_bounds(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_player_stats_recent_days(
runtime: State<'_, LibraryRuntime>,
limit: Option<u32>,
@@ -1235,6 +1272,7 @@ pub fn library_get_player_stats_recent_days(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_recent_play_sessions(
runtime: State<'_, LibraryRuntime>,
limit: Option<u32>,
@@ -1245,6 +1283,7 @@ pub fn library_get_recent_play_sessions(
}
#[tauri::command]
#[specta::specta]
pub fn library_purge_server(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -1359,6 +1398,7 @@ pub fn library_purge_server(
}
#[tauri::command]
#[specta::specta]
pub fn library_migrate_server_index_keys(
_runtime: State<'_, LibraryRuntime>,
mappings: Vec<LibraryServerKeyMigrationDto>,
@@ -1370,6 +1410,7 @@ pub fn library_migrate_server_index_keys(
}
#[tauri::command]
#[specta::specta]
pub fn library_delete_server_data(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -23,7 +23,7 @@ const MAX_BATCH: u32 = 256;
const SCAN_PAGE: i64 = 256;
const MAX_SCAN_PAGES: usize = 16;
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverBackfillItem {
pub cache_kind: String,
@@ -31,7 +31,7 @@ pub struct CoverBackfillItem {
pub fetch_cover_art_id: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryCoverBackfillBatchDto {
pub items: Vec<CoverBackfillItem>,
@@ -41,7 +41,7 @@ pub struct LibraryCoverBackfillBatchDto {
pub exhausted: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryCoverProgressDto {
pub total_distinct: i64,
@@ -6,7 +6,7 @@ use rusqlite::OptionalExtension;
use crate::store::LibraryStore;
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverEntryDto {
pub cache_kind: String,
+16 -16
View File
@@ -11,7 +11,7 @@ use crate::store::LibraryStore;
/// `library_get_status` payload — mirrors the `sync_state` row plus a
/// few derived counters from `track`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct SyncStateDto {
pub server_id: String,
@@ -177,7 +177,7 @@ pub struct LibraryTracksEnvelope {
}
/// `library_get_artifact` payload — one row of `track_artifact`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct TrackArtifactDto {
pub server_id: String,
@@ -196,7 +196,7 @@ pub struct TrackArtifactDto {
}
/// `library_get_facts` row.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct TrackFactDto {
pub server_id: String,
@@ -216,7 +216,7 @@ pub struct TrackFactDto {
/// `library_get_offline_path` outcome — either a path string or a
/// `missing` flag so the frontend can show a hint without polling.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct OfflinePathDto {
pub server_id: String,
@@ -238,7 +238,7 @@ pub struct TrackRefDto {
/// Input to `library_put_artifact`. Same shape as `TrackArtifactDto`
/// minus the server-supplied `server_id` / `track_id` (provided as
/// command args) and `fetched_at` (stamped server-side from `now`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactInputDto {
pub artifact_kind: String,
@@ -263,7 +263,7 @@ pub struct ArtifactInputDto {
/// Input to `library_put_fact`. Shape matches `TrackFactDto` minus the
/// indices.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct FactInputDto {
pub fact_kind: String,
@@ -290,7 +290,7 @@ fn default_confidence() -> f64 {
}
/// Input to `library_record_play_session`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionInputDto {
pub server_id: String,
@@ -305,7 +305,7 @@ pub struct PlaySessionInputDto {
}
/// Cross-server year summary for the Player stats tab.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionYearSummaryDto {
pub total_listened_sec: f64,
@@ -321,14 +321,14 @@ pub struct PlaySessionYearSummaryDto {
pub partial_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionHeatmapDayDto {
pub date: String,
pub track_play_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionDayTotalsDto {
pub total_listened_sec: f64,
@@ -338,7 +338,7 @@ pub struct PlaySessionDayTotalsDto {
pub partial_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionDayTrackDto {
pub server_id: String,
@@ -353,7 +353,7 @@ pub struct PlaySessionDayTrackDto {
pub cover_art_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionDayDetailDto {
pub totals: PlaySessionDayTotalsDto,
@@ -364,7 +364,7 @@ pub struct PlaySessionDayDetailDto {
pub type PlaySessionRecentTrackDto = PlaySessionDayTrackDto;
/// Summary for one day in the recent-days list (no track rows).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionRecentDayDto {
pub date: String,
@@ -376,7 +376,7 @@ pub struct PlaySessionRecentDayDto {
}
/// Earliest/latest calendar years with at least one session (local TZ).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionYearBoundsDto {
pub min_year: Option<i32>,
@@ -433,7 +433,7 @@ pub struct LibraryGenreAlbumsResponse {
}
/// `library_purge_server` outcome.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PurgeReportDto {
pub tracks_deleted: u32,
@@ -445,7 +445,7 @@ pub struct PurgeReportDto {
}
/// `library_sync_start` ack.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct SyncJobDto {
pub job_id: String,
@@ -26,7 +26,7 @@ fn ensure_genre_tags_tables(conn: &mut Connection) -> rusqlite::Result<()> {
crate::store::ensure_genre_tags_schema(conn)
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct GenreTagsInspectDto {
pub needed: bool,
@@ -17,6 +17,7 @@ tauri-plugin-shell = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
tokio = { version = "1", features = ["rt", "time", "sync"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] }
futures-util = "0.3"
+7 -1
View File
@@ -26,6 +26,7 @@ pub fn resolve_hot_cache_root(
/// Returns true if the current Linux system is Arch-based
/// (checks /etc/arch-release and /etc/os-release).
#[tauri::command]
#[specta::specta]
pub fn check_arch_linux() -> bool {
#[cfg(target_os = "linux")]
{
@@ -57,6 +58,7 @@ pub struct UpdateDownloadProgress {
/// Emits `update:download:progress` events with `{ bytes, total }` every 250 ms.
/// Returns the final absolute file path on success.
#[tauri::command]
#[specta::specta]
pub async fn download_update(url: String, filename: String, app: tauri::AppHandle) -> Result<String, String> {
use futures_util::StreamExt;
use std::time::{Duration, Instant};
@@ -130,6 +132,7 @@ pub async fn download_update(url: String, filename: String, app: tauri::AppHandl
/// Performs a track search, then fetches the LRC string for the best match.
/// Returns `None` if no match or no lyrics are found.
#[tauri::command]
#[specta::specta]
pub async fn fetch_netease_lyrics(artist: String, title: String) -> Result<Option<String>, String> {
let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
@@ -183,6 +186,7 @@ pub async fn fetch_netease_lyrics(artist: String, title: String) -> Result<Optio
/// Errors are silenced and mapped to `None` so the frontend falls through to the
/// next lyrics source without crashing.
#[tauri::command]
#[specta::specta]
pub fn get_embedded_lyrics(path: String) -> Option<String> {
use lofty::file::FileType;
use lofty::prelude::*;
@@ -282,6 +286,7 @@ pub fn get_embedded_lyrics(path: String) -> Option<String> {
/// Uses platform-specific process spawning — tauri-plugin-shell's open() only
/// allows https:// URLs per the capability scope and fails silently for paths.
#[tauri::command]
#[specta::specta]
pub fn open_folder(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
@@ -323,6 +328,7 @@ pub struct ZipProgress {
/// live MB-counter without holding any binary data in the WebView process.
/// Returns the final destination path on success.
#[tauri::command]
#[specta::specta]
pub async fn download_zip(
id: String,
url: String,
@@ -409,7 +415,7 @@ pub async fn download_zip(
}
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct HotCacheDownloadResult {
pub path: String,
+5
View File
@@ -10,6 +10,7 @@ use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult};
use super::offline::enqueue_analysis_seed_from_file;
#[tauri::command]
#[specta::specta]
pub async fn download_track_hot_cache(
track_id: String,
server_id: String,
@@ -129,6 +130,7 @@ pub async fn download_track_hot_cache(
/// Promotes bytes captured by the manual streaming path into hot cache on disk.
/// Returns `Ok(None)` when no completed stream cache is available for this URL.
#[tauri::command]
#[specta::specta]
pub async fn promote_stream_cache_to_hot_cache(
track_id: String,
server_id: String,
@@ -244,6 +246,7 @@ pub async fn promote_stream_cache_to_hot_cache(
}
#[tauri::command]
#[specta::specta]
pub async fn get_hot_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
resolve_hot_cache_root(custom_dir, &app)
.map(|root| super::fs_utils::dir_size_recursive(&root))
@@ -251,6 +254,7 @@ pub async fn get_hot_cache_size(custom_dir: Option<String>, app: tauri::AppHandl
}
#[tauri::command]
#[specta::specta]
pub async fn delete_hot_cache_track(
local_path: String,
custom_dir: Option<String>,
@@ -282,6 +286,7 @@ pub async fn delete_hot_cache_track(
/// Removes the entire hot cache root (`psysonic-hot-cache` for the active location).
#[tauri::command]
#[specta::specta]
pub async fn purge_hot_cache(custom_dir: Option<String>, app: tauri::AppHandle) -> Result<(), String> {
let root = resolve_hot_cache_root(custom_dir, &app)?;
if !root.exists() {
+17 -4
View File
@@ -44,7 +44,7 @@ pub fn resolve_media_dir(custom_media_dir: Option<&str>, app: &AppHandle) -> Res
}
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LocalTrackDownloadResult {
pub path: String,
@@ -52,7 +52,7 @@ pub struct LocalTrackDownloadResult {
pub layout_fingerprint: String,
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryTrackProbeResult {
pub path: String,
@@ -61,7 +61,7 @@ pub struct LibraryTrackProbeResult {
pub exists: bool,
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryTierDiskHit {
pub track_id: String,
@@ -230,6 +230,7 @@ async fn local_track_hit_if_exists(
/// `TRACK_NOT_INDEXED` when the row is missing. Disk scope uses `server_index_key`;
/// SQL lookup uses `library_server_id`.
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn download_track_local(
tier: String,
@@ -409,6 +410,7 @@ pub async fn download_track_local(
/// Scan library-tier bytes on disk and match them to known candidates only
/// (`track_offline.local_path` + canonical paths for `candidate_track_ids`).
#[tauri::command]
#[specta::specta]
pub async fn discover_library_tier_on_disk(
server_index_key: String,
library_server_id: String,
@@ -517,6 +519,7 @@ pub async fn discover_library_tier_on_disk(
/// Resolve the canonical `library/` path for a track and report on-disk presence only
/// (no download, no analysis seed).
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn probe_library_track_local(
track_id: String,
@@ -580,6 +583,7 @@ async fn prune_orphan_files_under_root(root: &Path, keep_paths: &[String]) -> Ve
/// Remove library-tier files under `{server_index_key}` that are not listed in `keep_paths`.
#[tauri::command]
#[specta::specta]
pub async fn prune_orphan_library_tier_files(
server_index_key: String,
keep_paths: Vec<String>,
@@ -654,6 +658,7 @@ async fn evict_orphan_files_under_root_to_fit(
/// Evict unindexed ephemeral cache files (oldest first) until tier size ≤ `max_bytes`.
#[tauri::command]
#[specta::specta]
pub async fn evict_ephemeral_cache_orphans_to_fit(
keep_paths: Vec<String>,
max_bytes: u64,
@@ -667,6 +672,7 @@ pub async fn evict_ephemeral_cache_orphans_to_fit(
/// Remove ephemeral-tier files under `{media}/cache/` not listed in `keep_paths`.
#[tauri::command]
#[specta::specta]
pub async fn prune_orphan_ephemeral_cache_files(
keep_paths: Vec<String>,
media_dir: Option<String>,
@@ -679,6 +685,7 @@ pub async fn prune_orphan_ephemeral_cache_files(
/// Batch existence probe for reconcile (index rows without on-disk bytes).
#[tauri::command]
#[specta::specta]
pub fn probe_media_files(local_paths: Vec<String>) -> Vec<bool> {
local_paths
.iter()
@@ -696,6 +703,7 @@ fn resolve_media_tier_root(
/// Recursive byte size under `{media}/{cache|library}/`.
#[tauri::command]
#[specta::specta]
pub async fn get_media_tier_size(
tier: String,
media_dir: Option<String>,
@@ -712,6 +720,7 @@ pub async fn get_media_tier_size(
/// Deletes the entire `{cache|library}/` subtree under the media root.
#[tauri::command]
#[specta::specta]
pub async fn purge_media_tier(
tier: String,
media_dir: Option<String>,
@@ -749,6 +758,7 @@ fn prune_parents_after_media_file_delete(
/// Deletes one media file and prunes empty parents up to the tier root.
#[tauri::command]
#[specta::specta]
pub async fn delete_media_file(
local_path: String,
media_dir: Option<String>,
@@ -766,6 +776,7 @@ pub async fn delete_media_file(
/// Removes empty directories under `{media}/{cache|library}/` (post-eviction sweep).
#[tauri::command]
#[specta::specta]
pub async fn prune_empty_media_tier_dirs(
tier: String,
media_dir: Option<String>,
@@ -780,6 +791,7 @@ pub async fn prune_empty_media_tier_dirs(
/// Promotes stream-cache bytes into `{media}/cache/…` using library-index paths.
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn promote_stream_cache_to_local(
track_id: String,
@@ -905,7 +917,7 @@ pub async fn promote_stream_cache_to_local(
}))
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LegacyOfflineMigrationResult {
pub track_id: String,
@@ -1228,6 +1240,7 @@ async fn relocate_legacy_track_file(
/// Scan `psysonic-offline/{segment}/{trackId}.ext`, verify each id in the library
/// index, and relocate live tracks into `{media}/library/…`.
#[tauri::command]
#[specta::specta]
pub async fn migrate_legacy_offline_disk(
media_dir: Option<String>,
custom_offline_dir: Option<String>,
+5
View File
@@ -88,6 +88,7 @@ pub(crate) fn resolve_offline_cache_dir(
/// Returns the absolute file path so TypeScript can store it and later
/// construct a `psysonic-local://<path>` URL for the audio engine.
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)] // Tauri command surface — args map 1:1 to the JS call.
pub async fn download_track_offline(
track_id: String,
@@ -160,6 +161,7 @@ pub async fn download_track_offline(
/// boundary; ones still parked on the download semaphore bail as soon as they
/// acquire a slot. Mirrors `cancel_device_sync` for the device-sync side.
#[tauri::command]
#[specta::specta]
pub fn cancel_offline_downloads(download_ids: Vec<String>) {
if let Ok(mut flags) = offline_cancel_flags().lock() {
for id in download_ids {
@@ -175,6 +177,7 @@ pub fn cancel_offline_downloads(download_ids: Vec<String>) {
/// across a long session. The frontend calls this once an album/playlist
/// download settles (completed or cancelled).
#[tauri::command]
#[specta::specta]
pub fn clear_offline_cancel(download_id: String) {
if let Ok(mut flags) = offline_cancel_flags().lock() {
flags.remove(&download_id);
@@ -435,6 +438,7 @@ mod tests {
/// Returns the total size in bytes of all files in the offline cache directory (and optional custom dir).
#[tauri::command]
#[specta::specta]
pub async fn get_offline_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
let default_dir = match app.path().app_data_dir() {
Ok(d) => d.join("psysonic-offline"),
@@ -474,6 +478,7 @@ pub(crate) async fn delete_offline_track_with_boundary(
/// After deleting the file, empty parent directories up to (but not including)
/// `base_dir` are pruned using `remove_dir` (never `remove_dir_all`).
#[tauri::command]
#[specta::specta]
pub async fn delete_offline_track(
local_path: String,
base_dir: Option<String>,
@@ -12,6 +12,7 @@ use super::device::{
};
#[tauri::command]
#[specta::specta]
pub async fn list_device_dir_files(dir: String) -> Result<Vec<String>, String> {
let root = std::path::PathBuf::from(&dir);
if !root.exists() {
@@ -45,6 +46,7 @@ pub async fn list_device_dir_files(dir: String) -> Result<Vec<String>, String> {
/// Deletes a file from the device and prunes empty parent directories
/// (up to 2 levels: album folder, then artist folder).
#[tauri::command]
#[specta::specta]
pub async fn delete_device_file(path: String) -> Result<(), String> {
let p = std::path::PathBuf::from(&path);
if p.exists() {
@@ -391,6 +393,7 @@ pub async fn calculate_sync_payload(
/// Signals a running `sync_batch_to_device` job to stop after its current tracks finish.
#[tauri::command]
#[specta::specta]
pub fn cancel_device_sync(job_id: String, app: tauri::AppHandle) {
if let Ok(flags) = sync_cancel_flags().lock() {
if let Some(flag) = flags.get(&job_id) {
@@ -405,6 +408,7 @@ pub fn cancel_device_sync(job_id: String, app: tauri::AppHandle) {
/// Emits throttled `device:sync:progress` events (max once per 500ms) and a
/// final `device:sync:complete` event with the summary.
#[tauri::command]
#[specta::specta]
pub async fn sync_batch_to_device(
tracks: Vec<TrackSyncInfo>,
dest_dir: String,
@@ -599,6 +603,7 @@ pub async fn sync_batch_to_device(
/// Deletes multiple files from the device in one call and prunes empty parent
/// directories. Returns the number of files successfully deleted.
#[tauri::command]
#[specta::specta]
pub async fn delete_device_files(paths: Vec<String>) -> Result<u32, String> {
let mut deleted: u32 = 0;
for path in &paths {
@@ -5,7 +5,7 @@ use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, su
// ─── Device Sync ─────────────────────────────────────────────────────────────
/// Information about a single mounted removable drive.
#[derive(Clone, serde::Serialize)]
#[derive(Clone, serde::Serialize, specta::Type)]
pub struct RemovableDrive {
pub name: String,
pub mount_point: String,
@@ -19,6 +19,7 @@ pub struct RemovableDrive {
/// On Linux these are typically USB sticks / SD cards under /media or /run/media.
/// On macOS they appear under /Volumes. On Windows they are separate drive letters.
#[tauri::command]
#[specta::specta]
pub fn get_removable_drives() -> Vec<RemovableDrive> {
use sysinfo::Disks;
let disks = Disks::new_with_refreshed_list();
@@ -64,7 +65,7 @@ pub fn read_device_manifest(dest_dir: String) -> Option<serde_json::Value> {
}
/// Per-entry result for `rename_device_files`.
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
pub struct RenameResult {
#[serde(rename = "oldPath")]
old_path: String,
@@ -85,6 +86,7 @@ pub struct RenameResult {
/// and which failed. Does not roll back on partial failure — each `fs::rename`
/// is atomic, so nothing can be half-renamed.
#[tauri::command]
#[specta::specta]
pub fn rename_device_files(
target_dir: String,
pairs: Vec<(String, String)>,
@@ -170,6 +172,7 @@ pub fn rename_device_files(
/// playlist is self-contained — moving/copying the folder anywhere keeps it
/// working. Tracks are expected to be in playlist order (index starts at 1).
#[tauri::command]
#[specta::specta]
pub fn write_playlist_m3u8(
dest_dir: String,
playlist_name: String,
@@ -231,7 +234,7 @@ pub fn is_path_on_mounted_volume(path: &std::path::Path) -> bool {
best_len > 0
}
#[derive(serde::Deserialize, Clone)]
#[derive(serde::Deserialize, Clone, specta::Type)]
pub struct TrackSyncInfo {
pub id: String,
pub url: String,
@@ -261,14 +264,14 @@ pub struct TrackSyncInfo {
}
/// Summary returned by `sync_batch_to_device` after all tracks are processed.
#[derive(Clone, serde::Serialize)]
#[derive(Clone, serde::Serialize, specta::Type)]
pub struct SyncBatchResult {
pub done: u32,
pub skipped: u32,
pub failed: u32,
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
pub struct SyncTrackResult {
pub path: String,
pub skipped: bool,
@@ -359,6 +362,7 @@ pub(crate) async fn sync_download_one_track(
/// Downloads a single track to a USB/SD device using the configured filename template.
/// Emits `device:sync:progress` events with `{ jobId, trackId, status, path? }`.
#[tauri::command]
#[specta::specta]
pub async fn sync_track_to_device(
track: TrackSyncInfo,
dest_dir: String,
@@ -408,6 +412,7 @@ pub async fn sync_track_to_device(
/// Computes the expected file paths for a batch of tracks under the fixed schema.
/// Used by the cleanup flow to find orphans.
#[tauri::command]
#[specta::specta]
pub fn compute_sync_paths(tracks: Vec<TrackSyncInfo>, dest_dir: String) -> Vec<String> {
tracks.iter().map(|track| {
let relative = build_track_path(track);
+2 -2
View File
@@ -97,7 +97,7 @@ pub struct CoverBackfillWorker {
rerun_pending: AtomicBool,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverBackfillPulseDto {
pub scheduled: u32,
@@ -108,7 +108,7 @@ pub struct CoverBackfillPulseDto {
pub status: String,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverBackfillRunDto {
pub started: bool,
+30 -5
View File
@@ -51,7 +51,7 @@ use std::time::Duration;
use tokio::sync::{Mutex, Semaphore};
use tauri::{AppHandle, Emitter, Manager};
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverCacheEnsureResult {
pub hit: bool,
@@ -65,7 +65,7 @@ pub struct CoverCacheEnsureResult {
/// path, which writes every tier up front).
type EncodeTiersOutcome = Result<(bool, Vec<(u32, PathBuf)>, Option<DynamicImage>), String>;
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverCacheStatsDto {
pub bytes: u64,
@@ -76,7 +76,7 @@ pub struct CoverCacheStatsDto {
}
/// Live cover HTTP / WebP-encode slots — mirrors analysis pipeline probe shape.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverPipelineQueueStatsDto {
pub http_max: u32,
@@ -121,7 +121,7 @@ pub(crate) fn cover_pipeline_queue_stats(
}
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverCacheEnsureArgs {
pub server_index_key: String,
@@ -752,6 +752,7 @@ pub fn init_cover_cache(app: &AppHandle) -> Result<(), String> {
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_run_full_pass(
app: AppHandle,
force: Option<bool>,
@@ -762,6 +763,7 @@ pub async fn library_cover_backfill_run_full_pass(
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_pulse(app: AppHandle) -> Result<CoverBackfillPulseDto, String> {
let worker = app
.try_state::<Arc<CoverBackfillWorker>>()
@@ -770,6 +772,7 @@ pub async fn library_cover_backfill_pulse(app: AppHandle) -> Result<CoverBackfil
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_reset_cursor(app: AppHandle) -> Result<(), String> {
let worker = app
.try_state::<Arc<CoverBackfillWorker>>()
@@ -780,6 +783,7 @@ pub async fn library_cover_backfill_reset_cursor(app: AppHandle) -> Result<(), S
/// Pause library backfill while the user navigates / visible covers load (Rust pass yields).
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_set_ui_priority(
app: AppHandle,
hold: bool,
@@ -795,6 +799,7 @@ pub async fn library_cover_backfill_set_ui_priority(
/// + encode pools move together). Not exposed in app Settings by design.
/// Returns the clamped value actually applied.
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_set_parallel(
app: AppHandle,
threads: usize,
@@ -810,6 +815,7 @@ pub async fn library_cover_backfill_set_parallel(
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_configure(
app: AppHandle,
enabled: bool,
@@ -848,6 +854,7 @@ pub async fn library_cover_backfill_configure(
/// timed out against the old address) is cleared and a pass is kicked so they
/// retry on the now-reachable endpoint.
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_set_base_url(
app: AppHandle,
rest_base_url: String,
@@ -867,7 +874,7 @@ pub async fn library_cover_backfill_set_base_url(
Ok(())
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverCachePeekItem {
pub server_index_key: String,
@@ -880,6 +887,7 @@ pub struct CoverCachePeekItem {
/// Best-effort disk hit without network (exact tier, then largest tier on disk ≤ wanted).
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_peek_batch(
app: AppHandle,
items: Vec<CoverCachePeekItem>,
@@ -956,6 +964,7 @@ fn ensure_peek(dir: &Path, tier: u32, args: &CoverCacheEnsureArgs) -> Option<Pat
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_ensure(
app: AppHandle,
args: CoverCacheEnsureArgs,
@@ -965,6 +974,7 @@ pub async fn cover_cache_ensure(
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_ensure_batch(
app: AppHandle,
items: Vec<CoverCacheEnsureArgs>,
@@ -984,6 +994,7 @@ pub async fn cover_cache_ensure_batch(
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_stats(app: AppHandle) -> Result<CoverCacheStatsDto, String> {
let st = state(&app)?;
let root = {
@@ -1006,11 +1017,13 @@ pub async fn cover_cache_stats(app: AppHandle) -> Result<CoverCacheStatsDto, Str
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_evict_tick(_app: AppHandle) -> Result<u32, String> {
Ok(0)
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_stats_server(
app: AppHandle,
server_index_key: String,
@@ -1029,6 +1042,7 @@ pub async fn cover_cache_stats_server(
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_get_pipeline_queue_stats(
app: AppHandle,
) -> Result<CoverPipelineQueueStatsDto, String> {
@@ -1042,6 +1056,7 @@ pub async fn cover_cache_get_pipeline_queue_stats(
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_clear_server(
app: AppHandle,
server_index_key: String,
@@ -1113,6 +1128,7 @@ fn purge_external_files(server_dir: &Path) -> usize {
/// intact. Fired when the user turns the External Artwork toggle off. Unlike
/// `cover_cache_clear_server`, Navidrome tiers survive.
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_purge_external(
app: AppHandle,
server_index_key: String,
@@ -1156,6 +1172,7 @@ pub async fn cover_cache_purge_external(
/// Always emits `cover:bucket-renamed` with `{oldKey, newKey}` on success so
/// the frontend in-memory disk-src cache can invalidate stale entries.
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_rename_server_bucket(
app: AppHandle,
old_key: String,
@@ -1259,6 +1276,7 @@ fn merge_cover_bucket(old_dir: &std::path::Path, new_dir: &std::path::Path) -> R
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_configure(
app: AppHandle,
max_mb: u64,
@@ -1274,6 +1292,7 @@ pub async fn cover_cache_configure(
}
#[tauri::command]
#[specta::specta]
pub async fn cover_cache_clear(app: AppHandle) -> Result<(), String> {
let st = state(&app)?;
let guard = st.lock().await;
@@ -1302,6 +1321,7 @@ pub async fn cover_cache_clear(app: AppHandle) -> Result<(), String> {
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_backfill_batch(
app: AppHandle,
server_index_key: String,
@@ -1333,6 +1353,7 @@ pub async fn library_cover_backfill_batch(
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_progress(
app: AppHandle,
server_index_key: String,
@@ -1363,6 +1384,7 @@ pub async fn library_cover_progress(
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_clear_fetch_failures(
app: AppHandle,
server_index_key: String,
@@ -1373,6 +1395,7 @@ pub async fn library_cover_clear_fetch_failures(
}
#[tauri::command]
#[specta::specta]
pub async fn library_cover_catalog_size(
app: AppHandle,
library_server_id: String,
@@ -1389,11 +1412,13 @@ pub async fn library_cover_catalog_size(
}
#[tauri::command]
#[specta::specta]
pub fn cover_revalidate_enqueue() -> Result<(), String> {
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn cover_revalidate_tick(_cycle_days: Option<u32>) -> Result<u32, String> {
Ok(0)
}
+581 -6
View File
@@ -3,9 +3,9 @@
pub mod cli;
mod cover_cache;
mod library_analysis_backfill;
pub(crate) mod library_analysis_backfill;
mod lib_commands;
mod theme_import;
pub(crate) mod theme_import;
pub mod theme_animation;
pub use psysonic_integration::discord;
@@ -86,10 +86,232 @@ fn set_app_user_model_id() {
/// crate-by-crate (see the specta-contract plan).
fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
tauri_specta::Builder::<tauri::Wry>::new()
// Map Rust `i64`/`u64`/`usize`/… to TS `number` globally. This is
// behaviour-preserving, NOT a change: Tauri already serialises these as
// JSON numbers and the hand-written FE DTOs already type them `number`
// (with the same >2^53 truncation JSON.parse does today). The flag only
// makes the generated types match that reality — it avoids ~200 per-field
// `#[specta(type = Number)]` casts across the DTOs. `enable_lossless_bigints`
// (→ TS `bigint`) is the real behaviour change and stays rejected.
//
// Bigint precision audit (Option-A guard, done 2026-07-02): every returned
// i64/u64 field across the collected DTOs is an epoch-ms timestamp (~1e13),
// a catalog/play count (~1e7), a byte size (~1e13 for a large library) or a
// tiny scalar (year, track/disc no., bpm, rating) — all well under 2^53
// (~9e15). No nanosecond timestamps, no integer-encoded hashes (content_hash
// is a String), no unbounded accumulators. If a future DTO adds one, model
// it as a String at the edge instead of relying on this cast.
.dangerously_cast_bigints_to_number()
.commands(tauri_specta::collect_commands![
crate::lib_commands::app_api::core::greet,
psysonic_library::browse_support::library_get_catalog_year_bounds,
psysonic_library::browse_support::library_get_genre_album_counts,
// psysonic-library — remaining typeable commands. Excluded (stay on
// generate_handler! only): the 10 search/browse/track reads whose envelopes
// carry LibraryTrack/Album/ArtistDto (each has `raw_json: Value`, dto.rs) +
// library_upsert_songs_from_api / library_patch_track (serde_json::Value args).
psysonic_library::browse_support::library_reconcile_album_stars,
psysonic_library::commands::library_resolve_cover_entry,
psysonic_library::commands::library_analysis_backfill_batch,
psysonic_library::commands::library_analysis_progress,
psysonic_library::commands::library_count_live_tracks,
psysonic_library::commands::library_get_status,
psysonic_library::commands::library_get_artifact,
psysonic_library::commands::library_get_facts,
psysonic_library::commands::library_get_offline_path,
psysonic_library::commands::library_genre_tags_inspect,
psysonic_library::commands::library_genre_tags_run,
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_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_get_recent_play_sessions,
psysonic_library::commands::library_purge_server,
psysonic_library::commands::library_migrate_server_index_keys,
psysonic_library::commands::library_delete_server_data,
// psysonic-audio (audio_play + audio_chain_preload excluded: >10 args
// exceed specta's SpectaFn limit — see the note at their definitions)
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::mix_commands::audio_set_crossfade,
audio::mix_commands::audio_set_gapless,
audio::mix_commands::audio_begin_outgoing_fade,
audio::mix_commands::audio_set_autodj_suppress,
audio::mix_commands::audio_set_normalization,
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::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,
// psysonic-analysis (no Value / all ≤10 args — fully typeable)
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-syncfs (calculate_sync_payload + write/read_device_manifest excluded: serde_json::Value)
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::local::probe_library_track_local,
psysonic_syncfs::cache::local::discover_library_tier_on_disk,
psysonic_syncfs::cache::local::prune_orphan_library_tier_files,
psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files,
psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit,
psysonic_syncfs::cache::local::probe_media_files,
psysonic_syncfs::cache::local::get_media_tier_size,
psysonic_syncfs::cache::local::purge_media_tier,
psysonic_syncfs::cache::local::delete_media_file,
psysonic_syncfs::cache::local::prune_empty_media_tier_dirs,
psysonic_syncfs::cache::local::promote_stream_cache_to_local,
psysonic_syncfs::cache::local::migrate_legacy_offline_disk,
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_playlist_m3u8,
psysonic_syncfs::sync::device::rename_device_files,
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,
// cover_cache (cover_revalidate_batch excluded: returns serde_json::Value)
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_purge_external,
cover_cache::cover_cache_rename_server_bucket,
cover_cache::cover_cache_stats_server,
cover_cache::cover_cache_get_pipeline_queue_stats,
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_set_base_url,
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_set_parallel,
cover_cache::library_cover_backfill_run_full_pass,
cover_cache::cover_revalidate_enqueue,
cover_cache::cover_revalidate_tick,
// top crate shell commands (set_tray_menu_labels >10 args, backup_*_full/cli_publish_* are Value — all excluded)
crate::lib_commands::app_api::core::exit_app,
crate::lib_commands::app_api::core::set_logging_mode,
crate::lib_commands::app_api::core::get_logging_mode,
crate::lib_commands::app_api::core::tail_runtime_logs,
crate::lib_commands::app_api::core::export_runtime_logs,
crate::lib_commands::app_api::core::frontend_debug_log,
crate::lib_commands::app_api::core::set_subsonic_wire_user_agent,
crate::lib_commands::app_api::perf::performance_cpu_snapshot,
crate::lib_commands::app_api::platform::set_window_decorations,
crate::lib_commands::app_api::platform::set_linux_webkit_smooth_scrolling,
crate::lib_commands::app_api::platform::linux_wayland_gpu_font_tuning_active,
crate::lib_commands::app_api::platform::linux_wayland_text_render_settings_available,
crate::lib_commands::app_api::platform::set_linux_wayland_text_render_profile,
crate::lib_commands::app_api::platform::theme_animation_risk,
crate::lib_commands::app_api::migration::migration_inspect,
crate::lib_commands::app_api::migration::migration_run,
crate::lib_commands::app_api::network::resolve_host_addresses,
crate::lib_commands::app_api::network::server_http_context_clear,
crate::lib_commands::app_api::network::server_http_context_sync,
crate::lib_commands::app_api::network::server_http_context_sync_all,
crate::lib_commands::app_api::backup::backup_export_library_db,
crate::lib_commands::app_api::backup::backup_import_library_db,
crate::lib_commands::app_api::integration::register_global_shortcut,
crate::lib_commands::app_api::integration::unregister_global_shortcut,
crate::lib_commands::app_api::integration::mpris_set_metadata,
crate::lib_commands::app_api::integration::mpris_set_playback,
crate::lib_commands::app_api::integration::check_dir_accessible,
crate::lib_commands::ui::mini::open_mini_player,
crate::lib_commands::ui::mini::preload_mini_player,
crate::lib_commands::ui::mini::close_mini_player,
crate::lib_commands::ui::mini::set_mini_player_always_on_top,
crate::lib_commands::ui::mini::resize_mini_player,
crate::lib_commands::ui::mini::show_main_window,
crate::lib_commands::ui::mini::pause_rendering,
crate::lib_commands::ui::mini::resume_rendering,
crate::lib_commands::sync::tray::no_compositing_mode,
crate::lib_commands::sync::tray::linux_xdg_session_type,
crate::lib_commands::sync::tray::is_tiling_wm_cmd,
crate::lib_commands::sync::tray::toggle_tray_icon,
crate::lib_commands::sync::tray::set_tray_tooltip,
crate::lib_commands::sync::tray::set_tray_menu_labels,
crate::theme_import::import_theme_zip,
crate::library_analysis_backfill::library_analysis_backfill_configure,
// psysonic-integration — typeable subset. Excluded (stay on generate_handler!):
// the nd_list_*/nd_create_*/nd_update_* + scrobbler (audioscrobbler/listenbrainz/
// maloja) + radio-browser + fetch_json_url raw-JSON commands (serde_json::Value /
// passthrough), and discord_update_presence (>10 args) — noted at their defs.
psysonic_integration::bandsintown::fetch_bandsintown_events,
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::playlists::nd_delete_playlist,
psysonic_integration::navidrome::queries::nd_set_user_libraries,
psysonic_integration::navidrome::queries::nd_get_song_path,
psysonic_integration::navidrome::users::navidrome_login,
psysonic_integration::navidrome::users::nd_delete_user,
psysonic_integration::remote::fetch_url_bytes,
psysonic_integration::remote::fetch_icy_metadata,
psysonic_integration::remote::resolve_stream_url,
psysonic_integration::discord::discord_clear_presence,
])
}
@@ -949,6 +1171,32 @@ pub fn run() {
psysonic_syncfs::cache::downloads::open_folder,
psysonic_syncfs::cache::downloads::get_embedded_lyrics,
psysonic_syncfs::cache::downloads::fetch_netease_lyrics,
// cover_cache (cover_revalidate_batch excluded: returns serde_json::Value)
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_purge_external,
cover_cache::cover_cache_rename_server_bucket,
cover_cache::cover_cache_stats_server,
cover_cache::cover_cache_get_pipeline_queue_stats,
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_set_base_url,
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_set_parallel,
cover_cache::library_cover_backfill_run_full_pass,
cover_cache::cover_revalidate_enqueue,
cover_cache::cover_revalidate_tick,
psysonic_integration::bandsintown::fetch_bandsintown_events,
#[cfg(target_os = "windows")]
taskbar_win::update_taskbar_icon,
@@ -959,12 +1207,339 @@ pub fn run() {
#[cfg(test)]
mod specta_export {
// Headless generator + freshness seed: runs the same export as a debug launch
// without a GUI, so `cargo test` regenerates `bindings.ts` and CI can diff it.
// Freshness gate. Exports to a throwaway temp path and asserts byte-equality
// with the committed `src/generated/bindings.ts`. A Rust command/DTO change
// that isn't regenerated diverges here → `cargo test` fails → CI catches the
// stale bindings. Crucially the test exports to a temp file, never the tracked
// one, so it neither dirties the working tree nor races other export tests.
// Regenerate the committed file headlessly with the `#[ignore]`d
// `regenerate_committed_bindings` below (or a `npm run tauri:dev` debug
// launch, which runs `export_specta_bindings()`), then commit the result.
#[test]
fn bindings_are_exportable() {
fn committed_bindings_are_fresh() {
let committed_path = "../src/generated/bindings.ts";
let tmp = std::env::temp_dir().join(format!(
"psysonic-bindings-freshness-{}.ts",
std::process::id()
));
super::specta_builder()
.export(super::bindings_exporter(), "../src/generated/bindings.ts")
.export(super::bindings_exporter(), &tmp)
.expect("failed to export typescript bindings");
let generated = std::fs::read_to_string(&tmp).expect("read freshly exported bindings");
let _ = std::fs::remove_file(&tmp);
let committed =
std::fs::read_to_string(committed_path).expect("read committed bindings.ts");
assert_eq!(
committed, generated,
"src/generated/bindings.ts is stale — regenerate it with \
`cargo test -p psysonic regenerate_committed_bindings -- --ignored` \
and commit the result"
);
}
// Headless regeneration of the committed bindings. `#[ignore]`d so it never
// runs in CI / default `cargo test` (which keeps the tracked file untouched
// and lets `committed_bindings_are_fresh` be the gate); run it explicitly to
// rewrite the file after annotating commands:
// cargo test -p psysonic regenerate_committed_bindings -- --ignored
#[test]
#[ignore = "writes the tracked src/generated/bindings.ts; run explicitly to regenerate"]
fn regenerate_committed_bindings() {
// Export to a sibling temp file then atomically rename over the tracked
// one, so a concurrent reader (e.g. `committed_bindings_are_fresh` under
// `--include-ignored`) never observes a half-written/truncated file.
let target = "../src/generated/bindings.ts";
let tmp = "../src/generated/bindings.ts.regen.tmp";
super::specta_builder()
.export(super::bindings_exporter(), tmp)
.expect("failed to export typescript bindings");
std::fs::rename(tmp, target).expect("failed to move regenerated bindings into place");
}
// ── G-sync anti-drift guard (Option A) ──────────────────────────────────
// Every command registered in the live `generate_handler!` must EITHER be
// collected into `collect_commands!` (so the FE gets a typed binding) OR
// appear in `UNTYPEABLE` below with the reason it can't be typed under the
// pinned specta =2.0.0-rc.25. This fails if a new command lands in the
// handler without doing one or the other — so the typed IPC surface can't
// silently rot as commands are added. The allowlist is exact and can only
// shrink: making a command typeable (a `Value` return replaced by a DTO, an
// arg count dropping to ≤10) means collecting it AND removing it here.
//
// Under Option A `generate_handler!` stays the permanent live handler; this
// test — not a handler flip — is what keeps the two lists in sync.
#[test]
fn typeable_commands_are_all_collected() {
// Known-untypeable under specta =2.0.0-rc.25 (see the specta-contract
// plan). Three reasons only:
const UNTYPEABLE: &[&str] = &[
// (1) serde_json::Value / raw-JSON passthrough in the signature —
// rc.25 registers `Value` as inline-self-recursive and overflows the
// exporter. `raw_json`-carrying library DTO envelopes count too.
"audioscrobbler_request",
"listenbrainz_request",
"maloja_request",
"backup_export_full",
"backup_import_full",
"cli_publish_library_list",
"cli_publish_player_snapshot",
"cli_publish_search_results",
"cli_publish_server_list",
"calculate_sync_payload",
"read_device_manifest",
"write_device_manifest",
"cover_revalidate_batch",
"fetch_json_url",
"get_top_radio_stations",
"search_radio_browser",
"library_advanced_search",
"library_get_artist_lossless_browse",
"library_get_track",
"library_get_tracks_batch",
"library_get_tracks_by_album",
"library_list_albums_by_genre",
"library_list_lossless_albums",
"library_live_search",
"library_search",
"library_search_cross_server",
"library_patch_track",
"library_upsert_songs_from_api",
"nd_create_playlist",
"nd_get_playlist",
"nd_list_playlists",
"nd_update_playlist",
"nd_create_user",
"nd_list_users",
"nd_update_user",
"nd_list_albums_by_artist_role",
"nd_list_artists_by_role",
"nd_list_libraries",
"nd_list_songs",
// (2) >10 total params (State/AppHandle/Window included) exceed
// specta's SpectaFn arg cap. Typing needs the args bundled into a
// struct = an IPC arg-shape change, out of scope for Option A.
"audio_play",
"audio_chain_preload",
"discord_update_presence",
"download_track_local",
// (3) platform-gated — `#[cfg(target_os = "windows")]`, so absent
// from the Linux specta export the committed bindings are built from.
"update_taskbar_icon",
];
let src = include_str!("lib.rs");
let handler = command_names(src, "generate_handler!");
let collected = command_names(src, "collect_commands!");
assert!(
handler.len() > 200 && collected.len() > 150,
"command-list parser found too few entries (handler={}, collected={}) \
the macro formatting probably changed; fix `command_names`",
handler.len(),
collected.len()
);
let mut uncollected: Vec<&str> =
handler.iter().filter(|c| !collected.contains(*c)).map(String::as_str).collect();
uncollected.sort_unstable();
let unexplained: Vec<&str> =
uncollected.iter().copied().filter(|c| !UNTYPEABLE.contains(c)).collect();
assert!(
unexplained.is_empty(),
"these commands are in generate_handler! but neither collected into \
collect_commands! nor listed in UNTYPEABLE annotate them with \
#[specta::specta] + add to collect_commands!, or add them to \
UNTYPEABLE with the reason: {unexplained:?}"
);
let stale: Vec<&str> =
UNTYPEABLE.iter().copied().filter(|c| !uncollected.contains(c)).collect();
assert!(
stale.is_empty(),
"these commands are in the UNTYPEABLE allowlist but are no longer \
uncollected (they got collected, or left generate_handler!) remove \
them from UNTYPEABLE: {stale:?}"
);
}
/// Extract the last `::`-segment identifier of every entry inside a
/// `<macro>![ ... ]` invocation. Mirrors the Python worklist parser: one
/// command per line, skips `//` comments and `#[..]` attribute lines. Picks
/// the real invocation (a `[` follows the macro name), not a doc-comment
/// mention of the macro.
fn command_names(src: &str, macro_call: &str) -> std::collections::HashSet<String> {
let mut out = std::collections::HashSet::new();
let open = src.match_indices(macro_call).find_map(|(idx, _)| {
let rest = &src[idx + macro_call.len()..];
let trimmed = rest.trim_start();
trimmed
.starts_with('[')
.then(|| idx + macro_call.len() + (rest.len() - trimmed.len()))
});
let Some(open) = open else {
return out;
};
let bytes = src.as_bytes();
let (mut i, mut depth, mut close) = (open, 0i32, open);
while i < bytes.len() {
match bytes[i] {
b'[' => depth += 1,
b']' => {
depth -= 1;
if depth == 0 {
close = i;
break;
}
}
_ => {}
}
i += 1;
}
for raw in src[open + 1..close].lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with("//") || line.starts_with('#') {
continue;
}
let code = line.split("//").next().unwrap_or("").trim().trim_end_matches(',');
let seg = code.rsplit("::").next().unwrap_or("").trim();
let ok = seg.starts_with(|c: char| c.is_ascii_lowercase())
&& seg.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
if ok {
out.insert(seg.to_string());
}
}
out
}
// ── Reverse anti-drift guard ────────────────────────────────────────────
// The forward guard above catches a command that lands in `generate_handler!`
// without being collected. It cannot see a command that leaves the handler
// ENTIRELY — exactly how `download_track_local` silently became unregistered
// (offline caching went dead) when the specta annotation pass dropped it from
// `generate_handler!` and its >10-arg signature kept it out of
// `collect_commands!` too, so it fell through both lists. This test scans every
// `#[tauri::command]` fn in the workspace sources and asserts each is wired into
// `generate_handler!`, so a command can never fall out of the live IPC surface
// unnoticed.
#[test]
fn every_command_is_registered_in_the_handler() {
// Commands intentionally NOT in `generate_handler!` (invoked in-process,
// never over IPC). Empty by default — add one only with the reason it is
// internal-only; the stale-check below drops any entry that got registered.
const HANDLER_EXEMPT: &[&str] = &[];
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut command_fns = Vec::new();
for root in ["src", "crates"] {
collect_command_fns(&manifest.join(root), &mut command_fns);
}
assert!(
command_fns.len() > 200,
"command-fn scanner found too few #[tauri::command] fns ({}) — the \
attribute/fn layout probably changed; fix `collect_command_fns`",
command_fns.len()
);
// This guard compares handler↔command by BARE fn name, so two commands
// sharing a bare name across crates would be indistinguishable — one could
// drop out of the handler while its namesake keeps the check green. Fail
// loudly if that ever happens rather than silently trusting the name.
let mut counts = std::collections::HashMap::new();
for name in &command_fns {
*counts.entry(name.as_str()).or_insert(0) += 1;
}
let mut collisions: Vec<&str> =
counts.iter().filter(|(_, n)| **n > 1).map(|(name, _)| *name).collect();
collisions.sort_unstable();
assert!(
collisions.is_empty(),
"two #[tauri::command] fns share a bare name {collisions:?} — this guard \
can't tell them apart; disambiguate before the name-based check is trusted"
);
let handler = command_names(include_str!("lib.rs"), "generate_handler!");
let mut missing: Vec<&str> = command_fns
.iter()
.map(String::as_str)
.filter(|c| !handler.contains(*c) && !HANDLER_EXEMPT.contains(c))
.collect();
missing.sort_unstable();
missing.dedup();
assert!(
missing.is_empty(),
"these #[tauri::command] fns are defined but NOT registered in \
generate_handler! they are dead over IPC (add them to the handler, \
or to HANDLER_EXEMPT if intentionally internal-only): {missing:?}"
);
let stale: Vec<&str> = HANDLER_EXEMPT
.iter()
.copied()
.filter(|c| command_fns.iter().any(|f| f == c) && handler.contains(*c))
.collect();
assert!(
stale.is_empty(),
"these commands are in HANDLER_EXEMPT but are actually registered in \
generate_handler! remove them from HANDLER_EXEMPT: {stale:?}"
);
}
/// Recursively collect the name of every `#[tauri::command]` fn under `dir`.
/// Matches only a real attribute line (`#[tauri::command...`), never a
/// doc-comment *mention* of the macro (those start with `///`), then reads the
/// fn name from the next `fn` declaration, skipping intervening `#[..]`
/// attribute lines.
fn collect_command_fns(dir: &std::path::Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if path.file_name().and_then(|n| n.to_str()) == Some("target") {
continue;
}
collect_command_fns(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
let Ok(src) = std::fs::read_to_string(&path) else {
continue;
};
let lines: Vec<&str> = src.lines().collect();
for (i, raw) in lines.iter().enumerate() {
if !raw.trim_start().starts_with("#[tauri::command") {
continue;
}
for line in &lines[i + 1..] {
let t = line.trim_start();
if t.starts_with("#[") || t.starts_with("//") || t.is_empty() {
continue;
}
if let Some(name) = fn_name(t) {
out.push(name);
break;
}
// A non-attribute, non-comment, non-blank line that is not a
// fn decl is an attribute *continuation* (a multi-line
// `#[tauri::command(\n rename_all = ...\n)]`) — keep scanning
// to the fn instead of giving up and dropping the command.
}
}
}
}
}
/// Extract `name` from a `... fn name(...)` declaration line.
fn fn_name(line: &str) -> Option<String> {
let after = line.split(" fn ").nth(1).or_else(|| line.strip_prefix("fn "))?;
let name: String = after
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
(!name.is_empty()).then_some(name)
}
}
@@ -21,6 +21,7 @@ struct FullBackupPayload {
}
#[tauri::command]
#[specta::specta]
pub(crate) async fn backup_export_library_db(
app: AppHandle,
destination_path: String,
@@ -64,6 +65,7 @@ fn backup_export_library_db_blocking(app: &AppHandle, destination_path: String)
}
#[tauri::command]
#[specta::specta]
pub(crate) async fn backup_import_library_db(app: AppHandle, source_path: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
backup_import_library_db_blocking(&app, source_path)
+9 -2
View File
@@ -11,6 +11,7 @@ pub(crate) fn greet(name: &str) -> String {
}
#[tauri::command]
#[specta::specta]
pub(crate) fn exit_app(app_handle: tauri::AppHandle) {
if let Some(cache) = app_handle.try_state::<analysis_cache::AnalysisCache>() {
let _ = cache.checkpoint_wal("exit");
@@ -21,28 +22,31 @@ pub(crate) fn exit_app(app_handle: tauri::AppHandle) {
#[tauri::command]
#[specta::specta]
pub(crate) fn set_logging_mode(mode: String) -> Result<(), String> {
crate::logging::set_logging_mode_from_str(&mode)
}
#[tauri::command]
#[specta::specta]
pub(crate) fn get_logging_mode() -> String {
crate::logging::current_mode_str().to_string()
}
#[tauri::command]
#[specta::specta]
pub(crate) fn export_runtime_logs(path: String) -> Result<usize, String> {
crate::logging::export_logs_to_file(&path)
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LogLineDto {
pub seq: u64,
pub text: String,
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LogTailDto {
pub lines: Vec<LogLineDto>,
@@ -54,6 +58,7 @@ pub(crate) struct LogTailDto {
/// `after_seq` is the highest seq the UI already has (omit for
/// the initial fetch of the most recent `max` lines).
#[tauri::command]
#[specta::specta]
pub(crate) fn tail_runtime_logs(after_seq: Option<u64>, max: Option<usize>) -> LogTailDto {
let tail = crate::logging::tail_logs(after_seq, max.unwrap_or(2000));
LogTailDto {
@@ -68,12 +73,14 @@ pub(crate) fn tail_runtime_logs(after_seq: Option<u64>, max: Option<usize>) -> L
}
#[tauri::command]
#[specta::specta]
pub(crate) fn frontend_debug_log(scope: String, message: String) -> Result<(), String> {
crate::app_deprintln!("[frontend][{}] {}", scope, message);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub(crate) fn set_subsonic_wire_user_agent(
user_agent: String,
window_label: String,
@@ -4,6 +4,7 @@ use tauri::Emitter;
use crate::{MprisControls, ShortcutMap};
#[tauri::command]
#[specta::specta]
pub(crate) fn register_global_shortcut(
app: tauri::AppHandle,
shortcut_map: tauri::State<ShortcutMap>,
@@ -49,6 +50,7 @@ pub(crate) fn register_global_shortcut(
}
#[tauri::command]
#[specta::specta]
pub(crate) fn unregister_global_shortcut(
app: tauri::AppHandle,
shortcut_map: tauri::State<ShortcutMap>,
@@ -70,6 +72,7 @@ pub(crate) fn unregister_global_shortcut(
}
#[tauri::command]
#[specta::specta]
pub(crate) fn mpris_set_metadata(
controls: tauri::State<MprisControls>,
title: Option<String>,
@@ -146,6 +149,7 @@ fn webp_file_to_temp_png(webp_path: &str) -> Result<String, String> {
}
#[tauri::command]
#[specta::specta]
pub(crate) fn mpris_set_playback(
controls: tauri::State<MprisControls>,
playing: bool,
@@ -168,6 +172,7 @@ pub(crate) fn mpris_set_playback(
/// Returns true if `path` is an accessible directory (used for pre-flight checks in the frontend).
#[tauri::command]
#[specta::specta]
pub(crate) fn check_dir_accessible(path: String) -> bool {
std::path::Path::new(&path).is_dir()
}
@@ -27,14 +27,14 @@ fn migration_lock() -> &'static Mutex<()> {
LOCK.get_or_init(|| Mutex::new(()))
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ServerIndexMapping {
pub legacy_id: String,
pub index_key: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct MigrationScopeInspect {
pub total_legacy_rows: u64,
@@ -42,7 +42,7 @@ pub struct MigrationScopeInspect {
pub tables: HashMap<String, u64>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct MigrationInspectReport {
pub needs_migration: bool,
@@ -64,7 +64,7 @@ pub struct MigrationProgressEvent {
pub total: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct MigrationRunScope {
pub imported_rows: u64,
@@ -72,7 +72,7 @@ pub struct MigrationRunScope {
pub skipped_unknown_server_rows: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct MigrationRunResult {
pub library: MigrationRunScope,
@@ -83,6 +83,7 @@ pub struct MigrationRunResult {
}
#[tauri::command]
#[specta::specta]
pub fn migration_inspect(
app: AppHandle,
mappings: Vec<ServerIndexMapping>,
@@ -91,6 +92,7 @@ pub fn migration_inspect(
}
#[tauri::command]
#[specta::specta]
pub fn migration_run(
app: AppHandle,
mappings: Vec<ServerIndexMapping>,
+5 -5
View File
@@ -1,12 +1,12 @@
mod backup;
pub(crate) mod backup;
mod cli_bridge;
// `pub(crate)` so tauri-specta's `collect_commands!` can reach the `#[specta::specta]`
// helper macro by full path (a `pub use` of the fn does not carry the macro).
pub(crate) mod core;
mod integration;
mod migration;
mod network;
mod perf;
pub(crate) mod integration;
pub(crate) mod migration;
pub(crate) mod network;
pub(crate) mod perf;
pub(crate) mod platform;
// Tauri commands re-exported for the lib.rs invoke_handler.
@@ -21,6 +21,7 @@ use tokio::net::lookup_host;
/// Returns an empty vec on lookup failure (the UI then shows no hint, by
/// design: a transient DNS hiccup shouldn't block save).
#[tauri::command]
#[specta::specta]
pub(crate) async fn resolve_host_addresses(hostname: String) -> Result<Vec<String>, String> {
let trimmed = hostname.trim();
if trimmed.is_empty() {
@@ -64,6 +65,7 @@ pub(crate) async fn resolve_host_addresses(hostname: String) -> Result<Vec<Strin
}
#[tauri::command]
#[specta::specta]
pub(crate) fn server_http_context_sync(
registry: State<'_, Arc<ServerHttpRegistry>>,
wire: ServerHttpContextSyncWire,
@@ -73,6 +75,7 @@ pub(crate) fn server_http_context_sync(
}
#[tauri::command]
#[specta::specta]
pub(crate) fn server_http_context_sync_all(
registry: State<'_, Arc<ServerHttpRegistry>>,
entries: Vec<ServerHttpContextSyncWire>,
@@ -82,6 +85,7 @@ pub(crate) fn server_http_context_sync_all(
}
#[tauri::command]
#[specta::specta]
pub(crate) fn server_http_context_clear(
registry: State<'_, Arc<ServerHttpRegistry>>,
server_id: String,
+4 -3
View File
@@ -14,20 +14,20 @@ use std::fs;
#[cfg(any(target_os = "linux", target_os = "macos"))]
const CHILD_RESCAN_EVERY: u8 = 8;
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
pub(crate) struct PerfProcessMemory {
pub label: String,
pub rss_kb: u64,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
pub(crate) struct PerfThreadCpuGroup {
pub label: String,
pub thread_count: u32,
pub jiffies: u64,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, specta::Type)]
pub(crate) struct PerformanceCpuSnapshot {
pub supported: bool,
pub total_jiffies: u64,
@@ -503,6 +503,7 @@ mod macos {
}
#[tauri::command]
#[specta::specta]
pub(crate) async fn performance_cpu_snapshot(
include_thread_groups: Option<bool>,
) -> Result<PerformanceCpuSnapshot, String> {
@@ -166,6 +166,7 @@ pub(crate) fn linux_webkit_disable_media_session(win: &tauri::WebviewWindow) ->
/// compositing forced off. The frontend warns on animated themes when true.
/// Always false off Linux.
#[tauri::command]
#[specta::specta]
pub(crate) fn theme_animation_risk() -> bool {
#[cfg(target_os = "linux")]
{
@@ -185,6 +186,7 @@ pub(crate) fn theme_animation_risk() -> bool {
}
#[tauri::command]
#[specta::specta]
pub(crate) fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
if let Some(win) = app_handle.get_webview_window("main") {
let _ = win.set_decorations(enabled);
@@ -210,6 +212,7 @@ pub(crate) fn linux_webkit_apply_smooth_scrolling(win: &tauri::WebviewWindow, en
/// Called from the frontend settings toggle (Linux); no-op on other platforms.
#[tauri::command]
#[specta::specta]
pub(crate) fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
@@ -232,6 +235,7 @@ pub(crate) fn set_linux_webkit_smooth_scrolling(enabled: bool, app_handle: tauri
/// True when [`linux_webkit_apply_wayland_gpu_font_tuning`] would change WebKit settings
/// (Wayland + GPU compositing, user has not set `PSYSONIC_SKIP_WAYLAND_FONT_TUNING`).
#[tauri::command]
#[specta::specta]
pub(crate) fn linux_wayland_gpu_font_tuning_active() -> bool {
#[cfg(target_os = "linux")]
{
@@ -284,6 +288,7 @@ pub(crate) fn linux_webkit_apply_wayland_text_render_profile(
/// Persist the Wayland text profile for the next app start and for new mini-player webviews.
/// Does **not** touch WebKit on existing windows (avoids WebKitGTK hangs when toggling policy live).
#[tauri::command]
#[specta::specta]
pub(crate) fn set_linux_wayland_text_render_profile(
profile: String,
app_handle: tauri::AppHandle,
@@ -303,6 +308,7 @@ pub(crate) fn set_linux_wayland_text_render_profile(
}
#[tauri::command]
#[specta::specta]
pub(crate) fn linux_wayland_text_render_settings_available() -> bool {
#[cfg(target_os = "linux")]
{
+1 -1
View File
@@ -1,4 +1,4 @@
mod tray;
pub(crate) mod tray;
pub(crate) use tray::{
is_tiling_wm_cmd, linux_xdg_session_type, no_compositing_mode, set_tray_menu_labels, set_tray_tooltip,
+6
View File
@@ -259,6 +259,7 @@ pub(crate) fn try_build_tray_icon(app: &tauri::AppHandle) -> Option<TrayIcon> {
/// StatusNotifierItem-aware panels (KDE, Cinnamon, GNOME with AppIndicator
/// extension) show it; pure-GNOME without the extension does not.
#[tauri::command]
#[specta::specta]
pub(crate) fn set_tray_tooltip(
app: tauri::AppHandle,
tray_state: tauri::State<TrayState>,
@@ -323,6 +324,7 @@ pub(crate) fn set_tray_tooltip(
/// immediately to live menu items via `set_text` (no tray rebuild required)
/// and cached so the labels survive a tray hide/show cycle.
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub(crate) fn set_tray_menu_labels(
app: tauri::AppHandle,
@@ -385,6 +387,7 @@ pub(crate) fn set_tray_menu_labels(
/// process the OS removal asynchronously — hiding first prevents a brief "ghost"
/// icon from appearing alongside a freshly created one.
#[tauri::command]
#[specta::specta]
pub(crate) fn toggle_tray_icon(
app: tauri::AppHandle,
tray_state: tauri::State<TrayState>,
@@ -453,6 +456,7 @@ pub(crate) fn is_tiling_wm() -> bool {
/// The frontend uses this to apply a CSS class that swaps out GPU-only effects
/// (backdrop-filter, CSS filter, mask-image) for software-friendly equivalents.
#[tauri::command]
#[specta::specta]
pub(crate) fn no_compositing_mode() -> bool {
std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE")
.map(|v| v == "1")
@@ -462,6 +466,7 @@ pub(crate) fn no_compositing_mode() -> bool {
/// Tauri command: `XDG_SESSION_TYPE` from the host environment (e.g. `wayland`, `x11`).
/// Used for Linux-only UI tweaks such as font rasterisation hints; empty string when unset.
#[tauri::command]
#[specta::specta]
pub(crate) fn linux_xdg_session_type() -> String {
#[cfg(target_os = "linux")]
{
@@ -481,6 +486,7 @@ pub(crate) fn is_tiling_wm() -> bool {
/// Tauri command: lets the frontend know whether we're running under a tiling
/// WM so it can decide whether to render the custom TitleBar component.
#[tauri::command]
#[specta::specta]
pub(crate) fn is_tiling_wm_cmd() -> bool {
is_tiling_wm()
}
+8
View File
@@ -271,6 +271,7 @@ pub(crate) fn build_mini_player_window(
/// workaround; this command is used by Linux/macOS when the user opts into
/// the `preloadMiniPlayer` setting. Idempotent — no-op if the window exists.
#[tauri::command]
#[specta::specta]
pub(crate) fn preload_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if app.get_webview_window("mini").is_some() {
return Ok(());
@@ -284,6 +285,7 @@ pub(crate) fn preload_mini_player(app: tauri::AppHandle) -> Result<(), String> {
/// Opening the mini player minimizes the main window; hiding the mini
/// player restores the main window.
#[tauri::command]
#[specta::specta]
pub(crate) fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
let win = match app.get_webview_window("mini") {
Some(w) => w,
@@ -332,6 +334,7 @@ pub(crate) fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
/// Hide the mini player window if it exists and restore the main window.
/// Does not destroy the mini window so its state is preserved for next open.
#[tauri::command]
#[specta::specta]
pub(crate) fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") {
let _ = win.eval(PAUSE_RENDERING_JS);
@@ -350,6 +353,7 @@ pub(crate) fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> {
/// window's JS is paused while minimized on WebKitGTK. Also hides the mini
/// window so the two don't sit on screen at the same time.
#[tauri::command]
#[specta::specta]
pub(crate) fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(mini) = app.get_webview_window("mini") {
let _ = mini.eval(PAUSE_RENDERING_JS);
@@ -366,6 +370,7 @@ pub(crate) fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
/// Inject the pause script into this webview (CSS @keyframes pause + `__psyHidden`).
#[tauri::command]
#[specta::specta]
pub(crate) fn pause_rendering(window: tauri::WebviewWindow) -> Result<(), String> {
window.eval(PAUSE_RENDERING_JS).map_err(|e| e.to_string())
}
@@ -373,6 +378,7 @@ pub(crate) fn pause_rendering(window: tauri::WebviewWindow) -> Result<(), String
/// Resume rendering work in the current webview. Called when the window
/// becomes visible again.
#[tauri::command]
#[specta::specta]
pub(crate) fn resume_rendering(window: tauri::WebviewWindow) -> Result<(), String> {
window.eval(RESUME_RENDERING_JS).map_err(|e| e.to_string())
}
@@ -385,6 +391,7 @@ pub(crate) fn resume_rendering(window: tauri::WebviewWindow) -> Result<(), Strin
/// re-shown, or focus was lost and the WM dropped the constraint. We
/// always force a `false → true` cycle so the WM re-evaluates the layer.
#[tauri::command]
#[specta::specta]
pub(crate) fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") {
if on_top {
@@ -401,6 +408,7 @@ pub(crate) fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool)
/// can't shrink past the layout's minimum (e.g. 2 visible queue rows when
/// the queue panel is open).
#[tauri::command]
#[specta::specta]
pub(crate) fn resize_mini_player(
app: tauri::AppHandle,
width: f64,
+1 -1
View File
@@ -1,4 +1,4 @@
mod mini;
pub(crate) mod mini;
pub(crate) use mini::{
close_mini_player, open_mini_player, pause_rendering, persist_mini_pos_throttled,
@@ -20,6 +20,7 @@ pub fn init_library_analysis_backfill(app: &AppHandle) -> Result<(), String> {
}
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)] // Tauri command surface — args map 1:1 to the JS call (like cover configure + workers).
pub async fn library_analysis_backfill_configure(
app: AppHandle,
+2 -1
View File
@@ -21,13 +21,14 @@ const MAX_ARCHIVE_BYTES: u64 = 4 * 1024 * 1024;
const MAX_MANIFEST_BYTES: usize = 64 * 1024;
const MAX_CSS_BYTES: usize = 256 * 1024;
#[derive(Serialize)]
#[derive(Serialize, specta::Type)]
pub struct ImportedThemeFiles {
pub manifest: String,
pub css: String,
}
#[tauri::command]
#[specta::specta]
pub fn import_theme_zip(path: String) -> Result<ImportedThemeFiles, String> {
let file = std::fs::File::open(&path).map_err(|e| format!("cannot open file: {e}"))?;
let len = file
+3 -3
View File
@@ -2,7 +2,7 @@ import { initAudioListeners } from '@/features/playback/store/initAudioListeners
import '@/features/playback/store/playbackEngineBridgeRegister'; // installs the playback-engine bridge at boot
import { lazy, Suspense, useEffect, useState } from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core';
import { preloadMiniPlayer as preloadMiniPlayerWindow } from '@/lib/api/miniPlayer';
import { showToast } from '@/lib/dom/toast';
import { WindowVisibilityProvider } from '@/lib/hooks/useWindowVisibility';
import { DragDropProvider } from '@/lib/dnd/DragDropContext';
@@ -93,7 +93,7 @@ export default function MainApp() {
const preloadMiniPlayer = useAuthStore(s => s.preloadMiniPlayer);
useEffect(() => {
if (!migrationReady || IS_WINDOWS || !preloadMiniPlayer) return;
invoke('preload_mini_player').catch(() => {});
preloadMiniPlayerWindow().catch(() => {});
}, [preloadMiniPlayer, migrationReady]);
useEffect(() => {
@@ -156,7 +156,7 @@ export default function MainApp() {
showToast('📭 Keine Alben in diesem Zeitraum gefunden');
}
} catch (err) {
showToast(`❌ Export fehlgeschlagen: ${String(err).slice(0, 80)}`);
showToast(`❌ Export fehlgeschlagen: ${(err instanceof Error ? err.message : String(err)).slice(0, 80)}`);
console.error('[easter egg] export failed:', err);
}
};
+3 -3
View File
@@ -1,7 +1,7 @@
import { installQueueUndoHotkey } from '@/features/playback/store/queueUndoHotkey';
import { configureStartupSplash } from './startupSplash';
import { setupMusicNetworkRuntime } from './musicNetworkBridge';
import { invoke } from '@tauri-apps/api/core';
import { setLoggingMode, setSubsonicWireUserAgent } from '@/lib/api/platformShell';
import { getWindowKind } from './windowKind';
import { migrateThemeSelection } from '@/lib/themes/themeMigration';
import { getScheduledTheme, useThemeStore } from '../store/themeStore';
@@ -14,7 +14,7 @@ export function pushUserAgentToBackend(): void {
if (getWindowKind() !== 'main') return;
const ua = window.navigator.userAgent?.trim();
if (ua) {
void invoke('set_subsonic_wire_user_agent', { userAgent: ua, windowLabel: 'main' });
void setSubsonicWireUserAgent({ userAgent: ua, windowLabel: 'main' }).catch(() => {});
}
} catch {
// Ignore in non-Tauri runtimes.
@@ -34,7 +34,7 @@ export function pushLoggingModeToBackend(): void {
const parsed = JSON.parse(raw) as { state?: { loggingMode?: string } };
const mode = parsed.state?.loggingMode;
if (mode === 'off' || mode === 'normal' || mode === 'debug') {
void invoke('set_logging_mode', { mode });
void setLoggingMode({ mode }).catch(() => {});
}
} catch {
// Ignore parse / non-Tauri.
+2 -2
View File
@@ -138,7 +138,7 @@ async function runOrchestrator(force = false): Promise<void> {
})()
.catch((error: unknown) => {
if (!(error instanceof Error && error.message === 'genre_tags_incomplete')) {
useMigrationStore.getState().setError(String(error));
useMigrationStore.getState().setError(error instanceof Error ? error.message : String(error));
}
useMigrationStore.getState().setPhase('error');
})
@@ -166,7 +166,7 @@ export function retryGenreTagsMigration(): void {
state.setPhase('completed');
} catch (error: unknown) {
if (!(error instanceof Error && error.message === 'genre_tags_incomplete')) {
state.setError(String(error));
state.setError(error instanceof Error ? error.message : String(error));
}
state.setPhase('error');
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { setTrayTooltip } from '@/lib/api/tray';
import { getCurrentWindow } from '@tauri-apps/api/window';
import type { Track } from '@/lib/media/trackTypes';
@@ -22,14 +22,14 @@ export function useNowPlayingTrayTitle(currentTrack: Track | null, isPlaying: bo
const title = `${state} ${currentTrack.artist} - ${currentTrack.title} | Psysonic`;
document.title = title;
await appWindow.setTitle(title);
await invoke('set_tray_tooltip', {
await setTrayTooltip({
tooltip: `${currentTrack.artist} ${currentTrack.title}`,
playbackState: isPlaying ? 'play' : 'pause',
}).catch(() => {});
} else {
document.title = 'Psysonic';
await appWindow.setTitle('Psysonic');
await invoke('set_tray_tooltip', {
await setTrayTooltip({
tooltip: '',
playbackState: 'stop',
}).catch(() => {});
+17 -9
View File
@@ -1,5 +1,13 @@
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
isTilingWmCmd,
linuxWaylandTextRenderSettingsAvailable,
noCompositingMode,
setLinuxWaylandTextRenderProfile,
setLinuxWebkitSmoothScrolling,
setLoggingMode,
setWindowDecorations,
} from '@/lib/api/platformShell';
import { useAuthStore } from '@/store/authStore';
import type { LinuxWaylandTextRenderProfile } from '@/store/authStoreTypes';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '@/lib/util/platform';
@@ -21,19 +29,19 @@ export function usePlatformShellSetup(): { isTilingWm: boolean } {
useEffect(() => {
if (!IS_LINUX) return;
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
isTilingWmCmd().then(setIsTilingWm).catch(() => {});
}, []);
useEffect(() => {
if (!IS_LINUX) return;
invoke<boolean>('no_compositing_mode').then(noComp => {
noCompositingMode().then(noComp => {
if (noComp) document.documentElement.classList.add('no-compositing');
}).catch(() => {});
}, []);
useEffect(() => {
if (!IS_LINUX) return;
invoke<boolean>('linux_wayland_text_render_settings_available')
linuxWaylandTextRenderSettingsAvailable()
.then(av => {
setWaylandTextUi(av);
if (av) {
@@ -63,7 +71,7 @@ export function usePlatformShellSetup(): { isTilingWm: boolean } {
const apply = (profile: LinuxWaylandTextRenderProfile) => {
document.documentElement.setAttribute('data-wayland-text-profile', profile);
invoke('set_linux_wayland_text_render_profile', { profile }).catch(() => {});
setLinuxWaylandTextRenderProfile({ profile }).catch(() => {});
};
apply(linuxWaylandTextRenderProfile);
@@ -84,12 +92,12 @@ export function usePlatformShellSetup(): { isTilingWm: boolean } {
useEffect(() => {
if (!IS_LINUX) return;
const enabled = isTilingWm ? false : !useCustomTitlebar;
invoke('set_window_decorations', { enabled }).catch(() => {});
setWindowDecorations({ enabled }).catch(() => {});
}, [useCustomTitlebar, isTilingWm]);
useEffect(() => {
if (!IS_LINUX) return;
invoke('set_linux_webkit_smooth_scrolling', { enabled: linuxWebkitKineticScroll }).catch(() => {});
setLinuxWebkitSmoothScrolling({ enabled: linuxWebkitKineticScroll }).catch(() => {});
}, [linuxWebkitKineticScroll]);
// Persist rehydrates after first paint — default store has kinetic scroll ON until localStorage merges.
@@ -97,7 +105,7 @@ export function usePlatformShellSetup(): { isTilingWm: boolean } {
useEffect(() => {
if (!IS_LINUX) return;
const applySmoothFromStore = () => {
invoke('set_linux_webkit_smooth_scrolling', {
setLinuxWebkitSmoothScrolling({
enabled: useAuthStore.getState().linuxWebkitKineticScroll,
}).catch(() => {});
};
@@ -110,7 +118,7 @@ export function usePlatformShellSetup(): { isTilingWm: boolean } {
}, []);
useEffect(() => {
invoke('set_logging_mode', { mode: loggingMode }).catch(() => {});
setLoggingMode({ mode: loggingMode }).catch(() => {});
}, [loggingMode]);
return { isTilingWm };
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { setTrayMenuLabels } from '@/lib/api/tray';
import { useTranslation } from 'react-i18next';
/**
@@ -11,7 +11,7 @@ export function useTrayMenuI18n(): void {
const { t, i18n } = useTranslation();
useEffect(() => {
const apply = () => {
invoke('set_tray_menu_labels', {
setTrayMenuLabels({
playPause: t('tray.playPause'),
next: t('tray.nextTrack'),
previous: t('tray.previousTrack'),
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { audioSetDevice } from '@/lib/api/audio';
import type { NavigateFunction } from 'react-router-dom';
import { getSimilarSongs } from '@/lib/api/subsonicArtists';
import { getMusicFolders } from '@/lib/api/subsonicLibrary';
@@ -26,7 +27,7 @@ export function useCliBridge(navigate: NavigateFunction) {
const raw = typeof e.payload === 'string' ? e.payload : '';
const deviceName = raw.length > 0 ? raw : null;
try {
await invoke('audio_set_device', { deviceName });
await audioSetDevice({ deviceName });
useAuthStore.getState().setAudioOutputDevice(deviceName);
} catch {
/* device open failed — do not persist (same as Settings) */
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { exitApp, pauseRendering } from '@/lib/api/platformShell';
import type { NavigateFunction } from 'react-router-dom';
import { flushPlayQueuePosition } from '@/features/playback/store/queueSync';
import { playListenSessionFinalize } from '@/features/playback/store/playListenSession';
@@ -130,14 +130,14 @@ export function useMediaAndWindowBridge(navigate: NavigateFunction) {
new Promise(r => setTimeout(r, 1500)),
]);
}
await invoke('exit_app');
await exitApp();
};
// window:close-requested is emitted by Rust (prevent_close + emit) on
// the X-button. JS decides: minimize to tray or exit.
const u = await listen('window:close-requested', async () => {
if (useAuthStore.getState().minimizeToTray) {
await invoke('pause_rendering').catch(() => {});
await pauseRendering().catch(() => {});
await getCurrentWindow().hide();
} else {
await performExit();
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { toggleTrayIcon } from '@/lib/api/tray';
import { useAuthStore } from '@/store/authStore';
/** Sync tray-icon visibility with the user's stored setting. Runs once on mount
@@ -7,6 +7,6 @@ import { useAuthStore } from '@/store/authStore';
export function useTrayIconSync() {
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
useEffect(() => {
invoke('toggle_tray_icon', { show: showTrayIcon }).catch(console.error);
toggleTrayIcon({ show: showTrayIcon }).catch(console.error);
}, [showTrayIcon]);
}
+1
View File
@@ -396,6 +396,7 @@ const CONTRIBUTOR_ENTRIES = [
'Configurable artist backdrops — per-place source order + on/off (mainstage hero, artist page, fullscreen), with the mainstage hero showing the artist backdrop and prefetching upcoming ones (PR #1193)',
'Themed window title bar on macOS — follows the active theme instead of the grey system bar, with the native window buttons floating over it (PR #1199)',
'Frontend feature-folder restructure — CI-enforced layering guard, added unit/behavior-scenario/boot-smoke tests, and a compile-time frontend/backend IPC contract via tauri-specta (PR #1225)',
'Completed the tauri-specta typed-IPC cutover — CI guards for bindings freshness and full command registration (PR #1230)',
],
},
{
+4 -3
View File
@@ -1,7 +1,8 @@
import { queueSongStar } from '@/features/playback/store/pendingStarSync';
import { getSong } from '@/lib/api/subsonicLibrary';
import { songToTrack } from '@/lib/media/songToTrack';
import { invoke } from '@tauri-apps/api/core';
import { openMiniPlayer } from '@/lib/api/miniPlayer';
import { audioStop } from '@/lib/api/audio';
import { getCurrentWindow } from '@tauri-apps/api/window';
import i18n from '@/lib/i18n';
import { usePlayerStore } from '@/features/playback/store/playerStore';
@@ -170,7 +171,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
inApp: { defaultBinding: null },
runInMiniWindow: true,
run: () => {
invoke('open_mini_player').catch(() => {});
openMiniPlayer().catch(() => {});
},
},
'start-search': {
@@ -334,7 +335,7 @@ export const SHORTCUT_ACTION_REGISTRY = {
const { currentTrack, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store;
stop();
resetAudioPause();
invoke('audio_stop')
audioStop()
.catch(() => {})
.then(async () => {
if (currentTrack) {
+4 -6
View File
@@ -3,7 +3,7 @@
* when the album/artist/track row exists in SQLite.
*/
import { invoke } from '@tauri-apps/api/core';
import { commands } from '@/generated/bindings';
import { librarySqlServerId } from '@/lib/api/coverCache';
import { useAuthStore } from '../store/authStore';
import { COVER_SCOPE_ACTIVE, type CoverArtRef, CoverCacheKind, CoverServerScope } from './types';
@@ -101,11 +101,9 @@ export async function libraryResolveCoverEntry(
const promise = runLibraryResolveLimited(async () => {
try {
const dto = await invoke<LibraryCoverEntryDto | null>('library_resolve_cover_entry', {
serverId: librarySqlServerId(serverId),
entity,
entityId: id,
});
const res = await commands.libraryResolveCoverEntry(librarySqlServerId(serverId), entity, id);
if (res.status === 'error') throw new Error(res.error);
const dto = res.data as LibraryCoverEntryDto | null;
const entry = dto ? dtoToEntry(dto) : null;
resolvedEntryCache.set(key, entry);
trimResolvedEntryCache();
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { commands } from '@/generated/bindings';
const EVICT_TICK_MS = 45_000;
@@ -8,7 +8,7 @@ export function useCoverCacheEvictTick(enabled = true): void {
useEffect(() => {
if (!enabled) return;
const tick = () => {
void invoke<number>('cover_cache_evict_tick', {}).catch(() => {});
void commands.coverCacheEvictTick().catch(() => {});
};
tick();
const id = window.setInterval(tick, EVICT_TICK_MS);
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { commands } from '@/generated/bindings';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
@@ -15,7 +15,7 @@ export function useCoverRevalidateScheduler(enabled = true): void {
let cancelled = false;
const tick = () => {
if (cancelled || usePlayerStore.getState().isPlaying) return;
void invoke<number>('cover_revalidate_tick', { cycleDays }).catch(() => {});
void commands.coverRevalidateTick(cycleDays).catch(() => {});
};
tick();
const id = window.setInterval(tick, TICK_MS);
+1 -1
View File
@@ -15,7 +15,7 @@ import type { CoverPrefetchPriority } from '@/cover/types';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '@/cover/layoutSizes';
import { resolveCoverDisplayTier } from '@/cover/tiers';
import { acquireUrl } from '@/cover';
import { OpenArtistRefInline } from '@/features/artist';
import { OpenArtistRefInline } from '@/ui/OpenArtistRefInline';
import { fetchAlbumTracks, playAlbum, playAlbumShuffled } from '@/features/playback/utils/playback/playAlbum';
import { useLongPressAction } from '@/lib/hooks/useLongPressAction';
import { LongPressWaveOverlay } from '@/ui/LongPressWaveOverlay';
@@ -19,7 +19,7 @@ vi.mock('@/features/album/hooks/useAlbumDetailBack', () => ({ useAlbumDetailBack
vi.mock('@/lib/hooks/useIsMobile', () => ({ useIsMobile: () => false }));
vi.mock('@/store/themeStore', () => ({ useThemeStore: () => false }));
vi.mock('@/ui/StarRating', () => ({ default: () => null }));
vi.mock('@/features/artist', () => ({ OpenArtistRefInline: () => null }));
vi.mock('@/ui/OpenArtistRefInline', () => ({ OpenArtistRefInline: () => null }));
vi.mock('@/cover/CoverArtImage', () => ({ CoverArtImage: () => null }));
function baseProps() {
@@ -17,7 +17,7 @@ import { isAlbumRecentlyAdded } from '@/features/album/utils/albumRecency';
import { formatLongDuration } from '@/lib/format/formatDuration';
import { formatMb } from '@/lib/format/formatBytes';
import { sanitizeHtml } from '@/lib/util/sanitizeHtml';
import { OpenArtistRefInline } from '@/features/artist';
import { OpenArtistRefInline } from '@/ui/OpenArtistRefInline';
import { tooltipAttrs } from '@/ui/tooltipAttrs';
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
import { deriveAlbumGenreTags } from '@/lib/library/genreTags';
+2 -2
View File
@@ -8,7 +8,7 @@ import { songToTrack } from '@/lib/media/songToTrack';
import { shuffleArray } from '@/lib/util/shuffleArray';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core';
import { downloadZip } from '@/lib/api/downloadZip';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { useOrbitSongRowBehavior } from '@/features/orbit';
@@ -245,7 +245,7 @@ const handleShuffleAll = () => {
const { start, complete, fail } = useZipDownloadStore.getState();
start(downloadId, filename);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
await downloadZip({ id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
fail(downloadId);
+2 -2
View File
@@ -18,7 +18,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { useOfflineStore } from '@/features/offline';
import { useDownloadModalStore } from '@/features/offline';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { invoke } from '@tauri-apps/api/core';
import { downloadZip } from '@/lib/api/downloadZip';
import { join } from '@tauri-apps/api/path';
import { showToast } from '@/lib/dom/toast';
import { useZipDownloadStore } from '@/features/offline';
@@ -272,7 +272,7 @@ export default function Albums() {
const url = buildDownloadUrl(album.id);
start(downloadId, filename);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
await downloadZip({ id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
fail(downloadId);
+2 -2
View File
@@ -16,7 +16,7 @@ import { useRangeSelection } from '@/lib/hooks/useRangeSelection';
import { useMainstageInpageHeaderTight } from '@/lib/hooks/useMainstageInpageHeaderTight';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import { showToast } from '@/lib/dom/toast';
import { invoke } from '@tauri-apps/api/core';
import { downloadZip } from '@/lib/api/downloadZip';
import { join } from '@tauri-apps/api/path';
import { Download, HardDriveDownload, ListPlus } from 'lucide-react';
import SelectionToggleButton from '@/ui/SelectionToggleButton';
@@ -280,7 +280,7 @@ export default function LosslessAlbums() {
const url = buildDownloadUrl(album.id);
start(downloadId, filename);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
await downloadZip({ id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
fail(downloadId);
+2 -2
View File
@@ -14,7 +14,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/store/authStore';
import { useOfflineStore } from '@/features/offline';
import { useDownloadModalStore } from '@/features/offline';
import { invoke } from '@tauri-apps/api/core';
import { downloadZip } from '@/lib/api/downloadZip';
import { join } from '@tauri-apps/api/path';
import { showToast } from '@/lib/dom/toast';
import { useZipDownloadStore } from '@/features/offline';
@@ -148,7 +148,7 @@ export default function NewReleases() {
const url = buildDownloadUrl(album.id);
start(downloadId, filename);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
await downloadZip({ id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
fail(downloadId);
+2 -2
View File
@@ -18,7 +18,7 @@ import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/feat
import { runLocalRandomAlbums, runLocalAlbumsByGenres } from '@/lib/library/browseTextSearch';
import { useOfflineStore } from '@/features/offline';
import { useDownloadModalStore } from '@/features/offline';
import { invoke } from '@tauri-apps/api/core';
import { downloadZip } from '@/lib/api/downloadZip';
import { join } from '@tauri-apps/api/path';
import { showToast } from '@/lib/dom/toast';
import { useZipDownloadStore } from '@/features/offline';
@@ -188,7 +188,7 @@ export default function RandomAlbums() {
const url = buildDownloadUrl(album.id);
start(downloadId, filename);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
await downloadZip({ id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
fail(downloadId);
+4 -4
View File
@@ -2,9 +2,10 @@
* Artist feature the Artists overview + ArtistDetail (both lazy via their deep
* `pages/*` paths, not re-exported), artist cards/rows/avatars, the detail hero +
* similar-artists + top-tracks UI, the artist Subsonic API, browse catalog/
* filter/scroll hooks, per-artist offline status, and the open-artist-ref nav
* cluster. `sortArtistAlbums` moved in from `lib/library/` (artist-only sort,
* not the shared index query engine).
* filter/scroll hooks, and per-artist offline status. `sortArtistAlbums` moved
* in from `lib/library/` (artist-only sort, not the shared index query engine).
* `OpenArtistRefInline` moved out to `ui/` (a shared presentational primitive,
* consumed by ~10 features) to break the album artist import cycle.
*
* Stays OUT (other owners): `deriveAlbumHeaderArtistRefs` (album header),
* `playArtistShuffled` + `trackArtistRefs` (playback/track helpers), the
@@ -30,7 +31,6 @@ export * from './utils/artistsHelpers';
export * from './utils/runArtistDetailActions';
export * from './utils/runArtistDetailPlay';
export * from './utils/sortArtistAlbums';
export { OpenArtistRefInline } from './components/OpenArtistRefInline';
export { default as ArtistCardLocal } from './components/ArtistCardLocal';
export { default as ArtistDetailHero } from './components/ArtistDetailHero';
export { default as ArtistDetailSimilarArtists } from './components/ArtistDetailSimilarArtists';
@@ -1,5 +1,5 @@
import { join } from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api/core';
import { downloadZip } from '@/lib/api/downloadZip';
import { getSimilarSongs2, fetchSimilarTracksRouted, getTopSongs } from '@/lib/api/subsonicArtists';
import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback/utils/mixRatingFilter';
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
@@ -147,7 +147,7 @@ export async function downloadAlbum(albumName: string, albumId: string) {
const { start, complete, fail } = useZipDownloadStore.getState();
start(id, filename);
try {
await invoke('download_zip', { id, url, destPath });
await downloadZip({ id, url, destPath });
complete(id);
} catch (e) {
fail(id);
@@ -1,6 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { invoke } from '@tauri-apps/api/core';
import { cancelDeviceSync } from '@/lib/api/syncfs';
import {
AlertCircle, CheckCircle2, Clock, HardDriveUpload, Loader2,
Trash2, Undo2,
@@ -200,7 +200,7 @@ export default function DeviceSyncDevicePanel({
style={{ fontSize: 12, padding: '2px 10px' }}
onClick={() => {
const jobId = useDeviceSyncJobStore.getState().jobId;
if (jobId) invoke('cancel_device_sync', { jobId });
if (jobId) cancelDeviceSync({ jobId });
useDeviceSyncJobStore.getState().cancel();
}}
>
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listDeviceDirFiles } from '@/lib/api/syncfs';
import type { TFunction } from 'i18next';
import { useDeviceSyncStore, type DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
import { showToast } from '@/lib/dom/toast';
@@ -24,7 +25,7 @@ export function useDeviceSyncDeviceScan(
}
setScanning(true);
try {
const files = await invoke<string[]>('list_device_dir_files', { dir: targetDir });
const files = await listDeviceDirFiles({ dir: targetDir });
setDeviceFilePaths(files);
} catch {
setDeviceFilePaths([]);
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { getRemovableDrives } from '@/lib/api/syncfs';
import type { RemovableDrive } from '@/features/deviceSync/utils/deviceSyncHelpers';
export interface DeviceSyncDrivesResult {
@@ -17,7 +17,7 @@ export function useDeviceSyncDrives(targetDir: string | null): DeviceSyncDrivesR
const refreshDrives = useCallback(async () => {
setDrivesLoading(true);
try {
const result = await invoke<RemovableDrive[]>('get_removable_drives');
const result = await getRemovableDrives();
setDrives(result);
} catch {
setDrives([]);
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { computeSyncPaths } from '@/lib/api/syncfs';
import { fetchTracksForSource } from '@/features/playback/utils/playback/fetchTracksForSource';
import { trackToSyncInfo, type SyncStatus } from '@/features/deviceSync/utils/deviceSyncHelpers';
import type { DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
@@ -34,7 +34,7 @@ export function useDeviceSyncSourceStatuses(
if (cancelled) return;
try {
const tracks = await fetchTracksForSource(source);
const paths = await invoke<string[]>('compute_sync_paths', {
const paths = await computeSyncPaths({
tracks: tracks.map((tr, idx) => trackToSyncInfo(
tr, '',
source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined,
@@ -1,4 +1,5 @@
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import type { TrackSyncInfo } from '@/generated/bindings';
export type SourceTab = 'playlists' | 'albums' | 'artists';
@@ -6,14 +7,7 @@ export function uuid(): string { return crypto.randomUUID(); }
export type SyncStatus = 'synced' | 'pending' | 'deletion';
export interface RemovableDrive {
name: string;
mount_point: string;
available_space: number;
total_space: number;
file_system: string;
is_removable: boolean;
}
export type { RemovableDrive } from '@/generated/bindings';
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
@@ -32,7 +26,7 @@ export function trackToSyncInfo(
track: SyncTrackMaybePlaylist,
url: string,
playlistCtx?: { name: string; index: number },
) {
): TrackSyncInfo {
// Fall back to track artist when the file has no albumArtist tag — not every
// library is tagged with it. Treat empty strings as missing (some Subsonic
// servers return "" rather than omitting the field).
@@ -44,7 +38,7 @@ export function trackToSyncInfo(
albumArtist,
album: track.album ?? '',
title: track.title ?? '',
trackNumber: track.track,
trackNumber: track.track ?? null,
duration: track.duration,
playlistName: playlistCtx?.name ?? track._playlistName,
playlistIndex: playlistCtx?.index ?? track._playlistIndex,
@@ -1,5 +1,6 @@
import type { TFunction } from 'i18next';
import { invoke } from '@tauri-apps/api/core';
import { computeSyncPaths, deleteDeviceFiles, syncBatchToDevice } from '@/lib/api/syncfs';
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { useDeviceSyncStore, type DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
@@ -68,6 +69,7 @@ export interface RunDeviceSyncExecuteDeps {
export async function runDeviceSyncExecute(deps: RunDeviceSyncExecuteDeps): Promise<void> {
const { targetDir, sources, pendingDeletion, syncDelta, t, setPreSyncOpen, removeSources, scanDevice } = deps;
if (!targetDir) return;
setPreSyncOpen(false);
@@ -80,7 +82,7 @@ export async function runDeviceSyncExecute(deps: RunDeviceSyncExecuteDeps): Prom
// folder (Playlists/{Name}/…) rather than from the album tree.
for (const source of deletionSources) {
const tracks = await fetchTracksForSource(source);
const paths = await invoke<string[]>('compute_sync_paths', {
const paths = await computeSyncPaths({
tracks: tracks.map((tr, idx) => trackToSyncInfo(
tr, '',
source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined,
@@ -90,7 +92,7 @@ export async function runDeviceSyncExecute(deps: RunDeviceSyncExecuteDeps): Prom
allPaths.push(...paths);
}
await invoke<number>('delete_device_files', { paths: allPaths });
await deleteDeviceFiles({ paths: allPaths });
removeSources(deletionSources.map(s => s.id));
// Update manifest so it stays in sync after deletions
const remainingSources = useDeviceSyncStore.getState().sources;
@@ -130,16 +132,19 @@ export async function runDeviceSyncExecute(deps: RunDeviceSyncExecuteDeps): Prom
showToast(t('deviceSync.syncInBackground'), 3000, 'info');
invoke('sync_batch_to_device', {
syncBatchToDevice({
tracks: allTracks.map(track => trackToSyncInfo(track, buildDownloadUrl(track.id))),
destDir: targetDir,
jobId,
expectedBytes: syncDelta.addBytes,
}).catch((err: string) => {
}).catch((err: unknown) => {
// The typed facade rejects with an Error whose message is the raw Rust error
// string (previously invoke rejected with the bare string).
const msg = err instanceof Error ? err.message : String(err);
useDeviceSyncJobStore.getState().complete(0, 0, allTracks.length);
if (err.includes('NOT_ENOUGH_SPACE')) {
if (msg.includes('NOT_ENOUGH_SPACE')) {
showToast(t('deviceSync.notEnoughSpace'), 5000, 'error');
} else if (err === 'NOT_MOUNTED_VOLUME') {
} else if (msg === 'NOT_MOUNTED_VOLUME') {
showToast(t('deviceSync.notMountedVolume'), 5000, 'error');
} else {
showToast(t('deviceSync.fetchError'), 3000, 'error');
@@ -1,5 +1,6 @@
import type React from 'react';
import { invoke } from '@tauri-apps/api/core';
import { computeSyncPaths } from '@/lib/api/syncfs';
import type { DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { applyLegacyTemplate } from '@/features/deviceSync/utils/deviceSyncLegacyTemplate';
@@ -64,7 +65,7 @@ export async function runDeviceSyncMigrationPreview(deps: RunMigrationPreviewDep
}
// New paths via Rust (fixed album-tree schema).
const newAbsPaths = await invoke<string[]>('compute_sync_paths', {
const newAbsPaths = await computeSyncPaths({
tracks: albumSourceTracks.map(tr => trackToSyncInfo(tr, '')),
destDir: targetDir,
});
+12 -8
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { invoke } from '@tauri-apps/api/core';
import { commands } from '@/generated/bindings';
import { useEqStore } from '@/store/eqStore';
import { parseFixedBandEqString, type AutoEqVariant, type AutoEqResult } from '@/features/playback/utils/audio/autoEqParse';
@@ -26,7 +26,9 @@ export function useAutoEq() {
setEntriesLoading(true);
setAutoEqError(null);
try {
const json = await invoke<string>('autoeq_entries');
const entriesRes = await commands.autoeqEntries();
if (entriesRes.status === 'error') throw new Error(entriesRes.error);
const json = entriesRes.data;
entriesCacheRef.current = JSON.parse(json);
} catch (e: unknown) {
setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqError'));
@@ -56,12 +58,14 @@ export function useAutoEq() {
setAutoEqLoading(true);
setAutoEqError(null);
try {
const text = await invoke<string>('autoeq_fetch_profile', {
name: result.name,
source: result.source,
rig: result.rig ?? null,
form: result.form,
});
const fetchRes = await commands.autoeqFetchProfile(
result.name,
result.source,
result.rig ?? null,
result.form,
);
if (fetchRes.status === 'error') throw new Error(fetchRes.error);
const text = fetchRes.data;
if (!text) throw new Error(t('settings.eqAutoEqFetchError'));
const { gains: newGains, preamp } = parseFixedBandEqString(text);
applyAutoEq(result.name, newGains, preamp);
@@ -8,7 +8,7 @@ import { formatLastSeen } from '@/lib/format/userMgmtHelpers';
import i18n from '@/lib/i18n';
import { formatTrackTime } from '@/lib/format/formatDuration';
import StarRating from '@/ui/StarRating';
import { OpenArtistRefInline } from '@/features/artist';
import { OpenArtistRefInline } from '@/ui/OpenArtistRefInline';
import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
export interface FavoriteSongRowCallbacks {
+1 -1
View File
@@ -13,7 +13,7 @@ import { useDragDrop } from '@/lib/dnd/DragDropContext';
import { useOrbitSongRowBehavior } from '@/features/orbit';
import { useNavigateToAlbum } from '@/features/album';
import { useNavigateToArtist } from '@/features/artist';
import { OpenArtistRefInline } from '@/features/artist';
import { OpenArtistRefInline } from '@/ui/OpenArtistRefInline';
import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
interface SongCardProps {
+4 -2
View File
@@ -1,9 +1,11 @@
import { invoke } from '@tauri-apps/api/core';
import { commands } from '@/generated/bindings';
/** Fetches a synced LRC string from Netease Cloud Music via Rust proxy. Returns null if not found. */
export async function fetchNeteaselyrics(artist: string, title: string): Promise<string | null> {
try {
return await invoke<string | null>('fetch_netease_lyrics', { artist, title });
const res = await commands.fetchNeteaseLyrics(artist, title);
if (res.status === 'error') throw new Error(res.error);
return res.data;
} catch {
return null;
}
+2 -2
View File
@@ -3,7 +3,7 @@ import type { SubsonicStructuredLyrics } from '@/lib/api/subsonicTypes';
import type { Track } from '@/lib/media/trackTypes';
import { useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { invoke } from '@tauri-apps/api/core';
import { commands } from '@/generated/bindings';
import { fetchLyrics, parseLrc, LrcLine } from '@/features/lyrics/api/lrclib';
import { fetchNeteaselyrics } from '@/features/lyrics/api/netease';
import { fetchLyricsPlus, hasWordSync } from '@/features/lyrics/api/lyricsplus';
@@ -158,7 +158,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
if (!filePath) return false;
try {
const lrcString = await invoke<string | null>('get_embedded_lyrics', { path: filePath });
const lrcString = await commands.getEmbeddedLyrics(filePath);
if (!lrcString) return false;
const lines = parseLrc(lrcString);
@@ -1,6 +1,6 @@
import { emit } from '@tauri-apps/api/event';
import CachedImage from '@/ui/CachedImage';
import { OpenArtistRefInline } from '@/features/artist';
import { OpenArtistRefInline } from '@/ui/OpenArtistRefInline';
import type { MiniTrackInfo } from '@/features/miniPlayer/utils/miniPlayerBridge';
interface Props {
@@ -2,7 +2,7 @@ import { usePlaybackCoverArt } from '@/cover/usePlaybackCoverArt';
import { usePlaybackTrackCoverRef } from '@/cover/useLibraryCoverRef';
import { useEffect, useRef, useState } from 'react';
import { emit } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { closeMiniPlayer, resizeMiniPlayer, setMiniPlayerAlwaysOnTop, showMainWindow } from '@/lib/api/miniPlayer';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { registerQueueDragHitTest } from '@/lib/dnd/DragDropContext';
@@ -95,14 +95,14 @@ export default function MiniPlayer() {
const toggleOnTop = async () => {
const next = !alwaysOnTop;
setAlwaysOnTop(next);
try { await invoke('set_mini_player_always_on_top', { onTop: next }); } catch { /* ignore: best-effort */ }
try { await setMiniPlayerAlwaysOnTop({ onTop: next }); } catch { /* ignore: best-effort */ }
};
const closeMini = async () => {
try { await invoke('close_mini_player'); } catch { /* ignore: best-effort */ }
try { await closeMiniPlayer(); } catch { /* ignore: best-effort */ }
};
const showMain = () => invoke('show_main_window').catch(() => {});
const showMain = () => showMainWindow().catch(() => {});
const toggleQueue = async () => {
const next = !queueOpen;
@@ -121,7 +121,7 @@ export default function MiniPlayer() {
const targetW = next ? EXPANDED_SIZE.w : COLLAPSED_SIZE.w;
const min = next ? EXPANDED_MIN : COLLAPSED_MIN;
try {
await invoke('resize_mini_player', {
await resizeMiniPlayer({
width: targetW,
height: targetH,
minWidth: min.w,
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { setLinuxWebkitSmoothScrolling } from '@/lib/api/platformShell';
import { resizeMiniPlayer, setMiniPlayerAlwaysOnTop } from '@/lib/api/miniPlayer';
import { useAuthStore } from '@/store/authStore';
import { IS_LINUX } from '@/lib/util/platform';
import {
@@ -18,7 +19,7 @@ export function useMiniWindowSetup(alwaysOnTop: boolean, initialQueueOpen: boole
useEffect(() => {
if (!IS_LINUX) return;
const apply = () => {
invoke('set_linux_webkit_smooth_scrolling', {
setLinuxWebkitSmoothScrolling({
enabled: useAuthStore.getState().linuxWebkitKineticScroll,
}).catch(() => {});
};
@@ -30,7 +31,7 @@ export function useMiniWindowSetup(alwaysOnTop: boolean, initialQueueOpen: boole
useEffect(() => {
if (!initialQueueOpen) return;
invoke('resize_mini_player', {
resizeMiniPlayer({
width: EXPANDED_SIZE.w,
height: readStoredExpandedHeight(),
minWidth: EXPANDED_MIN.w,
@@ -40,10 +41,10 @@ export function useMiniWindowSetup(alwaysOnTop: boolean, initialQueueOpen: boole
}, []);
useEffect(() => {
invoke('set_mini_player_always_on_top', { onTop: alwaysOnTop }).catch(() => {});
setMiniPlayerAlwaysOnTop({ onTop: alwaysOnTop }).catch(() => {});
const reapply = () => {
if (alwaysOnTop) {
invoke('set_mini_player_always_on_top', { onTop: true }).catch(() => {});
setMiniPlayerAlwaysOnTop({ onTop: true }).catch(() => {});
}
};
window.addEventListener('focus', reapply);
+1 -1
View File
@@ -5,7 +5,7 @@ import { CoverArtImage } from '@/cover/CoverArtImage';
import type { CoverArtRef } from '@/cover/types';
import type { ArtistStats, TrackStats } from '@/music-network';
import type { SubsonicOpenArtistRef } from '@/lib/api/subsonicTypes';
import { OpenArtistRefInline } from '@/features/artist';
import { OpenArtistRefInline } from '@/ui/OpenArtistRefInline';
import { formatTrackTime } from '@/lib/format/formatDuration';
import { renderPresetIcon, useEnrichmentPrimaryIcon, useEnrichmentPrimaryLabel } from '@/music-network';
@@ -15,7 +15,7 @@ import {
} from 'lucide-react';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useCachedUrl } from '@/ui/CachedImage';
import { OpenArtistRefInline } from '@/features/artist';
import { OpenArtistRefInline } from '@/ui/OpenArtistRefInline';
import { formatTrackTime } from '@/lib/format/formatDuration';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import {
@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core';
import { Play, HardDriveDownload, Trash2, ListPlus, ListMusic, Heart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOfflineStore } from '@/features/offline/store/offlineStore';
@@ -35,6 +34,7 @@ import {
import { showToast } from '@/lib/dom/toast';
import { shuffleArray } from '@/lib/util/shuffleArray';
import { getMediaDir } from '@/lib/media/mediaDir';
import { getMediaTierSize } from '@/lib/api/syncfs';
import { canonicalQueueServerKey, resolveIndexKey } from '@/lib/server/serverIndexKey';
import { reconcileAllLibraryTiersFromDisk } from '@/features/offline/utils/libraryTierReconcile';
import {
@@ -100,8 +100,8 @@ export default function OfflineLibrary() {
const refreshOfflineDiskSizes = useCallback(async () => {
const mediaDir = getMediaDir();
const [library, favorites] = await Promise.all([
invoke<number>('get_media_tier_size', { tier: 'library', mediaDir }).catch(() => 0),
invoke<number>('get_media_tier_size', { tier: 'favorites', mediaDir }).catch(() => 0),
getMediaTierSize({ tier: 'library', mediaDir }).catch(() => 0),
getMediaTierSize({ tier: 'favorites', mediaDir }).catch(() => 0),
]);
setOfflineDiskBytes({ library, favorites });
}, []);
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { cancelOfflineDownloads } from '@/lib/api/syncfs';
export interface DownloadJob {
trackId: string;
@@ -40,7 +40,7 @@ export const cancelledDownloads = new Set<string>();
function abortDownloadsInRust(jobs: DownloadJob[]) {
const downloadIds = [...new Set(jobs.map(j => j.downloadId).filter(Boolean))];
if (downloadIds.length > 0) {
invoke('cancel_offline_downloads', { downloadIds }).catch(() => {});
cancelOfflineDownloads({ downloadIds }).catch(() => {});
}
}

Some files were not shown because too many files have changed in this diff Show More