docs(player): allocate UR-074/DR-162 for the streaming bitrate cap
🏗️ 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

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
This commit is contained in:
2026-08-15 16:39:53 +02:00
parent 9c352fdb77
commit d49d027020
13 changed files with 197 additions and 38 deletions
+2 -2
View File
@@ -347,7 +347,7 @@ pub enum AudioTrackSwitchResponse {
/// Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
/// has to reload anything, so no strategy branch lives in the UI.
///
/// TRACES: UR-074 | DR-160
/// TRACES: UR-074 | DR-162
#[derive(specta::Type, Debug, Serialize)]
#[serde(tag = "strategy", rename_all = "camelCase")]
pub enum StreamQualityResponse {
@@ -1485,7 +1485,7 @@ pub async fn player_switch_audio_track(
/// durable default belongs to Settings. `player_set_video_settings` is the one
/// that writes to the database.
///
/// TRACES: UR-074 | DR-160
/// TRACES: UR-074 | DR-162
#[tauri::command]
#[specta::specta]
pub async fn player_set_stream_quality(
+6 -6
View File
@@ -1,6 +1,6 @@
//! Audio and video playback settings commands.
//!
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033, UR-074 | DR-025, DR-030, DR-034, DR-035, DR-036, DR-160, IR-020
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033, UR-074 | DR-025, DR-030, DR-034, DR-035, DR-036, DR-162, IR-020
use std::sync::Arc;
@@ -22,7 +22,7 @@ use crate::utils::lock::MutexSafe;
/// reverts to uncapped on the next launch spends their data allowance without
/// ever showing them a changed setting.
///
/// TRACES: UR-074 | DR-160
/// TRACES: UR-074 | DR-162
const STREAMING_QUALITY_KEY: &str = "streaming_quality";
#[tauri::command]
@@ -82,7 +82,7 @@ pub async fn player_set_video_settings(
// The bandwidth ceiling is read by the repository's URL builders and by the
// PlaybackInfo negotiation, neither of which can see this wrapper.
// TRACES: UR-074 | DR-160
// TRACES: UR-074 | DR-162
crate::repository::online::set_streaming_quality(validated.streaming_quality);
persist_streaming_quality(&db, validated.streaming_quality).await;
@@ -104,7 +104,7 @@ pub async fn player_set_video_settings(
/// frontend reads them here rather than encoding them — the same arrangement as
/// [`player_get_eq_presets`].
///
/// TRACES: UR-074 | DR-160
/// TRACES: UR-074 | DR-162
#[tauri::command]
#[specta::specta]
pub async fn player_get_streaming_qualities(
@@ -119,7 +119,7 @@ pub async fn player_get_streaming_qualities(
/// setting has already been applied in memory, and refusing the whole call
/// because the write failed would leave the UI showing a cap that *is* active.
///
/// TRACES: UR-074 | DR-160
/// TRACES: UR-074 | DR-162
async fn persist_streaming_quality(db: &State<'_, DatabaseWrapper>, quality: StreamingQuality) {
let db_service = {
let database = db.0.lock_safe();
@@ -155,7 +155,7 @@ async fn persist_streaming_quality(db: &State<'_, DatabaseWrapper>, quality: Str
/// default — uncapped — in place, so a database problem degrades to the old
/// behaviour rather than to an arbitrary limit.
///
/// TRACES: UR-074 | DR-160
/// TRACES: UR-074 | DR-162
pub async fn restore_streaming_quality(app: &tauri::AppHandle) {
let db_service = {
let Some(db) = app.try_state::<DatabaseWrapper>() else {
+1 -1
View File
@@ -1252,7 +1252,7 @@ pub fn run() {
// are uncapped — the pre-existing behaviour — and no playback can
// have started this early anyway (login happens after setup).
//
// TRACES: UR-074 | DR-160
// TRACES: UR-074 | DR-162
{
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
+11 -11
View File
@@ -23,7 +23,7 @@ use crate::utils::lock::RwLockSafe;
/// Set from `player_set_video_settings` / `player_set_stream_quality`, and
/// restored from the database at startup.
///
/// TRACES: UR-074 | DR-160
/// TRACES: UR-074 | DR-162
static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);
/// Apply a bandwidth ceiling to every subsequently-opened video stream.
@@ -32,14 +32,14 @@ static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQualit
/// property of the URL the server is transcoding for, so changing it mid-stream
/// requires re-opening at the new quality (`player_set_stream_quality`).
///
/// TRACES: UR-074 | DR-160
/// TRACES: UR-074 | DR-162
pub fn set_streaming_quality(quality: StreamingQuality) {
*STREAMING_QUALITY.write_safe() = quality;
}
/// The ceiling currently applied to new video streams.
///
/// TRACES: UR-074 | DR-160
/// TRACES: UR-074 | DR-162
pub fn streaming_quality() -> StreamingQuality {
*STREAMING_QUALITY.read_safe()
}
@@ -421,7 +421,7 @@ impl OnlineRepository {
/// that suits the budget. `Original` keeps the historical 20/18 Mbps
/// allowance, which is a transcode ceiling rather than a user-facing limit.
///
/// TRACES: UR-004, UR-074 | DR-140, DR-160 | UT-130, UT-156
/// TRACES: UR-004, UR-074 | DR-140, DR-162 | UT-130, UT-156
pub async fn get_video_stream_url(
&self,
item_id: &str,
@@ -537,7 +537,7 @@ impl OnlineRepository {
// Audio-only is already far under any video cap, but a user on the
// bottom rungs of the ladder asked for *less traffic*, so take the
// lower of the two rather than always 384 kbps.
// TRACES: UR-074 | DR-160
// TRACES: UR-074 | DR-162
(
"MaxStreamingBitrate",
streaming_quality().audio_bitrate().min(384_000).to_string(),
@@ -1473,7 +1473,7 @@ impl MediaRepository for OnlineRepository {
// downstream is moot. `Original` keeps the historical "no ceiling"
// sentinel so the default path negotiates exactly as before.
//
// TRACES: UR-074 | DR-160
// TRACES: UR-074 | DR-162
let quality = streaming_quality();
let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64;
if let Some(cap) = quality.max_bitrate() {
@@ -1714,7 +1714,7 @@ impl MediaRepository for OnlineRepository {
is_playback: true,
// Live TV is video like any other, so the user's cap applies here
// too — a channel opened at the source bitrate would walk straight
// past a limit set for the connection. TRACES: UR-074 | DR-160
// past a limit set for the connection. TRACES: UR-074 | DR-162
max_streaming_bitrate: streaming_quality().max_bitrate().unwrap_or(20_000_000),
};
@@ -2466,7 +2466,7 @@ mod tests {
/// streaming ceiling, and restores the uncapped default afterwards — without
/// it, a capped test running concurrently changes what an uncapped one sees.
///
/// TRACES: UR-074 | DR-160
/// TRACES: UR-074 | DR-162
static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
@@ -2490,7 +2490,7 @@ mod tests {
/// can carry. Capping only `MaxStreamingBitrate` would leave the server
/// encoding 1080p into 2 Mbps.
///
/// TRACES: UR-074 | DR-160 | UT-156
/// TRACES: UR-074 | DR-162 | UT-156
#[tokio::test]
async fn test_video_stream_url_applies_bitrate_cap() {
let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
@@ -2512,7 +2512,7 @@ mod tests {
/// The uncapped default must keep the exact transcode allowance this
/// endpoint has always used, and must not start constraining resolution.
///
/// TRACES: UR-074 | DR-160 | UT-156
/// TRACES: UR-074 | DR-162 | UT-156
#[tokio::test]
async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
let _fixture = QualityFixture::set(StreamingQuality::Original);
@@ -2535,7 +2535,7 @@ mod tests {
/// The background-audio handoff is already cheap, but someone who capped the
/// connection at 720 kbps asked for less traffic than its fixed 384 kbps.
///
/// TRACES: UR-040, UR-074 | DR-160 | UT-156
/// TRACES: UR-040, UR-074 | DR-162 | UT-156
#[tokio::test]
async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
{
+5 -5
View File
@@ -161,7 +161,7 @@ impl AudioSettings {
/// 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-160
/// TRACES: UR-074 | DR-162
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum StreamingQuality {
@@ -296,7 +296,7 @@ pub struct VideoSettings {
/// `#[serde(default)]` so settings JSON persisted before this field existed
/// loads as the previous behaviour (uncapped).
///
/// TRACES: UR-074 | DR-160
/// TRACES: UR-074 | DR-162
#[serde(default)]
pub streaming_quality: StreamingQuality,
}
@@ -601,7 +601,7 @@ mod tests {
/// 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-160 | UT-157
/// TRACES: UR-074 | DR-162 | UT-157
#[test]
fn test_streaming_quality_budget_is_internally_consistent() {
for quality in StreamingQuality::ALL {
@@ -637,7 +637,7 @@ mod tests {
/// 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-160 | UT-157
/// TRACES: UR-074 | DR-162 | UT-157
#[test]
fn test_streaming_quality_ladder_descends() {
let steps = StreamingQuality::ALL;
@@ -664,7 +664,7 @@ mod tests {
/// 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-160 | UT-157
/// TRACES: UR-074 | DR-162 | UT-157
#[test]
fn test_streaming_quality_round_trips_through_json() {
for quality in StreamingQuality::ALL {