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