Files
qobuzd/src/qconnect.rs
joren 44a2494c73 Port QConnect to self-contained prost implementation
Replaces qbz path-dependencies with a standalone implementation using
tokio-tungstenite and prost. Fixes queue-clear sentinel handling, pause
position reporting, track-end queue advancement, and adds periodic
position progress reports to keep the app progress bar in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 00:47:32 +02:00

1298 lines
48 KiB
Rust

use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use futures_util::{SinkExt, StreamExt};
use prost::encoding::{decode_varint, encode_varint};
use prost::Message;
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::{
connect_async_with_config,
tungstenite::{protocol::WebSocketConfig, Message as WsMessage},
};
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use crate::api::{QobuzApi, TrackStream};
use crate::config::Config;
use crate::player::{AudioPlayer, PlayerCommand, PlayerState, StreamSource};
use crate::proto::*;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MSG_TYPE_AUTHENTICATE: u8 = 1;
const MSG_TYPE_SUBSCRIBE: u8 = 2;
const MSG_TYPE_PAYLOAD: u8 = 6;
// ---------------------------------------------------------------------------
// Wire-level prost structs (mirror qbz transport)
// ---------------------------------------------------------------------------
#[derive(Clone, PartialEq, ::prost::Message)]
struct Authenticate {
#[prost(uint32, optional, tag = "1")]
msg_id: Option<u32>,
#[prost(uint64, optional, tag = "2")]
msg_date: Option<u64>,
#[prost(string, optional, tag = "3")]
jwt: Option<String>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
struct Subscribe {
#[prost(uint32, optional, tag = "1")]
msg_id: Option<u32>,
#[prost(uint64, optional, tag = "2")]
msg_date: Option<u64>,
#[prost(uint32, optional, tag = "3")]
proto: Option<u32>,
#[prost(bytes = "vec", repeated, tag = "4")]
channels: Vec<Vec<u8>>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
struct CloudPayload {
#[prost(uint32, optional, tag = "1")]
msg_id: Option<u32>,
#[prost(uint64, optional, tag = "2")]
msg_date: Option<u64>,
#[prost(uint32, optional, tag = "3")]
proto: Option<u32>,
#[prost(bytes = "vec", optional, tag = "4")]
src: Option<Vec<u8>>,
#[prost(bytes = "vec", repeated, tag = "5")]
dests: Vec<Vec<u8>>,
#[prost(bytes = "vec", optional, tag = "7")]
payload: Option<Vec<u8>>,
}
// ---------------------------------------------------------------------------
// Frame / wire helpers
// ---------------------------------------------------------------------------
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
fn encode_frame(msg_type: u8, payload: &[u8]) -> Vec<u8> {
let mut frame = Vec::with_capacity(1 + 10 + payload.len());
frame.push(msg_type);
encode_varint(payload.len() as u64, &mut frame);
frame.extend_from_slice(payload);
frame
}
fn decode_frame(data: &[u8]) -> anyhow::Result<(u8, Vec<u8>)> {
if data.is_empty() {
return Err(anyhow::anyhow!("empty qcloud frame"));
}
let msg_type = data[0];
let mut cursor = &data[1..];
let payload_len = decode_varint(&mut cursor)
.map_err(|e| anyhow::anyhow!("decode varint: {e}"))? as usize;
let consumed = data.len().saturating_sub(1 + cursor.len());
let start = 1 + consumed;
if data.len() < start + payload_len {
return Err(anyhow::anyhow!(
"truncated qcloud frame: expected={}, got={}",
start + payload_len,
data.len()
));
}
Ok((msg_type, data[start..start + payload_len].to_vec()))
}
static BATCH_SEQ: AtomicI32 = AtomicI32::new(1);
fn build_payload_frame(message: QConnectMessage, msg_id: &mut u32) -> Vec<u8> {
let batch = QConnectMessages {
messages_time: Some(now_ms()),
messages_id: Some(BATCH_SEQ.fetch_add(1, Ordering::Relaxed)),
messages: vec![message],
};
let inner = batch.encode_to_vec();
*msg_id = msg_id.saturating_add(1);
let cloud = CloudPayload {
msg_id: Some(*msg_id),
msg_date: Some(now_ms()),
proto: Some(1),
src: None,
dests: Vec::new(),
payload: Some(inner),
};
encode_frame(MSG_TYPE_PAYLOAD, &cloud.encode_to_vec())
}
fn build_auth_frame(jwt: &str, msg_id: &mut u32) -> Vec<u8> {
*msg_id = msg_id.saturating_add(1);
let auth = Authenticate {
msg_id: Some(*msg_id),
msg_date: Some(now_ms()),
jwt: Some(jwt.to_string()),
};
encode_frame(MSG_TYPE_AUTHENTICATE, &auth.encode_to_vec())
}
fn build_subscribe_frame(msg_id: &mut u32) -> Vec<u8> {
*msg_id = msg_id.saturating_add(1);
let sub = Subscribe {
msg_id: Some(*msg_id),
msg_date: Some(now_ms()),
proto: Some(1),
channels: vec![vec![0x02]],
};
encode_frame(MSG_TYPE_SUBSCRIBE, &sub.encode_to_vec())
}
// ---------------------------------------------------------------------------
// Outbound message builders
// ---------------------------------------------------------------------------
fn device_uuid_bytes(config: &Config) -> Option<Vec<u8>> {
Uuid::parse_str(&config.device_id)
.map(|u| u.as_bytes().to_vec())
.ok()
}
fn make_device_info(config: &Config) -> DeviceInfoMessage {
DeviceInfoMessage {
device_uuid: device_uuid_bytes(config),
friendly_name: Some(config.device_name.clone()),
brand: Some("QobuzD".to_string()),
model: Some("Linux".to_string()),
serial_number: None,
device_type: Some(5),
capabilities: None,
software_version: Some("qobuzd-0.1.0".to_string()),
}
}
fn build_ctrl_join_session(config: &Config) -> QConnectMessage {
QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeCtrlSrvrJoinSession as i32),
ctrl_srvr_join_session: Some(JoinSessionMessage {
session_uuid: None,
device_info: Some(make_device_info(config)),
reason: None,
initial_state: None,
is_active: None,
}),
..Default::default()
}
}
fn build_renderer_join_session(
session_uuid: Vec<u8>,
config: &Config,
playing_state: i32,
) -> QConnectMessage {
QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeRndrSrvrJoinSession as i32),
rndr_srvr_join_session: Some(JoinSessionMessage {
session_uuid: Some(session_uuid),
device_info: Some(make_device_info(config)),
reason: None,
initial_state: Some(RendererStateMessage {
playing_state: Some(playing_state),
buffer_state: Some(if playing_state == 2 || playing_state == 3 {
2
} else {
1
}),
current_position: None,
duration: None,
queue_version: Some(QueueVersionRef {
major: Some(0),
minor: Some(0),
}),
current_queue_item_id: None,
next_queue_item_id: None,
}),
is_active: Some(true),
}),
..Default::default()
}
}
fn build_state_updated(
playing_state: i32,
position_ms: u64,
duration_ms: u64,
queue_item_id: i32,
next_queue_item_id: i32,
) -> QConnectMessage {
let current_qid = if queue_item_id <= 0 {
None
} else {
Some(queue_item_id)
};
let next_qid = if next_queue_item_id <= 0 {
None
} else {
Some(next_queue_item_id)
};
QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeRndrSrvrStateUpdated as i32),
rndr_srvr_state_updated: Some(RendererStateUpdatedMessage {
state: Some(RendererStateMessage {
playing_state: Some(playing_state),
buffer_state: Some(if playing_state == 2 || playing_state == 3 {
2
} else {
1
}),
current_position: Some(PlaybackPositionMessage {
timestamp: Some(now_ms()),
value: Some(position_ms as i32),
}),
duration: if duration_ms > 0 {
Some(duration_ms as i32)
} else {
None
},
queue_version: Some(QueueVersionRef {
major: Some(0),
minor: Some(0),
}),
current_queue_item_id: current_qid,
next_queue_item_id: next_qid,
}),
}),
..Default::default()
}
}
fn build_volume_changed(volume: i32) -> QConnectMessage {
QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeRndrSrvrVolumeChanged as i32),
rndr_srvr_volume_changed: Some(RendererVolumeChangedMessage {
volume: Some(volume),
}),
..Default::default()
}
}
fn build_volume_muted(value: bool) -> QConnectMessage {
QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeRndrSrvrVolumeMuted as i32),
rndr_srvr_volume_muted: Some(RendererVolumeMutedMessage { value: Some(value) }),
..Default::default()
}
}
fn build_file_quality_changed(sr: i32, bits: i32, ch: i32, quality: i32) -> QConnectMessage {
QConnectMessage {
message_type: Some(
QConnectMessageType::MessageTypeRndrSrvrFileAudioQualityChanged as i32,
),
rndr_srvr_file_audio_quality_changed: Some(RendererFileAudioQualityChangedMessage {
sampling_rate: Some(sr),
bit_depth: Some(bits),
nb_channels: Some(ch),
audio_quality: Some(quality),
}),
..Default::default()
}
}
fn build_renderer_action_next() -> QConnectMessage {
QConnectMessage {
message_type: Some(QConnectMessageType::MessageTypeRndrSrvrRendererAction as i32),
rndr_srvr_renderer_action: Some(RendererActionMessage {
seek_position: None,
action: 2, // ACTION_TYPE_NEXT
}),
..Default::default()
}
}
fn build_max_quality_changed(max_quality: i32) -> QConnectMessage {
QConnectMessage {
message_type: Some(
QConnectMessageType::MessageTypeRndrSrvrMaxAudioQualityChanged as i32,
),
rndr_srvr_max_audio_quality_changed: Some(RendererMaxAudioQualityChangedMessage {
max_audio_quality: Some(max_quality),
network_type: None,
}),
..Default::default()
}
}
// ---------------------------------------------------------------------------
// Quality helpers
// ---------------------------------------------------------------------------
fn quality_to_format_id(quality: i32) -> u32 {
match quality {
1 => 5, // MP3 320kbps
2 => 6, // FLAC CD 16-bit/44.1kHz
3 => 7, // FLAC Hi-Res 24-bit/96kHz
4 | 5 => 27, // FLAC Hi-Res 24-bit/192kHz
_ => 27, // default to max quality
}
}
fn quality_fallback_audio_params(quality: i32) -> (u32, u32, u32) {
match quality {
1 => (44100, 16, 2), // MP3
2 => (44100, 16, 2), // CD
3 => (96000, 24, 2), // Hi-Res up to 96kHz
4 | 5 => (192000, 24, 2), // Hi-Res up to 192/384kHz
_ => (44100, 16, 2),
}
}
// ---------------------------------------------------------------------------
// Local renderer state
// ---------------------------------------------------------------------------
struct RendererLocal {
current_track_id: i32,
current_queue_item_id: i32,
current_next_queue_item_id: i32,
current_duration_ms: u64,
current_playing_state: i32, // 1=stopped, 2=playing, 3=paused
max_audio_quality: i32, // default: 4
volume: u8, // default: 100
pre_mute_volume: u8, // default: 100
muted: bool,
track_ended: bool,
has_position_progress: bool,
last_play_at: Instant,
ignore_seek_until: Option<Instant>,
joined: bool,
}
impl Default for RendererLocal {
fn default() -> Self {
Self {
current_track_id: 0,
current_queue_item_id: -1,
current_next_queue_item_id: -1,
current_duration_ms: 0,
current_playing_state: 1,
max_audio_quality: 4,
volume: 100,
pre_mute_volume: 100,
muted: false,
track_ended: false,
has_position_progress: false,
last_play_at: Instant::now(),
ignore_seek_until: None,
joined: false,
}
}
}
// ---------------------------------------------------------------------------
// RendererHandler
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct RendererHandler {
api: Arc<QobuzApi>,
auth_token: Arc<String>,
player: Arc<AudioPlayer>,
local: Arc<Mutex<RendererLocal>>,
out_tx: mpsc::Sender<QConnectMessage>,
config: Arc<Config>,
}
impl RendererHandler {
async fn send(&self, msg: QConnectMessage) {
if let Err(e) = self.out_tx.send(msg).await {
warn!("[QConnect] Failed to queue outbound message: {e}");
}
}
async fn join_session(&self, session_uuid: Vec<u8>) {
let (playing_state, already_joined) = {
let local = self.local.lock().await;
(local.current_playing_state, local.joined)
};
if already_joined {
return;
}
info!("[QConnect] Sending renderer join");
let msg = build_renderer_join_session(session_uuid, &self.config, playing_state);
self.send(msg).await;
{
let mut local = self.local.lock().await;
local.joined = true;
}
// Send initial volume report
let vol = self.local.lock().await.volume;
self.send(build_volume_changed(vol as i32)).await;
info!("[QConnect] Renderer joined, initial volume={vol} sent");
}
async fn send_state_report(
&self,
playing_state: i32,
position_ms: u64,
duration_ms: u64,
queue_item_id: i32,
next_queue_item_id: i32,
) {
debug!(
"[QConnect] StateReport: playing={playing_state} pos={position_ms}ms dur={duration_ms}ms qi={queue_item_id} nqi={next_queue_item_id}"
);
self.send(build_state_updated(
playing_state,
position_ms,
duration_ms,
queue_item_id,
next_queue_item_id,
))
.await;
}
async fn dispatch_inbound(&self, batch: QConnectMessages) {
for msg in batch.messages {
let Some(mt) = msg.message_type.or_else(|| resolve_message_type_from_fields(&msg))
else {
continue;
};
if mt == QConnectMessageType::MessageTypeSrvrCtrlSessionState as i32 {
if let Some(ss) = msg.srvr_ctrl_session_state {
if let Some(uuid_bytes) = ss.session_uuid {
let should_join = !self.local.lock().await.joined;
if should_join {
info!("[QConnect] Got session UUID from SessionState");
let h = self.clone();
tokio::spawn(async move {
h.join_session(uuid_bytes).await;
});
}
}
}
continue;
}
if mt == QConnectMessageType::MessageTypeSrvrRndrSetState as i32 {
if let Some(payload) = msg.srvr_rndr_set_state {
// Echo detection: server echoes our state reports as SET_STATE with only
// next_track set. Skip those — but not seek commands (current_position set).
if payload.playing_state.is_none()
&& payload.current_track.is_none()
&& payload.current_position.is_none()
{
debug!("[QConnect] Skipping echo SET_STATE");
continue;
}
let h = self.clone();
tokio::spawn(async move {
h.handle_set_state(payload).await;
});
}
continue;
}
if mt == QConnectMessageType::MessageTypeSrvrRndrSetVolume as i32 {
if let Some(payload) = msg.srvr_rndr_set_volume {
let h = self.clone();
tokio::spawn(async move {
h.handle_set_volume(payload).await;
});
}
continue;
}
if mt == QConnectMessageType::MessageTypeSrvrRndrSetActive as i32 {
if let Some(payload) = msg.srvr_rndr_set_active {
let h = self.clone();
tokio::spawn(async move {
h.handle_set_active(payload).await;
});
}
continue;
}
if mt == QConnectMessageType::MessageTypeSrvrRndrSetMaxAudioQuality as i32 {
if let Some(payload) = msg.srvr_rndr_set_max_audio_quality {
let h = self.clone();
tokio::spawn(async move {
h.handle_set_max_audio_quality(payload).await;
});
}
continue;
}
if mt == QConnectMessageType::MessageTypeSrvrRndrSetLoopMode as i32 {
if let Some(payload) = msg.srvr_rndr_set_loop_mode {
info!("[QConnect] SetLoopMode: {:?}", payload.loop_mode);
}
continue;
}
if mt == QConnectMessageType::MessageTypeSrvrRndrSetShuffleMode as i32 {
if let Some(payload) = msg.srvr_rndr_set_shuffle_mode {
info!("[QConnect] SetShuffleMode: {:?}", payload.shuffle_mode);
}
continue;
}
if mt == QConnectMessageType::MessageTypeSrvrRndrMuteVolume as i32 {
if let Some(payload) = msg.srvr_rndr_mute_volume {
let h = self.clone();
tokio::spawn(async move {
h.handle_mute_volume(payload).await;
});
}
continue;
}
}
}
async fn handle_set_state(&self, payload: RendererSetStateMessage) {
let playing_state = payload.playing_state;
let current_position_ms = payload.current_position.map(|p| p.max(0) as u64);
let current_track = payload.current_track;
let next_track = payload.next_track;
info!(
"[QConnect] SET_STATE: playing_state={:?} pos={:?} current_track={:?} next_track={:?}",
playing_state,
current_position_ms,
current_track
.as_ref()
.map(|t| (t.track_id, t.queue_item_id)),
next_track.as_ref().map(|t| (t.track_id, t.queue_item_id)),
);
// Store next_track metadata
if let Some(nt) = &next_track {
let mut local = self.local.lock().await;
local.current_next_queue_item_id = nt.queue_item_id.unwrap_or(-1);
}
// Seek-only detection (no playing_state, no current/next track, position present)
let seek_only = playing_state.is_none()
&& current_track.is_none()
&& next_track.is_none()
&& current_position_ms.is_some();
if seek_only {
let target_pos = current_position_ms.unwrap_or(0);
let player_pos = self.player.status().position_ms;
let (qi, nqi, ps, dur) = {
let mut local = self.local.lock().await;
let suppress = target_pos > 0
&& local
.ignore_seek_until
.map(|deadline| Instant::now() < deadline)
.unwrap_or(false);
let should_seek =
!suppress && (target_pos == 0 || target_pos.abs_diff(player_pos) > 350);
if should_seek {
self.player.send(PlayerCommand::Seek(target_pos));
local.track_ended = false;
if target_pos == 0 {
local.ignore_seek_until =
Some(Instant::now() + Duration::from_secs(2));
}
}
info!(
"[QConnect] seek-only: target={target_pos}ms local={player_pos}ms should_seek={should_seek} suppressed={suppress}"
);
(
local.current_queue_item_id,
local.current_next_queue_item_id,
local.current_playing_state,
local.current_duration_ms,
)
};
self.send_state_report(ps, target_pos, dur, qi, nqi).await;
return;
}
// New track detection
// Sentinel value: track_id=u32::MAX means "no track" (queue cleared by another client)
let queue_cleared = current_track
.as_ref()
.map_or(false, |t| t.track_id.unwrap_or(0) == u32::MAX);
if queue_cleared {
debug!("[QConnect] Queue cleared (sentinel track), stopping playback");
self.player.send(PlayerCommand::Stop);
let mut local = self.local.lock().await;
local.current_track_id = -1;
local.current_queue_item_id = -1;
local.current_next_queue_item_id = -1;
local.current_playing_state = 1;
local.current_duration_ms = 0;
local.track_ended = false;
return;
}
let new_track_info: Option<(i32, i32, i32, u64, i32)> = {
let local = self.local.lock().await;
if let Some(track) = &current_track {
let tid = track.track_id.unwrap_or(0) as i32;
let qid = track.queue_item_id.unwrap_or(-1);
if tid != local.current_track_id || qid != local.current_queue_item_id {
Some((
tid,
qid,
local.max_audio_quality,
current_position_ms.unwrap_or(0),
local.current_next_queue_item_id,
))
} else {
None
}
} else {
None
}
};
if let Some((tid, qid, max_q, start_pos, nqi_snap)) = new_track_info {
info!("[QConnect] Loading new track {tid} (qi={qid})");
{
let mut local = self.local.lock().await;
local.current_track_id = tid;
local.current_queue_item_id = qid;
local.current_playing_state = 2;
local.current_duration_ms = 0;
local.last_play_at = Instant::now();
local.has_position_progress = false;
local.track_ended = false;
local.ignore_seek_until = None;
}
// Send BUFFERING state immediately
self.send_state_report(2, start_pos, 0, qid, nqi_snap).await;
let track_id_str = tid.to_string();
let format_id = quality_to_format_id(max_q);
let duration_ms = match self.api.get_track(&self.auth_token, &track_id_str).await {
Ok(t) => t.duration.unwrap_or(0) as u64 * 1000,
Err(e) => {
warn!("[QConnect] get_track failed: {e}");
0
}
};
match self
.api
.get_track_stream(&self.auth_token, &track_id_str, format_id)
.await
{
Ok(stream) => {
let (player_stream, stream_sr, stream_bits, stream_ch) = match stream {
TrackStream::DirectUrl {
url,
sampling_rate_hz,
bit_depth,
channels,
} => {
info!(
"[QConnect] Got direct stream URL (duration={duration_ms}ms)"
);
(
StreamSource::DirectUrl(url),
sampling_rate_hz,
bit_depth,
channels,
)
}
TrackStream::Segmented {
url_template,
n_segments,
encryption_key_hex,
sampling_rate_hz,
bit_depth,
channels,
} => {
info!("[QConnect] Got segmented stream (segments={n_segments}, duration={duration_ms}ms)");
(
StreamSource::Segmented {
url_template,
n_segments,
encryption_key_hex,
},
sampling_rate_hz,
bit_depth,
channels,
)
}
};
self.player.send(PlayerCommand::Play {
stream: player_stream,
track_id: tid,
queue_item_id: qid,
duration_ms,
start_position_ms: start_pos,
});
{
let mut loc = self.local.lock().await;
loc.current_duration_ms = duration_ms;
}
let (fallback_sr, fallback_bits, fallback_ch) =
quality_fallback_audio_params(max_q);
let sr = stream_sr.unwrap_or(fallback_sr).max(1);
let bits = stream_bits.unwrap_or(fallback_bits).max(1);
let ch = stream_ch.unwrap_or(fallback_ch).max(1);
self.send(build_file_quality_changed(
sr as i32,
bits as i32,
ch as i32,
max_q,
))
.await;
self.send(build_max_quality_changed(max_q)).await;
self.send_state_report(2, start_pos, duration_ms, qid, nqi_snap)
.await;
}
Err(e) => {
error!("[QConnect] Failed to get stream URL: {e}");
{
let mut loc = self.local.lock().await;
loc.current_playing_state = 1;
}
self.send_state_report(1, 0, 0, qid, nqi_snap).await;
}
}
return;
}
// playing_state only (no new track)
if let Some(ps) = playing_state {
let start_pos = current_position_ms.unwrap_or(0);
let (cur_ps, cur_pos, dur, qi, nqi) = {
let mut local = self.local.lock().await;
match ps {
2 => {
let status = self.player.status();
let should_restart = current_track.is_some()
&& (local.track_ended || status.state == PlayerState::Stopped);
if should_restart {
info!(
"[QConnect] Restarting current track from {start_pos}ms (ended={} state={:?})",
local.track_ended, status.state
);
self.player.send(PlayerCommand::Seek(start_pos));
local.current_playing_state = 2;
local.track_ended = false;
} else if local.current_playing_state == 3 {
info!("[QConnect] Resuming playback");
self.player.send(PlayerCommand::Resume);
local.current_playing_state = 2;
local.track_ended = false;
} else if local.current_playing_state != 2 {
info!(
"[QConnect] Play requested, state={}",
local.current_playing_state
);
local.current_playing_state = 2;
}
}
3 => {
info!("[QConnect] Pausing playback");
self.player.send(PlayerCommand::Pause);
local.current_playing_state = 3;
}
1 => {
info!("[QConnect] Stopping playback");
self.player.send(PlayerCommand::Stop);
local.current_playing_state = 1;
local.current_queue_item_id = -1;
local.current_next_queue_item_id = -1;
local.current_track_id = 0;
local.track_ended = false;
}
_ => {}
}
let pos_out = match ps {
1 => 0,
_ => current_position_ms.unwrap_or_else(|| self.player.status().position_ms),
};
(
local.current_playing_state,
pos_out,
local.current_duration_ms,
local.current_queue_item_id,
local.current_next_queue_item_id,
)
};
self.send_state_report(cur_ps, cur_pos, dur, qi, nqi).await;
return;
}
// Position control with no playing_state and no track change
let (ps, pos, dur, qi, nqi) = {
let mut local = self.local.lock().await;
if let Some(p) = current_position_ms {
let player_pos = self.player.status().position_ms;
let suppress = p > 0
&& local
.ignore_seek_until
.map(|deadline| Instant::now() < deadline)
.unwrap_or(false);
let should_seek = !suppress && (p == 0 || p.abs_diff(player_pos) > 350);
if should_seek {
info!("[QConnect] Position jump: seeking to {p}ms (local={player_pos}ms)");
self.player.send(PlayerCommand::Seek(p));
local.track_ended = false;
if p == 0 {
local.ignore_seek_until =
Some(Instant::now() + Duration::from_secs(2));
}
}
}
let pos_out =
current_position_ms.unwrap_or_else(|| self.player.status().position_ms);
(
local.current_playing_state,
pos_out,
local.current_duration_ms,
local.current_queue_item_id,
local.current_next_queue_item_id,
)
};
self.send_state_report(ps, pos, dur, qi, nqi).await;
}
async fn handle_set_volume(&self, payload: RendererSetVolumeMessage) {
let mut local = self.local.lock().await;
let new_vol = if let Some(v) = payload.volume {
v.clamp(0, 100) as u8
} else if let Some(d) = payload.volume_delta {
(local.volume as i32 + d).clamp(0, 100) as u8
} else {
local.volume
};
info!("[QConnect] SetVolume: {new_vol}");
local.volume = new_vol;
if local.muted && new_vol > 0 {
local.muted = false;
}
drop(local);
self.player.send(PlayerCommand::SetVolume(new_vol));
self.send(build_volume_changed(new_vol as i32)).await;
}
async fn handle_set_active(&self, payload: RendererSetActiveMessage) {
let active = payload.active.unwrap_or(true);
info!("[QConnect] SetActive: {active}");
if !active {
let mut local = self.local.lock().await;
local.current_playing_state = 1;
local.current_queue_item_id = -1;
local.current_next_queue_item_id = -1;
local.current_track_id = 0;
let nqi = local.current_next_queue_item_id;
drop(local);
self.player.send(PlayerCommand::Stop);
self.send_state_report(1, 0, 0, -1, nqi).await;
}
}
async fn handle_mute_volume(&self, payload: RendererMuteVolumeMessage) {
let value = payload.value.unwrap_or(false);
info!("[QConnect] MuteVolume: {value}");
let mut local = self.local.lock().await;
if value {
local.pre_mute_volume = local.volume;
local.volume = 0;
local.muted = true;
} else {
local.volume = local.pre_mute_volume;
local.muted = false;
}
let vol = local.volume;
drop(local);
self.player.send(PlayerCommand::SetVolume(vol));
self.send(build_volume_muted(value)).await;
}
async fn handle_set_max_audio_quality(&self, payload: RendererSetMaxAudioQualityMessage) {
let max_audio_quality = payload.max_audio_quality.unwrap_or(4);
let format_id = quality_to_format_id(max_audio_quality);
info!("[QConnect] SetMaxAudioQuality: {max_audio_quality} (format_id={format_id})");
{
let mut local = self.local.lock().await;
local.max_audio_quality = max_audio_quality;
}
self.send(build_max_quality_changed(max_audio_quality)).await;
let (tid, qi, nqi, cur_ps, dur) = {
let local = self.local.lock().await;
(
local.current_track_id,
local.current_queue_item_id,
local.current_next_queue_item_id,
local.current_playing_state,
local.current_duration_ms,
)
};
if cur_ps == 2 && tid != 0 {
let restart_pos = self.player.status().position_ms;
info!("[QConnect] Restarting track {tid} at new quality format_id={format_id}");
self.send_state_report(2, restart_pos, dur, qi, nqi).await;
let track_id_str = tid.to_string();
let duration_ms = match self.api.get_track(&self.auth_token, &track_id_str).await {
Ok(t) => t.duration.unwrap_or(0) as u64 * 1000,
Err(e) => {
warn!("[QConnect] get_track failed: {e}");
dur
}
};
match self
.api
.get_track_stream(&self.auth_token, &track_id_str, format_id)
.await
{
Ok(stream) => {
let (player_stream, stream_sr, stream_bits, stream_ch) = match stream {
TrackStream::DirectUrl {
url,
sampling_rate_hz,
bit_depth,
channels,
} => (StreamSource::DirectUrl(url), sampling_rate_hz, bit_depth, channels),
TrackStream::Segmented {
url_template,
n_segments,
encryption_key_hex,
sampling_rate_hz,
bit_depth,
channels,
} => (
StreamSource::Segmented {
url_template,
n_segments,
encryption_key_hex,
},
sampling_rate_hz,
bit_depth,
channels,
),
};
self.player.send(PlayerCommand::Play {
stream: player_stream,
track_id: tid,
queue_item_id: qi,
duration_ms,
start_position_ms: restart_pos,
});
{
let mut loc = self.local.lock().await;
loc.current_duration_ms = duration_ms;
}
let (fallback_sr, fallback_bits, fallback_ch) =
quality_fallback_audio_params(max_audio_quality);
let sr = stream_sr.unwrap_or(fallback_sr).max(1);
let bits = stream_bits.unwrap_or(fallback_bits).max(1);
let ch = stream_ch.unwrap_or(fallback_ch).max(1);
self.send(build_file_quality_changed(
sr as i32,
bits as i32,
ch as i32,
max_audio_quality,
))
.await;
self.send(build_max_quality_changed(max_audio_quality)).await;
self.send_state_report(2, restart_pos, duration_ms, qi, nqi)
.await;
info!("[QConnect] Restarted at format_id={format_id}");
}
Err(e) => {
error!("[QConnect] Failed to get stream for quality change: {e}");
let mut loc = self.local.lock().await;
loc.current_playing_state = 1;
drop(loc);
self.send_state_report(1, 0, duration_ms, qi, nqi).await;
}
}
}
}
}
// Fallback: resolve message type from which field is populated (mirrors qbz decoder)
fn resolve_message_type_from_fields(msg: &QConnectMessage) -> Option<i32> {
if msg.srvr_rndr_set_state.is_some() {
return Some(QConnectMessageType::MessageTypeSrvrRndrSetState as i32);
}
if msg.srvr_rndr_set_volume.is_some() {
return Some(QConnectMessageType::MessageTypeSrvrRndrSetVolume as i32);
}
if msg.srvr_rndr_set_active.is_some() {
return Some(QConnectMessageType::MessageTypeSrvrRndrSetActive as i32);
}
if msg.srvr_rndr_set_max_audio_quality.is_some() {
return Some(QConnectMessageType::MessageTypeSrvrRndrSetMaxAudioQuality as i32);
}
if msg.srvr_rndr_set_loop_mode.is_some() {
return Some(QConnectMessageType::MessageTypeSrvrRndrSetLoopMode as i32);
}
if msg.srvr_rndr_set_shuffle_mode.is_some() {
return Some(QConnectMessageType::MessageTypeSrvrRndrSetShuffleMode as i32);
}
if msg.srvr_rndr_mute_volume.is_some() {
return Some(QConnectMessageType::MessageTypeSrvrRndrMuteVolume as i32);
}
if msg.srvr_ctrl_session_state.is_some() {
return Some(QConnectMessageType::MessageTypeSrvrCtrlSessionState as i32);
}
None
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
pub struct QConnect {
_handle: tokio::task::JoinHandle<()>,
}
impl QConnect {
pub fn start(auth_token: String, _device_uuid: String, _device_name: String) -> Self {
let handle = tokio::spawn(async move {
let config = match Config::load() {
Ok(c) => c,
Err(e) => {
error!("[QConnect] Failed to load config: {e}");
return;
}
};
run_qconnect(auth_token, config).await;
});
Self { _handle: handle }
}
}
pub async fn run_qconnect(auth_token: String, config: Config) {
let mut backoff = 5u64;
loop {
info!("[QConnect] Connecting...");
match run_qconnect_once(&auth_token, &config).await {
Ok(()) => {
info!("[QConnect] Disconnected cleanly");
backoff = 5;
}
Err(e) => {
error!("[QConnect] Error: {e}");
}
}
info!("[QConnect] Reconnecting in {backoff}s");
tokio::time::sleep(Duration::from_secs(backoff)).await;
backoff = (backoff * 2).min(120);
}
}
async fn run_qconnect_once(auth_token: &str, config: &Config) -> anyhow::Result<()> {
let api = Arc::new(QobuzApi::new(config));
let token_resp = api.get_qws_token(auth_token).await?;
let jwt = token_resp.jwt_qws.jwt;
let endpoint = token_resp.jwt_qws.endpoint;
info!("[QConnect] Connecting to {endpoint}");
let (ws, _) = connect_async_with_config(
&endpoint,
Some(WebSocketConfig::default()),
false,
)
.await
.map_err(|e| anyhow::anyhow!("WebSocket connect failed: {e}"))?;
let (mut ws_write, mut ws_read) = ws.split();
let mut msg_id: u32 = 0;
// Authenticate
let auth_frame = build_auth_frame(&jwt, &mut msg_id);
ws_write
.send(WsMessage::Binary(auth_frame.into()))
.await
.map_err(|e| anyhow::anyhow!("auth send failed: {e}"))?;
// Subscribe
let sub_frame = build_subscribe_frame(&mut msg_id);
ws_write
.send(WsMessage::Binary(sub_frame.into()))
.await
.map_err(|e| anyhow::anyhow!("subscribe send failed: {e}"))?;
let player = Arc::new(AudioPlayer::new());
let local = Arc::new(Mutex::new(RendererLocal::default()));
let (out_tx, mut out_rx) = mpsc::channel::<QConnectMessage>(64);
let handler = RendererHandler {
api: Arc::clone(&api),
auth_token: Arc::new(auth_token.to_string()),
player: Arc::clone(&player),
local: Arc::clone(&local),
out_tx,
config: Arc::new(config.clone()),
};
// Send ctrl join after subscribe
{
let ctrl_join = build_ctrl_join_session(config);
let frame = build_payload_frame(ctrl_join, &mut msg_id);
ws_write
.send(WsMessage::Binary(frame.into()))
.await
.map_err(|e| anyhow::anyhow!("ctrl join send failed: {e}"))?;
info!("[QConnect] Subscribed — sent CtrlSrvrJoinSession");
}
let mut position_ticker = tokio::time::interval(Duration::from_millis(500));
position_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = position_ticker.tick() => {
let (playing_state, last_play_at, has_progress, track_ended_local, dur, qi, nqi) = {
let loc = local.lock().await;
(
loc.current_playing_state,
loc.last_play_at,
loc.has_position_progress,
loc.track_ended,
loc.current_duration_ms,
loc.current_queue_item_id,
loc.current_next_queue_item_id,
)
};
if playing_state == 2 {
let status = player.status();
let elapsed = last_play_at.elapsed();
if status.state == PlayerState::Stopped
&& has_progress
&& elapsed > Duration::from_secs(3)
{
if !track_ended_local {
info!("[QConnect] Track ended naturally, nqi={nqi}");
{
let mut loc = local.lock().await;
loc.track_ended = true;
loc.current_playing_state = 1;
}
handler.send_state_report(1, dur, dur, qi, nqi).await;
if nqi > 0 {
// There is a next track — ask server to advance
handler.send(build_renderer_action_next()).await;
}
// If nqi <= 0, just stay stopped; server will keep loop mode off
}
} else if status.state != PlayerState::Stopped {
let new_pos = status.position_ms;
if new_pos > 0 {
{
let mut loc = local.lock().await;
if !loc.has_position_progress {
loc.has_position_progress = true;
info!("[QConnect] First position progress: {new_pos}ms");
}
}
// Periodic position report so the app's progress bar updates
handler.send_state_report(2, new_pos, dur, qi, nqi).await;
}
}
}
}
incoming = ws_read.next() => {
match incoming {
Some(Ok(WsMessage::Binary(data))) => {
match decode_frame(&data) {
Ok((MSG_TYPE_PAYLOAD, payload)) => {
match CloudPayload::decode(payload.as_slice()) {
Ok(cloud) => {
let inner = cloud.payload.unwrap_or_default();
match QConnectMessages::decode(inner.as_slice()) {
Ok(batch) => {
handler.dispatch_inbound(batch).await;
}
Err(e) => {
debug!("[QConnect] QConnectMessages decode error: {e}");
}
}
}
Err(e) => {
debug!("[QConnect] CloudPayload decode error: {e}");
}
}
}
Ok((MSG_TYPE_AUTHENTICATE, _)) => {
debug!("[QConnect] Auth ack received");
}
Ok((MSG_TYPE_SUBSCRIBE, _)) => {
debug!("[QConnect] Subscribe ack received");
}
Ok((9, _)) => {
return Err(anyhow::anyhow!("server sent error frame"));
}
Ok((10, _)) => {
return Err(anyhow::anyhow!("server sent disconnect frame"));
}
Ok(_) => {}
Err(e) => {
warn!("[QConnect] Frame decode error: {e}");
}
}
}
Some(Ok(WsMessage::Ping(payload))) => {
let _ = ws_write.send(WsMessage::Pong(payload)).await;
}
Some(Ok(WsMessage::Pong(_))) => {}
Some(Ok(WsMessage::Close(_))) => {
return Err(anyhow::anyhow!("server closed connection"));
}
Some(Ok(_)) => {}
Some(Err(e)) => {
return Err(anyhow::anyhow!("WebSocket read error: {e}"));
}
None => {
return Err(anyhow::anyhow!("WebSocket stream ended"));
}
}
}
Some(msg) = out_rx.recv() => {
let frame = build_payload_frame(msg, &mut msg_id);
if let Err(e) = ws_write.send(WsMessage::Binary(frame.into())).await {
return Err(anyhow::anyhow!("WebSocket write error: {e}"));
}
}
}
}
}