1 unstable release
Uses new Rust 2024
| new 0.1.0-alpha.1 | Feb 27, 2026 |
|---|
#179 in WebSocket
83 downloads per month
215KB
4K
SLoC
Qonductor
UNDER ACTIVE DEVELOPMENT, EVERYTHING WILL BREAK
Rust implementation of the Qobuz Connect protocol.
Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ SessionManager │
│ - Main entry point │
│ - Owns DeviceRegistry and SessionHandles │
│ - Routes events to user via mpsc channel │
└─────────────────────────────────────────────────────────────────────────────┘
│ owns │ owns
▼ ▼
┌─────────────────────────────┐ ┌──────────────────────────────┐
│ DeviceRegistry │ │ SessionHandle (per session)│
│ - 1 HTTP server (axum) │ │ - Lightweight handle │
│ - N mDNS announcements │ │ - Sends commands via channel│
│ - Emits DeviceSelected │ │ - Spawns SessionRunner task │
└─────────────────────────────┘ └──────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────┐ ┌──────────────────────────────┐
│ Per-Device mDNS Service │ │ SessionRunner (spawned) │
│ _qobuz-connect._tcp.local │ │ - Owns WebSocket connection │
│ TXT: path, device_uuid │ │ - tokio::select! loop │
└─────────────────────────────┘ │ - Handles WS + commands │
└──────────────────────────────┘
Device Discovery Flow
1. Device Registration
When you call manager.add_device(config):
┌──────────┐ ┌────────────────┐ ┌─────────────────┐
│ User │ │ DeviceRegistry │ │ mDNS (Avahi) │
└────┬─────┘ └───────┬────────┘ └────────┬────────┘
│ │ │
│ add_device(config) │ │
│──────────────────────>│ │
│ │ │
│ │ Register service │
│ │ "_qobuz-connect._tcp" │
│ │──────────────────────────>│
│ │ │
│ │ TXT records: │
│ │ - path=/devices/{uuid} │
│ │ - device_uuid={uuid} │
│ │ - type=SPEAKER │
│ │──────────────────────────>│
│ │ │
│ Ok(()) │ │
│<──────────────────────│ │
The device is now discoverable on the local network via mDNS/Zeroconf.
2. Device Selection (Qobuz App → Your Device)
When a user selects your device in the Qobuz app:
┌────────────┐ ┌────────────────┐ ┌────────────────┐ ┌─────────────┐
│ Qobuz App │ │ HTTP Server │ │ DeviceRegistry │ │ Manager │
└─────┬──────┘ └───────┬────────┘ └───────┬────────┘ └──────┬──────┘
│ │ │ │
│ GET /devices/{uuid}/get-display-info │ │
│────────────────────>│ │ │
│ { name, type } │ │ │
│<────────────────────│ │ │
│ │ │ │
│ GET /devices/{uuid}/get-connect-info │ │
│────────────────────>│ │ │
│ { app_id } │ │ │
│<────────────────────│ │ │
│ │ │ │
│ POST /devices/{uuid}/connect-to-qconnect │ │
│ { session_id, jwt_qconnect, jwt_api } │ │
│────────────────────>│ │ │
│ │ DeviceSelected │ │
│ │──────────────────────>│ │
│ │ │ DeviceSelected │
│ │ │─────────────────────>│
│ { success } │ │ │
│<────────────────────│ │ │
3. WebSocket Session Creation
When SessionManager receives DeviceSelected:
┌─────────────┐ ┌───────────────┐ ┌────────────────┐ ┌─────────────┐
│ Manager │ │ SessionHandle │ │ SessionRunner │ │ Qobuz WS │
└──────┬──────┘ └───────┬───────┘ └───────┬────────┘ └──────┬──────┘
│ │ │ │
│ SessionHandle::connect(session_info, device_config) │
│────────────────────>│ │ │
│ │ │ │
│ │ Connect WebSocket │ │
│ │────────────────────────────────────────────>│
│ │ │ │
│ │ Subscribe + Join │ │
│ │────────────────────────────────────────────>│
│ │ │ │
│ │ Spawn runner task │ │
│ │─────────────────────>│ │
│ │ │ │
│ SessionHandle │ │ tokio::select! { │
│<────────────────────│ │ ws.recv() │
│ │ │ cmd_rx.recv() │
│ │ │ } │
│ │ │<────────────────────>│
4. Event Flow
Events from Qobuz server flow to user code:
┌─────────────┐ ┌───────────────┐ ┌─────────────┐ ┌──────────┐
│ Qobuz WS │ │ SessionRunner │ │ Manager │ │ User │
└──────┬──────┘ └───────┬───────┘ └──────┬──────┘ └────┬─────┘
│ │ │ │
│ PlaybackCommand │ │ │
│────────────────────>│ │ │
│ │ │ │
│ │ event_tx.send() │ │
│ │────────────────────>│ │
│ │ │ │
│ │ │ events.recv() │
│ │ │──────────────────>│
│ │ │ │
│ │ │ SessionEvent:: │
│ │ │ PlaybackCommand │
│ │ │──────────────────>│
Usage
use qonductor::{
SessionManager, DeviceConfig, SessionEvent, Command, Notification,
ActivationState, msg, PlayingState, BufferState,
msg::{PositionExt, QueueRendererStateExt},
};
#[tokio::main]
async fn main() -> qonductor::Result<()> {
// Start the session manager (HTTP server + mDNS)
let mut manager = SessionManager::start(7864).await?;
// Register device and get session handle for bidirectional communication
let mut session = manager.add_device(
DeviceConfig::new("Living Room Speaker", "your_app_id")
).await?;
// Spawn manager to handle device selections
tokio::spawn(async move { manager.run().await });
// Handle events for this device
while let Some(event) = session.recv().await {
match event {
// Commands require a response via the Responder
SessionEvent::Command(cmd) => match cmd {
Command::SetState { cmd, respond } => {
println!("Play {:?} at {:?}ms", cmd.playing_state, cmd.current_position);
let mut response = msg::QueueRendererState::default();
response.set_state(PlayingState::Playing).set_buffer(BufferState::Ok);
respond.send(response);
}
Command::SetActive { respond, .. } => {
println!("Device activated!");
respond.send(ActivationState {
muted: false,
volume: 100,
max_quality: 4,
playback: msg::QueueRendererState::default(),
});
}
Command::Heartbeat { respond } => {
respond.send(None); // or Some(state) if playing
}
},
// Notifications are informational (use _ => for forward compatibility)
SessionEvent::Notification(n) => match n {
Notification::Connected => println!("Connected!"),
Notification::DeviceRegistered { renderer_id, .. } => {
println!("Registered as renderer {}", renderer_id);
}
Notification::QueueState(queue) => {
println!("Queue has {} tracks", queue.tracks.len());
}
_ => {}
},
}
}
Ok(())
}
Key Types
| Type | Description |
|---|---|
SessionManager |
Main entry point. Manages devices and sessions. |
DeviceConfig |
Configuration for a discoverable device. |
DeviceSession |
Bidirectional session handle returned by add_device(). |
SessionEvent |
Wrapper: Command(Command) or Notification(Notification) |
Command |
Events requiring response: SetState, SetActive, Heartbeat |
Notification |
Informational events: Connected, QueueState, etc. |
Responder<T> |
Used to send required responses back to the server. |
PlayingState |
Playback state: Playing, Paused, Stopped |
How It Works
-
mDNS Advertisement: Each device is advertised via
_qobuz-connect._tcpwith a unique path in the TXT record. -
HTTP Endpoints: A single HTTP server handles all devices via parameterized routes (
/devices/{uuid}/*). Qobuz apps hit these endpoints when the device is selected. -
1:1 Device-Session Mapping: When a device is selected in the Qobuz app, a dedicated session is created for that device between Qonductor and the Qobuz servers.
-
Actor Pattern: Each WebSocket session runs in its own spawned task, communicating with the manager via channels.
Building
cargo build
cargo run --example discovery_server
License
MIT
Dependencies
~15–35MB
~449K SLoC