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

This commit is contained in:
Psychotoxical
2026-07-04 16:00:31 +02:00
committed by GitHub
parent fdbb9deac6
commit 77ab95170d
199 changed files with 3691 additions and 1252 deletions
@@ -12,6 +12,7 @@ psysonic-core = { path = "../psysonic-core" }
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
tokio = { version = "1", features = ["rt", "time", "sync"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "rustls", "gzip", "brotli"] }
futures-util = "0.3"
@@ -37,7 +37,7 @@ impl AnalysisTierCounts {
}
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPipelineQueueStatsDto {
pub pipeline_workers: u32,
@@ -11,7 +11,7 @@ use crate::analysis_runtime::{
prune_analysis_queues, AnalysisBackfillPriority, PlaybackPriorityHints,
};
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct WaveformCachePayload {
pub bins: Vec<u8>,
@@ -35,7 +35,7 @@ impl From<analysis_cache::WaveformEntry> for WaveformCachePayload {
}
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LoudnessCachePayload {
pub integrated_lufs: f64,
@@ -45,7 +45,7 @@ pub struct LoudnessCachePayload {
pub updated_at: i64,
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisDeleteServerReportDto {
pub analysis_tracks: u64,
@@ -53,7 +53,7 @@ pub struct AnalysisDeleteServerReportDto {
pub loudness: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisFailedTrackDto {
pub track_id: String,
@@ -81,7 +81,7 @@ impl From<analysis_cache::FailedTrackEntry> for AnalysisFailedTrackDto {
}
}
#[derive(serde::Deserialize)]
#[derive(serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisServerKeyMigrationDto {
pub legacy_id: String,
@@ -148,6 +148,7 @@ pub fn get_loudness_payload_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_waveform(
track_id: String,
md5_16kb: String,
@@ -172,6 +173,7 @@ pub fn analysis_get_waveform(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_waveform_for_track(
track_id: String,
server_id: Option<String>,
@@ -192,6 +194,7 @@ pub fn analysis_get_waveform_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_loudness_for_track(
track_id: String,
target_lufs: Option<f64>,
@@ -203,6 +206,7 @@ pub fn analysis_get_loudness_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_delete_loudness_for_track(
track_id: String,
server_id: Option<String>,
@@ -212,6 +216,7 @@ pub fn analysis_delete_loudness_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_delete_waveform_for_track(
track_id: String,
server_id: Option<String>,
@@ -221,6 +226,7 @@ pub fn analysis_delete_waveform_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_delete_all_waveforms(
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
@@ -228,6 +234,7 @@ pub fn analysis_delete_all_waveforms(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_delete_all_for_server(
server_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
@@ -240,6 +247,7 @@ pub fn analysis_delete_all_for_server(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_failed_track_count(
server_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
@@ -252,6 +260,7 @@ pub fn analysis_get_failed_track_count(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_list_failed_tracks(
server_id: String,
limit: Option<u32>,
@@ -269,6 +278,7 @@ pub fn analysis_list_failed_tracks(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_clear_failed_tracks(
server_id: String,
track_ids: Option<Vec<String>>,
@@ -288,6 +298,7 @@ pub fn analysis_clear_failed_tracks(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_migrate_server_index_keys(
mappings: Vec<AnalysisServerKeyMigrationDto>,
_cache: tauri::State<'_, analysis_cache::AnalysisCache>,
@@ -299,6 +310,7 @@ pub fn analysis_migrate_server_index_keys(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_enqueue_seed_from_url(
track_id: String,
url: String,
@@ -318,7 +330,7 @@ pub fn analysis_enqueue_seed_from_url(
)
}
#[derive(Debug, Clone, serde::Deserialize)]
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPriorityHintDto {
pub server_id: String,
@@ -326,6 +338,7 @@ pub struct AnalysisPriorityHintDto {
}
#[tauri::command]
#[specta::specta]
pub fn analysis_set_playback_priority_hints(
middle_track_refs: Vec<AnalysisPriorityHintDto>,
hints: tauri::State<'_, PlaybackPriorityHints>,
@@ -337,7 +350,7 @@ pub fn analysis_set_playback_priority_hints(
Ok(())
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisBackfillQueueStatsDto {
pub queued: usize,
@@ -346,17 +359,20 @@ pub struct AnalysisBackfillQueueStatsDto {
}
#[tauri::command]
#[specta::specta]
pub fn analysis_set_pipeline_parallelism(workers: u32) -> Result<(), String> {
crate::analysis_runtime::analysis_set_pipeline_parallelism(workers as usize);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_pipeline_queue_stats() -> Result<crate::analysis_runtime::AnalysisPipelineQueueStatsDto, String> {
Ok(analysis_pipeline_queue_stats())
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsDto, String> {
let (queued, in_progress_count, in_progress_track_id) =
analysis_backfill_queue_stats();
@@ -367,7 +383,7 @@ pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsD
})
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPrunePendingResult {
pub keep_count: usize,
@@ -380,6 +396,7 @@ pub struct AnalysisPrunePendingResult {
///
/// Keeps currently-running jobs untouched; only queued (not-yet-started) jobs are removed.
#[tauri::command]
#[specta::specta]
pub fn analysis_prune_pending_to_track_ids(
track_ids: Vec<String>,
server_id: String,
@@ -11,6 +11,7 @@ psysonic-core = { path = "../psysonic-core" }
psysonic-analysis = { path = "../psysonic-analysis" }
tauri = { version = "2" }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
@@ -40,6 +40,7 @@ pub(crate) fn autoeq_profile_url_candidates(
/// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions.
#[tauri::command]
#[specta::specta]
pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, String> {
audio_http_client(&state)
.get("https://autoeq.app/entries")
@@ -49,6 +50,7 @@ pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, Str
/// Fetches the AutoEQ FixedBandEQ profile for a specific headphone from GitHub raw content.
#[tauri::command]
#[specta::specta]
pub async fn autoeq_fetch_profile(
name: String,
source: String,
@@ -40,6 +40,10 @@ use super::state::{ChainedInfo, PreloadedTrack};
/// `stream_format_suffix`: Subsonic `song.suffix` (e.g. m4a); `stream.view` URLs have no
/// file extension, so this helps pick a Symphonia `format_hint` for ranged HTTP.
#[tauri::command]
// NOTE: excluded from tauri-specta collect_commands! — specta's SpectaFn is only
// implemented up to 10 args and this has 24. Typing it needs the args bundled into
// a struct (a behaviour/contract change), tracked for the D4 flip; stays on
// generate_handler! for now.
#[allow(clippy::too_many_arguments)]
pub async fn audio_play(
url: String,
@@ -602,6 +606,9 @@ pub async fn audio_play(
/// audio_play() checks chained_info.url on arrival: if it matches, it returns
/// immediately without touching the Sink (pure no-op on the audio path).
#[tauri::command]
// NOTE: excluded from tauri-specta collect_commands! — 13 args exceed specta's
// 10-arg SpectaFn limit; needs arg-bundling for the D4 flip. Stays on
// generate_handler! for now.
#[allow(clippy::too_many_arguments)]
pub async fn audio_chain_preload(
url: String,
@@ -15,6 +15,7 @@ use super::engine::AudioEngine;
/// When the saved `selected_device` no longer literally matches any listed
/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form.
#[tauri::command]
#[specta::specta]
pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option<String> {
let pinned = state.selected_device.lock().unwrap().clone()?;
if pinned.is_empty() {
@@ -50,12 +51,14 @@ pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<String> {
/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
#[tauri::command]
#[specta::specta]
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
audio_list_devices_for_engine(&state)
}
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
#[tauri::command]
#[specta::specta]
pub fn audio_default_output_device_name() -> Option<String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
with_suppressed_alsa_stderr(|| {
@@ -68,6 +71,7 @@ pub fn audio_default_output_device_name() -> Option<String> {
/// Switch the audio output device. `device_name = null` → follow system default.
/// Reopens the stream immediately; frontend must restart playback via audio:device-changed.
#[tauri::command]
#[specta::specta]
pub async fn audio_set_device(
device_name: Option<String>,
state: State<'_, AudioEngine>,
@@ -11,6 +11,7 @@ use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
#[tauri::command]
#[specta::specta]
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
cur.base_volume = volume.clamp(0.0, 1.0);
@@ -22,6 +23,7 @@ pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
}
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub fn audio_update_replay_gain(
volume: f32,
@@ -132,6 +134,7 @@ pub fn audio_update_replay_gain(
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State<'_, AudioEngine>) {
state.eq_enabled.store(enabled, Ordering::Relaxed);
state.eq_pre_gain.store(pre_gain.clamp(-30.0, 6.0).to_bits(), Ordering::Relaxed);
@@ -141,12 +144,14 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
state.crossfade_secs.store(secs.clamp(0.1, 12.0).to_bits(), Ordering::Relaxed);
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
state.gapless_enabled.store(enabled, Ordering::Relaxed);
}
@@ -154,6 +159,7 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
/// Duck the current sink over `fade_secs` without exhausting its source (which
/// would spuriously emit `audio:ended` before the interrupt handoff).
#[tauri::command]
#[specta::specta]
pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>) {
let fade_secs = fade_secs.clamp(0.1, 12.0);
let cur = state.current.lock().unwrap();
@@ -173,6 +179,7 @@ pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>)
/// (only when the next track is actually playable). When `false`, the engine's
/// normal early crossfade trigger is restored (plain crossfade / loud→loud).
#[tauri::command]
#[specta::specta]
pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
state
.autodj_suppress_autocrossfade
@@ -180,6 +187,7 @@ pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_playback_rate(
enabled: bool,
strategy: String,
@@ -263,6 +271,7 @@ pub fn audio_set_playback_rate(
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_normalization(
engine: String,
target_lufs: f32,
@@ -114,6 +114,7 @@ fn emit_preload_cancelled(app: &AppHandle, url: String, track_id: Option<String>
}
#[tauri::command]
#[specta::specta]
pub async fn audio_preload(
url: String,
duration_hint: f64,
@@ -336,6 +336,7 @@ async fn open_preview_decoder(
}
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
pub async fn audio_preview_play(
id: String,
@@ -532,6 +533,7 @@ mod tests {
}
#[tauri::command]
#[specta::specta]
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, true);
}
@@ -542,6 +544,7 @@ pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
/// auto-resume main playback the moment the preview ends and the user perceives
/// the click as having no effect.
#[tauri::command]
#[specta::specta]
pub fn audio_preview_stop_silent(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, false);
}
@@ -552,6 +555,7 @@ pub fn audio_preview_stop_silent(app: AppHandle, state: State<'_, AudioEngine>)
/// start, so the engine just clamps and applies the master headroom. No-op
/// when no preview is active.
#[tauri::command]
#[specta::specta]
pub fn audio_preview_set_volume(volume: f32, state: State<'_, AudioEngine>) {
if let Some(sink) = state.preview_sink.lock().unwrap().as_ref() {
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
@@ -31,6 +31,7 @@ use super::stream::{
/// Emits `audio:playing` with `duration = 0.0` (sentinel for live stream)
/// and `radio:metadata` whenever the StreamTitle changes.
#[tauri::command]
#[specta::specta]
pub async fn audio_play_radio(
url: String,
volume: f32,
@@ -18,6 +18,7 @@ use super::preview::preview_clear_for_new_main_playback;
use super::stream::{radio_download_task, RADIO_BUF_CAPACITY};
#[tauri::command]
#[specta::specta]
pub fn audio_pause(state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
if let Some(sink) = &cur.sink {
@@ -49,6 +50,7 @@ pub fn audio_pause(state: State<'_, AudioEngine>) {
/// ring buffer is created, its consumer is sent to `AudioStreamReader` (which
/// swaps it in on the next `read()`), and a new download task is spawned.
#[tauri::command]
#[specta::specta]
pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Result<(), String> {
// If a preview is running, cancel it first — otherwise sink.play() on the
// main sink would mix on top of the preview sink.
@@ -112,6 +114,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu
}
#[tauri::command]
#[specta::specta]
pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
preview_clear_for_new_main_playback(&state, &app);
state.generation.fetch_add(1, Ordering::SeqCst);
@@ -135,6 +138,7 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
}
#[tauri::command]
#[specta::specta]
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
let state = state.inner();
const AUDIO_SEEK_TIMEOUT_MS: u64 = 700;
@@ -8,6 +8,7 @@ publish = false
[dependencies]
tauri = { version = "2" }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls"] }
url = "2"
@@ -8,14 +8,14 @@ use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::RequestBuilder;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "lowercase")]
pub enum EndpointKind {
Local,
Public,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "lowercase")]
pub enum CustomHeadersApplyTo {
Local,
@@ -24,19 +24,19 @@ pub enum CustomHeadersApplyTo {
Both,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
pub struct ServerHttpEndpointWire {
pub url: String,
pub kind: EndpointKind,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
pub struct CustomHeaderEntryWire {
pub name: String,
pub value: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
pub struct ServerHttpContextSyncWire {
#[serde(rename = "serverId")]
pub server_id: String,
@@ -10,6 +10,7 @@ publish = false
psysonic-core = { path = "../psysonic-core" }
tauri = { version = "2" }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
@@ -4,7 +4,7 @@
// `js_app_id` is the ID their own embeddable widget uses and is broadly accepted.
pub const BANDSINTOWN_APP_ID: &str = "js_app_id";
#[derive(serde::Serialize, Default)]
#[derive(serde::Serialize, Default, specta::Type)]
pub struct BandsintownEvent {
datetime: String, // ISO 8601 (e.g. "2026-04-23T20:30:00")
venue_name: String,
@@ -20,6 +20,7 @@ pub struct BandsintownEvent {
/// Returns an empty list on any failure (404, network, parse) — the UI
/// just hides the section in that case.
#[tauri::command]
#[specta::specta]
pub async fn fetch_bandsintown_events(artist_name: String) -> Result<Vec<BandsintownEvent>, String> {
let trimmed = artist_name.trim();
if trimmed.is_empty() {
@@ -326,6 +326,7 @@ pub(crate) fn compute_discord_start_timestamp(elapsed_secs: f64, now_unix_secs:
/// user list (e.g. "🎵 Bohemian Rhapsody" instead of "🎵 Psysonic"). Default: "{title}".
/// Empty string falls back to the registered Discord application name.
/// Supported placeholders: {title}, {artist}, {album}
// NOT specta-collected: >10 total params exceed specta's SpectaFn arg cap. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn discord_update_presence(
@@ -448,6 +449,7 @@ pub async fn discord_update_presence(
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
#[tauri::command]
#[specta::specta]
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
let mut guard = state.client.lock().unwrap();
if let Some(client) = guard.as_mut() {
@@ -44,7 +44,7 @@ pub fn nd_apply_request(
}
/// Payload returned by Navidrome's `/auth/login`.
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
pub struct NdLoginResult {
pub(super) token: String,
#[serde(rename = "userId")]
@@ -10,6 +10,7 @@ use tauri::State;
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_http_client};
#[tauri::command]
#[specta::specta]
pub async fn upload_playlist_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -45,6 +46,7 @@ pub async fn upload_playlist_cover(
}
#[tauri::command]
#[specta::specta]
pub async fn upload_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -80,6 +82,7 @@ pub async fn upload_radio_cover(
}
#[tauri::command]
#[specta::specta]
pub async fn upload_artist_image(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -115,6 +118,7 @@ pub async fn upload_artist_image(
}
#[tauri::command]
#[specta::specta]
pub async fn delete_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -10,6 +10,7 @@ use tauri::State;
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry};
/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_list_playlists(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -46,6 +47,7 @@ pub async fn nd_list_playlists(
}
/// POST `/api/playlist` — create playlist (supports smart rules payload).
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_create_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -84,6 +86,7 @@ pub async fn nd_create_playlist(
}
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_update_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -123,6 +126,7 @@ pub async fn nd_update_playlist(
}
/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_get_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -160,6 +164,7 @@ pub async fn nd_get_playlist(
/// DELETE `/api/playlist/{id}` — delete playlist.
#[tauri::command]
#[specta::specta]
pub async fn nd_delete_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -54,6 +54,7 @@ pub async fn nd_list_songs_internal(
/// Tauri-visible variant — owned-String arguments to keep the IPC
/// surface unchanged for existing call sites in the WebView.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_list_songs(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -98,6 +99,7 @@ fn nd_build_filters(seed: serde_json::Map<String, serde_json::Value>, library_id
/// — paginated list of artists that have at least one credit in the given role.
/// Navidrome 0.55.0+ (uses `library_artist.stats` JSON aggregate). Available to any
/// authenticated user. Returns raw JSON array.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_artists_by_role(
@@ -158,6 +160,7 @@ pub async fn nd_list_artists_by_role(
/// Subsonic `getArtist.view` only walks AlbumArtist relations, so composer-only
/// (or conductor-only, lyricist-only, …) credits are unreachable there. Navidrome
/// generates `role_<role>_id` filters dynamically from `model.AllRoles`.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_albums_by_artist_role(
@@ -216,6 +219,7 @@ pub async fn nd_list_albums_by_artist_role(
}
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_list_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -249,6 +253,7 @@ pub async fn nd_list_libraries(
/// PUT `/api/user/{id}/library` — assign libraries to a non-admin user.
/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
#[tauri::command]
#[specta::specta]
pub async fn nd_set_user_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -299,6 +304,7 @@ pub async fn nd_set_user_libraries(
/// Returns `Ok(None)` when the response has no `path` field — Navidrome can omit
/// it for non-admin users on some configurations.
#[tauri::command]
#[specta::specta]
pub async fn nd_get_song_path(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -11,6 +11,7 @@ use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry, NdLoginR
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
#[tauri::command]
#[specta::specta]
pub async fn navidrome_login(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -50,6 +51,7 @@ pub async fn navidrome_login(
}
/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_list_users(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -83,6 +85,7 @@ pub async fn nd_list_users(
}
/// POST `/api/user` — create a user.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_create_user(
@@ -133,6 +136,7 @@ pub async fn nd_create_user(
}
/// PUT `/api/user/{id}` — update a user. Pass an empty `password` to leave it unchanged.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_update_user(
@@ -188,6 +192,7 @@ pub async fn nd_update_user(
/// DELETE `/api/user/{id}`.
#[tauri::command]
#[specta::specta]
pub async fn nd_delete_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
@@ -3,6 +3,7 @@ use psysonic_core::user_agent::subsonic_wire_user_agent;
pub const RADIO_PAGE_SIZE: u32 = 25;
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
@@ -26,6 +27,7 @@ pub async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serd
}
/// Fetch top-voted stations from radio-browser.info for initial suggestions.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
@@ -46,6 +48,7 @@ pub async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value
/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS.
/// Returns (bytes, content_type).
#[tauri::command]
#[specta::specta]
pub async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
@@ -75,6 +78,7 @@ pub async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
/// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions.
/// Returns the response body as a UTF-8 string for parsing on the JS side.
// NOT specta-collected: raw-JSON passthrough (FE JSON.parses the string) — kept hand-written on generate_handler! with the scrobbler/fetch family.
#[tauri::command]
pub async fn fetch_json_url(url: String) -> Result<String, String> {
let client = reqwest::Client::builder()
@@ -95,7 +99,7 @@ pub async fn fetch_json_url(url: String) -> Result<String, String> {
}
/// ICY metadata response returned to the frontend.
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
pub struct IcyMetadata {
/// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`).
stream_title: Option<String>,
@@ -173,6 +177,7 @@ pub async fn resolve_playlist_url(client: &reqwest::Client, url: &str) -> Option
/// If `url` is a PLS or M3U playlist file it is resolved to the first direct
/// stream URL before the ICY request is made.
#[tauri::command]
#[specta::specta]
pub async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
use futures_util::StreamExt;
@@ -277,6 +282,7 @@ pub async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
/// Returns the original URL unchanged if it is not a recognised playlist format
/// or if the playlist cannot be fetched/parsed.
#[tauri::command]
#[specta::specta]
pub async fn resolve_stream_url(url: String) -> String {
let Ok(client) = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
@@ -309,6 +315,7 @@ fn provider_http_client() -> Result<reqwest::Client, String> {
/// `params` is a list of [key, value] pairs (method must be included). If `sign`
/// is true an `api_sig` is computed (MD5 of sorted params + secret). If `get` is
/// true a GET request is made, otherwise a form POST.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn audioscrobbler_request(
base_url: String,
@@ -371,6 +378,7 @@ pub async fn audioscrobbler_request(
///
/// `path` is appended to `base_url` (e.g. `/1/submit-listens`). When `json_body`
/// is present the request is a POST with that body; otherwise a GET.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn listenbrainz_request(
base_url: String,
@@ -410,6 +418,7 @@ pub async fn listenbrainz_request(
///
/// `path` is appended to `base_url`. When `json_body` is present the request is a
/// POST with that body; otherwise a GET with `query` pairs.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn maloja_request(
base_url: String,
@@ -13,7 +13,7 @@ const DEFAULT_BATCH: u32 = 20;
const MAX_BATCH: u32 = 50;
const PROGRESS_SCAN_CHUNK: usize = 1000;
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAnalysisBackfillBatchDto {
pub track_ids: Vec<String>,
@@ -21,7 +21,7 @@ pub struct LibraryAnalysisBackfillBatchDto {
pub exhausted: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAnalysisProgressDto {
pub total_tracks: i64,
@@ -9,7 +9,7 @@ use crate::runtime::LibraryRuntime;
use crate::search::library_scope_equals_sql;
use crate::store::LibraryStore;
#[derive(Debug, Clone, serde::Deserialize)]
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct StarredAlbumReconcileItem {
pub id: String,
@@ -19,6 +19,7 @@ pub struct StarredAlbumReconcileItem {
/// Align `album.starred_at` with server favorites: UPDATE existing rows only
/// (no INSERT / stub rows). Clears local stars absent from `starred_albums`.
#[tauri::command]
#[specta::specta]
pub fn library_reconcile_album_stars(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -57,7 +57,7 @@ where
const TRACKS_BATCH_LIMIT: usize = 100;
const ANALYSIS_PROGRESS_CACHE_TTL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, serde::Deserialize)]
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryServerKeyMigrationDto {
pub legacy_id: String,
@@ -66,6 +66,7 @@ pub struct LibraryServerKeyMigrationDto {
/// Resolve cover disk + fetch ids from the local library (`album` | `artist` | `track`).
#[tauri::command]
#[specta::specta]
pub fn library_resolve_cover_entry(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -87,6 +88,7 @@ pub fn library_resolve_cover_entry(
}
#[tauri::command]
#[specta::specta]
pub fn library_analysis_backfill_batch(
app: AppHandle,
runtime: State<'_, LibraryRuntime>,
@@ -106,6 +108,7 @@ pub fn library_analysis_backfill_batch(
}
#[tauri::command]
#[specta::specta]
pub fn library_analysis_progress(
app: AppHandle,
runtime: State<'_, LibraryRuntime>,
@@ -156,6 +159,7 @@ pub fn library_analysis_progress(
}
#[tauri::command]
#[specta::specta]
pub fn library_count_live_tracks(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -169,6 +173,7 @@ pub fn library_count_live_tracks(
}
#[tauri::command]
#[specta::specta]
pub async fn library_get_status(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -302,6 +307,7 @@ fn resolve_local_track_count(
}
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_search(
runtime: State<'_, LibraryRuntime>,
@@ -335,6 +341,7 @@ pub async fn library_search(
Ok(LibraryTracksEnvelope { tracks, total })
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_get_track(
runtime: State<'_, LibraryRuntime>,
@@ -374,6 +381,7 @@ pub async fn library_get_track(
Ok(Some(dto))
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_get_tracks_batch(
runtime: State<'_, LibraryRuntime>,
@@ -389,6 +397,7 @@ pub async fn library_get_tracks_batch(
hydrate_refs(&runtime, &refs)
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_get_tracks_by_album(
runtime: State<'_, LibraryRuntime>,
@@ -401,6 +410,7 @@ pub async fn library_get_tracks_by_album(
/// Upsert Subsonic API song payloads into the library index so pin/download can
/// build `media/library/…` paths before a full sync has ingested the rows.
// NOT specta-collected: takes a serde_json::Value arg — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub fn library_upsert_songs_from_api(
runtime: State<'_, LibraryRuntime>,
@@ -434,6 +444,7 @@ pub fn library_upsert_songs_from_api(
}
#[tauri::command]
#[specta::specta]
pub async fn library_get_artifact(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -456,6 +467,7 @@ pub async fn library_get_artifact(
}
#[tauri::command]
#[specta::specta]
pub async fn library_get_facts(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -472,6 +484,7 @@ pub async fn library_get_facts(
}
#[tauri::command]
#[specta::specta]
pub async fn library_get_offline_path(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -501,6 +514,7 @@ pub async fn library_get_offline_path(
// PR-5d — Advanced Search (§5.13) + cross-server search (§5.5B)
// ──────────────────────────────────────────────────────────────────────
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_advanced_search(
runtime: State<'_, LibraryRuntime>,
@@ -510,6 +524,7 @@ pub async fn library_advanced_search(
library_spawn_blocking(move || advanced_search::run_advanced_search(&store, &request)).await
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_list_lossless_albums(
runtime: State<'_, LibraryRuntime>,
@@ -519,6 +534,7 @@ pub async fn library_list_lossless_albums(
library_spawn_blocking(move || crate::lossless_albums::list_lossless_albums(&store, &request)).await
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_list_albums_by_genre(
runtime: State<'_, LibraryRuntime>,
@@ -530,6 +546,7 @@ pub async fn library_list_albums_by_genre(
}
#[tauri::command]
#[specta::specta]
pub fn library_genre_tags_inspect(
runtime: State<'_, LibraryRuntime>,
) -> Result<crate::genre_tags_backfill::GenreTagsInspectDto, String> {
@@ -537,6 +554,7 @@ pub fn library_genre_tags_inspect(
}
#[tauri::command]
#[specta::specta]
pub async fn library_genre_tags_run(
app: tauri::AppHandle,
runtime: State<'_, LibraryRuntime>,
@@ -546,6 +564,7 @@ pub async fn library_genre_tags_run(
.await
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_get_artist_lossless_browse(
runtime: State<'_, LibraryRuntime>,
@@ -554,6 +573,7 @@ pub async fn library_get_artist_lossless_browse(
crate::artist_lossless_browse::get_artist_lossless_browse(&runtime.store, &request)
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_live_search(
runtime: State<'_, LibraryRuntime>,
@@ -589,6 +609,7 @@ pub async fn library_live_search(
Ok(result)
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_search_cross_server(
runtime: State<'_, LibraryRuntime>,
@@ -677,6 +698,7 @@ async fn navidrome_token_with_retry(
}
#[tauri::command]
#[specta::specta]
pub async fn library_sync_bind_session(
runtime: State<'_, LibraryRuntime>,
http_registry: State<'_, Arc<ServerHttpRegistry>>,
@@ -750,6 +772,7 @@ fn subsonic_base_url_from(runtime: &LibraryRuntime, server_id: &str) -> String {
}
#[tauri::command]
#[specta::specta]
pub fn library_sync_clear_session(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -759,6 +782,7 @@ pub fn library_sync_clear_session(
}
#[tauri::command]
#[specta::specta]
pub fn library_set_playback_hint(
runtime: State<'_, LibraryRuntime>,
hint: String,
@@ -774,6 +798,7 @@ pub fn library_set_playback_hint(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_playback_hint(runtime: State<'_, LibraryRuntime>) -> Result<String, String> {
Ok(match runtime.current_playback_hint() {
PlaybackHint::Idle => "idle".to_string(),
@@ -783,6 +808,7 @@ pub fn library_get_playback_hint(runtime: State<'_, LibraryRuntime>) -> Result<S
}
#[tauri::command]
#[specta::specta]
pub async fn library_sync_start(
app: AppHandle,
runtime: State<'_, LibraryRuntime>,
@@ -1017,6 +1043,7 @@ async fn library_sync_start_inner(
/// Mode A user-initiated full reconcile bypasses the threshold
/// check.
#[tauri::command]
#[specta::specta]
pub async fn library_sync_verify_integrity(
app: AppHandle,
runtime: State<'_, LibraryRuntime>,
@@ -1035,6 +1062,7 @@ pub async fn library_sync_verify_integrity(
}
#[tauri::command]
#[specta::specta]
pub fn library_sync_cancel(
runtime: State<'_, LibraryRuntime>,
job_id: Option<String>,
@@ -1080,6 +1108,7 @@ pub fn patch_content_hash(
.map_err(|e| e.to_string())
}
// NOT specta-collected: takes a serde_json::Value arg — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub fn library_patch_track(
runtime: State<'_, LibraryRuntime>,
@@ -1160,6 +1189,7 @@ pub(crate) fn apply_track_patch(
}
#[tauri::command]
#[specta::specta]
pub fn library_put_artifact(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -1176,6 +1206,7 @@ pub fn library_put_artifact(
}
#[tauri::command]
#[specta::specta]
pub fn library_put_fact(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -1188,6 +1219,7 @@ pub fn library_put_fact(
}
#[tauri::command]
#[specta::specta]
pub fn library_record_play_session(
runtime: State<'_, LibraryRuntime>,
input: PlaySessionInputDto,
@@ -1196,6 +1228,7 @@ pub fn library_record_play_session(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_player_stats_year_summary(
runtime: State<'_, LibraryRuntime>,
year: i32,
@@ -1204,6 +1237,7 @@ pub fn library_get_player_stats_year_summary(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_player_stats_heatmap(
runtime: State<'_, LibraryRuntime>,
year: i32,
@@ -1212,6 +1246,7 @@ pub fn library_get_player_stats_heatmap(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_player_stats_day_detail(
runtime: State<'_, LibraryRuntime>,
date_iso: String,
@@ -1220,6 +1255,7 @@ pub fn library_get_player_stats_day_detail(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_player_stats_year_bounds(
runtime: State<'_, LibraryRuntime>,
) -> Result<PlaySessionYearBoundsDto, String> {
@@ -1227,6 +1263,7 @@ pub fn library_get_player_stats_year_bounds(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_player_stats_recent_days(
runtime: State<'_, LibraryRuntime>,
limit: Option<u32>,
@@ -1235,6 +1272,7 @@ pub fn library_get_player_stats_recent_days(
}
#[tauri::command]
#[specta::specta]
pub fn library_get_recent_play_sessions(
runtime: State<'_, LibraryRuntime>,
limit: Option<u32>,
@@ -1245,6 +1283,7 @@ pub fn library_get_recent_play_sessions(
}
#[tauri::command]
#[specta::specta]
pub fn library_purge_server(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -1359,6 +1398,7 @@ pub fn library_purge_server(
}
#[tauri::command]
#[specta::specta]
pub fn library_migrate_server_index_keys(
_runtime: State<'_, LibraryRuntime>,
mappings: Vec<LibraryServerKeyMigrationDto>,
@@ -1370,6 +1410,7 @@ pub fn library_migrate_server_index_keys(
}
#[tauri::command]
#[specta::specta]
pub fn library_delete_server_data(
runtime: State<'_, LibraryRuntime>,
server_id: String,
@@ -23,7 +23,7 @@ const MAX_BATCH: u32 = 256;
const SCAN_PAGE: i64 = 256;
const MAX_SCAN_PAGES: usize = 16;
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverBackfillItem {
pub cache_kind: String,
@@ -31,7 +31,7 @@ pub struct CoverBackfillItem {
pub fetch_cover_art_id: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryCoverBackfillBatchDto {
pub items: Vec<CoverBackfillItem>,
@@ -41,7 +41,7 @@ pub struct LibraryCoverBackfillBatchDto {
pub exhausted: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryCoverProgressDto {
pub total_distinct: i64,
@@ -6,7 +6,7 @@ use rusqlite::OptionalExtension;
use crate::store::LibraryStore;
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct CoverEntryDto {
pub cache_kind: String,
+16 -16
View File
@@ -11,7 +11,7 @@ use crate::store::LibraryStore;
/// `library_get_status` payload — mirrors the `sync_state` row plus a
/// few derived counters from `track`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct SyncStateDto {
pub server_id: String,
@@ -177,7 +177,7 @@ pub struct LibraryTracksEnvelope {
}
/// `library_get_artifact` payload — one row of `track_artifact`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct TrackArtifactDto {
pub server_id: String,
@@ -196,7 +196,7 @@ pub struct TrackArtifactDto {
}
/// `library_get_facts` row.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct TrackFactDto {
pub server_id: String,
@@ -216,7 +216,7 @@ pub struct TrackFactDto {
/// `library_get_offline_path` outcome — either a path string or a
/// `missing` flag so the frontend can show a hint without polling.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct OfflinePathDto {
pub server_id: String,
@@ -238,7 +238,7 @@ pub struct TrackRefDto {
/// Input to `library_put_artifact`. Same shape as `TrackArtifactDto`
/// minus the server-supplied `server_id` / `track_id` (provided as
/// command args) and `fetched_at` (stamped server-side from `now`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactInputDto {
pub artifact_kind: String,
@@ -263,7 +263,7 @@ pub struct ArtifactInputDto {
/// Input to `library_put_fact`. Shape matches `TrackFactDto` minus the
/// indices.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct FactInputDto {
pub fact_kind: String,
@@ -290,7 +290,7 @@ fn default_confidence() -> f64 {
}
/// Input to `library_record_play_session`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionInputDto {
pub server_id: String,
@@ -305,7 +305,7 @@ pub struct PlaySessionInputDto {
}
/// Cross-server year summary for the Player stats tab.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionYearSummaryDto {
pub total_listened_sec: f64,
@@ -321,14 +321,14 @@ pub struct PlaySessionYearSummaryDto {
pub partial_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionHeatmapDayDto {
pub date: String,
pub track_play_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionDayTotalsDto {
pub total_listened_sec: f64,
@@ -338,7 +338,7 @@ pub struct PlaySessionDayTotalsDto {
pub partial_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionDayTrackDto {
pub server_id: String,
@@ -353,7 +353,7 @@ pub struct PlaySessionDayTrackDto {
pub cover_art_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionDayDetailDto {
pub totals: PlaySessionDayTotalsDto,
@@ -364,7 +364,7 @@ pub struct PlaySessionDayDetailDto {
pub type PlaySessionRecentTrackDto = PlaySessionDayTrackDto;
/// Summary for one day in the recent-days list (no track rows).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionRecentDayDto {
pub date: String,
@@ -376,7 +376,7 @@ pub struct PlaySessionRecentDayDto {
}
/// Earliest/latest calendar years with at least one session (local TZ).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionYearBoundsDto {
pub min_year: Option<i32>,
@@ -433,7 +433,7 @@ pub struct LibraryGenreAlbumsResponse {
}
/// `library_purge_server` outcome.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PurgeReportDto {
pub tracks_deleted: u32,
@@ -445,7 +445,7 @@ pub struct PurgeReportDto {
}
/// `library_sync_start` ack.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct SyncJobDto {
pub job_id: String,
@@ -26,7 +26,7 @@ fn ensure_genre_tags_tables(conn: &mut Connection) -> rusqlite::Result<()> {
crate::store::ensure_genre_tags_schema(conn)
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct GenreTagsInspectDto {
pub needed: bool,
@@ -17,6 +17,7 @@ tauri-plugin-shell = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
tokio = { version = "1", features = ["rt", "time", "sync"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] }
futures-util = "0.3"
+7 -1
View File
@@ -26,6 +26,7 @@ pub fn resolve_hot_cache_root(
/// Returns true if the current Linux system is Arch-based
/// (checks /etc/arch-release and /etc/os-release).
#[tauri::command]
#[specta::specta]
pub fn check_arch_linux() -> bool {
#[cfg(target_os = "linux")]
{
@@ -57,6 +58,7 @@ pub struct UpdateDownloadProgress {
/// Emits `update:download:progress` events with `{ bytes, total }` every 250 ms.
/// Returns the final absolute file path on success.
#[tauri::command]
#[specta::specta]
pub async fn download_update(url: String, filename: String, app: tauri::AppHandle) -> Result<String, String> {
use futures_util::StreamExt;
use std::time::{Duration, Instant};
@@ -130,6 +132,7 @@ pub async fn download_update(url: String, filename: String, app: tauri::AppHandl
/// Performs a track search, then fetches the LRC string for the best match.
/// Returns `None` if no match or no lyrics are found.
#[tauri::command]
#[specta::specta]
pub async fn fetch_netease_lyrics(artist: String, title: String) -> Result<Option<String>, String> {
let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
@@ -183,6 +186,7 @@ pub async fn fetch_netease_lyrics(artist: String, title: String) -> Result<Optio
/// Errors are silenced and mapped to `None` so the frontend falls through to the
/// next lyrics source without crashing.
#[tauri::command]
#[specta::specta]
pub fn get_embedded_lyrics(path: String) -> Option<String> {
use lofty::file::FileType;
use lofty::prelude::*;
@@ -282,6 +286,7 @@ pub fn get_embedded_lyrics(path: String) -> Option<String> {
/// Uses platform-specific process spawning — tauri-plugin-shell's open() only
/// allows https:// URLs per the capability scope and fails silently for paths.
#[tauri::command]
#[specta::specta]
pub fn open_folder(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
@@ -323,6 +328,7 @@ pub struct ZipProgress {
/// live MB-counter without holding any binary data in the WebView process.
/// Returns the final destination path on success.
#[tauri::command]
#[specta::specta]
pub async fn download_zip(
id: String,
url: String,
@@ -409,7 +415,7 @@ pub async fn download_zip(
}
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct HotCacheDownloadResult {
pub path: String,
+5
View File
@@ -10,6 +10,7 @@ use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult};
use super::offline::enqueue_analysis_seed_from_file;
#[tauri::command]
#[specta::specta]
pub async fn download_track_hot_cache(
track_id: String,
server_id: String,
@@ -129,6 +130,7 @@ pub async fn download_track_hot_cache(
/// Promotes bytes captured by the manual streaming path into hot cache on disk.
/// Returns `Ok(None)` when no completed stream cache is available for this URL.
#[tauri::command]
#[specta::specta]
pub async fn promote_stream_cache_to_hot_cache(
track_id: String,
server_id: String,
@@ -244,6 +246,7 @@ pub async fn promote_stream_cache_to_hot_cache(
}
#[tauri::command]
#[specta::specta]
pub async fn get_hot_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
resolve_hot_cache_root(custom_dir, &app)
.map(|root| super::fs_utils::dir_size_recursive(&root))
@@ -251,6 +254,7 @@ pub async fn get_hot_cache_size(custom_dir: Option<String>, app: tauri::AppHandl
}
#[tauri::command]
#[specta::specta]
pub async fn delete_hot_cache_track(
local_path: String,
custom_dir: Option<String>,
@@ -282,6 +286,7 @@ pub async fn delete_hot_cache_track(
/// Removes the entire hot cache root (`psysonic-hot-cache` for the active location).
#[tauri::command]
#[specta::specta]
pub async fn purge_hot_cache(custom_dir: Option<String>, app: tauri::AppHandle) -> Result<(), String> {
let root = resolve_hot_cache_root(custom_dir, &app)?;
if !root.exists() {
+17 -4
View File
@@ -44,7 +44,7 @@ pub fn resolve_media_dir(custom_media_dir: Option<&str>, app: &AppHandle) -> Res
}
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LocalTrackDownloadResult {
pub path: String,
@@ -52,7 +52,7 @@ pub struct LocalTrackDownloadResult {
pub layout_fingerprint: String,
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryTrackProbeResult {
pub path: String,
@@ -61,7 +61,7 @@ pub struct LibraryTrackProbeResult {
pub exists: bool,
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryTierDiskHit {
pub track_id: String,
@@ -230,6 +230,7 @@ async fn local_track_hit_if_exists(
/// `TRACK_NOT_INDEXED` when the row is missing. Disk scope uses `server_index_key`;
/// SQL lookup uses `library_server_id`.
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn download_track_local(
tier: String,
@@ -409,6 +410,7 @@ pub async fn download_track_local(
/// Scan library-tier bytes on disk and match them to known candidates only
/// (`track_offline.local_path` + canonical paths for `candidate_track_ids`).
#[tauri::command]
#[specta::specta]
pub async fn discover_library_tier_on_disk(
server_index_key: String,
library_server_id: String,
@@ -517,6 +519,7 @@ pub async fn discover_library_tier_on_disk(
/// Resolve the canonical `library/` path for a track and report on-disk presence only
/// (no download, no analysis seed).
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn probe_library_track_local(
track_id: String,
@@ -580,6 +583,7 @@ async fn prune_orphan_files_under_root(root: &Path, keep_paths: &[String]) -> Ve
/// Remove library-tier files under `{server_index_key}` that are not listed in `keep_paths`.
#[tauri::command]
#[specta::specta]
pub async fn prune_orphan_library_tier_files(
server_index_key: String,
keep_paths: Vec<String>,
@@ -654,6 +658,7 @@ async fn evict_orphan_files_under_root_to_fit(
/// Evict unindexed ephemeral cache files (oldest first) until tier size ≤ `max_bytes`.
#[tauri::command]
#[specta::specta]
pub async fn evict_ephemeral_cache_orphans_to_fit(
keep_paths: Vec<String>,
max_bytes: u64,
@@ -667,6 +672,7 @@ pub async fn evict_ephemeral_cache_orphans_to_fit(
/// Remove ephemeral-tier files under `{media}/cache/` not listed in `keep_paths`.
#[tauri::command]
#[specta::specta]
pub async fn prune_orphan_ephemeral_cache_files(
keep_paths: Vec<String>,
media_dir: Option<String>,
@@ -679,6 +685,7 @@ pub async fn prune_orphan_ephemeral_cache_files(
/// Batch existence probe for reconcile (index rows without on-disk bytes).
#[tauri::command]
#[specta::specta]
pub fn probe_media_files(local_paths: Vec<String>) -> Vec<bool> {
local_paths
.iter()
@@ -696,6 +703,7 @@ fn resolve_media_tier_root(
/// Recursive byte size under `{media}/{cache|library}/`.
#[tauri::command]
#[specta::specta]
pub async fn get_media_tier_size(
tier: String,
media_dir: Option<String>,
@@ -712,6 +720,7 @@ pub async fn get_media_tier_size(
/// Deletes the entire `{cache|library}/` subtree under the media root.
#[tauri::command]
#[specta::specta]
pub async fn purge_media_tier(
tier: String,
media_dir: Option<String>,
@@ -749,6 +758,7 @@ fn prune_parents_after_media_file_delete(
/// Deletes one media file and prunes empty parents up to the tier root.
#[tauri::command]
#[specta::specta]
pub async fn delete_media_file(
local_path: String,
media_dir: Option<String>,
@@ -766,6 +776,7 @@ pub async fn delete_media_file(
/// Removes empty directories under `{media}/{cache|library}/` (post-eviction sweep).
#[tauri::command]
#[specta::specta]
pub async fn prune_empty_media_tier_dirs(
tier: String,
media_dir: Option<String>,
@@ -780,6 +791,7 @@ pub async fn prune_empty_media_tier_dirs(
/// Promotes stream-cache bytes into `{media}/cache/…` using library-index paths.
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn promote_stream_cache_to_local(
track_id: String,
@@ -905,7 +917,7 @@ pub async fn promote_stream_cache_to_local(
}))
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LegacyOfflineMigrationResult {
pub track_id: String,
@@ -1228,6 +1240,7 @@ async fn relocate_legacy_track_file(
/// Scan `psysonic-offline/{segment}/{trackId}.ext`, verify each id in the library
/// index, and relocate live tracks into `{media}/library/…`.
#[tauri::command]
#[specta::specta]
pub async fn migrate_legacy_offline_disk(
media_dir: Option<String>,
custom_offline_dir: Option<String>,
+5
View File
@@ -88,6 +88,7 @@ pub(crate) fn resolve_offline_cache_dir(
/// Returns the absolute file path so TypeScript can store it and later
/// construct a `psysonic-local://<path>` URL for the audio engine.
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)] // Tauri command surface — args map 1:1 to the JS call.
pub async fn download_track_offline(
track_id: String,
@@ -160,6 +161,7 @@ pub async fn download_track_offline(
/// boundary; ones still parked on the download semaphore bail as soon as they
/// acquire a slot. Mirrors `cancel_device_sync` for the device-sync side.
#[tauri::command]
#[specta::specta]
pub fn cancel_offline_downloads(download_ids: Vec<String>) {
if let Ok(mut flags) = offline_cancel_flags().lock() {
for id in download_ids {
@@ -175,6 +177,7 @@ pub fn cancel_offline_downloads(download_ids: Vec<String>) {
/// across a long session. The frontend calls this once an album/playlist
/// download settles (completed or cancelled).
#[tauri::command]
#[specta::specta]
pub fn clear_offline_cancel(download_id: String) {
if let Ok(mut flags) = offline_cancel_flags().lock() {
flags.remove(&download_id);
@@ -435,6 +438,7 @@ mod tests {
/// Returns the total size in bytes of all files in the offline cache directory (and optional custom dir).
#[tauri::command]
#[specta::specta]
pub async fn get_offline_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
let default_dir = match app.path().app_data_dir() {
Ok(d) => d.join("psysonic-offline"),
@@ -474,6 +478,7 @@ pub(crate) async fn delete_offline_track_with_boundary(
/// After deleting the file, empty parent directories up to (but not including)
/// `base_dir` are pruned using `remove_dir` (never `remove_dir_all`).
#[tauri::command]
#[specta::specta]
pub async fn delete_offline_track(
local_path: String,
base_dir: Option<String>,
@@ -12,6 +12,7 @@ use super::device::{
};
#[tauri::command]
#[specta::specta]
pub async fn list_device_dir_files(dir: String) -> Result<Vec<String>, String> {
let root = std::path::PathBuf::from(&dir);
if !root.exists() {
@@ -45,6 +46,7 @@ pub async fn list_device_dir_files(dir: String) -> Result<Vec<String>, String> {
/// Deletes a file from the device and prunes empty parent directories
/// (up to 2 levels: album folder, then artist folder).
#[tauri::command]
#[specta::specta]
pub async fn delete_device_file(path: String) -> Result<(), String> {
let p = std::path::PathBuf::from(&path);
if p.exists() {
@@ -391,6 +393,7 @@ pub async fn calculate_sync_payload(
/// Signals a running `sync_batch_to_device` job to stop after its current tracks finish.
#[tauri::command]
#[specta::specta]
pub fn cancel_device_sync(job_id: String, app: tauri::AppHandle) {
if let Ok(flags) = sync_cancel_flags().lock() {
if let Some(flag) = flags.get(&job_id) {
@@ -405,6 +408,7 @@ pub fn cancel_device_sync(job_id: String, app: tauri::AppHandle) {
/// Emits throttled `device:sync:progress` events (max once per 500ms) and a
/// final `device:sync:complete` event with the summary.
#[tauri::command]
#[specta::specta]
pub async fn sync_batch_to_device(
tracks: Vec<TrackSyncInfo>,
dest_dir: String,
@@ -599,6 +603,7 @@ pub async fn sync_batch_to_device(
/// Deletes multiple files from the device in one call and prunes empty parent
/// directories. Returns the number of files successfully deleted.
#[tauri::command]
#[specta::specta]
pub async fn delete_device_files(paths: Vec<String>) -> Result<u32, String> {
let mut deleted: u32 = 0;
for path in &paths {
@@ -5,7 +5,7 @@ use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, su
// ─── Device Sync ─────────────────────────────────────────────────────────────
/// Information about a single mounted removable drive.
#[derive(Clone, serde::Serialize)]
#[derive(Clone, serde::Serialize, specta::Type)]
pub struct RemovableDrive {
pub name: String,
pub mount_point: String,
@@ -19,6 +19,7 @@ pub struct RemovableDrive {
/// On Linux these are typically USB sticks / SD cards under /media or /run/media.
/// On macOS they appear under /Volumes. On Windows they are separate drive letters.
#[tauri::command]
#[specta::specta]
pub fn get_removable_drives() -> Vec<RemovableDrive> {
use sysinfo::Disks;
let disks = Disks::new_with_refreshed_list();
@@ -64,7 +65,7 @@ pub fn read_device_manifest(dest_dir: String) -> Option<serde_json::Value> {
}
/// Per-entry result for `rename_device_files`.
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
pub struct RenameResult {
#[serde(rename = "oldPath")]
old_path: String,
@@ -85,6 +86,7 @@ pub struct RenameResult {
/// and which failed. Does not roll back on partial failure — each `fs::rename`
/// is atomic, so nothing can be half-renamed.
#[tauri::command]
#[specta::specta]
pub fn rename_device_files(
target_dir: String,
pairs: Vec<(String, String)>,
@@ -170,6 +172,7 @@ pub fn rename_device_files(
/// playlist is self-contained — moving/copying the folder anywhere keeps it
/// working. Tracks are expected to be in playlist order (index starts at 1).
#[tauri::command]
#[specta::specta]
pub fn write_playlist_m3u8(
dest_dir: String,
playlist_name: String,
@@ -231,7 +234,7 @@ pub fn is_path_on_mounted_volume(path: &std::path::Path) -> bool {
best_len > 0
}
#[derive(serde::Deserialize, Clone)]
#[derive(serde::Deserialize, Clone, specta::Type)]
pub struct TrackSyncInfo {
pub id: String,
pub url: String,
@@ -261,14 +264,14 @@ pub struct TrackSyncInfo {
}
/// Summary returned by `sync_batch_to_device` after all tracks are processed.
#[derive(Clone, serde::Serialize)]
#[derive(Clone, serde::Serialize, specta::Type)]
pub struct SyncBatchResult {
pub done: u32,
pub skipped: u32,
pub failed: u32,
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
pub struct SyncTrackResult {
pub path: String,
pub skipped: bool,
@@ -359,6 +362,7 @@ pub(crate) async fn sync_download_one_track(
/// Downloads a single track to a USB/SD device using the configured filename template.
/// Emits `device:sync:progress` events with `{ jobId, trackId, status, path? }`.
#[tauri::command]
#[specta::specta]
pub async fn sync_track_to_device(
track: TrackSyncInfo,
dest_dir: String,
@@ -408,6 +412,7 @@ pub async fn sync_track_to_device(
/// Computes the expected file paths for a batch of tracks under the fixed schema.
/// Used by the cleanup flow to find orphans.
#[tauri::command]
#[specta::specta]
pub fn compute_sync_paths(tracks: Vec<TrackSyncInfo>, dest_dir: String) -> Vec<String> {
tracks.iter().map(|track| {
let relative = build_track_path(track);