The feature shipped tagged against DR-160, which a parallel session had claimed for picture-in-picture in the meantime. Renumbered to DR-162 across the Rust and frontend TRACES comments (the PiP tags in VideoPlayer.svelte, pictureInPicture.ts and nativeVideo.ts keep DR-160) and regenerated bindings.ts. Adds the requirement rows the tags point at: UR-074 for the user need, and DR-162 covering why the cap has to reach the PlaybackInfo negotiation and not only the transcode URL, why the ceiling is process-wide, and why the Settings default persists while the in-player override does not. Notes that this gives UR-070 its resume-at-the-same-point mechanism while the server-offered rendition list that requirement also asks for stays proposed. UT-156/157 record what the tests pin. docs/specs/streaming-bitrate-cap.md carries the layer assignment — the step definitions, the video/audio split, the resolution pairing and the reload decision are all Rust; the frontend holds a serde token and the labels it was handed. TRACES: UR-074 | DR-162 | UT-156, UT-157
7.6 KiB
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:
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
bun run checkpasses.cargo fmtclean,cargo clippyclean, Rust tests pass.bun run testpasses.bun run check:boundarypasses — no bitrate/resolution numbers insrc/.- New code carries
// TRACES:comments. bindings.tsregenerated from Rust.- 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 atMbps2.test_video_stream_url_uncapped_keeps_legacy_allowance—Originalis unchanged and adds noMaxHeight.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
videoBitRatewith a capital R is the download endpoint's binding quirk (DR-123). The streaming endpoint used here bindsVideoBitrate/MaxStreamingBitrateas spelled above — do not "correct" one to the other.- A parallel Claude session may be active in this repo;
git diffbefore 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.