fix(qbzd): materialize QConnect remote queue for auto-advance
When QConnect controls the daemon, the local QbzCore queue is empty so the orchestrator's auto-advance fails. Now on QueueUpdated, fetch all tracks via get_tracks_batch and populate the local queue, mirroring desktop's materialize_remote_queue. Bump version to 0.1.8. cc
This commit is contained in:
2 files changed
+125
-2
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "qbzd"
|
name = "qbzd"
|
||||||
version = "0.1.7"
|
version = "0.1.8"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "QBZ headless music daemon for SBCs and servers"
|
description = "QBZ headless music daemon for SBCs and servers"
|
||||||
|
|||||||
+124
-1
@@ -14,8 +14,9 @@ use qconnect_app::{
|
|||||||
QconnectApp, QconnectAppEvent, QconnectEventSink,
|
QconnectApp, QconnectAppEvent, QconnectEventSink,
|
||||||
QueueCommandType, RendererReport, RendererReportType,
|
QueueCommandType, RendererReport, RendererReportType,
|
||||||
};
|
};
|
||||||
use qconnect_core::RendererCommand;
|
use qconnect_core::{QConnectQueueState, RendererCommand};
|
||||||
use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig};
|
use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig};
|
||||||
|
use qbz_models::{QueueTrack, Track};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -61,6 +62,9 @@ impl QconnectEventSink for HeadlessQconnectSink {
|
|||||||
}
|
}
|
||||||
QconnectAppEvent::QueueUpdated(queue_state) => {
|
QconnectAppEvent::QueueUpdated(queue_state) => {
|
||||||
log::info!("[qbzd/qconnect] Queue: {} items", queue_state.queue_items.len());
|
log::info!("[qbzd/qconnect] Queue: {} items", queue_state.queue_items.len());
|
||||||
|
if let Err(e) = materialize_remote_queue(&self.core, queue_state).await {
|
||||||
|
log::warn!("[qbzd/qconnect] Failed to materialize queue: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
QconnectAppEvent::SessionManagementEvent { message_type, payload } => {
|
QconnectAppEvent::SessionManagementEvent { message_type, payload } => {
|
||||||
log::info!("[qbzd/qconnect] Session mgmt: {}", message_type);
|
log::info!("[qbzd/qconnect] Session mgmt: {}", message_type);
|
||||||
@@ -179,6 +183,125 @@ async fn download_and_play_track(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Materialize the QConnect remote queue into the local QbzCore queue.
|
||||||
|
/// This enables the local orchestrator's auto-advance and gapless logic
|
||||||
|
/// to work when QConnect is the playback source.
|
||||||
|
/// Mirrors desktop's materialize_remote_queue from qconnect_service.rs.
|
||||||
|
async fn materialize_remote_queue(
|
||||||
|
core: &qbz_core::QbzCore<DaemonAdapter>,
|
||||||
|
queue_state: &QConnectQueueState,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if queue_state.queue_items.is_empty() {
|
||||||
|
log::info!("[qbzd/qconnect] Empty queue, skipping materialization");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe track_ids before batch fetch
|
||||||
|
let mut unique_ids: Vec<u64> = Vec::with_capacity(queue_state.queue_items.len());
|
||||||
|
for item in &queue_state.queue_items {
|
||||||
|
if !unique_ids.contains(&item.track_id) {
|
||||||
|
unique_ids.push(item.track_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[qbzd/qconnect] Materializing remote queue: {} items, {} unique tracks",
|
||||||
|
queue_state.queue_items.len(),
|
||||||
|
unique_ids.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let fetched_tracks = core
|
||||||
|
.get_tracks_batch(&unique_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("batch fetch failed: {}", e))?;
|
||||||
|
|
||||||
|
let mut tracks_by_id: std::collections::HashMap<u64, QueueTrack> =
|
||||||
|
std::collections::HashMap::with_capacity(fetched_tracks.len());
|
||||||
|
for track in fetched_tracks {
|
||||||
|
tracks_by_id.insert(track.id, track_to_queue_track(&track));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the queue in QConnect order
|
||||||
|
let mut queue_tracks = Vec::with_capacity(queue_state.queue_items.len());
|
||||||
|
for item in &queue_state.queue_items {
|
||||||
|
if let Some(qt) = tracks_by_id.get(&item.track_id) {
|
||||||
|
queue_tracks.push(qt.clone());
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"[qbzd/qconnect] Track {} missing from batch fetch, skipping",
|
||||||
|
item.track_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if queue_tracks.is_empty() {
|
||||||
|
return Err("zero playable tracks after materialization".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine start index from currently playing track (if any)
|
||||||
|
let player = core.player();
|
||||||
|
let current_track_id = player.state.current_track_id();
|
||||||
|
let start_index = if current_track_id != 0 {
|
||||||
|
queue_state
|
||||||
|
.queue_items
|
||||||
|
.iter()
|
||||||
|
.position(|item| item.track_id == current_track_id)
|
||||||
|
} else {
|
||||||
|
Some(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[qbzd/qconnect] Setting local queue: {} tracks, start_index={:?}",
|
||||||
|
queue_tracks.len(),
|
||||||
|
start_index
|
||||||
|
);
|
||||||
|
|
||||||
|
core.set_queue(queue_tracks, start_index).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert qbz_models::Track to QueueTrack.
|
||||||
|
/// Mirrors desktop's model_track_to_core_queue_track.
|
||||||
|
fn track_to_queue_track(track: &Track) -> QueueTrack {
|
||||||
|
let artwork_url = track
|
||||||
|
.album
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|album| album.image.large.clone());
|
||||||
|
let artist = track
|
||||||
|
.performer
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.name.clone())
|
||||||
|
.unwrap_or_else(|| "Unknown Artist".to_string());
|
||||||
|
let album_title = track
|
||||||
|
.album
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.title.clone())
|
||||||
|
.unwrap_or_else(|| "Unknown Album".to_string());
|
||||||
|
let album_id = track.album.as_ref().and_then(|a| {
|
||||||
|
let trimmed = a.id.trim();
|
||||||
|
if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }
|
||||||
|
});
|
||||||
|
let artist_id = track.performer.as_ref().map(|p| p.id);
|
||||||
|
|
||||||
|
QueueTrack {
|
||||||
|
id: track.id,
|
||||||
|
title: track.title.clone(),
|
||||||
|
artist,
|
||||||
|
album: album_title,
|
||||||
|
duration_secs: track.duration as u64,
|
||||||
|
artwork_url,
|
||||||
|
hires: track.hires,
|
||||||
|
bit_depth: track.maximum_bit_depth,
|
||||||
|
sample_rate: track.maximum_sampling_rate,
|
||||||
|
is_local: false,
|
||||||
|
album_id,
|
||||||
|
artist_id,
|
||||||
|
streamable: track.streamable,
|
||||||
|
source: Some("qobuz".to_string()),
|
||||||
|
parental_warning: track.parental_warning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Start QConnect — exact replica of desktop's QconnectServiceState::connect().
|
/// Start QConnect — exact replica of desktop's QconnectServiceState::connect().
|
||||||
pub async fn start_qconnect(
|
pub async fn start_qconnect(
|
||||||
core: &Arc<qbz_core::QbzCore<DaemonAdapter>>,
|
core: &Arc<qbz_core::QbzCore<DaemonAdapter>>,
|
||||||
|
|||||||
Reference in new issue
Block a user