Skip to main content

jellytau_lib/
settings.rs

1//! TRACES: UR-023, UR-027, UR-031, UR-032, UR-033 | DR-030, DR-034, DR-035, DR-036, DR-048, IR-020
2
3use serde::{Deserialize, Serialize};
4
5/// Volume normalization levels matching Spotify's presets
6#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
7#[serde(rename_all = "lowercase")]
8pub enum VolumeLevel {
9    /// Louder output (-11 LUFS)
10    Loud,
11    /// Default level (-14 LUFS)
12    #[default]
13    Normal,
14    /// Quieter output (-23 LUFS)
15    Quiet,
16}
17
18impl VolumeLevel {
19    /// Get the target LUFS value for this volume level
20    pub fn target_lufs(&self) -> f32 {
21        match self {
22            VolumeLevel::Loud => -11.0,
23            VolumeLevel::Normal => -14.0,
24            VolumeLevel::Quiet => -23.0,
25        }
26    }
27}
28
29/// Centre frequencies (Hz) of the fixed 10-band ISO equalizer. The band count
30/// and layout are a property of the audio engine, not the UI — presets and the
31/// MPV filter are defined against these bands. See
32/// docs/architecture/05-platform-backends.md ("The equalizer, and where its
33/// vocabulary lives").
34///
35/// TRACES: UR-027 | DR-030, IR-020
36pub const EQ_BANDS: [f32; 10] = [
37    31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
38];
39/// Minimum per-band gain in dB.
40pub const EQ_GAIN_MIN: f32 = -12.0;
41/// Maximum per-band gain in dB.
42pub const EQ_GAIN_MAX: f32 = 12.0;
43
44/// Built-in equalizer presets. A preset *is* a gain curve defined by the band
45/// layout above (a domain concept), not a mere label — the curve numbers live
46/// in Rust so the frontend never encodes the taxonomy.
47///
48/// TRACES: UR-027 | DR-030
49#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub enum EqPreset {
52    Flat,
53    Rock,
54    Pop,
55    Jazz,
56    Classical,
57    BassBoost,
58    TrebleBoost,
59    Vocal,
60}
61
62impl EqPreset {
63    /// All presets, for enumerating the curve table across the IPC boundary.
64    pub const ALL: [EqPreset; 8] = [
65        EqPreset::Flat,
66        EqPreset::Rock,
67        EqPreset::Pop,
68        EqPreset::Jazz,
69        EqPreset::Classical,
70        EqPreset::BassBoost,
71        EqPreset::TrebleBoost,
72        EqPreset::Vocal,
73    ];
74
75    /// The 10-band gain curve (dB) for this preset, one entry per [`EQ_BANDS`].
76    /// Curves are conservative (within ±8 dB) so presets stack safely with the
77    /// player volume. Bands: 31 62 125 250 500 1k 2k 4k 8k 16k.
78    pub fn gains(&self) -> [f32; 10] {
79        match self {
80            EqPreset::Flat => [0.0; 10],
81            EqPreset::Rock => [5.0, 4.0, 3.0, 1.0, -1.0, -1.0, 1.0, 3.0, 4.0, 5.0],
82            EqPreset::Pop => [-1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0, -1.0],
83            EqPreset::Jazz => [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.0, 2.0, 3.0],
84            EqPreset::Classical => [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0],
85            EqPreset::BassBoost => [7.0, 6.0, 5.0, 3.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
86            EqPreset::TrebleBoost => [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 3.0, 5.0, 6.0, 7.0],
87            EqPreset::Vocal => [-2.0, -1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0],
88        }
89    }
90}
91
92/// Audio playback settings
93#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct AudioSettings {
96    /// Crossfade duration in seconds (0 = disabled, max 12)
97    pub crossfade_duration: f32,
98    /// Enable gapless playback between tracks
99    pub gapless_playback: bool,
100    /// Enable volume normalization
101    pub normalize_volume: bool,
102    /// Target volume level for normalization
103    pub volume_level: VolumeLevel,
104    /// Enable the graphic equalizer. When false, no EQ filter is applied.
105    #[serde(default)]
106    pub equalizer_enabled: bool,
107    /// Per-band gains in dB, one per [`EQ_BANDS`]. Normalised to 10 entries and
108    /// clamped to [`EQ_GAIN_MIN`, `EQ_GAIN_MAX`] via [`Self::with_equalizer_normalised`].
109    #[serde(default = "default_eq_bands")]
110    pub equalizer_bands: Vec<f32>,
111}
112
113/// Flat 10-band curve — the default equalizer state.
114fn default_eq_bands() -> Vec<f32> {
115    vec![0.0; EQ_BANDS.len()]
116}
117
118impl Default for AudioSettings {
119    fn default() -> Self {
120        Self {
121            crossfade_duration: 0.0,
122            gapless_playback: true,
123            normalize_volume: false,
124            volume_level: VolumeLevel::Normal,
125            equalizer_enabled: false,
126            equalizer_bands: default_eq_bands(),
127        }
128    }
129}
130
131impl AudioSettings {
132    /// Clamp crossfade duration to valid range (0-12 seconds)
133    pub fn with_crossfade_clamped(mut self) -> Self {
134        self.crossfade_duration = self.crossfade_duration.clamp(0.0, 12.0);
135        self
136    }
137
138    /// Normalise the equalizer band vector to exactly [`EQ_BANDS`]`.len()`
139    /// entries (pad with 0 dB / truncate) and clamp each gain to the valid
140    /// range. Guards against malformed persisted or IPC input.
141    ///
142    /// TRACES: UR-027 | DR-030
143    pub fn with_equalizer_normalised(mut self) -> Self {
144        let n = EQ_BANDS.len();
145        self.equalizer_bands.resize(n, 0.0);
146        for g in &mut self.equalizer_bands {
147            *g = g.clamp(EQ_GAIN_MIN, EQ_GAIN_MAX);
148        }
149        self
150    }
151}
152
153/// A ceiling on how much bandwidth a *video* stream may consume.
154///
155/// A quality step is a bundle of concrete transcode parameters — total stream
156/// ceiling, the audio share of it, and the resolution that ceiling can carry —
157/// not just a label. Those numbers are Jellyfin encoding domain vocabulary, so
158/// they live here and the frontend only ever names a variant; the labels the
159/// picker shows are served over IPC by `player_get_streaming_qualities`.
160///
161/// The ladder is deliberately expressed in bandwidth rather than resolution: it
162/// exists to fit a connection, and the resolution cap is chosen *from* the
163/// bitrate so the encoder does not spend a small budget on pixels it cannot
164/// afford. See docs/architecture/01-rust-backend.md ("Streaming quality
165/// ladder").
166///
167/// TRACES: UR-074 | DR-162
168#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
169#[serde(rename_all = "camelCase")]
170pub enum StreamingQuality {
171    /// No client-imposed cap — the server may direct-play the source as-is.
172    #[default]
173    Original,
174    Mbps20,
175    Mbps10,
176    Mbps8,
177    Mbps4,
178    Mbps2,
179    Mbps1,
180    Kbps720,
181}
182
183impl StreamingQuality {
184    /// The ladder, highest first, for enumerating across the IPC boundary.
185    pub const ALL: [StreamingQuality; 8] = [
186        StreamingQuality::Original,
187        StreamingQuality::Mbps20,
188        StreamingQuality::Mbps10,
189        StreamingQuality::Mbps8,
190        StreamingQuality::Mbps4,
191        StreamingQuality::Mbps2,
192        StreamingQuality::Mbps1,
193        StreamingQuality::Kbps720,
194    ];
195
196    /// Total bits per second the stream may use (video + audio), or `None` for
197    /// the uncapped `Original`.
198    ///
199    /// This is the number that goes to `PlaybackInfo` as `MaxStreamingBitrate`
200    /// and into the device profile. Sending it there — not just on the transcode
201    /// URL — is what makes the cap real: a stream the server decides to *direct
202    /// play* is served at the source file's own bitrate, and no URL parameter
203    /// afterwards can reduce it.
204    pub fn max_bitrate(&self) -> Option<u64> {
205        match self {
206            StreamingQuality::Original => None,
207            StreamingQuality::Mbps20 => Some(20_000_000),
208            StreamingQuality::Mbps10 => Some(10_000_000),
209            StreamingQuality::Mbps8 => Some(8_000_000),
210            StreamingQuality::Mbps4 => Some(4_000_000),
211            StreamingQuality::Mbps2 => Some(2_000_000),
212            StreamingQuality::Mbps1 => Some(1_000_000),
213            StreamingQuality::Kbps720 => Some(720_000),
214        }
215    }
216
217    /// Bits per second allotted to the audio track.
218    ///
219    /// The value shrinks with the ladder because at the bottom rungs a fixed
220    /// 384 kbps would be a third of the entire budget.
221    pub fn audio_bitrate(&self) -> u64 {
222        match self {
223            StreamingQuality::Original
224            | StreamingQuality::Mbps20
225            | StreamingQuality::Mbps10
226            | StreamingQuality::Mbps8 => 384_000,
227            StreamingQuality::Mbps4 => 256_000,
228            StreamingQuality::Mbps2 => 192_000,
229            StreamingQuality::Mbps1 => 128_000,
230            StreamingQuality::Kbps720 => 96_000,
231        }
232    }
233
234    /// Bits per second allotted to the video track: the total minus the audio
235    /// share, so the two together honour [`max_bitrate`](Self::max_bitrate)
236    /// rather than overshooting it by the size of the audio track.
237    pub fn video_bitrate(&self) -> Option<u64> {
238        self.max_bitrate()
239            .map(|total| total.saturating_sub(self.audio_bitrate()))
240    }
241
242    /// Resolution ceiling that suits the bitrate, or `None` to leave the source
243    /// resolution alone. Scaling down is what keeps a small budget looking like
244    /// clean video instead of blocky 1080p.
245    pub fn max_height(&self) -> Option<u32> {
246        match self {
247            // 20 Mbps carries 4K, so it caps bandwidth without capping pixels.
248            StreamingQuality::Original | StreamingQuality::Mbps20 => None,
249            StreamingQuality::Mbps10 | StreamingQuality::Mbps8 => Some(1080),
250            StreamingQuality::Mbps4 | StreamingQuality::Mbps2 => Some(720),
251            StreamingQuality::Mbps1 => Some(480),
252            StreamingQuality::Kbps720 => Some(360),
253        }
254    }
255
256    /// Human label for the picker. Lives in Rust with the numbers it describes,
257    /// so the two cannot drift apart.
258    pub fn label(&self) -> &'static str {
259        match self {
260            StreamingQuality::Original => "Original",
261            StreamingQuality::Mbps20 => "20 Mbps",
262            StreamingQuality::Mbps10 => "10 Mbps",
263            StreamingQuality::Mbps8 => "8 Mbps",
264            StreamingQuality::Mbps4 => "4 Mbps",
265            StreamingQuality::Mbps2 => "2 Mbps",
266            StreamingQuality::Mbps1 => "1 Mbps",
267            StreamingQuality::Kbps720 => "720 kbps",
268        }
269    }
270
271    /// Secondary line for the picker: what the cap means in practice.
272    pub fn detail(&self) -> &'static str {
273        match self {
274            StreamingQuality::Original => "No limit — highest quality",
275            StreamingQuality::Mbps20 => "Up to 4K",
276            StreamingQuality::Mbps10 => "1080p, high quality",
277            StreamingQuality::Mbps8 => "1080p",
278            StreamingQuality::Mbps4 => "720p",
279            StreamingQuality::Mbps2 => "720p, reduced",
280            StreamingQuality::Mbps1 => "480p",
281            StreamingQuality::Kbps720 => "360p — slowest connections",
282        }
283    }
284}
285
286/// Video playback settings
287#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
288#[serde(rename_all = "camelCase")]
289pub struct VideoSettings {
290    /// Enable auto-play of next episode (with countdown)
291    pub auto_play_next_episode: bool,
292    /// Countdown duration in seconds before auto-play (5-30 seconds)
293    pub auto_play_countdown_seconds: u32,
294    /// Maximum number of episodes to auto-play consecutively (0 = unlimited)
295    #[serde(default)]
296    pub auto_play_max_episodes: u32,
297    /// Bandwidth ceiling applied to every video stream.
298    ///
299    /// `#[serde(default)]` so settings JSON persisted before this field existed
300    /// loads as the previous behaviour (uncapped).
301    ///
302    /// TRACES: UR-074 | DR-162
303    #[serde(default)]
304    pub streaming_quality: StreamingQuality,
305}
306
307impl Default for VideoSettings {
308    fn default() -> Self {
309        Self {
310            auto_play_next_episode: true,
311            auto_play_countdown_seconds: 10,
312            auto_play_max_episodes: 0,
313            streaming_quality: StreamingQuality::Original,
314        }
315    }
316}
317
318impl VideoSettings {
319    /// Clamp countdown duration to valid range (5-30 seconds)
320    pub fn with_countdown_clamped(mut self) -> Self {
321        self.auto_play_countdown_seconds = self.auto_play_countdown_seconds.clamp(5, 30);
322        self
323    }
324}
325
326/// Library browsing preferences.
327///
328/// Currently a single list: the folders (or whole libraries) the user has asked
329/// to keep out of browsing. It is a *list of ids*, never names — names are
330/// unstable, locale-dependent and non-unique, and the hardcoded name filter this
331/// setting replaced broke on exactly that. What the ids then hide is decided in
332/// `repository::exclusions`; this struct is only how the choice is carried and
333/// persisted.
334///
335/// The default is an empty list: nobody inherits another user's folder layout.
336///
337/// TRACES: UR-076 | DR-209
338#[derive(specta::Type, Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "camelCase")]
340pub struct LibrarySettings {
341    /// Stable item ids of the folders/libraries hidden from browsing.
342    ///
343    /// `#[serde(default)]` so settings JSON persisted before this field existed
344    /// loads as the previous behaviour (nothing hidden).
345    #[serde(default)]
346    pub excluded_item_ids: Vec<String>,
347}
348
349impl LibrarySettings {
350    /// Drop blanks and duplicates from the id list.
351    ///
352    /// Applied on the way in from IPC and on the way out of the database, so a
353    /// hand-edited or half-written value cannot make the list grow without bound
354    /// or carry an empty id (which would match nothing but still be shown as a
355    /// selection in the picker).
356    ///
357    /// TRACES: UR-076 | DR-209
358    pub fn sanitised(mut self) -> Self {
359        let mut seen: Vec<String> = Vec::with_capacity(self.excluded_item_ids.len());
360        for id in self.excluded_item_ids.drain(..) {
361            let id = id.trim().to_string();
362            if id.is_empty() || seen.contains(&id) {
363                continue;
364            }
365            seen.push(id);
366        }
367        self.excluded_item_ids = seen;
368        self
369    }
370}
371
372/// Serialise `AudioSettings` into the JSON payload handed to the Android player
373/// over JNI.
374///
375/// Sanitises first (crossfade clamped, band vector normalised) so a malformed
376/// vector can never reach the Kotlin parser. JSON is used rather than a wide JNI
377/// signature so that adding a field does not change the method signature — the
378/// same approach `load()` already uses for subtitles.
379///
380/// The emitted keys are camelCase (serde) and `volumeLevel` is lowercase; the
381/// Kotlin side matches on those literals. Both are pinned by tests.
382///
383/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
384pub fn audio_settings_jni_payload(settings: &AudioSettings) -> Result<String, serde_json::Error> {
385    let sanitised = settings
386        .clone()
387        .with_crossfade_clamped()
388        .with_equalizer_normalised();
389    serde_json::to_string(&sanitised)
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    /// The JNI payload must sanitise before serialising: an over-long crossfade
397    /// is clamped and a wrong-length band vector is normalised to EQ_BANDS.len().
398    /// Sending raw values would let a malformed vector reach the Kotlin parser.
399    ///
400    /// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-1
401    #[test]
402    fn test_audio_settings_jni_payload_is_sanitised() {
403        let settings = AudioSettings {
404            crossfade_duration: 30.0,
405            equalizer_bands: vec![20.0, -30.0],
406            ..AudioSettings::default()
407        };
408
409        let json = audio_settings_jni_payload(&settings).expect("serialises");
410        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
411
412        assert_eq!(v["crossfadeDuration"], 12.0, "crossfade clamped to 12s");
413
414        let bands = v["equalizerBands"].as_array().expect("bands array");
415        assert_eq!(bands.len(), EQ_BANDS.len(), "band vector normalised to 10");
416        assert_eq!(bands[0], EQ_GAIN_MAX as f64, "gain clamped to +12dB");
417        assert_eq!(bands[1], EQ_GAIN_MIN as f64, "gain clamped to -12dB");
418    }
419
420    /// The Kotlin side parses these exact keys. camelCase is what serde emits
421    /// for AudioSettings; a rename here silently breaks the Android parser,
422    /// which is why the contract is pinned by a test rather than by convention.
423    ///
424    /// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-2
425    #[test]
426    fn test_audio_settings_jni_payload_key_contract() {
427        let json = audio_settings_jni_payload(&AudioSettings::default()).expect("serialises");
428        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
429
430        for key in [
431            "crossfadeDuration",
432            "gaplessPlayback",
433            "normalizeVolume",
434            "volumeLevel",
435            "equalizerEnabled",
436            "equalizerBands",
437        ] {
438            assert!(v.get(key).is_some(), "JNI payload must carry `{key}`");
439        }
440
441        // VolumeLevel is #[serde(rename_all = "lowercase")]; Kotlin matches on
442        // these literals.
443        assert_eq!(v["volumeLevel"], "normal");
444    }
445
446    #[test]
447    fn test_default_settings() {
448        let settings = AudioSettings::default();
449        assert_eq!(settings.crossfade_duration, 0.0);
450        assert!(settings.gapless_playback);
451        assert!(!settings.normalize_volume);
452        assert_eq!(settings.volume_level, VolumeLevel::Normal);
453        // Equalizer defaults: disabled and flat.
454        assert!(!settings.equalizer_enabled);
455        assert_eq!(settings.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
456    }
457
458    /// EQ presets each return one gain per band; Flat is all zeros.
459    ///
460    /// TRACES: UR-027 | DR-030 | UT-079
461    #[test]
462    fn test_eq_preset_curves() {
463        for preset in EqPreset::ALL {
464            assert_eq!(
465                preset.gains().len(),
466                EQ_BANDS.len(),
467                "preset {:?} must have one gain per band",
468                preset
469            );
470            // Every preset stays within the advertised gain range.
471            for g in preset.gains() {
472                assert!(
473                    (EQ_GAIN_MIN..=EQ_GAIN_MAX).contains(&g),
474                    "preset {:?} gain {} out of range",
475                    preset,
476                    g
477                );
478            }
479        }
480        assert_eq!(EqPreset::Flat.gains(), [0.0; 10]);
481        // Bass boost lifts the low bands and leaves the top flat.
482        let bass = EqPreset::BassBoost.gains();
483        assert!(bass[0] > 0.0 && bass[9] == 0.0);
484    }
485
486    /// `with_equalizer_normalised` clamps out-of-range gains and forces the
487    /// band vector to exactly EQ_BANDS.len() (pad short, truncate long).
488    ///
489    /// TRACES: UR-027 | DR-030 | UT-080
490    #[test]
491    fn test_eq_normalisation() {
492        // Out-of-range gains are clamped.
493        let s = AudioSettings {
494            equalizer_bands: vec![100.0, -100.0, 3.0],
495            ..Default::default()
496        }
497        .with_equalizer_normalised();
498        assert_eq!(s.equalizer_bands.len(), EQ_BANDS.len());
499        assert_eq!(s.equalizer_bands[0], EQ_GAIN_MAX);
500        assert_eq!(s.equalizer_bands[1], EQ_GAIN_MIN);
501        assert_eq!(s.equalizer_bands[2], 3.0);
502        // Short vector padded with 0 dB.
503        assert_eq!(s.equalizer_bands[9], 0.0);
504
505        // Over-long vector truncated.
506        let long = AudioSettings {
507            equalizer_bands: vec![1.0; 20],
508            ..Default::default()
509        }
510        .with_equalizer_normalised();
511        assert_eq!(long.equalizer_bands.len(), EQ_BANDS.len());
512    }
513
514    /// Old persisted JSON without the EQ fields loads as disabled + flat.
515    ///
516    /// TRACES: UR-027 | DR-030 | UT-081
517    #[test]
518    fn test_audio_settings_eq_backward_compat() {
519        let json = r#"{"crossfadeDuration":0.0,"gaplessPlayback":true,"normalizeVolume":false,"volumeLevel":"normal"}"#;
520        let parsed: AudioSettings = serde_json::from_str(json).unwrap();
521        assert!(!parsed.equalizer_enabled);
522        assert_eq!(parsed.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
523    }
524
525    /// EQ fields serialize as camelCase and round-trip.
526    ///
527    /// TRACES: UR-027 | DR-030 | UT-082
528    #[test]
529    fn test_audio_settings_eq_serialization() {
530        let settings = AudioSettings {
531            equalizer_enabled: true,
532            equalizer_bands: EqPreset::Rock.gains().to_vec(),
533            ..Default::default()
534        };
535        let json = serde_json::to_string(&settings).unwrap();
536        assert!(json.contains("\"equalizerEnabled\":true"));
537        assert!(json.contains("\"equalizerBands\":"));
538
539        let parsed: AudioSettings = serde_json::from_str(&json).unwrap();
540        assert!(parsed.equalizer_enabled);
541        assert_eq!(parsed.equalizer_bands, EqPreset::Rock.gains().to_vec());
542    }
543
544    #[test]
545    fn test_volume_level_lufs() {
546        assert_eq!(VolumeLevel::Loud.target_lufs(), -11.0);
547        assert_eq!(VolumeLevel::Normal.target_lufs(), -14.0);
548        assert_eq!(VolumeLevel::Quiet.target_lufs(), -23.0);
549    }
550
551    #[test]
552    fn test_crossfade_clamping() {
553        let settings = AudioSettings {
554            crossfade_duration: 20.0,
555            ..Default::default()
556        }
557        .with_crossfade_clamped();
558        assert_eq!(settings.crossfade_duration, 12.0);
559
560        let settings = AudioSettings {
561            crossfade_duration: -5.0,
562            ..Default::default()
563        }
564        .with_crossfade_clamped();
565        assert_eq!(settings.crossfade_duration, 0.0);
566    }
567
568    #[test]
569    fn test_settings_serialization() {
570        let settings = AudioSettings {
571            crossfade_duration: 5.0,
572            gapless_playback: true,
573            normalize_volume: true,
574            volume_level: VolumeLevel::Loud,
575            ..Default::default()
576        };
577
578        let json = serde_json::to_string(&settings).unwrap();
579        assert!(json.contains("\"crossfadeDuration\":5.0"));
580        assert!(json.contains("\"gaplessPlayback\":true"));
581        assert!(json.contains("\"normalizeVolume\":true"));
582        assert!(json.contains("\"volumeLevel\":\"loud\""));
583
584        let parsed: AudioSettings = serde_json::from_str(&json).unwrap();
585        assert_eq!(parsed.crossfade_duration, 5.0);
586        assert_eq!(parsed.volume_level, VolumeLevel::Loud);
587    }
588
589    #[test]
590    fn test_video_default_settings() {
591        let settings = VideoSettings::default();
592        assert!(settings.auto_play_next_episode);
593        assert_eq!(settings.auto_play_countdown_seconds, 10);
594        assert_eq!(settings.auto_play_max_episodes, 0);
595    }
596
597    #[test]
598    fn test_video_countdown_clamping() {
599        let settings = VideoSettings {
600            auto_play_countdown_seconds: 60,
601            ..Default::default()
602        }
603        .with_countdown_clamped();
604        assert_eq!(settings.auto_play_countdown_seconds, 30);
605
606        let settings = VideoSettings {
607            auto_play_countdown_seconds: 2,
608            ..Default::default()
609        }
610        .with_countdown_clamped();
611        assert_eq!(settings.auto_play_countdown_seconds, 5);
612    }
613
614    #[test]
615    fn test_video_settings_serialization() {
616        let settings = VideoSettings {
617            auto_play_next_episode: false,
618            auto_play_countdown_seconds: 15,
619            auto_play_max_episodes: 5,
620            streaming_quality: StreamingQuality::Mbps4,
621        };
622
623        let json = serde_json::to_string(&settings).unwrap();
624        assert!(json.contains("\"autoPlayNextEpisode\":false"));
625        assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
626        assert!(json.contains("\"autoPlayMaxEpisodes\":5"));
627        assert!(json.contains("\"streamingQuality\":\"mbps4\""));
628
629        let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
630        assert!(!parsed.auto_play_next_episode);
631        assert_eq!(parsed.auto_play_countdown_seconds, 15);
632        assert_eq!(parsed.auto_play_max_episodes, 5);
633    }
634
635    #[test]
636    fn test_video_settings_backward_compat() {
637        // Old JSON without auto_play_max_episodes field
638        let json = r#"{"autoPlayNextEpisode":true,"autoPlayCountdownSeconds":10}"#;
639        let parsed: VideoSettings = serde_json::from_str(json).unwrap();
640        assert!(parsed.auto_play_next_episode);
641        assert_eq!(parsed.auto_play_countdown_seconds, 10);
642        assert_eq!(parsed.auto_play_max_episodes, 0);
643        // Settings persisted before the cap existed must load as uncapped —
644        // inventing a limit for an upgrading user would silently degrade their
645        // picture with no setting having been changed.
646        assert_eq!(parsed.streaming_quality, StreamingQuality::Original);
647    }
648
649    /// The whole point of a step is the number of bits it promises not to
650    /// exceed, so video + audio must fit inside the total — a video bitrate set
651    /// to the full cap would overshoot it by the size of the audio track.
652    ///
653    /// TRACES: UR-074 | DR-162 | UT-157
654    #[test]
655    fn test_streaming_quality_budget_is_internally_consistent() {
656        for quality in StreamingQuality::ALL {
657            let Some(total) = quality.max_bitrate() else {
658                assert_eq!(
659                    quality,
660                    StreamingQuality::Original,
661                    "only Original may be uncapped"
662                );
663                assert!(quality.video_bitrate().is_none());
664                assert!(quality.max_height().is_none());
665                continue;
666            };
667
668            let video = quality.video_bitrate().expect("a capped step caps video");
669            assert_eq!(
670                video + quality.audio_bitrate(),
671                total,
672                "{:?}: video + audio must equal the cap",
673                quality
674            );
675            assert!(
676                video > 0,
677                "{:?}: audio must not consume the budget",
678                quality
679            );
680            assert!(!quality.label().is_empty());
681            assert!(!quality.detail().is_empty());
682        }
683    }
684
685    /// The ladder is presented to the user as descending, and the resolution cap
686    /// must fall with it — a lower bitrate paired with a higher resolution would
687    /// spend the smaller budget on more pixels, which is backwards.
688    ///
689    /// TRACES: UR-074 | DR-162 | UT-157
690    #[test]
691    fn test_streaming_quality_ladder_descends() {
692        let steps = StreamingQuality::ALL;
693        for pair in steps.windows(2) {
694            let (higher, lower) = (pair[0], pair[1]);
695            let higher_bitrate = higher.max_bitrate().unwrap_or(u64::MAX);
696            let lower_bitrate = lower.max_bitrate().unwrap_or(u64::MAX);
697            assert!(
698                higher_bitrate > lower_bitrate,
699                "{:?} must sit above {:?}",
700                higher,
701                lower
702            );
703            assert!(
704                higher.max_height().unwrap_or(u32::MAX) >= lower.max_height().unwrap_or(u32::MAX),
705                "{:?} must not cap resolution below {:?}",
706                higher,
707                lower
708            );
709            assert!(higher.audio_bitrate() >= lower.audio_bitrate());
710        }
711    }
712
713    /// The persisted form is the serde token, and it must survive a round trip —
714    /// a rename here silently resets everyone's saved cap to uncapped.
715    ///
716    /// TRACES: UR-074 | DR-162 | UT-157
717    #[test]
718    fn test_streaming_quality_round_trips_through_json() {
719        for quality in StreamingQuality::ALL {
720            let json = serde_json::to_string(&quality).expect("serialises");
721            let parsed: StreamingQuality = serde_json::from_str(&json).expect("parses back");
722            assert_eq!(parsed, quality);
723        }
724        assert_eq!(
725            serde_json::to_string(&StreamingQuality::Mbps10).unwrap(),
726            "\"mbps10\""
727        );
728    }
729}