From 77ab95170d5027a45d0224cd4da8a8748790e73a Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:00:31 +0200 Subject: [PATCH] refactor(ipc): complete tauri-specta typed-IPC cutover, contract guards, and layering cleanup (#1230) --- .dependency-cruiser-known-violations.json | 525 ------- CHANGELOG.md | 6 + src-tauri/Cargo.lock | 5 + src-tauri/crates/psysonic-analysis/Cargo.toml | 1 + .../psysonic-analysis/src/analysis_runtime.rs | 2 +- .../crates/psysonic-analysis/src/commands.rs | 33 +- src-tauri/crates/psysonic-audio/Cargo.toml | 1 + .../psysonic-audio/src/autoeq_commands.rs | 2 + .../crates/psysonic-audio/src/commands.rs | 7 + .../psysonic-audio/src/device_commands.rs | 4 + .../crates/psysonic-audio/src/mix_commands.rs | 9 + .../psysonic-audio/src/preload_commands.rs | 1 + .../crates/psysonic-audio/src/preview.rs | 4 + .../psysonic-audio/src/radio_commands.rs | 1 + .../psysonic-audio/src/transport_commands.rs | 4 + src-tauri/crates/psysonic-core/Cargo.toml | 1 + .../crates/psysonic-core/src/server_http.rs | 10 +- .../crates/psysonic-integration/Cargo.toml | 1 + .../psysonic-integration/src/bandsintown.rs | 3 +- .../psysonic-integration/src/discord.rs | 2 + .../src/navidrome/client.rs | 2 +- .../src/navidrome/covers.rs | 4 + .../src/navidrome/playlists.rs | 5 + .../src/navidrome/queries.rs | 6 + .../src/navidrome/users.rs | 5 + .../crates/psysonic-integration/src/remote.rs | 11 +- .../psysonic-library/src/analysis_backfill.rs | 4 +- .../psysonic-library/src/browse_support.rs | 3 +- .../crates/psysonic-library/src/commands.rs | 43 +- .../psysonic-library/src/cover_backfill.rs | 6 +- .../psysonic-library/src/cover_resolve.rs | 2 +- src-tauri/crates/psysonic-library/src/dto.rs | 32 +- .../src/genre_tags_backfill.rs | 2 +- src-tauri/crates/psysonic-syncfs/Cargo.toml | 1 + .../psysonic-syncfs/src/cache/downloads.rs | 8 +- .../crates/psysonic-syncfs/src/cache/hot.rs | 5 + .../crates/psysonic-syncfs/src/cache/local.rs | 21 +- .../psysonic-syncfs/src/cache/offline.rs | 5 + .../crates/psysonic-syncfs/src/sync/batch.rs | 5 + .../crates/psysonic-syncfs/src/sync/device.rs | 15 +- src-tauri/src/cover_cache/backfill_worker.rs | 4 +- src-tauri/src/cover_cache/mod.rs | 35 +- src-tauri/src/lib.rs | 587 +++++++- src-tauri/src/lib_commands/app_api/backup.rs | 2 + src-tauri/src/lib_commands/app_api/core.rs | 11 +- .../src/lib_commands/app_api/integration.rs | 5 + .../src/lib_commands/app_api/migration.rs | 12 +- src-tauri/src/lib_commands/app_api/mod.rs | 10 +- src-tauri/src/lib_commands/app_api/network.rs | 4 + src-tauri/src/lib_commands/app_api/perf.rs | 7 +- .../src/lib_commands/app_api/platform.rs | 6 + src-tauri/src/lib_commands/sync/mod.rs | 2 +- src-tauri/src/lib_commands/sync/tray.rs | 6 + src-tauri/src/lib_commands/ui/mini.rs | 8 + src-tauri/src/lib_commands/ui/mod.rs | 2 +- .../src/library_analysis_backfill/mod.rs | 1 + src-tauri/src/theme_import.rs | 3 +- src/app/MainApp.tsx | 6 +- src/app/bootstrap.ts | 6 +- src/app/hooks/useMigrationOrchestrator.ts | 4 +- src/app/hooks/useNowPlayingTrayTitle.ts | 6 +- src/app/hooks/usePlatformShellSetup.ts | 26 +- src/app/hooks/useTrayMenuI18n.ts | 4 +- src/app/tauriBridge/useCliBridge.ts | 3 +- .../tauriBridge/useMediaAndWindowBridge.ts | 6 +- src/app/tauriBridge/useTrayIconSync.ts | 4 +- src/config/settingsCredits.ts | 1 + src/config/shortcutActionRegistry.ts | 7 +- src/cover/resolveEntryLibrary.ts | 10 +- src/cover/useCoverCacheEvictTick.ts | 4 +- src/cover/useCoverRevalidateScheduler.ts | 4 +- src/features/album/components/AlbumCard.tsx | 2 +- .../album/components/AlbumHeader.test.tsx | 2 +- src/features/album/components/AlbumHeader.tsx | 2 +- src/features/album/pages/AlbumDetail.tsx | 4 +- src/features/album/pages/Albums.tsx | 4 +- src/features/album/pages/LosslessAlbums.tsx | 4 +- src/features/album/pages/NewReleases.tsx | 4 +- src/features/album/pages/RandomAlbums.tsx | 4 +- src/features/artist/index.ts | 8 +- .../contextMenu/utils/contextMenuActions.ts | 4 +- .../components/DeviceSyncDevicePanel.tsx | 4 +- .../hooks/useDeviceSyncDeviceScan.ts | 3 +- .../deviceSync/hooks/useDeviceSyncDrives.ts | 4 +- .../hooks/useDeviceSyncSourceStatuses.ts | 4 +- .../deviceSync/utils/deviceSyncHelpers.ts | 14 +- .../utils/runDeviceSyncExecution.ts | 17 +- .../utils/runDeviceSyncMigration.ts | 3 +- src/features/equalizer/hooks/useAutoEq.ts | 20 +- .../favorites/components/FavoriteSongRow.tsx | 2 +- src/features/home/components/SongCard.tsx | 2 +- src/features/lyrics/api/netease.ts | 6 +- src/features/lyrics/hooks/useLyrics.ts | 4 +- .../miniPlayer/components/MiniMeta.tsx | 2 +- .../miniPlayer/components/MiniPlayer.tsx | 10 +- .../miniPlayer/hooks/useMiniWindowSetup.ts | 11 +- src/features/nowPlaying/components/Hero.tsx | 2 +- .../components/MobilePlayerView.tsx | 2 +- src/features/offline/pages/OfflineLibrary.tsx | 6 +- src/features/offline/store/offlineJobStore.ts | 4 +- src/features/offline/store/offlineStore.ts | 7 +- .../offline/utils/favoritesOfflineSync.ts | 24 +- .../utils/legacyOfflineFileMigration.ts | 21 +- .../offline/utils/libraryTierReconcile.ts | 17 +- .../offline/utils/pinnedOfflineSync.ts | 4 +- src/features/orbit/utils/orbitDiag.ts | 7 +- .../playback/components/PlayerBar.tsx | 4 +- .../playerBar/PlayerOverflowMenu.tsx | 4 +- .../components/playerBar/PlayerTrackInfo.tsx | 2 +- .../playerBar/PlayerTransportControls.tsx | 6 +- .../playback/hooks/useAudioDevicesProbe.ts | 14 +- .../store/applyQueueHistorySnapshot.ts | 4 +- .../playback/store/audioEventHandlers.ts | 6 +- .../audioListenerSetup/authSyncListener.ts | 6 +- .../audioListenerSetup/discordPresence.ts | 3 +- .../audioListenerSetup/initialAudioSync.ts | 10 +- .../store/audioListenerSetup/mprisSync.ts | 11 +- .../audioListenerSetup/radioMprisMetadata.ts | 4 +- .../playback/store/crossfadePreload.ts | 6 +- .../store/engineLoadTrackAtPosition.ts | 5 +- src/features/playback/store/hotCacheStore.ts | 6 +- .../playback/store/loudnessRefresh.ts | 19 +- .../playback/store/loudnessReseed.test.ts | 2 + src/features/playback/store/loudnessReseed.ts | 16 +- src/features/playback/store/miscActions.ts | 11 +- src/features/playback/store/nextAction.ts | 10 +- .../playback/store/normalizationDebug.ts | 7 +- .../playback/store/normalizationIpcDedupe.ts | 6 +- .../playback/store/playTrackAction.ts | 3 +- .../playback/store/playbackRateStore.ts | 4 +- .../playback/store/previewPlayerVolumeSync.ts | 4 +- src/features/playback/store/previewStore.ts | 6 +- .../playback/store/queueMutationActions.ts | 4 +- src/features/playback/store/resumeAction.ts | 7 +- src/features/playback/store/seekAction.ts | 4 +- .../playback/store/seekFallbackState.ts | 4 +- .../playback/store/transportLightActions.ts | 6 +- .../playback/store/waveformRefresh.ts | 16 +- .../utils/playback/autodjInterruptPrep.ts | 4 +- .../playlist/utils/runPlaylistZipDownload.ts | 4 +- .../queue/components/QueueCurrentTrack.tsx | 2 +- src/features/radio/hooks/useRadioMetadata.ts | 16 +- src/features/radio/hooks/useRadioMprisSync.ts | 4 +- src/features/randomMix/utils/luckyMix.ts | 22 +- .../search/components/TracksPageChrome.tsx | 2 +- .../components/AnalyticsStrategySection.tsx | 2 +- .../settings/components/AppearanceTab.tsx | 4 +- .../settings/components/StorageTab.tsx | 4 +- .../settings/components/SystemTab.tsx | 10 +- .../components/ThemeImportSection.tsx | 6 +- .../audio/AudioOutputDeviceSection.tsx | 4 +- .../settings/hooks/useThemeAnimationRisk.ts | 4 +- src/features/settings/utils/backup.ts | 10 +- .../perfProbe/SidebarPerfProbeLogsTab.tsx | 4 +- src/features/updater/hooks/useAppUpdater.ts | 14 +- .../whatsNew/utils/releaseNotesFetch.ts | 7 +- src/generated/bindings.ts | 1316 +++++++++++++++++ src/hotCachePrefetch.ts | 5 +- src/hotCachePrefetch/analysisPrune.ts | 18 +- src/hotCachePrefetch/helpers.ts | 7 +- src/lib/api/analysis.ts | 92 +- src/lib/api/audio.ts | 171 +++ src/lib/api/bandsintown.ts | 24 +- src/lib/api/coverCache.ts | 100 +- src/lib/api/debugLog.ts | 13 + src/lib/api/downloadZip.ts | 16 + src/lib/api/library/events.ts | 13 +- src/lib/api/library/reads.ts | 39 +- src/lib/api/library/stats.ts | 66 +- src/lib/api/library/sync.ts | 83 +- src/lib/api/migration.ts | 14 +- src/lib/api/miniPlayer.ts | 41 + src/lib/api/mpris.ts | 31 + src/lib/api/navidromeAdmin.ts | 15 +- src/lib/api/navidromeSmart.ts | 4 +- src/lib/api/network.ts | 5 +- src/lib/api/platformShell.ts | 63 + src/lib/api/runtimeLogs.ts | 9 +- src/lib/api/subsonicArtists.ts | 12 +- src/lib/api/subsonicPlaylists.ts | 12 +- src/lib/api/subsonicRadio.ts | 33 +- src/lib/api/syncfs.ts | 141 ++ src/lib/api/tray.ts | 38 + src/lib/cache/ephemeralTierReconcile.test.ts | 5 +- src/lib/cache/ephemeralTierReconcile.ts | 42 +- src/lib/library/hooks/useLibraryIndexSync.ts | 2 +- src/lib/library/liveSearchDebug.ts | 7 +- src/lib/perf/perfLiveStore.ts | 6 +- src/lib/server/serverUrlRemigration.ts | 8 +- src/lib/server/syncServerHttpContext.ts | 13 +- src/localPlaybackInvalidation.ts | 6 +- src/store/eqStore.ts | 4 +- src/store/globalShortcutsStore.ts | 12 +- src/store/localPlaybackStore.ts | 25 +- src/store/seamContracts.test.ts | 17 +- .../crossfadeNextPreload.scenario.test.ts | 94 ++ .../normalizationTrackChange.scenario.test.ts | 70 + .../orbitGuestPlaybackRate.scenario.test.ts | 67 + .../components => ui}/OpenArtistRefInline.tsx | 0 199 files changed, 3691 insertions(+), 1252 deletions(-) create mode 100644 src/lib/api/audio.ts create mode 100644 src/lib/api/debugLog.ts create mode 100644 src/lib/api/downloadZip.ts create mode 100644 src/lib/api/miniPlayer.ts create mode 100644 src/lib/api/mpris.ts create mode 100644 src/lib/api/platformShell.ts create mode 100644 src/lib/api/syncfs.ts create mode 100644 src/lib/api/tray.ts create mode 100644 src/test/scenarios/crossfadeNextPreload.scenario.test.ts create mode 100644 src/test/scenarios/normalizationTrackChange.scenario.test.ts create mode 100644 src/test/scenarios/orbitGuestPlaybackRate.scenario.test.ts rename src/{features/artist/components => ui}/OpenArtistRefInline.tsx (100%) diff --git a/.dependency-cruiser-known-violations.json b/.dependency-cruiser-known-violations.json index c8ffd8e2..811bea9f 100644 --- a/.dependency-cruiser-known-violations.json +++ b/.dependency-cruiser-known-violations.json @@ -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", diff --git a/CHANGELOG.md b/CHANGELOG.md index a0914120..1fa9c09c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7dd310f0..0536911a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/crates/psysonic-analysis/Cargo.toml b/src-tauri/crates/psysonic-analysis/Cargo.toml index 2cf9dfc6..263daf5f 100644 --- a/src-tauri/crates/psysonic-analysis/Cargo.toml +++ b/src-tauri/crates/psysonic-analysis/Cargo.toml @@ -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" diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs index 89c7ac6d..c2e6844c 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs @@ -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, diff --git a/src-tauri/crates/psysonic-analysis/src/commands.rs b/src-tauri/crates/psysonic-analysis/src/commands.rs index c5ebdc29..ce3fe581 100644 --- a/src-tauri/crates/psysonic-analysis/src/commands.rs +++ b/src-tauri/crates/psysonic-analysis/src/commands.rs @@ -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, @@ -35,7 +35,7 @@ impl From 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 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, @@ -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, @@ -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, @@ -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, @@ -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 { @@ -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, @@ -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>, @@ -288,6 +298,7 @@ pub fn analysis_clear_failed_tracks( } #[tauri::command] +#[specta::specta] pub fn analysis_migrate_server_index_keys( mappings: Vec, _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, 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 { Ok(analysis_pipeline_queue_stats()) } #[tauri::command] +#[specta::specta] pub fn analysis_get_backfill_queue_stats() -> Result { 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, server_id: String, diff --git a/src-tauri/crates/psysonic-audio/Cargo.toml b/src-tauri/crates/psysonic-audio/Cargo.toml index 6187f155..8d83f54d 100644 --- a/src-tauri/crates/psysonic-audio/Cargo.toml +++ b/src-tauri/crates/psysonic-audio/Cargo.toml @@ -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"] } diff --git a/src-tauri/crates/psysonic-audio/src/autoeq_commands.rs b/src-tauri/crates/psysonic-audio/src/autoeq_commands.rs index b0cf3fcc..a6db96b7 100644 --- a/src-tauri/crates/psysonic-audio/src/autoeq_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/autoeq_commands.rs @@ -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 { audio_http_client(&state) .get("https://autoeq.app/entries") @@ -49,6 +50,7 @@ pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result) -> Option { 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 { /// 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 { 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 { use rodio::cpal::traits::{DeviceTrait, HostTrait}; with_suppressed_alsa_stderr(|| { @@ -68,6 +71,7 @@ pub fn audio_default_output_device_name() -> Option { /// 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, state: State<'_, AudioEngine>, diff --git a/src-tauri/crates/psysonic-audio/src/mix_commands.rs b/src-tauri/crates/psysonic-audio/src/mix_commands.rs index 269ebc5e..f1b19264 100644 --- a/src-tauri/crates/psysonic-audio/src/mix_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/mix_commands.rs @@ -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, diff --git a/src-tauri/crates/psysonic-audio/src/preload_commands.rs b/src-tauri/crates/psysonic-audio/src/preload_commands.rs index fcd86105..097e6fdf 100644 --- a/src-tauri/crates/psysonic-audio/src/preload_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/preload_commands.rs @@ -114,6 +114,7 @@ fn emit_preload_cancelled(app: &AppHandle, url: String, track_id: Option } #[tauri::command] +#[specta::specta] pub async fn audio_preload( url: String, duration_hint: f64, diff --git a/src-tauri/crates/psysonic-audio/src/preview.rs b/src-tauri/crates/psysonic-audio/src/preview.rs index e82f2abf..9a5b2e9f 100644 --- a/src-tauri/crates/psysonic-audio/src/preview.rs +++ b/src-tauri/crates/psysonic-audio/src/preview.rs @@ -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)); diff --git a/src-tauri/crates/psysonic-audio/src/radio_commands.rs b/src-tauri/crates/psysonic-audio/src/radio_commands.rs index 48cca9f6..ee401d5f 100644 --- a/src-tauri/crates/psysonic-audio/src/radio_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/radio_commands.rs @@ -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, diff --git a/src-tauri/crates/psysonic-audio/src/transport_commands.rs b/src-tauri/crates/psysonic-audio/src/transport_commands.rs index 2ea9b520..1ad2c08c 100644 --- a/src-tauri/crates/psysonic-audio/src/transport_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/transport_commands.rs @@ -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; diff --git a/src-tauri/crates/psysonic-core/Cargo.toml b/src-tauri/crates/psysonic-core/Cargo.toml index 6a659b13..3093d5bc 100644 --- a/src-tauri/crates/psysonic-core/Cargo.toml +++ b/src-tauri/crates/psysonic-core/Cargo.toml @@ -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" diff --git a/src-tauri/crates/psysonic-core/src/server_http.rs b/src-tauri/crates/psysonic-core/src/server_http.rs index cbd60107..875ece07 100644 --- a/src-tauri/crates/psysonic-core/src/server_http.rs +++ b/src-tauri/crates/psysonic-core/src/server_http.rs @@ -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, diff --git a/src-tauri/crates/psysonic-integration/Cargo.toml b/src-tauri/crates/psysonic-integration/Cargo.toml index 8f866ceb..1ee4c128 100644 --- a/src-tauri/crates/psysonic-integration/Cargo.toml +++ b/src-tauri/crates/psysonic-integration/Cargo.toml @@ -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"] } diff --git a/src-tauri/crates/psysonic-integration/src/bandsintown.rs b/src-tauri/crates/psysonic-integration/src/bandsintown.rs index abd85c21..00060bc6 100644 --- a/src-tauri/crates/psysonic-integration/src/bandsintown.rs +++ b/src-tauri/crates/psysonic-integration/src/bandsintown.rs @@ -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, String> { let trimmed = artist_name.trim(); if trimmed.is_empty() { diff --git a/src-tauri/crates/psysonic-integration/src/discord.rs b/src-tauri/crates/psysonic-integration/src/discord.rs index 058332d8..ee2b8578 100644 --- a/src-tauri/crates/psysonic-integration/src/discord.rs +++ b/src-tauri/crates/psysonic-integration/src/discord.rs @@ -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) -> Result<(), String> { let mut guard = state.client.lock().unwrap(); if let Some(client) = guard.as_mut() { diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/client.rs b/src-tauri/crates/psysonic-integration/src/navidrome/client.rs index 4e6628b4..0bc5ba51 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/client.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/client.rs @@ -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")] diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/covers.rs b/src-tauri/crates/psysonic-integration/src/navidrome/covers.rs index 78fce8e8..84cb0173 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/covers.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/covers.rs @@ -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>, 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>, 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>, 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>, server_url: String, diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/playlists.rs b/src-tauri/crates/psysonic-integration/src/navidrome/playlists.rs index 9a833d49..9b15f350 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/playlists.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/playlists.rs @@ -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>, @@ -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>, @@ -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>, @@ -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>, @@ -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>, server_url: String, diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs b/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs index 0dbf6d5f..41d5c731 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/queries.rs @@ -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>, @@ -98,6 +99,7 @@ fn nd_build_filters(seed: serde_json::Map, 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__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>, @@ -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>, 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>, server_url: String, diff --git a/src-tauri/crates/psysonic-integration/src/navidrome/users.rs b/src-tauri/crates/psysonic-integration/src/navidrome/users.rs index 393d001d..7554eb31 100644 --- a/src-tauri/crates/psysonic-integration/src/navidrome/users.rs +++ b/src-tauri/crates/psysonic-integration/src/navidrome/users.rs @@ -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>, 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>, @@ -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>, server_url: String, diff --git a/src-tauri/crates/psysonic-integration/src/remote.rs b/src-tauri/crates/psysonic-integration/src/remote.rs index 57e5fc1d..49ad1667 100644 --- a/src-tauri/crates/psysonic-integration/src/remote.rs +++ b/src-tauri/crates/psysonic-integration/src/remote.rs @@ -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, String> { let client = reqwest::Client::new(); @@ -26,6 +27,7 @@ pub async fn search_radio_browser(query: String, offset: u32) -> Result Result, String> { let client = reqwest::Client::new(); @@ -46,6 +48,7 @@ pub async fn get_top_radio_stations(offset: u32) -> Result Result<(Vec, 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, 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 { let client = reqwest::Client::builder() @@ -95,7 +99,7 @@ pub async fn fetch_json_url(url: String) -> Result { } /// 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, @@ -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 { use futures_util::StreamExt; @@ -277,6 +282,7 @@ pub async fn fetch_icy_metadata(url: String) -> Result { /// 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 { /// `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, diff --git a/src-tauri/crates/psysonic-library/src/analysis_backfill.rs b/src-tauri/crates/psysonic-library/src/analysis_backfill.rs index 3556de74..bcd1fa46 100644 --- a/src-tauri/crates/psysonic-library/src/analysis_backfill.rs +++ b/src-tauri/crates/psysonic-library/src/analysis_backfill.rs @@ -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, @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/browse_support.rs b/src-tauri/crates/psysonic-library/src/browse_support.rs index 0719db16..cbc13f3c 100644 --- a/src-tauri/crates/psysonic-library/src/browse_support.rs +++ b/src-tauri/crates/psysonic-library/src/browse_support.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index 9a3959b0..26ff90b8 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -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 { @@ -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>, @@ -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 { 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, @@ -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, @@ -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 { @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/cover_backfill.rs b/src-tauri/crates/psysonic-library/src/cover_backfill.rs index 663ee2ad..18893da3 100644 --- a/src-tauri/crates/psysonic-library/src/cover_backfill.rs +++ b/src-tauri/crates/psysonic-library/src/cover_backfill.rs @@ -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, @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/cover_resolve.rs b/src-tauri/crates/psysonic-library/src/cover_resolve.rs index 2a2d07c2..58c42c86 100644 --- a/src-tauri/crates/psysonic-library/src/cover_resolve.rs +++ b/src-tauri/crates/psysonic-library/src/cover_resolve.rs @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs index ae008240..6a0b9f5b 100644 --- a/src-tauri/crates/psysonic-library/src/dto.rs +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -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, } -#[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, @@ -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, diff --git a/src-tauri/crates/psysonic-library/src/genre_tags_backfill.rs b/src-tauri/crates/psysonic-library/src/genre_tags_backfill.rs index af637c30..7aee4e93 100644 --- a/src-tauri/crates/psysonic-library/src/genre_tags_backfill.rs +++ b/src-tauri/crates/psysonic-library/src/genre_tags_backfill.rs @@ -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, diff --git a/src-tauri/crates/psysonic-syncfs/Cargo.toml b/src-tauri/crates/psysonic-syncfs/Cargo.toml index ff990b89..3321658a 100644 --- a/src-tauri/crates/psysonic-syncfs/Cargo.toml +++ b/src-tauri/crates/psysonic-syncfs/Cargo.toml @@ -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" diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs b/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs index b7257c09..7ae20781 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/downloads.rs @@ -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 { 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, 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 Option { use lofty::file::FileType; use lofty::prelude::*; @@ -282,6 +286,7 @@ pub fn get_embedded_lyrics(path: String) -> Option { /// 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, diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs index 7f0a8068..64756f0c 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs @@ -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, 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, app: tauri::AppHandl } #[tauri::command] +#[specta::specta] pub async fn delete_hot_cache_track( local_path: String, custom_dir: Option, @@ -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, app: tauri::AppHandle) -> Result<(), String> { let root = resolve_hot_cache_root(custom_dir, &app)?; if !root.exists() { diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/local.rs b/src-tauri/crates/psysonic-syncfs/src/cache/local.rs index d595350c..fe1952eb 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/local.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/local.rs @@ -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, @@ -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, 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, media_dir: Option, @@ -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) -> Vec { 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, @@ -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, @@ -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, @@ -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, @@ -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, custom_offline_dir: Option, diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs b/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs index dbd67675..9ab28795 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/offline.rs @@ -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://` 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) { 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) { /// 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, 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, diff --git a/src-tauri/crates/psysonic-syncfs/src/sync/batch.rs b/src-tauri/crates/psysonic-syncfs/src/sync/batch.rs index 48f5067a..75728bdb 100644 --- a/src-tauri/crates/psysonic-syncfs/src/sync/batch.rs +++ b/src-tauri/crates/psysonic-syncfs/src/sync/batch.rs @@ -12,6 +12,7 @@ use super::device::{ }; #[tauri::command] +#[specta::specta] pub async fn list_device_dir_files(dir: String) -> Result, 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, 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, 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) -> Result { let mut deleted: u32 = 0; for path in &paths { diff --git a/src-tauri/crates/psysonic-syncfs/src/sync/device.rs b/src-tauri/crates/psysonic-syncfs/src/sync/device.rs index 9cd3837d..42f9a3ad 100644 --- a/src-tauri/crates/psysonic-syncfs/src/sync/device.rs +++ b/src-tauri/crates/psysonic-syncfs/src/sync/device.rs @@ -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 { use sysinfo::Disks; let disks = Disks::new_with_refreshed_list(); @@ -64,7 +65,7 @@ pub fn read_device_manifest(dest_dir: String) -> Option { } /// 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, dest_dir: String) -> Vec { tracks.iter().map(|track| { let relative = build_track_path(track); diff --git a/src-tauri/src/cover_cache/backfill_worker.rs b/src-tauri/src/cover_cache/backfill_worker.rs index 749fa60c..b27cc6ce 100644 --- a/src-tauri/src/cover_cache/backfill_worker.rs +++ b/src-tauri/src/cover_cache/backfill_worker.rs @@ -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, diff --git a/src-tauri/src/cover_cache/mod.rs b/src-tauri/src/cover_cache/mod.rs index 3941be47..5310d8c0 100644 --- a/src-tauri/src/cover_cache/mod.rs +++ b/src-tauri/src/cover_cache/mod.rs @@ -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), 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, @@ -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 { let worker = app .try_state::>() @@ -770,6 +772,7 @@ pub async fn library_cover_backfill_pulse(app: AppHandle) -> Result Result<(), String> { let worker = app .try_state::>() @@ -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, @@ -956,6 +964,7 @@ fn ensure_peek(dir: &Path, tier: u32, args: &CoverCacheEnsureArgs) -> Option, @@ -984,6 +994,7 @@ pub async fn cover_cache_ensure_batch( } #[tauri::command] +#[specta::specta] pub async fn cover_cache_stats(app: AppHandle) -> Result { let st = state(&app)?; let root = { @@ -1006,11 +1017,13 @@ pub async fn cover_cache_stats(app: AppHandle) -> Result Result { 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 { @@ -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) -> Result { Ok(0) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 997fe743..66c9d685 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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_specta::Builder::::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 + /// `![ ... ]` 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 { + 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) { + 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 { + 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) } } diff --git a/src-tauri/src/lib_commands/app_api/backup.rs b/src-tauri/src/lib_commands/app_api/backup.rs index 8dfe32de..1deebcaf 100644 --- a/src-tauri/src/lib_commands/app_api/backup.rs +++ b/src-tauri/src/lib_commands/app_api/backup.rs @@ -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) diff --git a/src-tauri/src/lib_commands/app_api/core.rs b/src-tauri/src/lib_commands/app_api/core.rs index 9f7a4f7f..cbc073f2 100644 --- a/src-tauri/src/lib_commands/app_api/core.rs +++ b/src-tauri/src/lib_commands/app_api/core.rs @@ -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::() { 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 { 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, @@ -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, max: Option) -> 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, max: Option) -> 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, diff --git a/src-tauri/src/lib_commands/app_api/integration.rs b/src-tauri/src/lib_commands/app_api/integration.rs index 0d105af4..0011eea4 100644 --- a/src-tauri/src/lib_commands/app_api/integration.rs +++ b/src-tauri/src/lib_commands/app_api/integration.rs @@ -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, @@ -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, @@ -70,6 +72,7 @@ pub(crate) fn unregister_global_shortcut( } #[tauri::command] +#[specta::specta] pub(crate) fn mpris_set_metadata( controls: tauri::State, title: Option, @@ -146,6 +149,7 @@ fn webp_file_to_temp_png(webp_path: &str) -> Result { } #[tauri::command] +#[specta::specta] pub(crate) fn mpris_set_playback( controls: tauri::State, 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() } diff --git a/src-tauri/src/lib_commands/app_api/migration.rs b/src-tauri/src/lib_commands/app_api/migration.rs index 340b2aae..c702ecea 100644 --- a/src-tauri/src/lib_commands/app_api/migration.rs +++ b/src-tauri/src/lib_commands/app_api/migration.rs @@ -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, } -#[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, @@ -91,6 +92,7 @@ pub fn migration_inspect( } #[tauri::command] +#[specta::specta] pub fn migration_run( app: AppHandle, mappings: Vec, diff --git a/src-tauri/src/lib_commands/app_api/mod.rs b/src-tauri/src/lib_commands/app_api/mod.rs index b0157e66..88c45ef0 100644 --- a/src-tauri/src/lib_commands/app_api/mod.rs +++ b/src-tauri/src/lib_commands/app_api/mod.rs @@ -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. diff --git a/src-tauri/src/lib_commands/app_api/network.rs b/src-tauri/src/lib_commands/app_api/network.rs index 990b0435..b72e137b 100644 --- a/src-tauri/src/lib_commands/app_api/network.rs +++ b/src-tauri/src/lib_commands/app_api/network.rs @@ -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, String> { let trimmed = hostname.trim(); if trimmed.is_empty() { @@ -64,6 +65,7 @@ pub(crate) async fn resolve_host_addresses(hostname: String) -> Result>, 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>, entries: Vec, @@ -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>, server_id: String, diff --git a/src-tauri/src/lib_commands/app_api/perf.rs b/src-tauri/src/lib_commands/app_api/perf.rs index 82ac37b4..c84c70e4 100644 --- a/src-tauri/src/lib_commands/app_api/perf.rs +++ b/src-tauri/src/lib_commands/app_api/perf.rs @@ -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, ) -> Result { diff --git a/src-tauri/src/lib_commands/app_api/platform.rs b/src-tauri/src/lib_commands/app_api/platform.rs index 712259a3..0a20a7df 100644 --- a/src-tauri/src/lib_commands/app_api/platform.rs +++ b/src-tauri/src/lib_commands/app_api/platform.rs @@ -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")] { diff --git a/src-tauri/src/lib_commands/sync/mod.rs b/src-tauri/src/lib_commands/sync/mod.rs index 225a9385..fb389f45 100644 --- a/src-tauri/src/lib_commands/sync/mod.rs +++ b/src-tauri/src/lib_commands/sync/mod.rs @@ -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, diff --git a/src-tauri/src/lib_commands/sync/tray.rs b/src-tauri/src/lib_commands/sync/tray.rs index 186b5ded..64307488 100644 --- a/src-tauri/src/lib_commands/sync/tray.rs +++ b/src-tauri/src/lib_commands/sync/tray.rs @@ -259,6 +259,7 @@ pub(crate) fn try_build_tray_icon(app: &tauri::AppHandle) -> Option { /// 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, @@ -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, @@ -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() } diff --git a/src-tauri/src/lib_commands/ui/mini.rs b/src-tauri/src/lib_commands/ui/mini.rs index 1287321a..54b6bbeb 100644 --- a/src-tauri/src/lib_commands/ui/mini.rs +++ b/src-tauri/src/lib_commands/ui/mini.rs @@ -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, diff --git a/src-tauri/src/lib_commands/ui/mod.rs b/src-tauri/src/lib_commands/ui/mod.rs index 77fedb2f..9f60e5b4 100644 --- a/src-tauri/src/lib_commands/ui/mod.rs +++ b/src-tauri/src/lib_commands/ui/mod.rs @@ -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, diff --git a/src-tauri/src/library_analysis_backfill/mod.rs b/src-tauri/src/library_analysis_backfill/mod.rs index 0d899326..15139bdb 100644 --- a/src-tauri/src/library_analysis_backfill/mod.rs +++ b/src-tauri/src/library_analysis_backfill/mod.rs @@ -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, diff --git a/src-tauri/src/theme_import.rs b/src-tauri/src/theme_import.rs index 9c818ca9..43e6ef39 100644 --- a/src-tauri/src/theme_import.rs +++ b/src-tauri/src/theme_import.rs @@ -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 { let file = std::fs::File::open(&path).map_err(|e| format!("cannot open file: {e}"))?; let len = file diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 8ec2fc0b..8db7aa0d 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -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); } }; diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index d6bf7ce6..087f883a 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -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. diff --git a/src/app/hooks/useMigrationOrchestrator.ts b/src/app/hooks/useMigrationOrchestrator.ts index ab3634a5..8caab563 100644 --- a/src/app/hooks/useMigrationOrchestrator.ts +++ b/src/app/hooks/useMigrationOrchestrator.ts @@ -138,7 +138,7 @@ async function runOrchestrator(force = false): Promise { })() .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'); } diff --git a/src/app/hooks/useNowPlayingTrayTitle.ts b/src/app/hooks/useNowPlayingTrayTitle.ts index cdfa9e50..e9daadac 100644 --- a/src/app/hooks/useNowPlayingTrayTitle.ts +++ b/src/app/hooks/useNowPlayingTrayTitle.ts @@ -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(() => {}); diff --git a/src/app/hooks/usePlatformShellSetup.ts b/src/app/hooks/usePlatformShellSetup.ts index ccf36b65..083b98c7 100644 --- a/src/app/hooks/usePlatformShellSetup.ts +++ b/src/app/hooks/usePlatformShellSetup.ts @@ -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('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {}); + isTilingWmCmd().then(setIsTilingWm).catch(() => {}); }, []); useEffect(() => { if (!IS_LINUX) return; - invoke('no_compositing_mode').then(noComp => { + noCompositingMode().then(noComp => { if (noComp) document.documentElement.classList.add('no-compositing'); }).catch(() => {}); }, []); useEffect(() => { if (!IS_LINUX) return; - invoke('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 }; diff --git a/src/app/hooks/useTrayMenuI18n.ts b/src/app/hooks/useTrayMenuI18n.ts index 5414f728..a99dfbdc 100644 --- a/src/app/hooks/useTrayMenuI18n.ts +++ b/src/app/hooks/useTrayMenuI18n.ts @@ -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'), diff --git a/src/app/tauriBridge/useCliBridge.ts b/src/app/tauriBridge/useCliBridge.ts index 3a404035..d5045172 100644 --- a/src/app/tauriBridge/useCliBridge.ts +++ b/src/app/tauriBridge/useCliBridge.ts @@ -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) */ diff --git a/src/app/tauriBridge/useMediaAndWindowBridge.ts b/src/app/tauriBridge/useMediaAndWindowBridge.ts index d4b190b0..40e865d5 100644 --- a/src/app/tauriBridge/useMediaAndWindowBridge.ts +++ b/src/app/tauriBridge/useMediaAndWindowBridge.ts @@ -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(); diff --git a/src/app/tauriBridge/useTrayIconSync.ts b/src/app/tauriBridge/useTrayIconSync.ts index c0bd1ba8..2284fb41 100644 --- a/src/app/tauriBridge/useTrayIconSync.ts +++ b/src/app/tauriBridge/useTrayIconSync.ts @@ -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]); } diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 3a80b416..d6bd9c68 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, { diff --git a/src/config/shortcutActionRegistry.ts b/src/config/shortcutActionRegistry.ts index e825670b..ac5eccc0 100644 --- a/src/config/shortcutActionRegistry.ts +++ b/src/config/shortcutActionRegistry.ts @@ -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) { diff --git a/src/cover/resolveEntryLibrary.ts b/src/cover/resolveEntryLibrary.ts index aba62431..01becb8c 100644 --- a/src/cover/resolveEntryLibrary.ts +++ b/src/cover/resolveEntryLibrary.ts @@ -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('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(); diff --git a/src/cover/useCoverCacheEvictTick.ts b/src/cover/useCoverCacheEvictTick.ts index ecb00a33..aa11ffeb 100644 --- a/src/cover/useCoverCacheEvictTick.ts +++ b/src/cover/useCoverCacheEvictTick.ts @@ -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('cover_cache_evict_tick', {}).catch(() => {}); + void commands.coverCacheEvictTick().catch(() => {}); }; tick(); const id = window.setInterval(tick, EVICT_TICK_MS); diff --git a/src/cover/useCoverRevalidateScheduler.ts b/src/cover/useCoverRevalidateScheduler.ts index e7920f3b..342ce5d5 100644 --- a/src/cover/useCoverRevalidateScheduler.ts +++ b/src/cover/useCoverRevalidateScheduler.ts @@ -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('cover_revalidate_tick', { cycleDays }).catch(() => {}); + void commands.coverRevalidateTick(cycleDays).catch(() => {}); }; tick(); const id = window.setInterval(tick, TICK_MS); diff --git a/src/features/album/components/AlbumCard.tsx b/src/features/album/components/AlbumCard.tsx index db944df0..99600a33 100644 --- a/src/features/album/components/AlbumCard.tsx +++ b/src/features/album/components/AlbumCard.tsx @@ -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'; diff --git a/src/features/album/components/AlbumHeader.test.tsx b/src/features/album/components/AlbumHeader.test.tsx index a4f3598e..64d104df 100644 --- a/src/features/album/components/AlbumHeader.test.tsx +++ b/src/features/album/components/AlbumHeader.test.tsx @@ -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() { diff --git a/src/features/album/components/AlbumHeader.tsx b/src/features/album/components/AlbumHeader.tsx index e364a4eb..ca976f5b 100644 --- a/src/features/album/components/AlbumHeader.tsx +++ b/src/features/album/components/AlbumHeader.tsx @@ -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'; diff --git a/src/features/album/pages/AlbumDetail.tsx b/src/features/album/pages/AlbumDetail.tsx index 894ebf01..1772040a 100644 --- a/src/features/album/pages/AlbumDetail.tsx +++ b/src/features/album/pages/AlbumDetail.tsx @@ -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); diff --git a/src/features/album/pages/Albums.tsx b/src/features/album/pages/Albums.tsx index ff458415..af6509e1 100644 --- a/src/features/album/pages/Albums.tsx +++ b/src/features/album/pages/Albums.tsx @@ -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); diff --git a/src/features/album/pages/LosslessAlbums.tsx b/src/features/album/pages/LosslessAlbums.tsx index 8bbe049a..2533b320 100644 --- a/src/features/album/pages/LosslessAlbums.tsx +++ b/src/features/album/pages/LosslessAlbums.tsx @@ -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); diff --git a/src/features/album/pages/NewReleases.tsx b/src/features/album/pages/NewReleases.tsx index c6a31ac9..c856355c 100644 --- a/src/features/album/pages/NewReleases.tsx +++ b/src/features/album/pages/NewReleases.tsx @@ -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); diff --git a/src/features/album/pages/RandomAlbums.tsx b/src/features/album/pages/RandomAlbums.tsx index 1c16ba05..e317a085 100644 --- a/src/features/album/pages/RandomAlbums.tsx +++ b/src/features/album/pages/RandomAlbums.tsx @@ -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); diff --git a/src/features/artist/index.ts b/src/features/artist/index.ts index 5e1b28df..93578b48 100644 --- a/src/features/artist/index.ts +++ b/src/features/artist/index.ts @@ -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'; diff --git a/src/features/contextMenu/utils/contextMenuActions.ts b/src/features/contextMenu/utils/contextMenuActions.ts index b843b680..4857135e 100644 --- a/src/features/contextMenu/utils/contextMenuActions.ts +++ b/src/features/contextMenu/utils/contextMenuActions.ts @@ -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); diff --git a/src/features/deviceSync/components/DeviceSyncDevicePanel.tsx b/src/features/deviceSync/components/DeviceSyncDevicePanel.tsx index a609f9d5..93e9274e 100644 --- a/src/features/deviceSync/components/DeviceSyncDevicePanel.tsx +++ b/src/features/deviceSync/components/DeviceSyncDevicePanel.tsx @@ -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(); }} > diff --git a/src/features/deviceSync/hooks/useDeviceSyncDeviceScan.ts b/src/features/deviceSync/hooks/useDeviceSyncDeviceScan.ts index 38c67ab6..d5f661fc 100644 --- a/src/features/deviceSync/hooks/useDeviceSyncDeviceScan.ts +++ b/src/features/deviceSync/hooks/useDeviceSyncDeviceScan.ts @@ -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('list_device_dir_files', { dir: targetDir }); + const files = await listDeviceDirFiles({ dir: targetDir }); setDeviceFilePaths(files); } catch { setDeviceFilePaths([]); diff --git a/src/features/deviceSync/hooks/useDeviceSyncDrives.ts b/src/features/deviceSync/hooks/useDeviceSyncDrives.ts index 4577368a..e1422888 100644 --- a/src/features/deviceSync/hooks/useDeviceSyncDrives.ts +++ b/src/features/deviceSync/hooks/useDeviceSyncDrives.ts @@ -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('get_removable_drives'); + const result = await getRemovableDrives(); setDrives(result); } catch { setDrives([]); diff --git a/src/features/deviceSync/hooks/useDeviceSyncSourceStatuses.ts b/src/features/deviceSync/hooks/useDeviceSyncSourceStatuses.ts index a0b2b847..eaa03f69 100644 --- a/src/features/deviceSync/hooks/useDeviceSyncSourceStatuses.ts +++ b/src/features/deviceSync/hooks/useDeviceSyncSourceStatuses.ts @@ -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('compute_sync_paths', { + const paths = await computeSyncPaths({ tracks: tracks.map((tr, idx) => trackToSyncInfo( tr, '', source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined, diff --git a/src/features/deviceSync/utils/deviceSyncHelpers.ts b/src/features/deviceSync/utils/deviceSyncHelpers.ts index e352931f..af8b8836 100644 --- a/src/features/deviceSync/utils/deviceSyncHelpers.ts +++ b/src/features/deviceSync/utils/deviceSyncHelpers.ts @@ -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, diff --git a/src/features/deviceSync/utils/runDeviceSyncExecution.ts b/src/features/deviceSync/utils/runDeviceSyncExecution.ts index dc394d98..92a49f6b 100644 --- a/src/features/deviceSync/utils/runDeviceSyncExecution.ts +++ b/src/features/deviceSync/utils/runDeviceSyncExecution.ts @@ -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 { 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('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('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'); diff --git a/src/features/deviceSync/utils/runDeviceSyncMigration.ts b/src/features/deviceSync/utils/runDeviceSyncMigration.ts index 2382d3f1..5372b0e5 100644 --- a/src/features/deviceSync/utils/runDeviceSyncMigration.ts +++ b/src/features/deviceSync/utils/runDeviceSyncMigration.ts @@ -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('compute_sync_paths', { + const newAbsPaths = await computeSyncPaths({ tracks: albumSourceTracks.map(tr => trackToSyncInfo(tr, '')), destDir: targetDir, }); diff --git a/src/features/equalizer/hooks/useAutoEq.ts b/src/features/equalizer/hooks/useAutoEq.ts index 65db5e11..0e6e8fa6 100644 --- a/src/features/equalizer/hooks/useAutoEq.ts +++ b/src/features/equalizer/hooks/useAutoEq.ts @@ -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('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('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); diff --git a/src/features/favorites/components/FavoriteSongRow.tsx b/src/features/favorites/components/FavoriteSongRow.tsx index 351d0b97..fd8b17dd 100644 --- a/src/features/favorites/components/FavoriteSongRow.tsx +++ b/src/features/favorites/components/FavoriteSongRow.tsx @@ -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 { diff --git a/src/features/home/components/SongCard.tsx b/src/features/home/components/SongCard.tsx index 607a936c..f28a3224 100644 --- a/src/features/home/components/SongCard.tsx +++ b/src/features/home/components/SongCard.tsx @@ -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 { diff --git a/src/features/lyrics/api/netease.ts b/src/features/lyrics/api/netease.ts index a018ef36..c41bfacd 100644 --- a/src/features/lyrics/api/netease.ts +++ b/src/features/lyrics/api/netease.ts @@ -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 { try { - return await invoke('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; } diff --git a/src/features/lyrics/hooks/useLyrics.ts b/src/features/lyrics/hooks/useLyrics.ts index caa04a9b..c96d7990 100644 --- a/src/features/lyrics/hooks/useLyrics.ts +++ b/src/features/lyrics/hooks/useLyrics.ts @@ -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('get_embedded_lyrics', { path: filePath }); + const lrcString = await commands.getEmbeddedLyrics(filePath); if (!lrcString) return false; const lines = parseLrc(lrcString); diff --git a/src/features/miniPlayer/components/MiniMeta.tsx b/src/features/miniPlayer/components/MiniMeta.tsx index 07469d81..9660c19c 100644 --- a/src/features/miniPlayer/components/MiniMeta.tsx +++ b/src/features/miniPlayer/components/MiniMeta.tsx @@ -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 { diff --git a/src/features/miniPlayer/components/MiniPlayer.tsx b/src/features/miniPlayer/components/MiniPlayer.tsx index 47ac7b7a..3ab9c647 100644 --- a/src/features/miniPlayer/components/MiniPlayer.tsx +++ b/src/features/miniPlayer/components/MiniPlayer.tsx @@ -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, diff --git a/src/features/miniPlayer/hooks/useMiniWindowSetup.ts b/src/features/miniPlayer/hooks/useMiniWindowSetup.ts index 14a32797..561a686d 100644 --- a/src/features/miniPlayer/hooks/useMiniWindowSetup.ts +++ b/src/features/miniPlayer/hooks/useMiniWindowSetup.ts @@ -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); diff --git a/src/features/nowPlaying/components/Hero.tsx b/src/features/nowPlaying/components/Hero.tsx index 21f364ea..066e2167 100644 --- a/src/features/nowPlaying/components/Hero.tsx +++ b/src/features/nowPlaying/components/Hero.tsx @@ -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'; diff --git a/src/features/nowPlaying/components/MobilePlayerView.tsx b/src/features/nowPlaying/components/MobilePlayerView.tsx index 2f54d994..82479419 100644 --- a/src/features/nowPlaying/components/MobilePlayerView.tsx +++ b/src/features/nowPlaying/components/MobilePlayerView.tsx @@ -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 { diff --git a/src/features/offline/pages/OfflineLibrary.tsx b/src/features/offline/pages/OfflineLibrary.tsx index 3da66973..7959a254 100644 --- a/src/features/offline/pages/OfflineLibrary.tsx +++ b/src/features/offline/pages/OfflineLibrary.tsx @@ -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('get_media_tier_size', { tier: 'library', mediaDir }).catch(() => 0), - invoke('get_media_tier_size', { tier: 'favorites', mediaDir }).catch(() => 0), + getMediaTierSize({ tier: 'library', mediaDir }).catch(() => 0), + getMediaTierSize({ tier: 'favorites', mediaDir }).catch(() => 0), ]); setOfflineDiskBytes({ library, favorites }); }, []); diff --git a/src/features/offline/store/offlineJobStore.ts b/src/features/offline/store/offlineJobStore.ts index d89e7648..c5414f25 100644 --- a/src/features/offline/store/offlineJobStore.ts +++ b/src/features/offline/store/offlineJobStore.ts @@ -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(); 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(() => {}); } } diff --git a/src/features/offline/store/offlineStore.ts b/src/features/offline/store/offlineStore.ts index 32ab7215..d09bb838 100644 --- a/src/features/offline/store/offlineStore.ts +++ b/src/features/offline/store/offlineStore.ts @@ -11,6 +11,7 @@ import { showToast } from '@/lib/dom/toast'; import { useOfflineJobStore, cancelledDownloads } from '@/features/offline/store/offlineJobStore'; import { useLocalPlaybackStore, type PinSource } from '@/store/localPlaybackStore'; import { getMediaDir } from '@/lib/media/mediaDir'; +import { checkDirAccessible, clearOfflineCancel } from '@/lib/api/syncfs'; import { findLocalPlaybackEntry } from '@/store/localPlaybackResolve'; import { isOfflinePinComplete, @@ -98,7 +99,7 @@ async function runOfflinePinDownload(task: OfflinePinTask): Promise { const mediaDir = getMediaDir(); if (mediaDir) { - const ok = await invoke('check_dir_accessible', { path: mediaDir }).catch(() => false); + const ok = await checkDirAccessible({ path: mediaDir }).catch(() => false); if (!ok) { showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error'); return; @@ -162,7 +163,7 @@ async function runOfflinePinDownload(task: OfflinePinTask): Promise { if (cancelledDownloads.has(albumId)) { cancelledDownloads.delete(albumId); jobStore.setState(state => ({ jobs: state.jobs.filter(j => j.albumId !== albumId) })); - invoke('clear_offline_cancel', { downloadId }).catch(() => {}); + clearOfflineCancel({ downloadId }).catch(() => {}); return; } @@ -241,7 +242,7 @@ async function runOfflinePinDownload(task: OfflinePinTask): Promise { })); } - invoke('clear_offline_cancel', { downloadId }).catch(() => {}); + clearOfflineCancel({ downloadId }).catch(() => {}); setTimeout(() => { jobStore.setState(state => ({ jobs: state.jobs.filter( diff --git a/src/features/offline/utils/favoritesOfflineSync.ts b/src/features/offline/utils/favoritesOfflineSync.ts index f8e78015..e28a285b 100644 --- a/src/features/offline/utils/favoritesOfflineSync.ts +++ b/src/features/offline/utils/favoritesOfflineSync.ts @@ -12,6 +12,12 @@ import { cancelledDownloads, useOfflineJobStore } from '@/features/offline/store import { useFavoritesOfflineSyncStore } from '@/features/offline/store/favoritesOfflineSyncStore'; import { useLocalPlaybackStore } from '@/store/localPlaybackStore'; import { getMediaDir } from '@/lib/media/mediaDir'; +import { + cancelOfflineDownloads, + clearOfflineCancel, + deleteMediaFile, + pruneEmptyMediaTierDirs, +} from '@/lib/api/syncfs'; import { resolveIndexKey, serverIndexKeyForProfile } from '@/lib/server/serverIndexKey'; import { FAVORITES_OFFLINE_JOB_ID } from '@/features/offline/utils/favoritesOfflineConstants'; import { isActiveServerReachable } from '@/lib/network/activeServerReachability'; @@ -49,9 +55,9 @@ function cancelInFlightFavoritesDownloads(): void { cancelledDownloads.add(FAVORITES_OFFLINE_JOB_ID); const downloadIds = rustDownloadIdsForFavoritesJobs(); if (downloadIds.length > 0) { - invoke('cancel_offline_downloads', { downloadIds }).catch(() => {}); + cancelOfflineDownloads({ downloadIds }).catch(() => {}); for (const id of downloadIds) { - invoke('clear_offline_cancel', { downloadId: id }).catch(() => {}); + clearOfflineCancel({ downloadId: id }).catch(() => {}); } } activeFavoritesDownloadId = null; @@ -149,10 +155,10 @@ async function pruneOrphanFavoriteAuto( if (entry.tier !== 'favorite-auto') continue; if (!entryBelongsToServer(entry, serverId)) continue; if (targetIds.has(entry.trackId)) continue; - await invoke('delete_media_file', { localPath: entry.localPath, mediaDir }).catch(() => {}); + await deleteMediaFile({ localPath: entry.localPath, mediaDir }).catch(() => {}); lp.removeEntry(entry.trackId, entry.serverIndexKey, 'favorite-unstar-prune'); } - await invoke('prune_empty_media_tier_dirs', { tier: 'favorite-auto', mediaDir }).catch(() => {}); + await pruneEmptyMediaTierDirs({ tier: 'favorite-auto', mediaDir }).catch(() => {}); } export async function disableFavoritesOfflineSync(): Promise { @@ -284,8 +290,8 @@ async function runFavoritesOfflineSyncOneServer(serverId: string, token: number) jobStore.setState(state => ({ jobs: state.jobs.filter(j => j.albumId !== FAVORITES_OFFLINE_JOB_ID), })); - invoke('cancel_offline_downloads', { downloadIds: [downloadId] }).catch(() => {}); - invoke('clear_offline_cancel', { downloadId }).catch(() => {}); + cancelOfflineDownloads({ downloadIds: [downloadId] }).catch(() => {}); + clearOfflineCancel({ downloadId }).catch(() => {}); activeFavoritesDownloadId = null; return; } @@ -329,7 +335,7 @@ async function runFavoritesOfflineSyncOneServer(serverId: string, token: number) || cancelledDownloads.has(FAVORITES_OFFLINE_JOB_ID) || !targetIds.has(song.id) ) { - await invoke('delete_media_file', { localPath: res.path, mediaDir }).catch(() => {}); + await deleteMediaFile({ localPath: res.path, mediaDir }).catch(() => {}); return { song, error: 'CANCELLED' }; } useLocalPlaybackStore.getState().upsertEntry({ @@ -371,10 +377,10 @@ async function runFavoritesOfflineSyncOneServer(serverId: string, token: number) ), })); if (activeFavoritesDownloadId === downloadId) { - invoke('clear_offline_cancel', { downloadId }).catch(() => {}); + clearOfflineCancel({ downloadId }).catch(() => {}); activeFavoritesDownloadId = null; } - await invoke('prune_empty_media_tier_dirs', { tier: 'favorite-auto', mediaDir }).catch(() => {}); + await pruneEmptyMediaTierDirs({ tier: 'favorite-auto', mediaDir }).catch(() => {}); } } catch (err) { if (token === runToken) { diff --git a/src/features/offline/utils/legacyOfflineFileMigration.ts b/src/features/offline/utils/legacyOfflineFileMigration.ts index 21d5f745..2e62933c 100644 --- a/src/features/offline/utils/legacyOfflineFileMigration.ts +++ b/src/features/offline/utils/legacyOfflineFileMigration.ts @@ -1,4 +1,4 @@ -import { invoke } from '@tauri-apps/api/core'; +import { frontendDebugLog } from '@/lib/api/debugLog'; import { libraryGetTracksBatch } from '@/lib/api/library'; import { useAuthStore } from '@/store/authStore'; import { useOfflineStore, type OfflineAlbumMeta } from '@/features/offline/store/offlineStore'; @@ -6,18 +6,10 @@ import { useLocalPlaybackStore, type LocalPlaybackEntry, type PinSource } from ' import { localPlaybackEntryKey } from '@/store/localPlaybackKeys'; import { importLegacyLocalPlayback } from '@/store/localPlaybackMigration'; import { getMediaDir } from '@/lib/media/mediaDir'; +import { migrateLegacyOfflineDisk } from '@/lib/api/syncfs'; import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup'; import { resolveIndexKey } from '@/lib/server/serverIndexKey'; - -interface LegacyOfflineMigrationResult { - trackId: string; - serverIndexKey: string; - path: string; - size: number; - layoutFingerprint: string; - relocated: boolean; - skippedReason?: string | null; -} +import type { LegacyOfflineMigrationResult } from '@/generated/bindings'; type PersistCapableStore = { persist: { @@ -44,10 +36,7 @@ function waitForStoreHydration(store: PersistCapableStore): Promise { function migrationDebug(payload: Record): void { if (useAuthStore.getState().loggingMode !== 'debug') return; - void invoke('frontend_debug_log', { - scope: 'legacy-offline-migration', - message: JSON.stringify(payload), - }).catch(() => {}); + frontendDebugLog('legacy-offline-migration', JSON.stringify(payload)); } function resolveIndexKeyForServerId(serverId: string): string { @@ -192,7 +181,7 @@ export async function runLegacyOfflineFileMigration(serverIndexKey?: string): Pr const customOfflineDir = useAuthStore.getState().offlineDownloadDir?.trim() || null; let relocated = 0; try { - const results = await invoke('migrate_legacy_offline_disk', { + const results = await migrateLegacyOfflineDisk({ mediaDir: getMediaDir(), customOfflineDir, serverIndexKeyFilter: serverIndexKey ?? null, diff --git a/src/features/offline/utils/libraryTierReconcile.ts b/src/features/offline/utils/libraryTierReconcile.ts index 69f26a68..d6f49be5 100644 --- a/src/features/offline/utils/libraryTierReconcile.ts +++ b/src/features/offline/utils/libraryTierReconcile.ts @@ -1,17 +1,18 @@ import { libraryUpsertSongsFromApi } from '@/lib/api/library'; import { librarySqlServerId } from '@/lib/api/coverCache'; import type { SubsonicSong } from '@/lib/api/subsonicTypes'; -import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from '@/store/authStore'; import type { LocalPlaybackEntry, PinSource } from '@/store/localPlaybackStore'; import { useLocalPlaybackStore } from '@/store/localPlaybackStore'; import { getMediaDir } from '@/lib/media/mediaDir'; +import { discoverLibraryTierOnDisk, pruneOrphanLibraryTierFiles } from '@/lib/api/syncfs'; import { resolveIndexKey, serverIndexKeyForProfile } from '@/lib/server/serverIndexKey'; import { entryBelongsToServer, findLocalPlaybackEntry, indexKeyBelongsToServer, } from '@/store/localPlaybackResolve'; +import type { LibraryTierDiskHit } from '@/generated/bindings'; interface LibraryTrackProbeResult { path: string; @@ -20,14 +21,6 @@ interface LibraryTrackProbeResult { exists: boolean; } -interface LibraryTierDiskHit { - trackId: string; - path: string; - size: number; - layoutFingerprint: string; - suffix: string; -} - export interface LibraryTierReconcileResult { syncedFromDisk: number; removedStaleIndex: number; @@ -93,7 +86,7 @@ async function discoverLibraryTierHits( const serverIndexKey = serverIndexKeyForServerId(serverId); const libraryServerId = librarySqlServerId(serverId); try { - return await invoke('discover_library_tier_on_disk', { + return await discoverLibraryTierOnDisk({ serverIndexKey, libraryServerId, candidateTrackIds, @@ -186,7 +179,7 @@ export async function reconcileLibraryTierForServer( let orphansRemoved: number; try { - const removed = await invoke('prune_orphan_library_tier_files', { + const removed = await pruneOrphanLibraryTierFiles({ serverIndexKey, keepPaths: [...keepPaths], mediaDir: getMediaDir(), @@ -258,7 +251,7 @@ export async function reconcileLibraryTierForAlbum( let orphansRemoved: number; try { - const removed = await invoke('prune_orphan_library_tier_files', { + const removed = await pruneOrphanLibraryTierFiles({ serverIndexKey, keepPaths: [...keepPaths], mediaDir: getMediaDir(), diff --git a/src/features/offline/utils/pinnedOfflineSync.ts b/src/features/offline/utils/pinnedOfflineSync.ts index fb722c28..b3d34319 100644 --- a/src/features/offline/utils/pinnedOfflineSync.ts +++ b/src/features/offline/utils/pinnedOfflineSync.ts @@ -3,7 +3,6 @@ import { getAlbumForServer, filterSongsToServerLibrary } from '@/lib/api/subsoni import { getPlaylistForServer } from '@/lib/api/subsonicPlaylists'; import { getArtistForServer } from '@/lib/api/subsonicArtists'; import type { SubsonicSong } from '@/lib/api/subsonicTypes'; -import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from '@/store/authStore'; import type { PinSource } from '@/store/localPlaybackStore'; import { useLocalPlaybackStore } from '@/store/localPlaybackStore'; @@ -11,6 +10,7 @@ import { useOfflineStore } from '@/features/offline/store/offlineStore'; import { usePlaylistStore } from '@/features/playlist'; import { isSmartPlaylistName } from '@/lib/format/playlistDetailHelpers'; import { getMediaDir } from '@/lib/media/mediaDir'; +import { deleteMediaFile } from '@/lib/api/syncfs'; import { isActiveServerReachable, onActiveServerBecameReachable, @@ -123,7 +123,7 @@ async function pruneRemovedPinTracks( if (!entry?.localPath || entry.tier !== 'library') continue; if (entry.pinSource?.kind !== kind || entry.pinSource.sourceId !== sourceId) continue; - await invoke('delete_media_file', { localPath: entry.localPath, mediaDir }).catch(() => {}); + await deleteMediaFile({ localPath: entry.localPath, mediaDir }).catch(() => {}); lp.removeEntry(trackId, entry.serverIndexKey, `${kind}-sync-prune`); } } diff --git a/src/features/orbit/utils/orbitDiag.ts b/src/features/orbit/utils/orbitDiag.ts index e9cb8da3..44bdda63 100644 --- a/src/features/orbit/utils/orbitDiag.ts +++ b/src/features/orbit/utils/orbitDiag.ts @@ -10,7 +10,7 @@ * logging on in Settings, so the same data lands in `psysonic-logs-*.log` * for power users who want a persistent file. */ -import { invoke } from '@tauri-apps/api/core'; +import { frontendDebugLog } from '@/lib/api/debugLog'; import { useAuthStore } from '@/store/authStore'; /** Hard cap so a long session doesn't spend memory unbounded. ~200 entries. */ @@ -54,10 +54,7 @@ export function pushOrbitEvent(scope: string, message: string | Record { /* best-effort */ }); + frontendDebugLog(`orbit:${scope}`, text); } } diff --git a/src/features/playback/components/PlayerBar.tsx b/src/features/playback/components/PlayerBar.tsx index 6c174e47..845ff642 100644 --- a/src/features/playback/components/PlayerBar.tsx +++ b/src/features/playback/components/PlayerBar.tsx @@ -7,7 +7,7 @@ import { SlidersVertical, X, PictureInPicture2, Ellipsis, } from 'lucide-react'; -import { invoke } from '@tauri-apps/api/core'; +import { openMiniPlayer } from '@/lib/api/miniPlayer'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { useShallow } from 'zustand/react/shallow'; import { useAuthStore } from '@/store/authStore'; @@ -276,7 +276,7 @@ export default function PlayerBar() { {isLayoutVisible('miniPlayer') && (