mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
refactor(ipc): complete tauri-specta typed-IPC cutover, contract guards, and layering cleanup (#1230)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+5
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user