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:
@@ -3,11 +3,46 @@
|
||||
use async_trait::async_trait;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use super::{types::*, MediaRepository};
|
||||
use crate::connectivity::ConnectivityReporter;
|
||||
use crate::jellyfin::HttpClient;
|
||||
use crate::settings::StreamingQuality;
|
||||
use crate::utils::lock::RwLockSafe;
|
||||
|
||||
/// The bandwidth ceiling every video stream this process opens is built against.
|
||||
///
|
||||
/// Process-wide rather than a field on [`OnlineRepository`] because it is a user
|
||||
/// preference about *this device's connection*, not about a server session: it
|
||||
/// must survive a repository being rebuilt on re-login, and every URL builder and
|
||||
/// the `PlaybackInfo` negotiation have to agree on it or the cap leaks (the
|
||||
/// negotiation would authorise a direct play the URL builder then never gets to
|
||||
/// constrain). Same shape as `offline::INCLUDE_CATALOG_BROWSE`.
|
||||
///
|
||||
/// Set from `player_set_video_settings` / `player_set_stream_quality`, and
|
||||
/// restored from the database at startup.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-160
|
||||
static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);
|
||||
|
||||
/// Apply a bandwidth ceiling to every subsequently-opened video stream.
|
||||
///
|
||||
/// Streams already playing keep the bitrate they were opened at — a cap is a
|
||||
/// 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
|
||||
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
|
||||
pub fn streaming_quality() -> StreamingQuality {
|
||||
*STREAMING_QUALITY.read_safe()
|
||||
}
|
||||
|
||||
/// A single actor returned by the JRay plugin's "context at time t" endpoint.
|
||||
///
|
||||
@@ -381,7 +416,12 @@ impl OnlineRepository {
|
||||
/// which manifests as playback never starting. `StartTimeTicks` makes the
|
||||
/// server begin the transcode at the requested position.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-140 | UT-130
|
||||
/// The stream is built against the current [`streaming_quality`] ceiling:
|
||||
/// `MaxStreamingBitrate`/`VideoBitrate`/`AudioBitrate`, plus a `MaxHeight`
|
||||
/// 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
|
||||
pub async fn get_video_stream_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
@@ -392,6 +432,13 @@ impl OnlineRepository {
|
||||
// Convert seconds to ticks (10,000,000 ticks per second)
|
||||
let start_time_ticks = start_time_seconds.map(|seconds| (seconds * 10_000_000.0) as i64);
|
||||
|
||||
let quality = streaming_quality();
|
||||
// `Original` is uncapped as a *user* setting, but a transcode still needs
|
||||
// a ceiling to encode against — keep the values this endpoint has always
|
||||
// used so nothing changes for the default.
|
||||
let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000);
|
||||
let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000);
|
||||
|
||||
// Build an HLS transcode URL. VideoCodec lists h264 first so the server
|
||||
// transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
|
||||
let mut params = vec![
|
||||
@@ -399,9 +446,9 @@ impl OnlineRepository {
|
||||
("DeviceId", "jellytau-tauri".to_string()),
|
||||
("VideoCodec", "h264".to_string()),
|
||||
("AudioCodec", "aac".to_string()),
|
||||
("MaxStreamingBitrate", "20000000".to_string()),
|
||||
("VideoBitrate", "18000000".to_string()),
|
||||
("AudioBitrate", "384000".to_string()),
|
||||
("MaxStreamingBitrate", max_bitrate.to_string()),
|
||||
("VideoBitrate", video_bitrate.to_string()),
|
||||
("AudioBitrate", quality.audio_bitrate().to_string()),
|
||||
(
|
||||
"TranscodingMaxAudioChannels",
|
||||
super::device_profile::max_audio_channels().to_string(),
|
||||
@@ -411,6 +458,12 @@ impl OnlineRepository {
|
||||
("TranscodingProtocol", "hls".to_string()),
|
||||
];
|
||||
|
||||
// Scale the picture down to what the budget can carry. Omitted for the
|
||||
// uncapped steps so the source resolution is preserved.
|
||||
if let Some(height) = quality.max_height() {
|
||||
params.push(("MaxHeight", height.to_string()));
|
||||
}
|
||||
|
||||
// Only pin an audio track when the user actually picked one. Jellyfin's
|
||||
// `MediaStream.Index` is global across *all* streams in a media source, so
|
||||
// index 0 is the video stream on virtually every file — defaulting to 0
|
||||
@@ -481,7 +534,14 @@ impl OnlineRepository {
|
||||
("AudioCodec", "mp3".to_string()),
|
||||
("TranscodingContainer", "mp3".to_string()),
|
||||
("TranscodingProtocol", "http".to_string()),
|
||||
("MaxStreamingBitrate", "384000".to_string()),
|
||||
// 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
|
||||
(
|
||||
"MaxStreamingBitrate",
|
||||
streaming_quality().audio_bitrate().min(384_000).to_string(),
|
||||
),
|
||||
];
|
||||
|
||||
// Carry the track over only if one was actually selected — index 0 is the
|
||||
@@ -1406,11 +1466,29 @@ impl MediaRepository for OnlineRepository {
|
||||
let max_audio_channels = super::device_profile::max_audio_channels().to_string();
|
||||
info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);
|
||||
|
||||
// The user's bandwidth ceiling has to be part of the *negotiation*, not
|
||||
// just the transcode URL: `max_static_bitrate` is what makes the server
|
||||
// refuse to direct-play a source fatter than the cap, and without it a
|
||||
// 30 Mbps remux is handed over untouched and every URL parameter
|
||||
// downstream is moot. `Original` keeps the historical "no ceiling"
|
||||
// sentinel so the default path negotiates exactly as before.
|
||||
//
|
||||
// TRACES: UR-074 | DR-160
|
||||
let quality = streaming_quality();
|
||||
let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64;
|
||||
if let Some(cap) = quality.max_bitrate() {
|
||||
info!(
|
||||
"[DeviceProfile] Streaming quality cap active: {} ({} bps)",
|
||||
quality.label(),
|
||||
cap
|
||||
);
|
||||
}
|
||||
|
||||
// Create device profile with detected hardware capabilities
|
||||
let device_profile = DeviceProfile {
|
||||
name: "JellyTau Native Player".to_string(),
|
||||
max_streaming_bitrate: 999_999_999,
|
||||
max_static_bitrate: 999_999_999,
|
||||
max_streaming_bitrate: negotiated_bitrate,
|
||||
max_static_bitrate: negotiated_bitrate,
|
||||
max_audio_channels: max_audio_channels.clone(),
|
||||
direct_play_profiles: vec![
|
||||
DirectPlayProfile {
|
||||
@@ -1470,7 +1548,8 @@ impl MediaRepository for OnlineRepository {
|
||||
start_time_ticks: 0,
|
||||
is_playback: true,
|
||||
auto_open_live_stream: true,
|
||||
max_streaming_bitrate: 20_000_000, // 20 Mbps
|
||||
// The user's cap, or the historical 20 Mbps allowance when uncapped.
|
||||
max_streaming_bitrate: quality.max_bitrate().unwrap_or(20_000_000) as i64,
|
||||
device_profile: Some(device_profile), // Now sending profile with detected codecs
|
||||
};
|
||||
|
||||
@@ -1633,7 +1712,10 @@ impl MediaRepository for OnlineRepository {
|
||||
user_id: self.user_id.clone(),
|
||||
auto_open_live_stream: true,
|
||||
is_playback: true,
|
||||
max_streaming_bitrate: 20_000_000,
|
||||
// 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
|
||||
max_streaming_bitrate: streaming_quality().max_bitrate().unwrap_or(20_000_000),
|
||||
};
|
||||
|
||||
let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
|
||||
@@ -2233,6 +2315,7 @@ impl MediaRepository for OnlineRepository {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn create_test_repository() -> OnlineRepository {
|
||||
@@ -2379,11 +2462,107 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Serialises every test whose expectations depend on the process-wide
|
||||
/// 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
|
||||
static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
|
||||
|
||||
impl QualityFixture {
|
||||
fn set(quality: StreamingQuality) -> Self {
|
||||
let guard = QUALITY_LOCK.lock_safe();
|
||||
set_streaming_quality(quality);
|
||||
Self(guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for QualityFixture {
|
||||
fn drop(&mut self) {
|
||||
set_streaming_quality(StreamingQuality::Original);
|
||||
}
|
||||
}
|
||||
|
||||
/// A cap has to reach the transcode URL as all four of its parts: the total
|
||||
/// ceiling, the split between video and audio, and the resolution the budget
|
||||
/// can carry. Capping only `MaxStreamingBitrate` would leave the server
|
||||
/// encoding 1080p into 2 Mbps.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-160 | UT-156
|
||||
#[tokio::test]
|
||||
async fn test_video_stream_url_applies_bitrate_cap() {
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
|
||||
let repo = create_test_repository();
|
||||
|
||||
let url = repo
|
||||
.get_video_stream_url("vid-1", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
|
||||
// 2 Mbps total less the 192 kbps audio share — the two must not sum to
|
||||
// more than the cap the user asked for.
|
||||
assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
|
||||
assert!(url.contains("AudioBitrate=192000"), "url: {url}");
|
||||
assert!(url.contains("MaxHeight=720"), "url: {url}");
|
||||
}
|
||||
|
||||
/// 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
|
||||
#[tokio::test]
|
||||
async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||
let repo = create_test_repository();
|
||||
|
||||
let url = repo
|
||||
.get_video_stream_url("vid-1", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
|
||||
assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
|
||||
assert!(url.contains("AudioBitrate=384000"), "url: {url}");
|
||||
assert!(
|
||||
!url.contains("MaxHeight"),
|
||||
"uncapped must not scale the picture down: {url}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
#[tokio::test]
|
||||
async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
|
||||
{
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
|
||||
let repo = create_test_repository();
|
||||
let url = repo
|
||||
.get_audio_only_stream_url_for_video("vid-1", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
|
||||
}
|
||||
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||
let repo = create_test_repository();
|
||||
let url = repo
|
||||
.get_audio_only_stream_url_for_video("vid-1", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_video_stream_url_returns_hls_with_position() {
|
||||
// Transcoded video resume/seek must produce an HLS master playlist with
|
||||
// StartTimeTicks, not a progressive stream.mp4 (which never starts playing
|
||||
// for HEVC sources). See get_video_stream_url docs.
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||
let repo = create_test_repository();
|
||||
|
||||
let url = repo
|
||||
@@ -2405,6 +2584,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_video_stream_url_omits_position_when_absent() {
|
||||
let _fixture = QualityFixture::set(StreamingQuality::Original);
|
||||
let repo = create_test_repository();
|
||||
|
||||
let url = repo
|
||||
|
||||
Reference in New Issue
Block a user