jellytau/src-tauri/src/settings.rs
Duncan Tourolle ada3ed64ab Workstream E (backend): wire tauri-specta — annotate commands, derive Type, generate-ready Builder
- Add #[specta::specta] to all 201 #[tauri::command] functions.
- Derive specta::Type on all IPC DTOs (repository/types, settings, player/storage/
  download command DTOs, player enums, jellyfin SessionInfo/NowPlayingItem/PlayState,
  ThumbnailCacheStats, DownloadInfo, CacheConfig, etc.).
- Replace tauri::generate_handler! with a tauri_specta::Builder + collect_commands!
  in lib.rs (exports bindings.ts in debug builds).

Two contract changes required by specta constraints (frontend migration follows):
- specta caps command arity at 10 args: download_item_and_start / download_item /
  download_video now take a single request struct (params bundled, body unchanged
  via destructuring).
- specta can't parse split serde rename_all: SessionInfo/NowPlayingItem/PlayState
  switched to rename_all = "PascalCase" (Jellyfin deserialization preserved; these
  now serialize PascalCase to the frontend).

cargo check --lib is clean (0 errors). Frontend migration to bindings.ts is the next step.
2026-06-20 18:20:25 +02:00

204 lines
6.3 KiB
Rust

//! TRACES: UR-023, UR-031, UR-032, UR-033 | DR-034, DR-035, DR-036, DR-048
use serde::{Deserialize, Serialize};
/// Volume normalization levels matching Spotify's presets
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum VolumeLevel {
/// Louder output (-11 LUFS)
Loud,
/// Default level (-14 LUFS)
#[default]
Normal,
/// Quieter output (-23 LUFS)
Quiet,
}
impl VolumeLevel {
/// Get the target LUFS value for this volume level
pub fn target_lufs(&self) -> f32 {
match self {
VolumeLevel::Loud => -11.0,
VolumeLevel::Normal => -14.0,
VolumeLevel::Quiet => -23.0,
}
}
}
/// Audio playback settings
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AudioSettings {
/// Crossfade duration in seconds (0 = disabled, max 12)
pub crossfade_duration: f32,
/// Enable gapless playback between tracks
pub gapless_playback: bool,
/// Enable volume normalization
pub normalize_volume: bool,
/// Target volume level for normalization
pub volume_level: VolumeLevel,
}
impl Default for AudioSettings {
fn default() -> Self {
Self {
crossfade_duration: 0.0,
gapless_playback: true,
normalize_volume: false,
volume_level: VolumeLevel::Normal,
}
}
}
impl AudioSettings {
/// Clamp crossfade duration to valid range (0-12 seconds)
pub fn with_crossfade_clamped(mut self) -> Self {
self.crossfade_duration = self.crossfade_duration.clamp(0.0, 12.0);
self
}
}
/// Video playback settings
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoSettings {
/// Enable auto-play of next episode (with countdown)
pub auto_play_next_episode: bool,
/// Countdown duration in seconds before auto-play (5-30 seconds)
pub auto_play_countdown_seconds: u32,
/// Maximum number of episodes to auto-play consecutively (0 = unlimited)
#[serde(default)]
pub auto_play_max_episodes: u32,
}
impl Default for VideoSettings {
fn default() -> Self {
Self {
auto_play_next_episode: true,
auto_play_countdown_seconds: 10,
auto_play_max_episodes: 0,
}
}
}
impl VideoSettings {
/// Clamp countdown duration to valid range (5-30 seconds)
pub fn with_countdown_clamped(mut self) -> Self {
self.auto_play_countdown_seconds = self.auto_play_countdown_seconds.clamp(5, 30);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_settings() {
let settings = AudioSettings::default();
assert_eq!(settings.crossfade_duration, 0.0);
assert!(settings.gapless_playback);
assert!(!settings.normalize_volume);
assert_eq!(settings.volume_level, VolumeLevel::Normal);
}
#[test]
fn test_volume_level_lufs() {
assert_eq!(VolumeLevel::Loud.target_lufs(), -11.0);
assert_eq!(VolumeLevel::Normal.target_lufs(), -14.0);
assert_eq!(VolumeLevel::Quiet.target_lufs(), -23.0);
}
#[test]
fn test_crossfade_clamping() {
let settings = AudioSettings {
crossfade_duration: 20.0,
..Default::default()
}
.with_crossfade_clamped();
assert_eq!(settings.crossfade_duration, 12.0);
let settings = AudioSettings {
crossfade_duration: -5.0,
..Default::default()
}
.with_crossfade_clamped();
assert_eq!(settings.crossfade_duration, 0.0);
}
#[test]
fn test_settings_serialization() {
let settings = AudioSettings {
crossfade_duration: 5.0,
gapless_playback: true,
normalize_volume: true,
volume_level: VolumeLevel::Loud,
};
let json = serde_json::to_string(&settings).unwrap();
assert!(json.contains("\"crossfadeDuration\":5.0"));
assert!(json.contains("\"gaplessPlayback\":true"));
assert!(json.contains("\"normalizeVolume\":true"));
assert!(json.contains("\"volumeLevel\":\"loud\""));
let parsed: AudioSettings = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.crossfade_duration, 5.0);
assert_eq!(parsed.volume_level, VolumeLevel::Loud);
}
#[test]
fn test_video_default_settings() {
let settings = VideoSettings::default();
assert!(settings.auto_play_next_episode);
assert_eq!(settings.auto_play_countdown_seconds, 10);
assert_eq!(settings.auto_play_max_episodes, 0);
}
#[test]
fn test_video_countdown_clamping() {
let settings = VideoSettings {
auto_play_countdown_seconds: 60,
..Default::default()
}
.with_countdown_clamped();
assert_eq!(settings.auto_play_countdown_seconds, 30);
let settings = VideoSettings {
auto_play_countdown_seconds: 2,
..Default::default()
}
.with_countdown_clamped();
assert_eq!(settings.auto_play_countdown_seconds, 5);
}
#[test]
fn test_video_settings_serialization() {
let settings = VideoSettings {
auto_play_next_episode: false,
auto_play_countdown_seconds: 15,
auto_play_max_episodes: 5,
};
let json = serde_json::to_string(&settings).unwrap();
assert!(json.contains("\"autoPlayNextEpisode\":false"));
assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
assert!(json.contains("\"autoPlayMaxEpisodes\":5"));
let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
assert!(!parsed.auto_play_next_episode);
assert_eq!(parsed.auto_play_countdown_seconds, 15);
assert_eq!(parsed.auto_play_max_episodes, 5);
}
#[test]
fn test_video_settings_backward_compat() {
// Old JSON without auto_play_max_episodes field
let json = r#"{"autoPlayNextEpisode":true,"autoPlayCountdownSeconds":10}"#;
let parsed: VideoSettings = serde_json::from_str(json).unwrap();
assert!(parsed.auto_play_next_episode);
assert_eq!(parsed.auto_play_countdown_seconds, 10);
assert_eq!(parsed.auto_play_max_episodes, 0);
}
}