//! TRACES: UR-023, UR-027, UR-031, UR-032, UR-033 | DR-030, DR-034, DR-035, DR-036, DR-048, IR-020 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, } } } /// Centre frequencies (Hz) of the fixed 10-band ISO equalizer. The band count /// and layout are a property of the audio engine, not the UI — presets and the /// MPV filter are defined against these bands. See /// docs/architecture/05-platform-backends.md ("The equalizer, and where its /// vocabulary lives"). /// /// TRACES: UR-027 | DR-030, IR-020 pub const EQ_BANDS: [f32; 10] = [ 31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0, ]; /// Minimum per-band gain in dB. pub const EQ_GAIN_MIN: f32 = -12.0; /// Maximum per-band gain in dB. pub const EQ_GAIN_MAX: f32 = 12.0; /// Built-in equalizer presets. A preset *is* a gain curve defined by the band /// layout above (a domain concept), not a mere label — the curve numbers live /// in Rust so the frontend never encodes the taxonomy. /// /// TRACES: UR-027 | DR-030 #[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum EqPreset { Flat, Rock, Pop, Jazz, Classical, BassBoost, TrebleBoost, Vocal, } impl EqPreset { /// All presets, for enumerating the curve table across the IPC boundary. pub const ALL: [EqPreset; 8] = [ EqPreset::Flat, EqPreset::Rock, EqPreset::Pop, EqPreset::Jazz, EqPreset::Classical, EqPreset::BassBoost, EqPreset::TrebleBoost, EqPreset::Vocal, ]; /// The 10-band gain curve (dB) for this preset, one entry per [`EQ_BANDS`]. /// Curves are conservative (within ±8 dB) so presets stack safely with the /// player volume. Bands: 31 62 125 250 500 1k 2k 4k 8k 16k. pub fn gains(&self) -> [f32; 10] { match self { EqPreset::Flat => [0.0; 10], EqPreset::Rock => [5.0, 4.0, 3.0, 1.0, -1.0, -1.0, 1.0, 3.0, 4.0, 5.0], EqPreset::Pop => [-1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0, -1.0], EqPreset::Jazz => [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.0, 2.0, 3.0], EqPreset::Classical => [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0], EqPreset::BassBoost => [7.0, 6.0, 5.0, 3.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], EqPreset::TrebleBoost => [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 3.0, 5.0, 6.0, 7.0], EqPreset::Vocal => [-2.0, -1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.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, /// Enable the graphic equalizer. When false, no EQ filter is applied. #[serde(default)] pub equalizer_enabled: bool, /// Per-band gains in dB, one per [`EQ_BANDS`]. Normalised to 10 entries and /// clamped to [`EQ_GAIN_MIN`, `EQ_GAIN_MAX`] via [`Self::with_equalizer_normalised`]. #[serde(default = "default_eq_bands")] pub equalizer_bands: Vec, } /// Flat 10-band curve — the default equalizer state. fn default_eq_bands() -> Vec { vec![0.0; EQ_BANDS.len()] } impl Default for AudioSettings { fn default() -> Self { Self { crossfade_duration: 0.0, gapless_playback: true, normalize_volume: false, volume_level: VolumeLevel::Normal, equalizer_enabled: false, equalizer_bands: default_eq_bands(), } } } 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 } /// Normalise the equalizer band vector to exactly [`EQ_BANDS`]`.len()` /// entries (pad with 0 dB / truncate) and clamp each gain to the valid /// range. Guards against malformed persisted or IPC input. /// /// TRACES: UR-027 | DR-030 pub fn with_equalizer_normalised(mut self) -> Self { let n = EQ_BANDS.len(); self.equalizer_bands.resize(n, 0.0); for g in &mut self.equalizer_bands { *g = g.clamp(EQ_GAIN_MIN, EQ_GAIN_MAX); } self } } /// A ceiling on how much bandwidth a *video* stream may consume. /// /// A quality step is a bundle of concrete transcode parameters — total stream /// ceiling, the audio share of it, and the resolution that ceiling can carry — /// not just a label. Those numbers are Jellyfin encoding domain vocabulary, so /// they live here and the frontend only ever names a variant; the labels the /// picker shows are served over IPC by `player_get_streaming_qualities`. /// /// The ladder is deliberately expressed in bandwidth rather than resolution: it /// exists to fit a connection, and the resolution cap is chosen *from* the /// bitrate so the encoder does not spend a small budget on pixels it cannot /// afford. See docs/architecture/01-rust-backend.md ("Streaming quality /// ladder"). /// /// TRACES: UR-074 | DR-162 #[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum StreamingQuality { /// No client-imposed cap — the server may direct-play the source as-is. #[default] Original, Mbps20, Mbps10, Mbps8, Mbps4, Mbps2, Mbps1, Kbps720, } impl StreamingQuality { /// The ladder, highest first, for enumerating across the IPC boundary. pub const ALL: [StreamingQuality; 8] = [ StreamingQuality::Original, StreamingQuality::Mbps20, StreamingQuality::Mbps10, StreamingQuality::Mbps8, StreamingQuality::Mbps4, StreamingQuality::Mbps2, StreamingQuality::Mbps1, StreamingQuality::Kbps720, ]; /// Total bits per second the stream may use (video + audio), or `None` for /// the uncapped `Original`. /// /// This is the number that goes to `PlaybackInfo` as `MaxStreamingBitrate` /// and into the device profile. Sending it there — not just on the transcode /// URL — is what makes the cap real: a stream the server decides to *direct /// play* is served at the source file's own bitrate, and no URL parameter /// afterwards can reduce it. pub fn max_bitrate(&self) -> Option { match self { StreamingQuality::Original => None, StreamingQuality::Mbps20 => Some(20_000_000), StreamingQuality::Mbps10 => Some(10_000_000), StreamingQuality::Mbps8 => Some(8_000_000), StreamingQuality::Mbps4 => Some(4_000_000), StreamingQuality::Mbps2 => Some(2_000_000), StreamingQuality::Mbps1 => Some(1_000_000), StreamingQuality::Kbps720 => Some(720_000), } } /// Bits per second allotted to the audio track. /// /// The value shrinks with the ladder because at the bottom rungs a fixed /// 384 kbps would be a third of the entire budget. pub fn audio_bitrate(&self) -> u64 { match self { StreamingQuality::Original | StreamingQuality::Mbps20 | StreamingQuality::Mbps10 | StreamingQuality::Mbps8 => 384_000, StreamingQuality::Mbps4 => 256_000, StreamingQuality::Mbps2 => 192_000, StreamingQuality::Mbps1 => 128_000, StreamingQuality::Kbps720 => 96_000, } } /// Bits per second allotted to the video track: the total minus the audio /// share, so the two together honour [`max_bitrate`](Self::max_bitrate) /// rather than overshooting it by the size of the audio track. pub fn video_bitrate(&self) -> Option { self.max_bitrate() .map(|total| total.saturating_sub(self.audio_bitrate())) } /// Resolution ceiling that suits the bitrate, or `None` to leave the source /// resolution alone. Scaling down is what keeps a small budget looking like /// clean video instead of blocky 1080p. pub fn max_height(&self) -> Option { match self { // 20 Mbps carries 4K, so it caps bandwidth without capping pixels. StreamingQuality::Original | StreamingQuality::Mbps20 => None, StreamingQuality::Mbps10 | StreamingQuality::Mbps8 => Some(1080), StreamingQuality::Mbps4 | StreamingQuality::Mbps2 => Some(720), StreamingQuality::Mbps1 => Some(480), StreamingQuality::Kbps720 => Some(360), } } /// Human label for the picker. Lives in Rust with the numbers it describes, /// so the two cannot drift apart. pub fn label(&self) -> &'static str { match self { StreamingQuality::Original => "Original", StreamingQuality::Mbps20 => "20 Mbps", StreamingQuality::Mbps10 => "10 Mbps", StreamingQuality::Mbps8 => "8 Mbps", StreamingQuality::Mbps4 => "4 Mbps", StreamingQuality::Mbps2 => "2 Mbps", StreamingQuality::Mbps1 => "1 Mbps", StreamingQuality::Kbps720 => "720 kbps", } } /// Secondary line for the picker: what the cap means in practice. pub fn detail(&self) -> &'static str { match self { StreamingQuality::Original => "No limit — highest quality", StreamingQuality::Mbps20 => "Up to 4K", StreamingQuality::Mbps10 => "1080p, high quality", StreamingQuality::Mbps8 => "1080p", StreamingQuality::Mbps4 => "720p", StreamingQuality::Mbps2 => "720p, reduced", StreamingQuality::Mbps1 => "480p", StreamingQuality::Kbps720 => "360p — slowest connections", } } } /// 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, /// Bandwidth ceiling applied to every video stream. /// /// `#[serde(default)]` so settings JSON persisted before this field existed /// loads as the previous behaviour (uncapped). /// /// TRACES: UR-074 | DR-162 #[serde(default)] pub streaming_quality: StreamingQuality, } impl Default for VideoSettings { fn default() -> Self { Self { auto_play_next_episode: true, auto_play_countdown_seconds: 10, auto_play_max_episodes: 0, streaming_quality: StreamingQuality::Original, } } } 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 } } /// Library browsing preferences. /// /// Currently a single list: the folders (or whole libraries) the user has asked /// to keep out of browsing. It is a *list of ids*, never names — names are /// unstable, locale-dependent and non-unique, and the hardcoded name filter this /// setting replaced broke on exactly that. What the ids then hide is decided in /// `repository::exclusions`; this struct is only how the choice is carried and /// persisted. /// /// The default is an empty list: nobody inherits another user's folder layout. /// /// TRACES: UR-076 | DR-209 #[derive(specta::Type, Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LibrarySettings { /// Stable item ids of the folders/libraries hidden from browsing. /// /// `#[serde(default)]` so settings JSON persisted before this field existed /// loads as the previous behaviour (nothing hidden). #[serde(default)] pub excluded_item_ids: Vec, } impl LibrarySettings { /// Drop blanks and duplicates from the id list. /// /// Applied on the way in from IPC and on the way out of the database, so a /// hand-edited or half-written value cannot make the list grow without bound /// or carry an empty id (which would match nothing but still be shown as a /// selection in the picker). /// /// TRACES: UR-076 | DR-209 pub fn sanitised(mut self) -> Self { let mut seen: Vec = Vec::with_capacity(self.excluded_item_ids.len()); for id in self.excluded_item_ids.drain(..) { let id = id.trim().to_string(); if id.is_empty() || seen.contains(&id) { continue; } seen.push(id); } self.excluded_item_ids = seen; self } } /// Serialise `AudioSettings` into the JSON payload handed to the Android player /// over JNI. /// /// Sanitises first (crossfade clamped, band vector normalised) so a malformed /// vector can never reach the Kotlin parser. JSON is used rather than a wide JNI /// signature so that adding a field does not change the method signature — the /// same approach `load()` already uses for subtitles. /// /// The emitted keys are camelCase (serde) and `volumeLevel` is lowercase; the /// Kotlin side matches on those literals. Both are pinned by tests. /// /// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 pub fn audio_settings_jni_payload(settings: &AudioSettings) -> Result { let sanitised = settings .clone() .with_crossfade_clamped() .with_equalizer_normalised(); serde_json::to_string(&sanitised) } #[cfg(test)] mod tests { use super::*; /// The JNI payload must sanitise before serialising: an over-long crossfade /// is clamped and a wrong-length band vector is normalised to EQ_BANDS.len(). /// Sending raw values would let a malformed vector reach the Kotlin parser. /// /// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-1 #[test] fn test_audio_settings_jni_payload_is_sanitised() { let settings = AudioSettings { crossfade_duration: 30.0, equalizer_bands: vec![20.0, -30.0], ..AudioSettings::default() }; let json = audio_settings_jni_payload(&settings).expect("serialises"); let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); assert_eq!(v["crossfadeDuration"], 12.0, "crossfade clamped to 12s"); let bands = v["equalizerBands"].as_array().expect("bands array"); assert_eq!(bands.len(), EQ_BANDS.len(), "band vector normalised to 10"); assert_eq!(bands[0], EQ_GAIN_MAX as f64, "gain clamped to +12dB"); assert_eq!(bands[1], EQ_GAIN_MIN as f64, "gain clamped to -12dB"); } /// The Kotlin side parses these exact keys. camelCase is what serde emits /// for AudioSettings; a rename here silently breaks the Android parser, /// which is why the contract is pinned by a test rather than by convention. /// /// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-2 #[test] fn test_audio_settings_jni_payload_key_contract() { let json = audio_settings_jni_payload(&AudioSettings::default()).expect("serialises"); let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); for key in [ "crossfadeDuration", "gaplessPlayback", "normalizeVolume", "volumeLevel", "equalizerEnabled", "equalizerBands", ] { assert!(v.get(key).is_some(), "JNI payload must carry `{key}`"); } // VolumeLevel is #[serde(rename_all = "lowercase")]; Kotlin matches on // these literals. assert_eq!(v["volumeLevel"], "normal"); } #[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); // Equalizer defaults: disabled and flat. assert!(!settings.equalizer_enabled); assert_eq!(settings.equalizer_bands, vec![0.0; EQ_BANDS.len()]); } /// EQ presets each return one gain per band; Flat is all zeros. /// /// TRACES: UR-027 | DR-030 | UT-079 #[test] fn test_eq_preset_curves() { for preset in EqPreset::ALL { assert_eq!( preset.gains().len(), EQ_BANDS.len(), "preset {:?} must have one gain per band", preset ); // Every preset stays within the advertised gain range. for g in preset.gains() { assert!( (EQ_GAIN_MIN..=EQ_GAIN_MAX).contains(&g), "preset {:?} gain {} out of range", preset, g ); } } assert_eq!(EqPreset::Flat.gains(), [0.0; 10]); // Bass boost lifts the low bands and leaves the top flat. let bass = EqPreset::BassBoost.gains(); assert!(bass[0] > 0.0 && bass[9] == 0.0); } /// `with_equalizer_normalised` clamps out-of-range gains and forces the /// band vector to exactly EQ_BANDS.len() (pad short, truncate long). /// /// TRACES: UR-027 | DR-030 | UT-080 #[test] fn test_eq_normalisation() { // Out-of-range gains are clamped. let s = AudioSettings { equalizer_bands: vec![100.0, -100.0, 3.0], ..Default::default() } .with_equalizer_normalised(); assert_eq!(s.equalizer_bands.len(), EQ_BANDS.len()); assert_eq!(s.equalizer_bands[0], EQ_GAIN_MAX); assert_eq!(s.equalizer_bands[1], EQ_GAIN_MIN); assert_eq!(s.equalizer_bands[2], 3.0); // Short vector padded with 0 dB. assert_eq!(s.equalizer_bands[9], 0.0); // Over-long vector truncated. let long = AudioSettings { equalizer_bands: vec![1.0; 20], ..Default::default() } .with_equalizer_normalised(); assert_eq!(long.equalizer_bands.len(), EQ_BANDS.len()); } /// Old persisted JSON without the EQ fields loads as disabled + flat. /// /// TRACES: UR-027 | DR-030 | UT-081 #[test] fn test_audio_settings_eq_backward_compat() { let json = r#"{"crossfadeDuration":0.0,"gaplessPlayback":true,"normalizeVolume":false,"volumeLevel":"normal"}"#; let parsed: AudioSettings = serde_json::from_str(json).unwrap(); assert!(!parsed.equalizer_enabled); assert_eq!(parsed.equalizer_bands, vec![0.0; EQ_BANDS.len()]); } /// EQ fields serialize as camelCase and round-trip. /// /// TRACES: UR-027 | DR-030 | UT-082 #[test] fn test_audio_settings_eq_serialization() { let settings = AudioSettings { equalizer_enabled: true, equalizer_bands: EqPreset::Rock.gains().to_vec(), ..Default::default() }; let json = serde_json::to_string(&settings).unwrap(); assert!(json.contains("\"equalizerEnabled\":true")); assert!(json.contains("\"equalizerBands\":")); let parsed: AudioSettings = serde_json::from_str(&json).unwrap(); assert!(parsed.equalizer_enabled); assert_eq!(parsed.equalizer_bands, EqPreset::Rock.gains().to_vec()); } #[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, ..Default::default() }; 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, streaming_quality: StreamingQuality::Mbps4, }; let json = serde_json::to_string(&settings).unwrap(); assert!(json.contains("\"autoPlayNextEpisode\":false")); assert!(json.contains("\"autoPlayCountdownSeconds\":15")); assert!(json.contains("\"autoPlayMaxEpisodes\":5")); assert!(json.contains("\"streamingQuality\":\"mbps4\"")); 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); // Settings persisted before the cap existed must load as uncapped — // inventing a limit for an upgrading user would silently degrade their // picture with no setting having been changed. assert_eq!(parsed.streaming_quality, StreamingQuality::Original); } /// The whole point of a step is the number of bits it promises not to /// exceed, so video + audio must fit inside the total — a video bitrate set /// to the full cap would overshoot it by the size of the audio track. /// /// TRACES: UR-074 | DR-162 | UT-157 #[test] fn test_streaming_quality_budget_is_internally_consistent() { for quality in StreamingQuality::ALL { let Some(total) = quality.max_bitrate() else { assert_eq!( quality, StreamingQuality::Original, "only Original may be uncapped" ); assert!(quality.video_bitrate().is_none()); assert!(quality.max_height().is_none()); continue; }; let video = quality.video_bitrate().expect("a capped step caps video"); assert_eq!( video + quality.audio_bitrate(), total, "{:?}: video + audio must equal the cap", quality ); assert!( video > 0, "{:?}: audio must not consume the budget", quality ); assert!(!quality.label().is_empty()); assert!(!quality.detail().is_empty()); } } /// The ladder is presented to the user as descending, and the resolution cap /// must fall with it — a lower bitrate paired with a higher resolution would /// spend the smaller budget on more pixels, which is backwards. /// /// TRACES: UR-074 | DR-162 | UT-157 #[test] fn test_streaming_quality_ladder_descends() { let steps = StreamingQuality::ALL; for pair in steps.windows(2) { let (higher, lower) = (pair[0], pair[1]); let higher_bitrate = higher.max_bitrate().unwrap_or(u64::MAX); let lower_bitrate = lower.max_bitrate().unwrap_or(u64::MAX); assert!( higher_bitrate > lower_bitrate, "{:?} must sit above {:?}", higher, lower ); assert!( higher.max_height().unwrap_or(u32::MAX) >= lower.max_height().unwrap_or(u32::MAX), "{:?} must not cap resolution below {:?}", higher, lower ); assert!(higher.audio_bitrate() >= lower.audio_bitrate()); } } /// The persisted form is the serde token, and it must survive a round trip — /// a rename here silently resets everyone's saved cap to uncapped. /// /// TRACES: UR-074 | DR-162 | UT-157 #[test] fn test_streaming_quality_round_trips_through_json() { for quality in StreamingQuality::ALL { let json = serde_json::to_string(&quality).expect("serialises"); let parsed: StreamingQuality = serde_json::from_str(&json).expect("parses back"); assert_eq!(parsed, quality); } assert_eq!( serde_json::to_string(&StreamingQuality::Mbps10).unwrap(), "\"mbps10\"" ); } }