Files
jellytau/docs/specs/streaming-bitrate-cap.md
dtourolle d49d027020
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
docs(player): allocate UR-074/DR-162 for the streaming bitrate cap
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
2026-08-15 16:39:53 +02:00

7.6 KiB
Raw Permalink Blame History

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 check passes.
  • cargo fmt clean, cargo clippy clean, Rust tests pass.
  • bun run test passes.
  • bun run check:boundary passes — no bitrate/resolution numbers in src/.
  • New code carries // TRACES: comments.
  • bindings.ts regenerated 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 at Mbps2.
  • test_video_stream_url_uncapped_keeps_legacy_allowanceOriginal 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_qualityUR-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.