feat(ipc): typed bindings for library catalog/genre browse commands

First vertical slice of the tauri-specta rollout: annotate two psysonic-library
browse-metadata commands (library_get_catalog_year_bounds,
library_get_genre_album_counts) with #[specta::specta] and derive specta::Type on
their DTOs (CatalogYearBoundsDto, GenreAlbumCountDto), then add them to
collect_commands!. bindings.ts now carries their typed signatures + DTO shapes
(serde camelCase carried through). Non-breaking: generate_handler! stays the live
handler and nothing imports the bindings yet.

Both DTOs are i64-free (i32/u32/String), so no BigInt handling is needed here —
that convention is decided when the first i64 DTO is annotated. Generated
src/generated is excluded from eslint (its runtime helper uses `any`); tsc still
type-checks bindings.ts.
This commit is contained in:
Psychotoxical
2026-07-01 13:52:39 +02:00
parent c976b79c7d
commit 751ad35365
7 changed files with 41 additions and 4 deletions
+4 -1
View File
@@ -7,7 +7,10 @@ import tseslint from 'typescript-eslint';
export default tseslint.config(
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the
// browser `src/` tree. See `npm run lint:scripts` if scripts ever need a Node-globals lint pass.
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts'] },
// `src/generated` holds machine-generated output (release-notes bundle,
// tauri-specta `bindings.ts`) — not linted. `bindings.ts` is still type-checked
// by tsc (the FE↔BE contract); its generated runtime helper uses `any`.
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts', 'src/generated'] },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
+1
View File
@@ -4261,6 +4261,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"specta",
"tauri",
"tokio",
"wiremock",
@@ -11,6 +11,7 @@ psysonic-core = { path = "../psysonic-core" }
psysonic-integration = { path = "../psysonic-integration" }
tauri = { version = "2" }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
@@ -99,6 +99,7 @@ pub(crate) fn catalog_year_bounds_for_server(
/// Min/max album years from the local track catalog (for Albums browse filter spinners).
#[tauri::command]
#[specta::specta]
pub fn library_get_catalog_year_bounds(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -150,6 +151,7 @@ pub(crate) fn genre_album_counts_for_server(
/// Distinct album counts per track genre — same grouping as genre album browse.
#[tauri::command]
#[specta::specta]
pub fn library_get_genre_album_counts(
runtime: State<'_, LibraryRuntime>,
server_id: String,
+2 -2
View File
@@ -382,7 +382,7 @@ pub struct PlaySessionYearBoundsDto {
}
/// Min/max `year` from indexed tracks for a server (Albums year filter UI).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CatalogYearBoundsDto {
pub min_year: Option<i32>,
@@ -390,7 +390,7 @@ pub struct CatalogYearBoundsDto {
}
/// Per-genre album/track totals from the local track catalog (Genres cloud + browse).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct GenreAlbumCountDto {
pub value: String,
+3 -1
View File
@@ -87,7 +87,9 @@ fn set_app_user_model_id() {
fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
tauri_specta::Builder::<tauri::Wry>::new()
.commands(tauri_specta::collect_commands![
crate::lib_commands::app_api::core::greet
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,
])
}
+28
View File
@@ -5,5 +5,33 @@ import { invoke as __TAURI_INVOKE } from "@tauri-apps/api/core";
/** Commands */
export const commands = {
greet: (name: string) => __TAURI_INVOKE<string>("greet", { name }),
/** Min/max album years from the local track catalog (for Albums browse filter spinners). */
libraryGetCatalogYearBounds: (serverId: string) => typedError<CatalogYearBoundsDto, string>(__TAURI_INVOKE("library_get_catalog_year_bounds", { serverId })),
/** Distinct album counts per track genre — same grouping as genre album browse. */
libraryGetGenreAlbumCounts: (serverId: string, libraryScope: string | null) => typedError<GenreAlbumCountDto[], string>(__TAURI_INVOKE("library_get_genre_album_counts", { serverId, libraryScope })),
};
/* Types */
/** Min/max `year` from indexed tracks for a server (Albums year filter UI). */
export type CatalogYearBoundsDto = {
minYear: number | null,
maxYear: number | null,
};
/** Per-genre album/track totals from the local track catalog (Genres cloud + browse). */
export type GenreAlbumCountDto = {
value: string,
albumCount: number,
songCount: number,
};
/* Tauri Specta runtime */
async function typedError<T, E>(result: Promise<T>): Promise<{ status: "ok"; data: T } | { status: "error"; error: E }> {
try {
return { status: "ok", data: await result };
} catch (e) {
if (e instanceof Error) throw e;
return { status: "error", error: e as any };
}
}