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

This commit is contained in:
Psychotoxical
2026-07-04 16:00:31 +02:00
committed by GitHub
parent fdbb9deac6
commit 77ab95170d
199 changed files with 3691 additions and 1252 deletions
@@ -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,