# Spec: streaming bitrate cap **Status:** Implemented **Requirements:** UR-074 → DR-162 (partially serves UR-070) **UX spec:** n/a — the controls reuse existing patterns (Settings → Video Playback, and the player's track menus). ## Summary The viewer picks a bandwidth ceiling for video — from `Original` (no client limit) down to 720 kbps — and every video the app opens is fetched within it, live TV included. The choice is made once in Settings and persists across restarts; a single video can be moved to another ceiling from the player, which re-opens the stream and resumes where it was without changing the saved default. ## Motivation Every video URL the app built carried a fixed allowance — `MaxStreamingBitrate=20000000`, `VideoBitrate=18000000` — the `PlaybackInfo` negotiation asked for 20 Mbps, and the device profile advertised `999999999`, which invites the server to direct-play a source of any size. On a metered or slow connection there was no lever at all short of not watching. The related UR-070 asks for something adjacent but different: a list of the renditions *the server can produce for this item*. That needs per-item `MediaSources` negotiation and is still proposed. What was missing first is cruder and more valuable: a device-wide budget that holds regardless of what is playing. ## Layer assignment | Logic / responsibility | Layer | Why it belongs there | |------------------------|-------|----------------------| | What a quality step *is* — total ceiling, audio share, resolution cap | Rust | Jellyfin encoding vocabulary. It changes if Jellyfin's transcoder or parameter binding changes, not if the UI is redesigned. Exactly the shape of `EqPreset::gains()`. | | Splitting the ceiling between video and audio | Rust | A domain rule about what the server is being asked to produce; getting it wrong overshoots the user's cap. | | Choosing `MaxHeight` for a bitrate | Rust | An encoding judgement (how many pixels a budget can carry), not a display preference. | | Where the cap is applied (URL builders, `PlaybackInfo`, live TV, audio handoff) | Rust | All four are backend concerns, and the frontend must not have to know that a cap has more than one enforcement point. | | Whether a mid-playback change needs a stream reload, and performing it | Rust | Same decision the audio-track switch already delegates: the backend knows the playback mode and owns the queue. | | Persisting the default | Rust | Application state in `app_settings`, alongside every other durable setting. | | Rendering the picker, menu placement, which control is highlighted | Frontend | Pure presentation. | The frontend holds one string — the serde token for the chosen variant — and labels/details it received from Rust. It never encodes a bitrate, a resolution or a parameter name. ## Design `StreamingQuality` (`src-tauri/src/settings.rs`) is the ladder: `Original`, `Mbps20`, `Mbps10`, `Mbps8`, `Mbps4`, `Mbps2`, `Mbps1`, `Kbps720`, serialised camelCase (`"mbps10"`). Each step answers `max_bitrate()`, `audio_bitrate()`, `video_bitrate()` (= total − audio), `max_height()`, `label()`, `detail()`. The active ceiling is a process-wide `RwLock` in `repository/online.rs`, read by every builder. Process-wide rather than a field on `OnlineRepository` because it is a preference about *this device's connection*: it must survive a repository rebuilt on re-login, and the URL builders and the negotiation have to agree on it or the cap leaks. This mirrors `offline::INCLUDE_CATALOG_BROWSE`. Enforcement points — all four are required: | Point | What the cap sets | |-------|-------------------| | `get_video_stream_url` (HLS transcode) | `MaxStreamingBitrate`, `VideoBitrate`, `AudioBitrate`, `MaxHeight` | | `get_playback_info` | request `MaxStreamingBitrate`, and the device profile's `MaxStreamingBitrate`/`MaxStaticBitrate` | | `open_live_stream` | `MaxStreamingBitrate` | | `build_audio_only_stream_url_for_video` | `min(cap audio, 384 kbps)` | The negotiation is the one that matters most. `MaxStaticBitrate` is what makes the server refuse to *direct play* a source fatter than the ceiling; without it a 30 Mbps remux is served untouched and no URL parameter downstream can reduce it. IPC: ```rust player_get_streaming_qualities() -> Vec<(StreamingQuality, String, String)> // variant, label, detail player_set_stream_quality(repository_handle, quality, use_html5, current_position, media_source_id, audio_stream_index) -> StreamQualityResponse // #[serde(tag = "strategy")]: native | reloadStream ``` `VideoSettings` gains `streaming_quality` (`#[serde(default)]`, so settings persisted before the field existed load as uncapped). `player_set_video_settings` applies it and writes it to `app_settings`; `restore_streaming_quality` reads it back in the Tauri `setup` hook via `tauri::async_runtime::spawn`, defaulting to uncapped if anything fails. `StreamQualityResponse` keeps its Rust field names on the wire (`new_url`) — tauri-specta only camelCases the `strategy` tag. The facade (`playerController.setStreamQuality`) dispatches `reloadSource` for `reloadStream` and does nothing for `native`, because the backend has already reloaded itself. Mid-playback the change applies to the current video **and** becomes the process ceiling for what follows, but it is not persisted: the in-player menu is a "this film, this connection" control and Settings owns the durable default. ## Out of scope - Per-item rendition lists from the server's `MediaSources` (UR-070's other half). - Connection-aware caps (separate WiFi/cellular ceilings). One cap, all connections. - Adaptive/automatic selection from measured throughput. - Download quality, which already has its own preset vocabulary (UR-071/DR-123). ## Acceptance criteria - [x] `bun run check` passes. - [x] `cargo fmt` clean, `cargo clippy` clean, Rust tests pass. - [x] `bun run test` passes. - [x] `bun run check:boundary` passes — no bitrate/resolution numbers in `src/`. - [x] New code carries `// TRACES:` comments. - [x] `bindings.ts` regenerated from Rust. - [x] A capped step changes what the URL asks for; the uncapped default is byte-identical to the previous behaviour. ## Testing Rust (`cargo test`): - `test_video_stream_url_applies_bitrate_cap` — all four parameters at `Mbps2`. - `test_video_stream_url_uncapped_keeps_legacy_allowance` — `Original` is unchanged and adds no `MaxHeight`. - `test_audio_only_stream_url_takes_the_lower_of_cap_and_default`. - `test_streaming_quality_budget_is_internally_consistent`, `..._ladder_descends`, `..._round_trips_through_json`. The ceiling is process-wide, so tests that depend on it serialise on a guard (`QualityFixture`) that restores `Original` on drop — including the two pre-existing stream-URL tests, which would otherwise see another test's cap. `get_playback_info` and `open_live_stream` need a live server and are not unit tested; their behaviour is the enum's `max_bitrate()`, which is. ## TRACES - `StreamingQuality`, `VideoSettings.streaming_quality` — `UR-074 | DR-162` - URL builders / negotiation / live TV — `UR-004, UR-074 | DR-140, DR-162` - Audio-only handoff — `UR-040, UR-074 | DR-162` - Commands, facade, Settings UI, player menu — `UR-074 | DR-162` - Tests — `UT-156`, `UT-157` ## Notes for the implementer - `videoBitRate` with a capital R is the *download* endpoint's binding quirk (DR-123). The streaming endpoint used here binds `VideoBitrate`/ `MaxStreamingBitrate` as spelled above — do not "correct" one to the other. - A parallel Claude session may be active in this repo; `git diff` before repairing unexpected changes. DR-160/161 were claimed by such a session while this feature was in flight, which is why it is DR-162.