# 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<StreamingQuality>` 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.
