# 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
```rust
use qonductor::{
SessionManager, DeviceConfig, SessionEvent, Command, Notification,
ActivationState, msg, PlayingState, BufferState,
msg::{PositionExt, QueueRendererStateExt, SetStateExt, report::VolumeChanged},
};
#[tokio::main]
async fn main() -> qonductor::Result<()> {
// Start the session manager (HTTP server + mDNS)
let mut manager = SessionManager::start(7864, "your_app_id").await?;
// Register device and get session handle for bidirectional communication
let mut session = manager.add_device(
DeviceConfig::new("Living Room Speaker")
).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.state(), cmd.current_position);
let mut response = msg::QueueRendererState {
current_position: Some(msg::Position::now(cmd.current_position.unwrap_or(0))),
..Default::default()
};
response
.set_state(cmd.state().unwrap_or(PlayingState::Stopped))
.set_buffer(BufferState::Ok);
respond.send(response);
}
Command::SetVolume { cmd, respond } => {
println!("Volume: {:?}", cmd.volume);
respond.send(VolumeChanged { volume: cmd.volume });
}
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
| `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`, `SetVolume`, `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
1. **mDNS Advertisement**: Each device is advertised via `_qobuz-connect._tcp` with a unique path in the TXT record.
2. **HTTP Endpoints**: A single HTTP server handles all devices via parameterized routes (`/devices/{uuid}/*`). Qobuz apps hit these endpoints when the device is selected.
3. **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.
4. **Actor Pattern**: Each WebSocket session runs in its own spawned task, communicating with the manager via channels.
## Building
```bash
cargo build
cargo run --example discovery_server
```
## License
MIT