Skip to main content

jellytau_lib/commands/player/
settings.rs

1//! Audio and video playback settings commands.
2//!
3//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033, UR-074 | DR-025, DR-030, DR-034, DR-035, DR-036, DR-162, IR-020
4
5use std::sync::Arc;
6
7use log::{info, warn};
8use tauri::{Manager, State};
9
10use super::{PlayerStateWrapper, VideoSettingsWrapper};
11use crate::commands::storage::DatabaseWrapper;
12use crate::player::AutoplaySettings;
13use crate::settings::{AudioSettings, EqPreset, StreamingQuality, VideoSettings};
14use crate::storage::db_service::{DatabaseService, Query, QueryParam};
15use crate::utils::lock::MutexSafe;
16
17/// `app_settings` key holding the persisted streaming bandwidth ceiling.
18///
19/// The cap is persisted (unlike the rest of `VideoSettings`, which is
20/// process-lifetime state) because forgetting it is the one failure that costs
21/// the user something real: a limit set for a metered connection that silently
22/// reverts to uncapped on the next launch spends their data allowance without
23/// ever showing them a changed setting.
24///
25/// TRACES: UR-074 | DR-162
26const STREAMING_QUALITY_KEY: &str = "streaming_quality";
27
28#[tauri::command]
29#[specta::specta]
30pub async fn player_set_audio_settings(
31    player: State<'_, PlayerStateWrapper>,
32    settings: AudioSettings,
33) -> Result<AudioSettings, String> {
34    // Validate/normalise domain values before applying: clamp crossfade to its
35    // range and normalise the equalizer band vector (length + gain clamps).
36    let validated = settings
37        .with_crossfade_clamped()
38        .with_equalizer_normalised();
39    let mut controller = player.0.lock().await;
40    controller
41        .set_audio_settings(&validated)
42        .map_err(|e| e.to_string())?;
43    Ok(controller.audio_settings())
44}
45
46/// The built-in equalizer presets and their per-band gain curves (dB), for the
47/// settings UI. The curve numbers are domain data defined by the band layout,
48/// so the frontend reads them here rather than encoding them.
49///
50/// TRACES: UR-027 | DR-030
51#[tauri::command]
52#[specta::specta]
53pub async fn player_get_eq_presets() -> Result<Vec<(EqPreset, Vec<f32>)>, String> {
54    Ok(EqPreset::ALL
55        .iter()
56        .map(|p| (*p, p.gains().to_vec()))
57        .collect())
58}
59
60#[tauri::command]
61#[specta::specta]
62pub async fn player_get_audio_settings(
63    player: State<'_, PlayerStateWrapper>,
64) -> Result<AudioSettings, String> {
65    let controller = player.0.lock().await;
66    Ok(controller.audio_settings())
67}
68
69#[tauri::command]
70#[specta::specta]
71pub async fn player_set_video_settings(
72    video_settings: State<'_, VideoSettingsWrapper>,
73    player: State<'_, PlayerStateWrapper>,
74    db: State<'_, DatabaseWrapper>,
75    settings: VideoSettings,
76) -> Result<VideoSettings, String> {
77    let validated = settings.with_countdown_clamped();
78    {
79        let mut current = video_settings.0.lock().map_err(|e| e.to_string())?;
80        *current = validated.clone();
81    } // Drop MutexGuard before await
82
83    // The bandwidth ceiling is read by the repository's URL builders and by the
84    // PlaybackInfo negotiation, neither of which can see this wrapper.
85    // TRACES: UR-074 | DR-162
86    crate::repository::online::set_streaming_quality(validated.streaming_quality);
87    persist_streaming_quality(&db, validated.streaming_quality).await;
88
89    // Sync to PlayerController's autoplay settings so on_playback_ended() uses current values
90    let controller = player.0.lock().await;
91    controller.set_autoplay_settings(AutoplaySettings {
92        enabled: validated.auto_play_next_episode,
93        countdown_seconds: validated.auto_play_countdown_seconds,
94        max_episodes: validated.auto_play_max_episodes,
95    });
96
97    Ok(validated)
98}
99
100/// The bandwidth ceilings the quality picker may offer, each with the label and
101/// one-line detail to show for it, highest first.
102///
103/// The ladder and its numbers are Jellyfin encoding domain vocabulary, so the
104/// frontend reads them here rather than encoding them — the same arrangement as
105/// [`player_get_eq_presets`].
106///
107/// TRACES: UR-074 | DR-162
108#[tauri::command]
109#[specta::specta]
110pub async fn player_get_streaming_qualities(
111) -> Result<Vec<(StreamingQuality, String, String)>, String> {
112    Ok(StreamingQuality::ALL
113        .iter()
114        .map(|q| (*q, q.label().to_string(), q.detail().to_string()))
115        .collect())
116}
117
118/// Write the ceiling to `app_settings`. Failure is logged, not returned: the
119/// setting has already been applied in memory, and refusing the whole call
120/// because the write failed would leave the UI showing a cap that *is* active.
121///
122/// TRACES: UR-074 | DR-162
123async fn persist_streaming_quality(db: &State<'_, DatabaseWrapper>, quality: StreamingQuality) {
124    let db_service = {
125        let database = db.0.lock_safe();
126        Arc::new(database.service())
127    };
128
129    let encoded = match serde_json::to_string(&quality) {
130        Ok(value) => value,
131        Err(e) => {
132            warn!("[VideoSettings] Failed to encode streaming quality: {}", e);
133            return;
134        }
135    };
136
137    let query = Query::with_params(
138        "INSERT OR REPLACE INTO app_settings (key, value, updated_at)
139         VALUES (?, ?, CURRENT_TIMESTAMP)",
140        vec![
141            QueryParam::String(STREAMING_QUALITY_KEY.to_string()),
142            QueryParam::String(encoded),
143        ],
144    );
145
146    if let Err(e) = db_service.execute(query).await {
147        warn!("[VideoSettings] Failed to persist streaming quality: {}", e);
148    }
149}
150
151/// Restore the persisted bandwidth ceiling at startup, into both the repository
152/// (which enforces it) and `VideoSettings` (which the settings UI reads).
153///
154/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
155/// default — uncapped — in place, so a database problem degrades to the old
156/// behaviour rather than to an arbitrary limit.
157///
158/// TRACES: UR-074 | DR-162
159pub async fn restore_streaming_quality(app: &tauri::AppHandle) {
160    let db_service = {
161        let Some(db) = app.try_state::<DatabaseWrapper>() else {
162            warn!("[VideoSettings] No database available; streaming quality stays uncapped");
163            return;
164        };
165        let database = db.0.lock_safe();
166        Arc::new(database.service())
167    };
168
169    let query = Query::with_params(
170        "SELECT value FROM app_settings WHERE key = ?",
171        vec![QueryParam::String(STREAMING_QUALITY_KEY.to_string())],
172    );
173
174    let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
175        Ok(value) => value,
176        Err(e) => {
177            warn!("[VideoSettings] Failed to read streaming quality: {}", e);
178            return;
179        }
180    };
181
182    let Some(stored) = stored else { return };
183    let quality: StreamingQuality = match serde_json::from_str(&stored) {
184        Ok(quality) => quality,
185        Err(e) => {
186            warn!(
187                "[VideoSettings] Ignoring unrecognised persisted streaming quality {:?}: {}",
188                stored, e
189            );
190            return;
191        }
192    };
193
194    crate::repository::online::set_streaming_quality(quality);
195    if let Some(video_settings) = app.try_state::<VideoSettingsWrapper>() {
196        video_settings.0.lock_safe().streaming_quality = quality;
197    }
198    info!(
199        "[VideoSettings] Restored streaming quality cap: {}",
200        quality.label()
201    );
202}
203
204#[tauri::command]
205#[specta::specta]
206pub async fn player_get_video_settings(
207    video_settings: State<'_, VideoSettingsWrapper>,
208) -> Result<VideoSettings, String> {
209    let current = video_settings.0.lock().map_err(|e| e.to_string())?;
210    Ok(current.clone())
211}