fix(qbzd): enforce quality cap for QConnect
cx
This commit is contained in:
3 files changed
+61
-3
No files matched your search
@@ -234,6 +234,7 @@ pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>) -> Re
|
||||
booted.runtime.clone(),
|
||||
booted.shared.clone(),
|
||||
&roots,
|
||||
quality_cell.clone(),
|
||||
report_notify,
|
||||
booted.bus.subscribe(),
|
||||
);
|
||||
|
||||
@@ -84,13 +84,22 @@ pub struct DaemonRendererEngine {
|
||||
runtime: Arc<AppRuntime<DaemonAdapter>>,
|
||||
/// T10 (OD4): resolved volume policy for this session (from the KV at connect).
|
||||
volume_mode: VolumeMode,
|
||||
/// Live daemon `playback.quality` preference. The settings reload route
|
||||
/// updates this same cell, so an active QConnect session honors changes
|
||||
/// without reconnecting (#693).
|
||||
quality_cap: Arc<std::sync::Mutex<Quality>>,
|
||||
}
|
||||
|
||||
impl DaemonRendererEngine {
|
||||
pub fn new(runtime: Arc<AppRuntime<DaemonAdapter>>, volume_mode: VolumeMode) -> Self {
|
||||
pub fn new(
|
||||
runtime: Arc<AppRuntime<DaemonAdapter>>,
|
||||
volume_mode: VolumeMode,
|
||||
quality_cap: Arc<std::sync::Mutex<Quality>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
volume_mode,
|
||||
quality_cap,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +107,15 @@ impl DaemonRendererEngine {
|
||||
self.runtime.core()
|
||||
}
|
||||
|
||||
fn effective_quality(&self, requested: Quality) -> Quality {
|
||||
let cap = self
|
||||
.quality_cap
|
||||
.lock()
|
||||
.map(|quality| *quality)
|
||||
.unwrap_or_else(|poisoned| *poisoned.into_inner());
|
||||
clamp_quality(requested, cap)
|
||||
}
|
||||
|
||||
/// Last-resort load for tracks the raw-URL path cannot fetch (the CDN
|
||||
/// header flood defeats every reqwest attempt — see
|
||||
/// `remote_stream::is_header_flood_error`): the CMAF path is unaffected by
|
||||
@@ -209,10 +227,18 @@ impl QconnectRendererEngine for DaemonRendererEngine {
|
||||
async fn start_track_stream(
|
||||
&self,
|
||||
track_id: u64,
|
||||
quality: Quality,
|
||||
requested_quality: Quality,
|
||||
duration_secs: u64,
|
||||
start_position_secs: u64,
|
||||
) -> Result<(), String> {
|
||||
let quality = self.effective_quality(requested_quality);
|
||||
if quality != requested_quality {
|
||||
log::info!(
|
||||
"[QConnect] playback.quality capped track {track_id}: controller requested {:?}, using {:?}",
|
||||
requested_quality,
|
||||
quality
|
||||
);
|
||||
}
|
||||
let stream_url = self
|
||||
.core()
|
||||
.get_stream_url(track_id, quality)
|
||||
@@ -280,6 +306,9 @@ impl QconnectRendererEngine for DaemonRendererEngine {
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_quality(requested: Quality, cap: Quality) -> Quality {
|
||||
Quality::min_tier(requested, cap)
|
||||
}
|
||||
|
||||
async fn download_remote_audio(url: &str) -> Result<Vec<u8>, String> {
|
||||
let response = reqwest::Client::new()
|
||||
@@ -349,4 +378,22 @@ mod tests {
|
||||
// Whitespace around the real value is tolerated.
|
||||
assert_eq!(VolumeMode::from_kv(Some(" locked ")), VolumeMode::Locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qconnect_quality_never_exceeds_daemon_preference() {
|
||||
let tiers = [
|
||||
Quality::Mp3,
|
||||
Quality::Lossless,
|
||||
Quality::HiRes,
|
||||
Quality::UltraHiRes,
|
||||
];
|
||||
for requested in tiers {
|
||||
for cap in tiers {
|
||||
let effective = clamp_quality(requested, cap);
|
||||
assert!(effective <= requested);
|
||||
assert!(effective <= cap);
|
||||
assert_eq!(effective, requested.min(cap));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,10 @@ pub struct DaemonQconnectService {
|
||||
#[allow(dead_code)] // T11 (settings reload) re-reads the KV through this path.
|
||||
settings_db: PathBuf,
|
||||
custom_device_name: Arc<tokio::sync::RwLock<Option<String>>>,
|
||||
/// Shared with the playback driver and settings reload route; the engine
|
||||
/// reads it for every remote stream start so QConnect cannot bypass the
|
||||
/// daemon's configured maximum quality (#693).
|
||||
quality_cap: Arc<std::sync::Mutex<qbz_models::Quality>>,
|
||||
}
|
||||
|
||||
impl DaemonQconnectService {
|
||||
@@ -197,7 +201,11 @@ impl DaemonQconnectService {
|
||||
// next connect. Unset/unknown -> Software (the OD4 default).
|
||||
let volume_mode =
|
||||
engine::VolumeMode::from_kv(transport::load_volume_mode_at(&self.settings_db).as_deref());
|
||||
let engine = DaemonRendererEngine::new(Arc::clone(&self.runtime), volume_mode);
|
||||
let engine = DaemonRendererEngine::new(
|
||||
Arc::clone(&self.runtime),
|
||||
volume_mode,
|
||||
Arc::clone(&self.quality_cap),
|
||||
);
|
||||
let sink = Arc::new(DaemonEventSink::new(engine, Arc::clone(&sync_state)));
|
||||
let app = Arc::new(QconnectApp::new(
|
||||
Arc::clone(&transport),
|
||||
@@ -466,6 +474,7 @@ pub fn start(
|
||||
runtime: Runtime,
|
||||
shared: SharedState,
|
||||
roots: &ProfileRoots,
|
||||
quality_cap: Arc<std::sync::Mutex<qbz_models::Quality>>,
|
||||
report_notify: Arc<tokio::sync::Notify>,
|
||||
core_events: broadcast::Receiver<CoreEvent>,
|
||||
) -> QconnectHandle {
|
||||
@@ -496,6 +505,7 @@ pub fn start(
|
||||
shared,
|
||||
settings_db,
|
||||
custom_device_name: Arc::new(tokio::sync::RwLock::new(custom_name)),
|
||||
quality_cap,
|
||||
});
|
||||
|
||||
let watcher = if should_auto_connect {
|
||||
|
||||
Reference in new issue
Block a user