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}