Files
jellytau/src-tauri/src/settings.rs
T
dtourolle ac3cd67164 feat(library): exclude chosen folders from music browsing
Replaces `src/lib/utils/podcastFilter.ts` — a shipped personal workaround
that dropped any item whose name, album, album artist or artist was
literally "Podcasts" — with a real user setting applied in Rust.

The old filter was wrong twice over: it hardcoded one user's folder
layout keyed on an English literal, and it put a domain rule (what a
query should return) in the presentation layer. It slipped past
`check:boundary` only because it matched on names rather than on an
item-type array.

- `repository::exclusions` owns the rule and the process-wide id set,
  the same shape as `online::STREAMING_QUALITY` so it survives a
  repository being rebuilt on re-login.
- `HybridRepository` applies it where the cache and server legs of every
  cache-first query converge (`parallel_race` / `race_with_refresh`),
  plus the bespoke `get_items` path and the server-only reads. Filtering
  before the "has content" check is what makes a cache page of nothing
  but hidden items fall through to the server.
- Exclusion is by stable item id, never by name, and matches an item's
  own id or any container link it carries (parent, album, library,
  series, season, artist).
- A direct `get_item` lookup and the Downloads surface are deliberately
  unfiltered: hiding those would break playback and file management of
  anything inside a hidden folder.
- `LibrarySettings` persists to `app_settings` and is restored in the
  setup hook, alongside the streaming-quality cap. Default is an empty
  list — nobody inherits the old "Podcasts" behaviour.
- New commands `library_get_settings`, `library_set_settings` and
  `library_get_exclusion_candidates`; the candidates read goes through
  `get_items_unfiltered` so an already-hidden folder still appears in the
  picker and the setting can be undone.
- Settings page gains a "Hidden Folders" section that renders the
  backend's candidate list and sends back ticked ids; it decides nothing.

TRACES: UR-076 | DR-209 | UT-203
2026-08-20 19:38:05 +02:00

727 lines
27 KiB
Rust

//! 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/specs/audio-equalizer.md.
///
/// 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<f32>,
}
/// Flat 10-band curve — the default equalizer state.
fn default_eq_bands() -> Vec<f32> {
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/specs/streaming-bitrate-cap.md.
///
/// 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<u64> {
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<u64> {
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<u32> {
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<String>,
}
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<String> = 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<String, serde_json::Error> {
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\""
);
}
}