feat(player): cap streaming bandwidth with a user-chosen bitrate ceiling

Video streams were opened at a fixed allowance nobody could change:
MaxStreamingBitrate=20000000/VideoBitrate=18000000 on the HLS transcode
URL, 20 Mbps in the PlaybackInfo negotiation, and a 999999999 device
profile that let the server direct-play a source of any size. On a
metered or slow connection there was no way to spend less.

StreamingQuality is a ladder of bandwidth ceilings — Original, 20/10/8/
4/2/1 Mbps and 720 kbps — where a step bundles the total ceiling, the
audio share of it and the resolution that budget can carry. Those
numbers are Jellyfin encoding vocabulary, so they live in Rust and the
frontend only names a variant; labels and details come back over IPC
from player_get_streaming_qualities, the same arrangement as the EQ
presets.

The cap has to reach the *negotiation*, not just the transcode URL:
max_static_bitrate in the device profile is what makes the server refuse
to direct-play a file fatter than the cap, and without it a 30 Mbps
remux is handed over untouched and every URL parameter downstream is
moot. So it is applied at all four places that decide bandwidth — the
HLS URL builder, PlaybackInfo, the Live TV stream, and the
background-audio handoff (which takes the lower of the cap and its own
384 kbps). Video bitrate is the total minus the audio share so the two
together honour the ceiling rather than overshooting it.

The ceiling is process-wide rather than a repository field: it is a
preference about this device's connection, must survive a repository
rebuilt on re-login, and every URL builder plus the negotiation have to
agree on it or the cap leaks. Same shape as INCLUDE_CATALOG_BROWSE.

Two ways in. Settings holds the durable default, persisted to
app_settings and restored at startup — unlike the rest of VideoSettings,
because a limit set for a metered connection that silently reverts to
uncapped on the next launch spends the user's data with no changed
setting to see. The in-player menu is the "this film, this connection"
override: a cap is a property of the stream the server is producing, so
it cannot apply to one already in flight — player_set_stream_quality
re-opens the stream at the new quality and resumes at the current
position, reloading the native backend itself and handing HTML5 a URL
for the same reloadSource primitive the audio-track switch uses.

Tests pin the URL parameters at a capped and an uncapped step, the
handoff taking the lower of the two, the ladder's internal consistency
(video + audio == cap, resolution descending with bitrate) and the
persisted token's round trip. The ceiling is process-wide, so the tests
that depend on it serialise on a guard that restores the default.

TRACES: UR-074 | DR-160 | UT-156, UT-157
This commit is contained in:
2026-08-15 16:34:56 +02:00
parent 9f5f57cba4
commit dda2ff86a3
9 changed files with 971 additions and 16 deletions
+131 -3
View File
@@ -1,12 +1,29 @@
//! Audio and video playback settings commands.
//!
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033 | DR-025, DR-030, DR-034, DR-035, DR-036, 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-160, IR-020
use tauri::State;
use std::sync::Arc;
use log::{info, warn};
use tauri::{Manager, State};
use super::{PlayerStateWrapper, VideoSettingsWrapper};
use crate::commands::storage::DatabaseWrapper;
use crate::player::AutoplaySettings;
use crate::settings::{AudioSettings, EqPreset, VideoSettings};
use crate::settings::{AudioSettings, EqPreset, StreamingQuality, VideoSettings};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
use crate::utils::lock::MutexSafe;
/// `app_settings` key holding the persisted streaming bandwidth ceiling.
///
/// The cap is persisted (unlike the rest of `VideoSettings`, which is
/// process-lifetime state) because forgetting it is the one failure that costs
/// the user something real: a limit set for a metered connection that silently
/// reverts to uncapped on the next launch spends their data allowance without
/// ever showing them a changed setting.
///
/// TRACES: UR-074 | DR-160
const STREAMING_QUALITY_KEY: &str = "streaming_quality";
#[tauri::command]
#[specta::specta]
@@ -54,6 +71,7 @@ pub async fn player_get_audio_settings(
pub async fn player_set_video_settings(
video_settings: State<'_, VideoSettingsWrapper>,
player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
settings: VideoSettings,
) -> Result<VideoSettings, String> {
let validated = settings.with_countdown_clamped();
@@ -62,6 +80,12 @@ pub async fn player_set_video_settings(
*current = validated.clone();
} // Drop MutexGuard before await
// 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
crate::repository::online::set_streaming_quality(validated.streaming_quality);
persist_streaming_quality(&db, validated.streaming_quality).await;
// Sync to PlayerController's autoplay settings so on_playback_ended() uses current values
let controller = player.0.lock().await;
controller.set_autoplay_settings(AutoplaySettings {
@@ -73,6 +97,110 @@ pub async fn player_set_video_settings(
Ok(validated)
}
/// The bandwidth ceilings the quality picker may offer, each with the label and
/// one-line detail to show for it, highest first.
///
/// The ladder and its numbers are Jellyfin encoding domain vocabulary, so the
/// frontend reads them here rather than encoding them — the same arrangement as
/// [`player_get_eq_presets`].
///
/// TRACES: UR-074 | DR-160
#[tauri::command]
#[specta::specta]
pub async fn player_get_streaming_qualities(
) -> Result<Vec<(StreamingQuality, String, String)>, String> {
Ok(StreamingQuality::ALL
.iter()
.map(|q| (*q, q.label().to_string(), q.detail().to_string()))
.collect())
}
/// Write the ceiling to `app_settings`. Failure is logged, not returned: the
/// 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
async fn persist_streaming_quality(db: &State<'_, DatabaseWrapper>, quality: StreamingQuality) {
let db_service = {
let database = db.0.lock_safe();
Arc::new(database.service())
};
let encoded = match serde_json::to_string(&quality) {
Ok(value) => value,
Err(e) => {
warn!("[VideoSettings] Failed to encode streaming quality: {}", e);
return;
}
};
let query = Query::with_params(
"INSERT OR REPLACE INTO app_settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)",
vec![
QueryParam::String(STREAMING_QUALITY_KEY.to_string()),
QueryParam::String(encoded),
],
);
if let Err(e) = db_service.execute(query).await {
warn!("[VideoSettings] Failed to persist streaming quality: {}", e);
}
}
/// Restore the persisted bandwidth ceiling at startup, into both the repository
/// (which enforces it) and `VideoSettings` (which the settings UI reads).
///
/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
/// default — uncapped — in place, so a database problem degrades to the old
/// behaviour rather than to an arbitrary limit.
///
/// TRACES: UR-074 | DR-160
pub async fn restore_streaming_quality(app: &tauri::AppHandle) {
let db_service = {
let Some(db) = app.try_state::<DatabaseWrapper>() else {
warn!("[VideoSettings] No database available; streaming quality stays uncapped");
return;
};
let database = db.0.lock_safe();
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT value FROM app_settings WHERE key = ?",
vec![QueryParam::String(STREAMING_QUALITY_KEY.to_string())],
);
let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
Ok(value) => value,
Err(e) => {
warn!("[VideoSettings] Failed to read streaming quality: {}", e);
return;
}
};
let Some(stored) = stored else { return };
let quality: StreamingQuality = match serde_json::from_str(&stored) {
Ok(quality) => quality,
Err(e) => {
warn!(
"[VideoSettings] Ignoring unrecognised persisted streaming quality {:?}: {}",
stored, e
);
return;
}
};
crate::repository::online::set_streaming_quality(quality);
if let Some(video_settings) = app.try_state::<VideoSettingsWrapper>() {
video_settings.0.lock_safe().streaming_quality = quality;
}
info!(
"[VideoSettings] Restored streaming quality cap: {}",
quality.label()
);
}
#[tauri::command]
#[specta::specta]
pub async fn player_get_video_settings(