feat(qconnect): add session management protobuf types and outbound reporting
Add missing SRVR_CTRL session management message types (81-87, 97-101) that were completely absent from the protobuf definitions. Without these, prost silently discarded all session topology messages from the server. - Add 12 new proto structs for session/renderer/volume/quality events - Add decoder branches and map functions for all new message types - Add SessionManagementEvent variant to bypass queue reducer - Add v2_qconnect_report_playback_state and v2_qconnect_report_volume - Add comprehensive logging for inbound message diagnostics
This commit is contained in:
13 files changed
+950
-106
No files matched your search
@@ -209,6 +209,18 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn apply_server_event(&self, event: QueueServerEvent) -> Result<(), QconnectAppError> {
|
async fn apply_server_event(&self, event: QueueServerEvent) -> Result<(), QconnectAppError> {
|
||||||
|
// Session management events bypass the queue reducer entirely.
|
||||||
|
// They provide session topology info (renderers, active renderer, etc.)
|
||||||
|
if event.event_type.is_session_management() {
|
||||||
|
self.sink
|
||||||
|
.on_event(QconnectAppEvent::SessionManagementEvent {
|
||||||
|
message_type: event.message_type().to_string(),
|
||||||
|
payload: event.payload.clone(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let mut completed_uuid: Option<String> = None;
|
let mut completed_uuid: Option<String> = None;
|
||||||
let mut canceled_uuid: Option<String> = None;
|
let mut canceled_uuid: Option<String> = None;
|
||||||
let mut ignored_queue_error_uuid: Option<String> = None;
|
let mut ignored_queue_error_uuid: Option<String> = None;
|
||||||
@@ -601,6 +613,12 @@ fn map_server_event(event: &QueueServerEvent, current: &QConnectQueueState) -> Q
|
|||||||
.map(value_to_string)
|
.map(value_to_string)
|
||||||
.unwrap_or_else(|| "queue_error_message".to_string()),
|
.unwrap_or_else(|| "queue_error_message".to_string()),
|
||||||
},
|
},
|
||||||
|
// Session management events are intercepted in apply_server_event()
|
||||||
|
// and should never reach map_server_event(). Treat as no-op if they do.
|
||||||
|
_ => QueueEvent::QueueCleared {
|
||||||
|
action_uuid: None,
|
||||||
|
version: current.version,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use qconnect_core::{QConnectQueueState, QConnectRendererState, RendererCommand};
|
use qconnect_core::{QConnectQueueState, QConnectRendererState, RendererCommand};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum QconnectAppEvent {
|
pub enum QconnectAppEvent {
|
||||||
@@ -30,6 +31,12 @@ pub enum QconnectAppEvent {
|
|||||||
action_uuid: String,
|
action_uuid: String,
|
||||||
},
|
},
|
||||||
QueueResyncTriggered,
|
QueueResyncTriggered,
|
||||||
|
/// Session management event from server (types 81-87, 97-101).
|
||||||
|
/// These don't affect the queue reducer but provide session topology info.
|
||||||
|
SessionManagementEvent {
|
||||||
|
message_type: String,
|
||||||
|
payload: Value,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ pub use feature_flags::{
|
|||||||
};
|
};
|
||||||
pub use qconnect_core::{
|
pub use qconnect_core::{
|
||||||
evaluate_remote_queue_admission, resolve_handoff_intent, AdmissionDecision, HandoffIntent,
|
evaluate_remote_queue_admission, resolve_handoff_intent, AdmissionDecision, HandoffIntent,
|
||||||
QConnectQueueState, QConnectRendererState, RendererCommand, TrackOrigin,
|
QConnectQueueState, QConnectRendererState, QueueVersion, RendererCommand, TrackOrigin,
|
||||||
};
|
};
|
||||||
pub use qconnect_protocol::{QueueCommandType, RendererReport, RendererReportType};
|
pub use qconnect_protocol::{QueueCommandType, RendererReport, RendererReportType};
|
||||||
pub use state::QconnectRuntimeState;
|
pub use state::QconnectRuntimeState;
|
||||||
@@ -6,12 +6,16 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
queue_command_proto::{
|
queue_command_proto::{
|
||||||
AutoplayModeSetMessage, AutoplayTracksLoadedMessage, AutoplayTracksRemovedMessage,
|
AutoplayModeSetMessage, AutoplayTracksLoadedMessage, AutoplayTracksRemovedMessage,
|
||||||
QConnectMessage, QConnectMessageType, QConnectMessages, QueueClearedMessage,
|
CtrlActiveRendererChangedMessage, CtrlAddRendererMessage,
|
||||||
QueueErrorMessage, QueueStateMessage, QueueTrack, QueueTrackWithContext,
|
CtrlDeviceAudioQualityChangedMessage, CtrlFileAudioQualityChangedMessage,
|
||||||
QueueTracksAddedFromAutoplayMessage, QueueTracksAddedMessage, QueueTracksInsertedMessage,
|
CtrlLoopModeSetMessage, CtrlMaxAudioQualityChangedMessage, CtrlRemoveRendererMessage,
|
||||||
QueueTracksLoadedMessage, QueueTracksRemovedMessage, QueueTracksReorderedMessage,
|
CtrlRendererStateUpdatedMessage, CtrlSessionStateMessage, CtrlUpdateRendererMessage,
|
||||||
QueueVersionRef, RendererMuteVolumeMessage, RendererSetActiveMessage,
|
CtrlVolumeChangedMessage, CtrlVolumeMutedMessage, QConnectMessage, QConnectMessageType,
|
||||||
RendererSetLoopModeMessage, RendererSetMaxAudioQualityMessage,
|
QConnectMessages, QueueClearedMessage, QueueErrorMessage, QueueStateMessage, QueueTrack,
|
||||||
|
QueueTrackWithContext, QueueTracksAddedFromAutoplayMessage, QueueTracksAddedMessage,
|
||||||
|
QueueTracksInsertedMessage, QueueTracksLoadedMessage, QueueTracksRemovedMessage,
|
||||||
|
QueueTracksReorderedMessage, QueueVersionRef, RendererMuteVolumeMessage,
|
||||||
|
RendererSetActiveMessage, RendererSetLoopModeMessage, RendererSetMaxAudioQualityMessage,
|
||||||
RendererSetShuffleModeMessage, RendererSetStateMessage, RendererSetVolumeMessage,
|
RendererSetShuffleModeMessage, RendererSetStateMessage, RendererSetVolumeMessage,
|
||||||
ShuffleModeSetMessage,
|
ShuffleModeSetMessage,
|
||||||
},
|
},
|
||||||
@@ -120,6 +124,83 @@ fn decode_queue_server_event(
|
|||||||
};
|
};
|
||||||
map_tracks_added_from_autoplay(payload)?
|
map_tracks_added_from_autoplay(payload)?
|
||||||
}
|
}
|
||||||
|
// Session management events (81-87, 97-101)
|
||||||
|
code if code == QConnectMessageType::MessageTypeSrvrCtrlSessionState as i32 => {
|
||||||
|
let Some(payload) = message.srvr_ctrl_session_state else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
map_session_state(payload)?
|
||||||
|
}
|
||||||
|
code if code == QConnectMessageType::MessageTypeSrvrCtrlRendererStateUpdated as i32 => {
|
||||||
|
let Some(payload) = message.srvr_ctrl_renderer_state_updated else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
map_ctrl_renderer_state_updated(payload)?
|
||||||
|
}
|
||||||
|
code if code == QConnectMessageType::MessageTypeSrvrCtrlAddRenderer as i32 => {
|
||||||
|
let Some(payload) = message.srvr_ctrl_add_renderer else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
map_add_renderer(payload)?
|
||||||
|
}
|
||||||
|
code if code == QConnectMessageType::MessageTypeSrvrCtrlUpdateRenderer as i32 => {
|
||||||
|
let Some(payload) = message.srvr_ctrl_update_renderer else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
map_update_renderer(payload)?
|
||||||
|
}
|
||||||
|
code if code == QConnectMessageType::MessageTypeSrvrCtrlRemoveRenderer as i32 => {
|
||||||
|
let Some(payload) = message.srvr_ctrl_remove_renderer else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
map_remove_renderer(payload)?
|
||||||
|
}
|
||||||
|
code if code == QConnectMessageType::MessageTypeSrvrCtrlActiveRendererChanged as i32 => {
|
||||||
|
let Some(payload) = message.srvr_ctrl_active_renderer_changed else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
map_active_renderer_changed(payload)?
|
||||||
|
}
|
||||||
|
code if code == QConnectMessageType::MessageTypeSrvrCtrlVolumeChanged as i32 => {
|
||||||
|
let Some(payload) = message.srvr_ctrl_volume_changed else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
map_ctrl_volume_changed(payload)?
|
||||||
|
}
|
||||||
|
code if code == QConnectMessageType::MessageTypeSrvrCtrlLoopModeSet as i32 => {
|
||||||
|
let Some(payload) = message.srvr_ctrl_loop_mode_set else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
map_ctrl_loop_mode_set(payload)?
|
||||||
|
}
|
||||||
|
code if code == QConnectMessageType::MessageTypeSrvrCtrlVolumeMuted as i32 => {
|
||||||
|
let Some(payload) = message.srvr_ctrl_volume_muted else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
map_ctrl_volume_muted(payload)?
|
||||||
|
}
|
||||||
|
code if code == QConnectMessageType::MessageTypeSrvrCtrlMaxAudioQualityChanged as i32 => {
|
||||||
|
let Some(payload) = message.srvr_ctrl_max_audio_quality_changed else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
map_ctrl_max_audio_quality_changed(payload)?
|
||||||
|
}
|
||||||
|
code if code
|
||||||
|
== QConnectMessageType::MessageTypeSrvrCtrlFileAudioQualityChanged as i32 =>
|
||||||
|
{
|
||||||
|
let Some(payload) = message.srvr_ctrl_file_audio_quality_changed else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
map_ctrl_file_audio_quality_changed(payload)?
|
||||||
|
}
|
||||||
|
code if code
|
||||||
|
== QConnectMessageType::MessageTypeSrvrCtrlDeviceAudioQualityChanged as i32 =>
|
||||||
|
{
|
||||||
|
let Some(payload) = message.srvr_ctrl_device_audio_quality_changed else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
map_ctrl_device_audio_quality_changed(payload)?
|
||||||
|
}
|
||||||
_ => return Ok(None),
|
_ => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -169,6 +250,45 @@ fn resolve_queue_message_type(message: &QConnectMessage) -> Option<i32> {
|
|||||||
QConnectMessageType::MessageTypeSrvrCtrlQueueTracksAddedFromAutoplay as i32,
|
QConnectMessageType::MessageTypeSrvrCtrlQueueTracksAddedFromAutoplay as i32,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Session management
|
||||||
|
if message.srvr_ctrl_session_state.is_some() {
|
||||||
|
return Some(QConnectMessageType::MessageTypeSrvrCtrlSessionState as i32);
|
||||||
|
}
|
||||||
|
if message.srvr_ctrl_renderer_state_updated.is_some() {
|
||||||
|
return Some(QConnectMessageType::MessageTypeSrvrCtrlRendererStateUpdated as i32);
|
||||||
|
}
|
||||||
|
if message.srvr_ctrl_add_renderer.is_some() {
|
||||||
|
return Some(QConnectMessageType::MessageTypeSrvrCtrlAddRenderer as i32);
|
||||||
|
}
|
||||||
|
if message.srvr_ctrl_update_renderer.is_some() {
|
||||||
|
return Some(QConnectMessageType::MessageTypeSrvrCtrlUpdateRenderer as i32);
|
||||||
|
}
|
||||||
|
if message.srvr_ctrl_remove_renderer.is_some() {
|
||||||
|
return Some(QConnectMessageType::MessageTypeSrvrCtrlRemoveRenderer as i32);
|
||||||
|
}
|
||||||
|
if message.srvr_ctrl_active_renderer_changed.is_some() {
|
||||||
|
return Some(QConnectMessageType::MessageTypeSrvrCtrlActiveRendererChanged as i32);
|
||||||
|
}
|
||||||
|
if message.srvr_ctrl_volume_changed.is_some() {
|
||||||
|
return Some(QConnectMessageType::MessageTypeSrvrCtrlVolumeChanged as i32);
|
||||||
|
}
|
||||||
|
if message.srvr_ctrl_loop_mode_set.is_some() {
|
||||||
|
return Some(QConnectMessageType::MessageTypeSrvrCtrlLoopModeSet as i32);
|
||||||
|
}
|
||||||
|
if message.srvr_ctrl_volume_muted.is_some() {
|
||||||
|
return Some(QConnectMessageType::MessageTypeSrvrCtrlVolumeMuted as i32);
|
||||||
|
}
|
||||||
|
if message.srvr_ctrl_max_audio_quality_changed.is_some() {
|
||||||
|
return Some(QConnectMessageType::MessageTypeSrvrCtrlMaxAudioQualityChanged as i32);
|
||||||
|
}
|
||||||
|
if message.srvr_ctrl_file_audio_quality_changed.is_some() {
|
||||||
|
return Some(QConnectMessageType::MessageTypeSrvrCtrlFileAudioQualityChanged as i32);
|
||||||
|
}
|
||||||
|
if message.srvr_ctrl_device_audio_quality_changed.is_some() {
|
||||||
|
return Some(
|
||||||
|
QConnectMessageType::MessageTypeSrvrCtrlDeviceAudioQualityChanged as i32,
|
||||||
|
);
|
||||||
|
}
|
||||||
None
|
None
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -642,6 +762,207 @@ fn map_tracks_added_from_autoplay(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Session management event mappers ---
|
||||||
|
|
||||||
|
fn map_session_state(payload: CtrlSessionStateMessage) -> Result<QueueServerEvent, ProtocolError> {
|
||||||
|
let session_uuid = uuid_bytes_to_string_opt(payload.session_uuid, "session_state.session_uuid")?;
|
||||||
|
Ok(QueueServerEvent {
|
||||||
|
event_type: QueueEventType::SrvrCtrlSessionState,
|
||||||
|
action_uuid: None,
|
||||||
|
queue_version: queue_version_opt(payload.queue_version)?,
|
||||||
|
payload: json!({
|
||||||
|
"session_uuid": session_uuid,
|
||||||
|
"active_renderer_id": payload.active_renderer_id,
|
||||||
|
"playing_state": payload.playing_state,
|
||||||
|
"loop_mode": payload.loop_mode
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_ctrl_renderer_state_updated(
|
||||||
|
payload: CtrlRendererStateUpdatedMessage,
|
||||||
|
) -> Result<QueueServerEvent, ProtocolError> {
|
||||||
|
let player_state = payload.player_state.map(|ps| {
|
||||||
|
json!({
|
||||||
|
"playing_state": ps.playing_state,
|
||||||
|
"buffer_state": ps.buffer_state,
|
||||||
|
"current_position": ps.current_position.as_ref().and_then(|pos| pos.value),
|
||||||
|
"duration": ps.duration,
|
||||||
|
"current_queue_item_id": ps.current_queue_item_id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(QueueServerEvent {
|
||||||
|
event_type: QueueEventType::SrvrCtrlRendererStateUpdated,
|
||||||
|
action_uuid: None,
|
||||||
|
queue_version: None,
|
||||||
|
payload: json!({
|
||||||
|
"renderer_id": payload.renderer_id,
|
||||||
|
"status": payload.status,
|
||||||
|
"player_state": player_state
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_add_renderer(payload: CtrlAddRendererMessage) -> Result<QueueServerEvent, ProtocolError> {
|
||||||
|
let device_info = payload.device_info.map(|di| {
|
||||||
|
json!({
|
||||||
|
"friendly_name": di.friendly_name,
|
||||||
|
"brand": di.brand,
|
||||||
|
"model": di.model,
|
||||||
|
"device_type": di.device_type
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(QueueServerEvent {
|
||||||
|
event_type: QueueEventType::SrvrCtrlAddRenderer,
|
||||||
|
action_uuid: None,
|
||||||
|
queue_version: None,
|
||||||
|
payload: json!({
|
||||||
|
"renderer_id": payload.renderer_id,
|
||||||
|
"device_info": device_info
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_update_renderer(
|
||||||
|
payload: CtrlUpdateRendererMessage,
|
||||||
|
) -> Result<QueueServerEvent, ProtocolError> {
|
||||||
|
let device_info = payload.device_info.map(|di| {
|
||||||
|
json!({
|
||||||
|
"friendly_name": di.friendly_name,
|
||||||
|
"brand": di.brand,
|
||||||
|
"model": di.model,
|
||||||
|
"device_type": di.device_type
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(QueueServerEvent {
|
||||||
|
event_type: QueueEventType::SrvrCtrlUpdateRenderer,
|
||||||
|
action_uuid: None,
|
||||||
|
queue_version: None,
|
||||||
|
payload: json!({
|
||||||
|
"renderer_id": payload.renderer_id,
|
||||||
|
"device_info": device_info
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_remove_renderer(
|
||||||
|
payload: CtrlRemoveRendererMessage,
|
||||||
|
) -> Result<QueueServerEvent, ProtocolError> {
|
||||||
|
Ok(QueueServerEvent {
|
||||||
|
event_type: QueueEventType::SrvrCtrlRemoveRenderer,
|
||||||
|
action_uuid: None,
|
||||||
|
queue_version: None,
|
||||||
|
payload: json!({
|
||||||
|
"renderer_id": payload.renderer_id
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_active_renderer_changed(
|
||||||
|
payload: CtrlActiveRendererChangedMessage,
|
||||||
|
) -> Result<QueueServerEvent, ProtocolError> {
|
||||||
|
Ok(QueueServerEvent {
|
||||||
|
event_type: QueueEventType::SrvrCtrlActiveRendererChanged,
|
||||||
|
action_uuid: None,
|
||||||
|
queue_version: None,
|
||||||
|
payload: json!({
|
||||||
|
"active_renderer_id": payload.active_renderer_id
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_ctrl_volume_changed(
|
||||||
|
payload: CtrlVolumeChangedMessage,
|
||||||
|
) -> Result<QueueServerEvent, ProtocolError> {
|
||||||
|
Ok(QueueServerEvent {
|
||||||
|
event_type: QueueEventType::SrvrCtrlVolumeChanged,
|
||||||
|
action_uuid: None,
|
||||||
|
queue_version: None,
|
||||||
|
payload: json!({
|
||||||
|
"renderer_id": payload.renderer_id,
|
||||||
|
"volume": payload.volume
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_ctrl_loop_mode_set(
|
||||||
|
payload: CtrlLoopModeSetMessage,
|
||||||
|
) -> Result<QueueServerEvent, ProtocolError> {
|
||||||
|
Ok(QueueServerEvent {
|
||||||
|
event_type: QueueEventType::SrvrCtrlLoopModeSet,
|
||||||
|
action_uuid: None,
|
||||||
|
queue_version: None,
|
||||||
|
payload: json!({
|
||||||
|
"loop_mode": payload.loop_mode
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_ctrl_volume_muted(
|
||||||
|
payload: CtrlVolumeMutedMessage,
|
||||||
|
) -> Result<QueueServerEvent, ProtocolError> {
|
||||||
|
Ok(QueueServerEvent {
|
||||||
|
event_type: QueueEventType::SrvrCtrlVolumeMuted,
|
||||||
|
action_uuid: None,
|
||||||
|
queue_version: None,
|
||||||
|
payload: json!({
|
||||||
|
"renderer_id": payload.renderer_id,
|
||||||
|
"value": payload.value
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_ctrl_max_audio_quality_changed(
|
||||||
|
payload: CtrlMaxAudioQualityChangedMessage,
|
||||||
|
) -> Result<QueueServerEvent, ProtocolError> {
|
||||||
|
Ok(QueueServerEvent {
|
||||||
|
event_type: QueueEventType::SrvrCtrlMaxAudioQualityChanged,
|
||||||
|
action_uuid: None,
|
||||||
|
queue_version: None,
|
||||||
|
payload: json!({
|
||||||
|
"renderer_id": payload.renderer_id,
|
||||||
|
"max_audio_quality": payload.max_audio_quality,
|
||||||
|
"network_type": payload.network_type
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_ctrl_file_audio_quality_changed(
|
||||||
|
payload: CtrlFileAudioQualityChangedMessage,
|
||||||
|
) -> Result<QueueServerEvent, ProtocolError> {
|
||||||
|
Ok(QueueServerEvent {
|
||||||
|
event_type: QueueEventType::SrvrCtrlFileAudioQualityChanged,
|
||||||
|
action_uuid: None,
|
||||||
|
queue_version: None,
|
||||||
|
payload: json!({
|
||||||
|
"renderer_id": payload.renderer_id,
|
||||||
|
"sampling_rate": payload.sampling_rate,
|
||||||
|
"bit_depth": payload.bit_depth,
|
||||||
|
"nb_channels": payload.nb_channels,
|
||||||
|
"audio_quality": payload.audio_quality
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_ctrl_device_audio_quality_changed(
|
||||||
|
payload: CtrlDeviceAudioQualityChangedMessage,
|
||||||
|
) -> Result<QueueServerEvent, ProtocolError> {
|
||||||
|
Ok(QueueServerEvent {
|
||||||
|
event_type: QueueEventType::SrvrCtrlDeviceAudioQualityChanged,
|
||||||
|
action_uuid: None,
|
||||||
|
queue_version: None,
|
||||||
|
payload: json!({
|
||||||
|
"renderer_id": payload.renderer_id,
|
||||||
|
"sampling_rate": payload.sampling_rate,
|
||||||
|
"bit_depth": payload.bit_depth,
|
||||||
|
"nb_channels": payload.nb_channels
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn queue_track_to_json(
|
fn queue_track_to_json(
|
||||||
track: QueueTrack,
|
track: QueueTrack,
|
||||||
context_uuid: Option<&str>,
|
context_uuid: Option<&str>,
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ pub enum QueueEventType {
|
|||||||
SrvrCtrlAutoplayTracksRemoved,
|
SrvrCtrlAutoplayTracksRemoved,
|
||||||
SrvrCtrlQueueTracksAddedFromAutoplay,
|
SrvrCtrlQueueTracksAddedFromAutoplay,
|
||||||
SrvrCtrlQueueErrorMessage,
|
SrvrCtrlQueueErrorMessage,
|
||||||
|
// Session management events (types 81-87, 97-101)
|
||||||
|
SrvrCtrlSessionState,
|
||||||
|
SrvrCtrlRendererStateUpdated,
|
||||||
|
SrvrCtrlAddRenderer,
|
||||||
|
SrvrCtrlUpdateRenderer,
|
||||||
|
SrvrCtrlRemoveRenderer,
|
||||||
|
SrvrCtrlActiveRendererChanged,
|
||||||
|
SrvrCtrlVolumeChanged,
|
||||||
|
SrvrCtrlLoopModeSet,
|
||||||
|
SrvrCtrlVolumeMuted,
|
||||||
|
SrvrCtrlMaxAudioQualityChanged,
|
||||||
|
SrvrCtrlFileAudioQualityChanged,
|
||||||
|
SrvrCtrlDeviceAudioQualityChanged,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QueueEventType {
|
impl QueueEventType {
|
||||||
@@ -37,10 +50,52 @@ impl QueueEventType {
|
|||||||
"MESSAGE_TYPE_SRVR_CTRL_QUEUE_TRACKS_ADDED_FROM_AUTOPLAY"
|
"MESSAGE_TYPE_SRVR_CTRL_QUEUE_TRACKS_ADDED_FROM_AUTOPLAY"
|
||||||
}
|
}
|
||||||
Self::SrvrCtrlQueueErrorMessage => "MESSAGE_TYPE_SRVR_CTRL_QUEUE_ERROR_MESSAGE",
|
Self::SrvrCtrlQueueErrorMessage => "MESSAGE_TYPE_SRVR_CTRL_QUEUE_ERROR_MESSAGE",
|
||||||
|
Self::SrvrCtrlSessionState => "MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE",
|
||||||
|
Self::SrvrCtrlRendererStateUpdated => "MESSAGE_TYPE_SRVR_CTRL_RENDERER_STATE_UPDATED",
|
||||||
|
Self::SrvrCtrlAddRenderer => "MESSAGE_TYPE_SRVR_CTRL_ADD_RENDERER",
|
||||||
|
Self::SrvrCtrlUpdateRenderer => "MESSAGE_TYPE_SRVR_CTRL_UPDATE_RENDERER",
|
||||||
|
Self::SrvrCtrlRemoveRenderer => "MESSAGE_TYPE_SRVR_CTRL_REMOVE_RENDERER",
|
||||||
|
Self::SrvrCtrlActiveRendererChanged => {
|
||||||
|
"MESSAGE_TYPE_SRVR_CTRL_ACTIVE_RENDERER_CHANGED"
|
||||||
|
}
|
||||||
|
Self::SrvrCtrlVolumeChanged => "MESSAGE_TYPE_SRVR_CTRL_VOLUME_CHANGED",
|
||||||
|
Self::SrvrCtrlLoopModeSet => "MESSAGE_TYPE_SRVR_CTRL_LOOP_MODE_SET",
|
||||||
|
Self::SrvrCtrlVolumeMuted => "MESSAGE_TYPE_SRVR_CTRL_VOLUME_MUTED",
|
||||||
|
Self::SrvrCtrlMaxAudioQualityChanged => {
|
||||||
|
"MESSAGE_TYPE_SRVR_CTRL_MAX_AUDIO_QUALITY_CHANGED"
|
||||||
|
}
|
||||||
|
Self::SrvrCtrlFileAudioQualityChanged => {
|
||||||
|
"MESSAGE_TYPE_SRVR_CTRL_FILE_AUDIO_QUALITY_CHANGED"
|
||||||
|
}
|
||||||
|
Self::SrvrCtrlDeviceAudioQualityChanged => {
|
||||||
|
"MESSAGE_TYPE_SRVR_CTRL_DEVICE_AUDIO_QUALITY_CHANGED"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl QueueEventType {
|
||||||
|
/// Returns true for session management events that should NOT go through
|
||||||
|
/// the queue reducer.
|
||||||
|
pub const fn is_session_management(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::SrvrCtrlSessionState
|
||||||
|
| Self::SrvrCtrlRendererStateUpdated
|
||||||
|
| Self::SrvrCtrlAddRenderer
|
||||||
|
| Self::SrvrCtrlUpdateRenderer
|
||||||
|
| Self::SrvrCtrlRemoveRenderer
|
||||||
|
| Self::SrvrCtrlActiveRendererChanged
|
||||||
|
| Self::SrvrCtrlVolumeChanged
|
||||||
|
| Self::SrvrCtrlLoopModeSet
|
||||||
|
| Self::SrvrCtrlVolumeMuted
|
||||||
|
| Self::SrvrCtrlMaxAudioQualityChanged
|
||||||
|
| Self::SrvrCtrlFileAudioQualityChanged
|
||||||
|
| Self::SrvrCtrlDeviceAudioQualityChanged
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct QueueServerEvent {
|
pub struct QueueServerEvent {
|
||||||
pub event_type: QueueEventType,
|
pub event_type: QueueEventType,
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ pub enum QConnectMessageType {
|
|||||||
MessageTypeCtrlSrvrSetAutoplayMode = 78,
|
MessageTypeCtrlSrvrSetAutoplayMode = 78,
|
||||||
MessageTypeCtrlSrvrAutoplayLoadTracks = 79,
|
MessageTypeCtrlSrvrAutoplayLoadTracks = 79,
|
||||||
MessageTypeCtrlSrvrAutoplayRemoveTracks = 80,
|
MessageTypeCtrlSrvrAutoplayRemoveTracks = 80,
|
||||||
|
MessageTypeSrvrCtrlSessionState = 81,
|
||||||
|
MessageTypeSrvrCtrlRendererStateUpdated = 82,
|
||||||
|
MessageTypeSrvrCtrlAddRenderer = 83,
|
||||||
|
MessageTypeSrvrCtrlUpdateRenderer = 84,
|
||||||
|
MessageTypeSrvrCtrlRemoveRenderer = 85,
|
||||||
|
MessageTypeSrvrCtrlActiveRendererChanged = 86,
|
||||||
|
MessageTypeSrvrCtrlVolumeChanged = 87,
|
||||||
MessageTypeSrvrCtrlQueueErrorMessage = 88,
|
MessageTypeSrvrCtrlQueueErrorMessage = 88,
|
||||||
MessageTypeSrvrCtrlQueueCleared = 89,
|
MessageTypeSrvrCtrlQueueCleared = 89,
|
||||||
MessageTypeSrvrCtrlQueueState = 90,
|
MessageTypeSrvrCtrlQueueState = 90,
|
||||||
@@ -46,6 +53,11 @@ pub enum QConnectMessageType {
|
|||||||
MessageTypeSrvrCtrlQueueTracksRemoved = 94,
|
MessageTypeSrvrCtrlQueueTracksRemoved = 94,
|
||||||
MessageTypeSrvrCtrlQueueTracksReordered = 95,
|
MessageTypeSrvrCtrlQueueTracksReordered = 95,
|
||||||
MessageTypeSrvrCtrlShuffleModeSet = 96,
|
MessageTypeSrvrCtrlShuffleModeSet = 96,
|
||||||
|
MessageTypeSrvrCtrlLoopModeSet = 97,
|
||||||
|
MessageTypeSrvrCtrlVolumeMuted = 98,
|
||||||
|
MessageTypeSrvrCtrlMaxAudioQualityChanged = 99,
|
||||||
|
MessageTypeSrvrCtrlFileAudioQualityChanged = 100,
|
||||||
|
MessageTypeSrvrCtrlDeviceAudioQualityChanged = 101,
|
||||||
MessageTypeSrvrCtrlAutoplayModeSet = 102,
|
MessageTypeSrvrCtrlAutoplayModeSet = 102,
|
||||||
MessageTypeSrvrCtrlAutoplayTracksLoaded = 103,
|
MessageTypeSrvrCtrlAutoplayTracksLoaded = 103,
|
||||||
MessageTypeSrvrCtrlAutoplayTracksRemoved = 104,
|
MessageTypeSrvrCtrlAutoplayTracksRemoved = 104,
|
||||||
@@ -709,6 +721,145 @@ pub struct RendererMaxAudioQualityChangedMessage {
|
|||||||
pub network_type: Option<i32>,
|
pub network_type: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SRVR_CTRL session management messages (server → controller) ---
|
||||||
|
|
||||||
|
/// Type 81: Session state after joining
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct CtrlSessionStateMessage {
|
||||||
|
#[prost(bytes = "vec", optional, tag = "1")]
|
||||||
|
pub session_uuid: Option<Vec<u8>>,
|
||||||
|
#[prost(int32, optional, tag = "2")]
|
||||||
|
pub active_renderer_id: Option<i32>,
|
||||||
|
#[prost(message, optional, tag = "3")]
|
||||||
|
pub queue_version: Option<QueueVersionRef>,
|
||||||
|
#[prost(int32, optional, tag = "4")]
|
||||||
|
pub playing_state: Option<i32>,
|
||||||
|
#[prost(int32, optional, tag = "5")]
|
||||||
|
pub loop_mode: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nested player state for CtrlRendererStateUpdatedMessage
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct CtrlRendererPlayerState {
|
||||||
|
#[prost(int32, optional, tag = "1")]
|
||||||
|
pub playing_state: Option<i32>,
|
||||||
|
#[prost(int32, optional, tag = "2")]
|
||||||
|
pub buffer_state: Option<i32>,
|
||||||
|
#[prost(message, optional, tag = "3")]
|
||||||
|
pub current_position: Option<PlaybackPositionMessage>,
|
||||||
|
#[prost(uint32, optional, tag = "4")]
|
||||||
|
pub duration: Option<u32>,
|
||||||
|
#[prost(int32, optional, tag = "5")]
|
||||||
|
pub current_queue_item_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 82: Renderer state updated (controller view)
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct CtrlRendererStateUpdatedMessage {
|
||||||
|
#[prost(int32, optional, tag = "1")]
|
||||||
|
pub renderer_id: Option<i32>,
|
||||||
|
#[prost(int32, optional, tag = "2")]
|
||||||
|
pub status: Option<i32>,
|
||||||
|
#[prost(message, optional, tag = "3")]
|
||||||
|
pub player_state: Option<CtrlRendererPlayerState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 83: New renderer added to session
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct CtrlAddRendererMessage {
|
||||||
|
#[prost(int32, optional, tag = "1")]
|
||||||
|
pub renderer_id: Option<i32>,
|
||||||
|
#[prost(message, optional, tag = "2")]
|
||||||
|
pub device_info: Option<DeviceInfoMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 84: Renderer info updated
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct CtrlUpdateRendererMessage {
|
||||||
|
#[prost(int32, optional, tag = "1")]
|
||||||
|
pub renderer_id: Option<i32>,
|
||||||
|
#[prost(message, optional, tag = "2")]
|
||||||
|
pub device_info: Option<DeviceInfoMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 85: Renderer removed from session
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct CtrlRemoveRendererMessage {
|
||||||
|
#[prost(int32, optional, tag = "1")]
|
||||||
|
pub renderer_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 86: Active renderer changed
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct CtrlActiveRendererChangedMessage {
|
||||||
|
#[prost(int32, optional, tag = "1")]
|
||||||
|
pub active_renderer_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 87: Volume changed on a renderer (controller view)
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct CtrlVolumeChangedMessage {
|
||||||
|
#[prost(int32, optional, tag = "1")]
|
||||||
|
pub renderer_id: Option<i32>,
|
||||||
|
#[prost(uint32, optional, tag = "2")]
|
||||||
|
pub volume: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 97: Loop mode set
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct CtrlLoopModeSetMessage {
|
||||||
|
#[prost(int32, optional, tag = "1")]
|
||||||
|
pub loop_mode: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 98: Volume muted (controller view)
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct CtrlVolumeMutedMessage {
|
||||||
|
#[prost(int32, optional, tag = "1")]
|
||||||
|
pub renderer_id: Option<i32>,
|
||||||
|
#[prost(bool, optional, tag = "2")]
|
||||||
|
pub value: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 99: Max audio quality changed (controller view)
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct CtrlMaxAudioQualityChangedMessage {
|
||||||
|
#[prost(int32, optional, tag = "1")]
|
||||||
|
pub renderer_id: Option<i32>,
|
||||||
|
#[prost(int32, optional, tag = "2")]
|
||||||
|
pub max_audio_quality: Option<i32>,
|
||||||
|
#[prost(int32, optional, tag = "3")]
|
||||||
|
pub network_type: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 100: File audio quality changed (controller view)
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct CtrlFileAudioQualityChangedMessage {
|
||||||
|
#[prost(int32, optional, tag = "1")]
|
||||||
|
pub renderer_id: Option<i32>,
|
||||||
|
#[prost(uint32, optional, tag = "2")]
|
||||||
|
pub sampling_rate: Option<u32>,
|
||||||
|
#[prost(uint32, optional, tag = "3")]
|
||||||
|
pub bit_depth: Option<u32>,
|
||||||
|
#[prost(uint32, optional, tag = "4")]
|
||||||
|
pub nb_channels: Option<u32>,
|
||||||
|
#[prost(int32, optional, tag = "5")]
|
||||||
|
pub audio_quality: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type 101: Device audio quality changed (controller view)
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct CtrlDeviceAudioQualityChangedMessage {
|
||||||
|
#[prost(int32, optional, tag = "1")]
|
||||||
|
pub renderer_id: Option<i32>,
|
||||||
|
#[prost(uint32, optional, tag = "2")]
|
||||||
|
pub sampling_rate: Option<u32>,
|
||||||
|
#[prost(uint32, optional, tag = "3")]
|
||||||
|
pub bit_depth: Option<u32>,
|
||||||
|
#[prost(uint32, optional, tag = "4")]
|
||||||
|
pub nb_channels: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
pub struct QConnectMessages {
|
pub struct QConnectMessages {
|
||||||
#[prost(uint64, optional, tag = "1")]
|
#[prost(uint64, optional, tag = "1")]
|
||||||
@@ -791,6 +942,20 @@ pub struct QConnectMessage {
|
|||||||
pub ctrl_srvr_autoplay_load_tracks: Option<AutoplayLoadTracksMessage>,
|
pub ctrl_srvr_autoplay_load_tracks: Option<AutoplayLoadTracksMessage>,
|
||||||
#[prost(message, optional, tag = "80")]
|
#[prost(message, optional, tag = "80")]
|
||||||
pub ctrl_srvr_autoplay_remove_tracks: Option<AutoplayRemoveTracksMessage>,
|
pub ctrl_srvr_autoplay_remove_tracks: Option<AutoplayRemoveTracksMessage>,
|
||||||
|
#[prost(message, optional, tag = "81")]
|
||||||
|
pub srvr_ctrl_session_state: Option<CtrlSessionStateMessage>,
|
||||||
|
#[prost(message, optional, tag = "82")]
|
||||||
|
pub srvr_ctrl_renderer_state_updated: Option<CtrlRendererStateUpdatedMessage>,
|
||||||
|
#[prost(message, optional, tag = "83")]
|
||||||
|
pub srvr_ctrl_add_renderer: Option<CtrlAddRendererMessage>,
|
||||||
|
#[prost(message, optional, tag = "84")]
|
||||||
|
pub srvr_ctrl_update_renderer: Option<CtrlUpdateRendererMessage>,
|
||||||
|
#[prost(message, optional, tag = "85")]
|
||||||
|
pub srvr_ctrl_remove_renderer: Option<CtrlRemoveRendererMessage>,
|
||||||
|
#[prost(message, optional, tag = "86")]
|
||||||
|
pub srvr_ctrl_active_renderer_changed: Option<CtrlActiveRendererChangedMessage>,
|
||||||
|
#[prost(message, optional, tag = "87")]
|
||||||
|
pub srvr_ctrl_volume_changed: Option<CtrlVolumeChangedMessage>,
|
||||||
#[prost(message, optional, tag = "88")]
|
#[prost(message, optional, tag = "88")]
|
||||||
pub srvr_ctrl_queue_error_message: Option<QueueErrorMessage>,
|
pub srvr_ctrl_queue_error_message: Option<QueueErrorMessage>,
|
||||||
#[prost(message, optional, tag = "89")]
|
#[prost(message, optional, tag = "89")]
|
||||||
@@ -809,6 +974,16 @@ pub struct QConnectMessage {
|
|||||||
pub srvr_ctrl_queue_tracks_reordered: Option<QueueTracksReorderedMessage>,
|
pub srvr_ctrl_queue_tracks_reordered: Option<QueueTracksReorderedMessage>,
|
||||||
#[prost(message, optional, tag = "96")]
|
#[prost(message, optional, tag = "96")]
|
||||||
pub srvr_ctrl_shuffle_mode_set: Option<ShuffleModeSetMessage>,
|
pub srvr_ctrl_shuffle_mode_set: Option<ShuffleModeSetMessage>,
|
||||||
|
#[prost(message, optional, tag = "97")]
|
||||||
|
pub srvr_ctrl_loop_mode_set: Option<CtrlLoopModeSetMessage>,
|
||||||
|
#[prost(message, optional, tag = "98")]
|
||||||
|
pub srvr_ctrl_volume_muted: Option<CtrlVolumeMutedMessage>,
|
||||||
|
#[prost(message, optional, tag = "99")]
|
||||||
|
pub srvr_ctrl_max_audio_quality_changed: Option<CtrlMaxAudioQualityChangedMessage>,
|
||||||
|
#[prost(message, optional, tag = "100")]
|
||||||
|
pub srvr_ctrl_file_audio_quality_changed: Option<CtrlFileAudioQualityChangedMessage>,
|
||||||
|
#[prost(message, optional, tag = "101")]
|
||||||
|
pub srvr_ctrl_device_audio_quality_changed: Option<CtrlDeviceAudioQualityChangedMessage>,
|
||||||
#[prost(message, optional, tag = "102")]
|
#[prost(message, optional, tag = "102")]
|
||||||
pub srvr_ctrl_autoplay_mode_set: Option<AutoplayModeSetMessage>,
|
pub srvr_ctrl_autoplay_mode_set: Option<AutoplayModeSetMessage>,
|
||||||
#[prost(message, optional, tag = "103")]
|
#[prost(message, optional, tag = "103")]
|
||||||
|
|||||||
+24
-25
@@ -1049,10 +1049,7 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch radio tracks for an album (`/radio/album`).
|
/// Fetch radio tracks for an album (`/radio/album`).
|
||||||
pub async fn get_radio_album(
|
pub async fn get_radio_album(&self, album_id: &str) -> Result<RadioResponse> {
|
||||||
&self,
|
|
||||||
album_id: &str,
|
|
||||||
) -> Result<RadioResponse> {
|
|
||||||
let url = endpoints::build_url(paths::RADIO_ALBUM);
|
let url = endpoints::build_url(paths::RADIO_ALBUM);
|
||||||
|
|
||||||
let http_response = self
|
let http_response = self
|
||||||
@@ -1083,10 +1080,7 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch radio tracks for an artist (`/radio/artist`).
|
/// Fetch radio tracks for an artist (`/radio/artist`).
|
||||||
pub async fn get_radio_artist(
|
pub async fn get_radio_artist(&self, artist_id: &str) -> Result<RadioResponse> {
|
||||||
&self,
|
|
||||||
artist_id: &str,
|
|
||||||
) -> Result<RadioResponse> {
|
|
||||||
let url = endpoints::build_url(paths::RADIO_ARTIST);
|
let url = endpoints::build_url(paths::RADIO_ARTIST);
|
||||||
|
|
||||||
let http_response = self
|
let http_response = self
|
||||||
@@ -1117,10 +1111,7 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch radio tracks for a track (`/radio/track`).
|
/// Fetch radio tracks for a track (`/radio/track`).
|
||||||
pub async fn get_radio_track(
|
pub async fn get_radio_track(&self, track_id: &str) -> Result<RadioResponse> {
|
||||||
&self,
|
|
||||||
track_id: &str,
|
|
||||||
) -> Result<RadioResponse> {
|
|
||||||
let url = endpoints::build_url(paths::RADIO_TRACK);
|
let url = endpoints::build_url(paths::RADIO_TRACK);
|
||||||
|
|
||||||
let http_response = self
|
let http_response = self
|
||||||
@@ -1289,22 +1280,19 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get label explore (discover more labels)
|
/// Get label explore (discover more labels)
|
||||||
pub async fn get_label_explore(
|
pub async fn get_label_explore(&self, limit: u32, offset: u32) -> Result<LabelExploreResponse> {
|
||||||
&self,
|
|
||||||
limit: u32,
|
|
||||||
offset: u32,
|
|
||||||
) -> Result<LabelExploreResponse> {
|
|
||||||
let url = endpoints::build_url(paths::LABEL_EXPLORE);
|
let url = endpoints::build_url(paths::LABEL_EXPLORE);
|
||||||
|
|
||||||
log::debug!("[API] get_label_explore(limit={}, offset={})", limit, offset);
|
log::debug!(
|
||||||
|
"[API] get_label_explore(limit={}, offset={})",
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
);
|
||||||
let response: serde_json::Value = self
|
let response: serde_json::Value = self
|
||||||
.http
|
.http
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.headers(self.api_headers().await?)
|
.headers(self.api_headers().await?)
|
||||||
.query(&[
|
.query(&[("limit", limit.to_string()), ("offset", offset.to_string())])
|
||||||
("limit", limit.to_string()),
|
|
||||||
("offset", offset.to_string()),
|
|
||||||
])
|
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.json()
|
.json()
|
||||||
@@ -1497,7 +1485,10 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get all purchases for a single type by paginating through Qobuz purchases API.
|
/// Get all purchases for a single type by paginating through Qobuz purchases API.
|
||||||
pub async fn get_user_purchases_all_typed(&self, purchase_type: &str) -> Result<PurchaseResponse> {
|
pub async fn get_user_purchases_all_typed(
|
||||||
|
&self,
|
||||||
|
purchase_type: &str,
|
||||||
|
) -> Result<PurchaseResponse> {
|
||||||
let page_limit = 500u32;
|
let page_limit = 500u32;
|
||||||
let mut offset = 0u32;
|
let mut offset = 0u32;
|
||||||
|
|
||||||
@@ -1555,13 +1546,21 @@ impl QobuzClient {
|
|||||||
Ok(PurchaseResponse {
|
Ok(PurchaseResponse {
|
||||||
albums: SearchResultsPage {
|
albums: SearchResultsPage {
|
||||||
items: all_albums,
|
items: all_albums,
|
||||||
total: if purchase_type == "albums" { final_total } else { 0 },
|
total: if purchase_type == "albums" {
|
||||||
|
final_total
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: page_limit,
|
limit: page_limit,
|
||||||
},
|
},
|
||||||
tracks: SearchResultsPage {
|
tracks: SearchResultsPage {
|
||||||
items: all_tracks,
|
items: all_tracks,
|
||||||
total: if purchase_type == "tracks" { final_total } else { 0 },
|
total: if purchase_type == "tracks" {
|
||||||
|
final_total
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: page_limit,
|
limit: page_limit,
|
||||||
},
|
},
|
||||||
|
|||||||
+182
-63
@@ -12,8 +12,8 @@ use tokio::sync::RwLock;
|
|||||||
use qbz_models::{
|
use qbz_models::{
|
||||||
Album, Artist, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse,
|
Album, Artist, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse,
|
||||||
GenreInfo, LabelDetail, LabelExploreResponse, LabelPageData, PageArtistResponse, Playlist,
|
GenreInfo, LabelDetail, LabelExploreResponse, LabelPageData, PageArtistResponse, Playlist,
|
||||||
PlaylistTag, Quality, QueueState,
|
PlaylistTag, Quality, QueueState, QueueTrack as CoreQueueTrack, RepeatMode, SearchResultsPage,
|
||||||
QueueTrack as CoreQueueTrack, RepeatMode, SearchResultsPage, Track, UserSession,
|
Track, UserSession,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::api::models::{
|
use crate::api::models::{
|
||||||
@@ -1464,13 +1464,9 @@ pub enum MusicLinkResult {
|
|||||||
provider: Option<String>,
|
provider: Option<String>,
|
||||||
},
|
},
|
||||||
/// The URL is a playlist — redirect to the Playlist Importer.
|
/// The URL is a playlist — redirect to the Playlist Importer.
|
||||||
PlaylistDetected {
|
PlaylistDetected { provider: String },
|
||||||
provider: String,
|
|
||||||
},
|
|
||||||
/// The content exists on the source platform but is not available on Qobuz.
|
/// The content exists on the source platform but is not available on Qobuz.
|
||||||
NotOnQobuz {
|
NotOnQobuz { provider: Option<String> },
|
||||||
provider: Option<String>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a cross-platform music link to a Qobuz navigation action.
|
/// Resolve a cross-platform music link to a Qobuz navigation action.
|
||||||
@@ -1502,15 +1498,14 @@ pub async fn v2_resolve_music_link(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Detect what kind of resource this is
|
// 2. Detect what kind of resource this is
|
||||||
let resource = detect_music_resource(&url).ok_or_else(|| {
|
let resource = detect_music_resource(&url)
|
||||||
RuntimeError::Internal("Unsupported or invalid music link".to_string())
|
.ok_or_else(|| RuntimeError::Internal("Unsupported or invalid music link".to_string()))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
match resource {
|
match resource {
|
||||||
MusicResource::Qobuz => {
|
MusicResource::Qobuz => {
|
||||||
// Already handled above, but just in case
|
// Already handled above, but just in case
|
||||||
let resolved = qbz_qobuz::resolve_link(&url)
|
let resolved =
|
||||||
.map_err(|e| RuntimeError::Internal(e.to_string()))?;
|
qbz_qobuz::resolve_link(&url).map_err(|e| RuntimeError::Internal(e.to_string()))?;
|
||||||
Ok(MusicLinkResult::Resolved {
|
Ok(MusicLinkResult::Resolved {
|
||||||
link: resolved,
|
link: resolved,
|
||||||
provider: None,
|
provider: None,
|
||||||
@@ -1521,24 +1516,48 @@ pub async fn v2_resolve_music_link(
|
|||||||
provider: format!("{:?}", provider),
|
provider: format!("{:?}", provider),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
MusicResource::Track { provider, url: source_url } => {
|
MusicResource::Track {
|
||||||
|
provider,
|
||||||
|
url: source_url,
|
||||||
|
} => {
|
||||||
resolve_via_odesli_and_search(
|
resolve_via_odesli_and_search(
|
||||||
&state.songlink, &source_url, Some(&provider), true, &bridge, &runtime,
|
&state.songlink,
|
||||||
).await
|
&source_url,
|
||||||
|
Some(&provider),
|
||||||
|
true,
|
||||||
|
&bridge,
|
||||||
|
&runtime,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
MusicResource::Album { provider, url: source_url } => {
|
MusicResource::Album {
|
||||||
|
provider,
|
||||||
|
url: source_url,
|
||||||
|
} => {
|
||||||
resolve_via_odesli_and_search(
|
resolve_via_odesli_and_search(
|
||||||
&state.songlink, &source_url, Some(&provider), false, &bridge, &runtime,
|
&state.songlink,
|
||||||
).await
|
&source_url,
|
||||||
|
Some(&provider),
|
||||||
|
false,
|
||||||
|
&bridge,
|
||||||
|
&runtime,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
MusicResource::SongLink { url: source_url } => {
|
MusicResource::SongLink { url: source_url } => {
|
||||||
// song.link URLs: try to detect track vs album from the URL format
|
// song.link URLs: try to detect track vs album from the URL format
|
||||||
let is_track_hint = source_url.contains("song.link/");
|
let is_track_hint = source_url.contains("song.link/");
|
||||||
resolve_via_odesli_and_search(
|
resolve_via_odesli_and_search(
|
||||||
&state.songlink, &source_url, None, is_track_hint, &bridge, &runtime,
|
&state.songlink,
|
||||||
).await
|
&source_url,
|
||||||
|
None,
|
||||||
|
is_track_hint,
|
||||||
|
&bridge,
|
||||||
|
&runtime,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1564,7 +1583,11 @@ async fn resolve_via_odesli_and_search(
|
|||||||
let (title, artist) = if let Some(prov) = provider {
|
let (title, artist) = if let Some(prov) = provider {
|
||||||
match try_direct_platform_metadata(url, prov, is_track).await {
|
match try_direct_platform_metadata(url, prov, is_track).await {
|
||||||
Some(meta) => {
|
Some(meta) => {
|
||||||
log::info!("Link resolver: direct API resolved '{}' by '{}'", meta.0, meta.1);
|
log::info!(
|
||||||
|
"Link resolver: direct API resolved '{}' by '{}'",
|
||||||
|
meta.0,
|
||||||
|
meta.1
|
||||||
|
);
|
||||||
meta
|
meta
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -1591,13 +1614,17 @@ async fn resolve_via_odesli_and_search(
|
|||||||
|
|
||||||
let bridge_guard = bridge.get().await;
|
let bridge_guard = bridge.get().await;
|
||||||
|
|
||||||
if let Some(result) = search_qobuz_smart(
|
if let Some(result) =
|
||||||
&*bridge_guard, &title, &artist, is_track, &provider_name,
|
search_qobuz_smart(&*bridge_guard, &title, &artist, is_track, &provider_name).await?
|
||||||
).await? {
|
{
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("Link resolver: '{}' by '{}' not found on Qobuz", title, artist);
|
log::info!(
|
||||||
|
"Link resolver: '{}' by '{}' not found on Qobuz",
|
||||||
|
title,
|
||||||
|
artist
|
||||||
|
);
|
||||||
Ok(MusicLinkResult::NotOnQobuz {
|
Ok(MusicLinkResult::NotOnQobuz {
|
||||||
provider: provider_name,
|
provider: provider_name,
|
||||||
})
|
})
|
||||||
@@ -1614,7 +1641,10 @@ async fn fetch_metadata_via_odesli(
|
|||||||
{
|
{
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(first_err) => {
|
Err(first_err) => {
|
||||||
log::warn!("Link resolver: Odesli first attempt failed: {}, retrying...", first_err);
|
log::warn!(
|
||||||
|
"Link resolver: Odesli first attempt failed: {}, retrying...",
|
||||||
|
first_err
|
||||||
|
);
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
songlink
|
songlink
|
||||||
.get_by_url(url, crate::share::ContentType::Track)
|
.get_by_url(url, crate::share::ContentType::Track)
|
||||||
@@ -1649,9 +1679,15 @@ async fn search_qobuz_smart(
|
|||||||
|
|
||||||
// Attempt 1: full query
|
// Attempt 1: full query
|
||||||
if is_track {
|
if is_track {
|
||||||
let results = bridge.search_tracks(&full_query, 5, 0, None).await.map_err(RuntimeError::Internal)?;
|
let results = bridge
|
||||||
|
.search_tracks(&full_query, 5, 0, None)
|
||||||
|
.await
|
||||||
|
.map_err(RuntimeError::Internal)?;
|
||||||
if let Some(track) = results.items.first() {
|
if let Some(track) = results.items.first() {
|
||||||
log::info!("Link resolver: found Qobuz track id={} (full query)", track.id);
|
log::info!(
|
||||||
|
"Link resolver: found Qobuz track id={} (full query)",
|
||||||
|
track.id
|
||||||
|
);
|
||||||
return Ok(Some(MusicLinkResult::Resolved {
|
return Ok(Some(MusicLinkResult::Resolved {
|
||||||
link: qbz_qobuz::ResolvedLink::OpenTrack(track.id),
|
link: qbz_qobuz::ResolvedLink::OpenTrack(track.id),
|
||||||
provider: provider_name.clone(),
|
provider: provider_name.clone(),
|
||||||
@@ -1659,9 +1695,15 @@ async fn search_qobuz_smart(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let results = bridge.search_albums(&full_query, 5, 0, None).await.map_err(RuntimeError::Internal)?;
|
let results = bridge
|
||||||
|
.search_albums(&full_query, 5, 0, None)
|
||||||
|
.await
|
||||||
|
.map_err(RuntimeError::Internal)?;
|
||||||
if let Some(album) = results.items.first() {
|
if let Some(album) = results.items.first() {
|
||||||
log::info!("Link resolver: found Qobuz album id={} (full query)", album.id);
|
log::info!(
|
||||||
|
"Link resolver: found Qobuz album id={} (full query)",
|
||||||
|
album.id
|
||||||
|
);
|
||||||
return Ok(Some(MusicLinkResult::Resolved {
|
return Ok(Some(MusicLinkResult::Resolved {
|
||||||
link: qbz_qobuz::ResolvedLink::OpenAlbum(album.id.clone()),
|
link: qbz_qobuz::ResolvedLink::OpenAlbum(album.id.clone()),
|
||||||
provider: provider_name.clone(),
|
provider: provider_name.clone(),
|
||||||
@@ -1677,10 +1719,19 @@ async fn search_qobuz_smart(
|
|||||||
format!("{} {}", cleaned, artist)
|
format!("{} {}", cleaned, artist)
|
||||||
};
|
};
|
||||||
|
|
||||||
log::info!("Link resolver: retrying with cleaned query '{}'", clean_query);
|
log::info!(
|
||||||
let results = bridge.search_albums(&clean_query, 5, 0, None).await.map_err(RuntimeError::Internal)?;
|
"Link resolver: retrying with cleaned query '{}'",
|
||||||
|
clean_query
|
||||||
|
);
|
||||||
|
let results = bridge
|
||||||
|
.search_albums(&clean_query, 5, 0, None)
|
||||||
|
.await
|
||||||
|
.map_err(RuntimeError::Internal)?;
|
||||||
if let Some(album) = results.items.first() {
|
if let Some(album) = results.items.first() {
|
||||||
log::info!("Link resolver: found Qobuz album id={} (cleaned query)", album.id);
|
log::info!(
|
||||||
|
"Link resolver: found Qobuz album id={} (cleaned query)",
|
||||||
|
album.id
|
||||||
|
);
|
||||||
return Ok(Some(MusicLinkResult::Resolved {
|
return Ok(Some(MusicLinkResult::Resolved {
|
||||||
link: qbz_qobuz::ResolvedLink::OpenAlbum(album.id.clone()),
|
link: qbz_qobuz::ResolvedLink::OpenAlbum(album.id.clone()),
|
||||||
provider: provider_name.clone(),
|
provider: provider_name.clone(),
|
||||||
@@ -1690,8 +1741,14 @@ async fn search_qobuz_smart(
|
|||||||
|
|
||||||
// Attempt 3: search by artist name only (broad)
|
// Attempt 3: search by artist name only (broad)
|
||||||
if !artist.is_empty() && artist != title {
|
if !artist.is_empty() && artist != title {
|
||||||
log::info!("Link resolver: retrying with artist-only query '{}'", artist);
|
log::info!(
|
||||||
let results = bridge.search_albums(artist, 10, 0, None).await.map_err(RuntimeError::Internal)?;
|
"Link resolver: retrying with artist-only query '{}'",
|
||||||
|
artist
|
||||||
|
);
|
||||||
|
let results = bridge
|
||||||
|
.search_albums(artist, 10, 0, None)
|
||||||
|
.await
|
||||||
|
.map_err(RuntimeError::Internal)?;
|
||||||
let title_lower = title.to_ascii_lowercase();
|
let title_lower = title.to_ascii_lowercase();
|
||||||
let cleaned_lower = clean_title(title).to_ascii_lowercase();
|
let cleaned_lower = clean_title(title).to_ascii_lowercase();
|
||||||
for album in &results.items {
|
for album in &results.items {
|
||||||
@@ -1700,7 +1757,10 @@ async fn search_qobuz_smart(
|
|||||||
|| cleaned_lower.contains(&album_title_lower)
|
|| cleaned_lower.contains(&album_title_lower)
|
||||||
|| album_title_lower.contains(&title_lower)
|
|| album_title_lower.contains(&title_lower)
|
||||||
{
|
{
|
||||||
log::info!("Link resolver: found Qobuz album id={} (artist-only + title match)", album.id);
|
log::info!(
|
||||||
|
"Link resolver: found Qobuz album id={} (artist-only + title match)",
|
||||||
|
album.id
|
||||||
|
);
|
||||||
return Ok(Some(MusicLinkResult::Resolved {
|
return Ok(Some(MusicLinkResult::Resolved {
|
||||||
link: qbz_qobuz::ResolvedLink::OpenAlbum(album.id.clone()),
|
link: qbz_qobuz::ResolvedLink::OpenAlbum(album.id.clone()),
|
||||||
provider: provider_name.clone(),
|
provider: provider_name.clone(),
|
||||||
@@ -1762,7 +1822,11 @@ fn extract_entity_id(url: &str, entity_type: &str) -> Option<String> {
|
|||||||
let idx = url.find(&pattern)?;
|
let idx = url.find(&pattern)?;
|
||||||
let rest = &url[idx + pattern.len()..];
|
let rest = &url[idx + pattern.len()..];
|
||||||
let id = rest.split(['?', '/', '#']).next()?;
|
let id = rest.split(['?', '/', '#']).next()?;
|
||||||
if id.is_empty() { None } else { Some(id.to_string()) }
|
if id.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(id.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract Spotify ID from URL or URI.
|
/// Extract Spotify ID from URL or URI.
|
||||||
@@ -1771,23 +1835,33 @@ fn extract_spotify_entity_id(url: &str, entity_type: &str) -> Option<String> {
|
|||||||
let uri_pattern = format!("spotify:{}:", entity_type);
|
let uri_pattern = format!("spotify:{}:", entity_type);
|
||||||
if let Some(rest) = url.strip_prefix(&uri_pattern) {
|
if let Some(rest) = url.strip_prefix(&uri_pattern) {
|
||||||
let id = rest.split(['?', '/']).next()?;
|
let id = rest.split(['?', '/']).next()?;
|
||||||
if !id.is_empty() { return Some(id.to_string()); }
|
if !id.is_empty() {
|
||||||
|
return Some(id.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
extract_entity_id(url, entity_type)
|
extract_entity_id(url, entity_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn try_deezer_metadata(url: &str, is_track: bool) -> Option<(String, String)> {
|
async fn try_deezer_metadata(url: &str, is_track: bool) -> Option<(String, String)> {
|
||||||
let entity = if is_track { "track" } else { "album" };
|
let entity = if is_track { "track" } else { "album" };
|
||||||
let id = extract_entity_id(url, entity)
|
let id = extract_entity_id(url, entity).or_else(|| {
|
||||||
.or_else(|| if is_track { None } else { extract_entity_id(url, "track") })?;
|
if is_track {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
extract_entity_id(url, "track")
|
||||||
|
}
|
||||||
|
})?;
|
||||||
let api_url = format!("https://api.deezer.com/{}/{}", entity, id);
|
let api_url = format!("https://api.deezer.com/{}/{}", entity, id);
|
||||||
|
|
||||||
log::debug!("Link resolver: Deezer direct API: {}", api_url);
|
log::debug!("Link resolver: Deezer direct API: {}", api_url);
|
||||||
let data: serde_json::Value = reqwest::get(&api_url).await.ok()?.json().await.ok()?;
|
let data: serde_json::Value = reqwest::get(&api_url).await.ok()?.json().await.ok()?;
|
||||||
if data.get("error").is_some() { return None; }
|
if data.get("error").is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let title = data.get("title")?.as_str()?.to_string();
|
let title = data.get("title")?.as_str()?.to_string();
|
||||||
let artist = data.get("artist")
|
let artist = data
|
||||||
|
.get("artist")
|
||||||
.and_then(|a| a.get("name"))
|
.and_then(|a| a.get("name"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
@@ -1805,11 +1879,16 @@ async fn try_spotify_metadata(url: &str, is_track: bool) -> Option<(String, Stri
|
|||||||
let data: serde_json::Value = reqwest::Client::new()
|
let data: serde_json::Value = reqwest::Client::new()
|
||||||
.get(&api_url)
|
.get(&api_url)
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.send().await.ok()?
|
.send()
|
||||||
.json().await.ok()?;
|
.await
|
||||||
|
.ok()?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
let title = data.get("name")?.as_str()?.to_string();
|
let title = data.get("name")?.as_str()?.to_string();
|
||||||
let artist = data.get("artists")
|
let artist = data
|
||||||
|
.get("artists")
|
||||||
.and_then(|a| a.as_array())
|
.and_then(|a| a.as_array())
|
||||||
.and_then(|a| a.first())
|
.and_then(|a| a.first())
|
||||||
.and_then(|a| a.get("name"))
|
.and_then(|a| a.get("name"))
|
||||||
@@ -1834,21 +1913,28 @@ async fn try_tidal_metadata(url: &str, is_track: bool) -> Option<(String, String
|
|||||||
let data: serde_json::Value = reqwest::Client::new()
|
let data: serde_json::Value = reqwest::Client::new()
|
||||||
.get(&api_url)
|
.get(&api_url)
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.send().await.ok()?
|
.send()
|
||||||
.json().await.ok()?;
|
.await
|
||||||
|
.ok()?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
let title = data.get("data")
|
let title = data
|
||||||
|
.get("data")
|
||||||
.and_then(|d| d.get("attributes"))
|
.and_then(|d| d.get("attributes"))
|
||||||
.and_then(|a| a.get("title"))
|
.and_then(|a| a.get("title"))
|
||||||
.and_then(|v| v.as_str())?
|
.and_then(|v| v.as_str())?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Artist name is in the "included" array
|
// Artist name is in the "included" array
|
||||||
let artist = data.get("included")
|
let artist = data
|
||||||
|
.get("included")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.and_then(|arr| arr.iter().find(|item|
|
.and_then(|arr| {
|
||||||
item.get("type").and_then(|v| v.as_str()) == Some("artists")
|
arr.iter()
|
||||||
))
|
.find(|item| item.get("type").and_then(|v| v.as_str()) == Some("artists"))
|
||||||
|
})
|
||||||
.and_then(|item| item.get("attributes"))
|
.and_then(|item| item.get("attributes"))
|
||||||
.and_then(|a| a.get("name"))
|
.and_then(|a| a.get("name"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -1863,13 +1949,21 @@ async fn get_proxy_token(platform: &str) -> Option<String> {
|
|||||||
let data: serde_json::Value = reqwest::Client::builder()
|
let data: serde_json::Value = reqwest::Client::builder()
|
||||||
.default_headers({
|
.default_headers({
|
||||||
let mut h = reqwest::header::HeaderMap::new();
|
let mut h = reqwest::header::HeaderMap::new();
|
||||||
h.insert(reqwest::header::USER_AGENT, reqwest::header::HeaderValue::from_static("QBZ/1.0.0"));
|
h.insert(
|
||||||
|
reqwest::header::USER_AGENT,
|
||||||
|
reqwest::header::HeaderValue::from_static("QBZ/1.0.0"),
|
||||||
|
);
|
||||||
h
|
h
|
||||||
})
|
})
|
||||||
.build().ok()?
|
.build()
|
||||||
|
.ok()?
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.send().await.ok()?
|
.send()
|
||||||
.json().await.ok()?;
|
.await
|
||||||
|
.ok()?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
data.get("access_token")
|
data.get("access_token")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|v| v.to_string())
|
.map(|v| v.to_string())
|
||||||
@@ -4392,7 +4486,11 @@ pub async fn v2_create_album_radio(
|
|||||||
bridge.set_queue(queue_tracks, Some(0)).await;
|
bridge.set_queue(queue_tracks, Some(0)).await;
|
||||||
|
|
||||||
let queue_track_ids: Vec<u64> = tracks.iter().map(|track| track.id).collect();
|
let queue_track_ids: Vec<u64> = tracks.iter().map(|track| track.id).collect();
|
||||||
let context_id = format!("album_radio_{}_{}", album_id, chrono::Utc::now().timestamp());
|
let context_id = format!(
|
||||||
|
"album_radio_{}_{}",
|
||||||
|
album_id,
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
);
|
||||||
let context = PlaybackContext::new(
|
let context = PlaybackContext::new(
|
||||||
ContextType::Radio,
|
ContextType::Radio,
|
||||||
context_id.clone(),
|
context_id.clone(),
|
||||||
@@ -4431,7 +4529,12 @@ pub async fn v2_create_qobuz_artist_radio(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to fetch artist radio: {}", e))?;
|
.map_err(|e| format!("Failed to fetch artist radio: {}", e))?;
|
||||||
|
|
||||||
let track_ids: Vec<u64> = radio_response.tracks.items.iter().map(|track| track.id).collect();
|
let track_ids: Vec<u64> = radio_response
|
||||||
|
.tracks
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
if track_ids.is_empty() {
|
if track_ids.is_empty() {
|
||||||
return Err("No radio tracks returned for this artist".to_string());
|
return Err("No radio tracks returned for this artist".to_string());
|
||||||
@@ -4459,7 +4562,11 @@ pub async fn v2_create_qobuz_artist_radio(
|
|||||||
bridge.set_queue(queue_tracks, Some(0)).await;
|
bridge.set_queue(queue_tracks, Some(0)).await;
|
||||||
|
|
||||||
let queue_track_ids: Vec<u64> = tracks.iter().map(|track| track.id).collect();
|
let queue_track_ids: Vec<u64> = tracks.iter().map(|track| track.id).collect();
|
||||||
let context_id = format!("qobuz_artist_radio_{}_{}", artist_id, chrono::Utc::now().timestamp());
|
let context_id = format!(
|
||||||
|
"qobuz_artist_radio_{}_{}",
|
||||||
|
artist_id,
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
);
|
||||||
let context = PlaybackContext::new(
|
let context = PlaybackContext::new(
|
||||||
ContextType::Radio,
|
ContextType::Radio,
|
||||||
context_id.clone(),
|
context_id.clone(),
|
||||||
@@ -4498,7 +4605,12 @@ pub async fn v2_create_qobuz_track_radio(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to fetch track radio: {}", e))?;
|
.map_err(|e| format!("Failed to fetch track radio: {}", e))?;
|
||||||
|
|
||||||
let fetched_ids: Vec<u64> = radio_response.tracks.items.iter().map(|track| track.id).collect();
|
let fetched_ids: Vec<u64> = radio_response
|
||||||
|
.tracks
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
if fetched_ids.is_empty() {
|
if fetched_ids.is_empty() {
|
||||||
return Err("No radio tracks returned for this track".to_string());
|
return Err("No radio tracks returned for this track".to_string());
|
||||||
@@ -4526,7 +4638,11 @@ pub async fn v2_create_qobuz_track_radio(
|
|||||||
bridge.set_queue(queue_tracks, Some(0)).await;
|
bridge.set_queue(queue_tracks, Some(0)).await;
|
||||||
|
|
||||||
let queue_track_ids: Vec<u64> = tracks.iter().map(|track| track.id).collect();
|
let queue_track_ids: Vec<u64> = tracks.iter().map(|track| track.id).collect();
|
||||||
let context_id = format!("qobuz_track_radio_{}_{}", track_id, chrono::Utc::now().timestamp());
|
let context_id = format!(
|
||||||
|
"qobuz_track_radio_{}_{}",
|
||||||
|
track_id,
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
);
|
||||||
let context = PlaybackContext::new(
|
let context = PlaybackContext::new(
|
||||||
ContextType::Radio,
|
ContextType::Radio,
|
||||||
context_id.clone(),
|
context_id.clone(),
|
||||||
@@ -4603,7 +4719,10 @@ pub async fn v2_get_all_queue_tracks(
|
|||||||
) -> Result<AllQueueTracksResponse, RuntimeError> {
|
) -> Result<AllQueueTracksResponse, RuntimeError> {
|
||||||
let bridge = bridge.get().await;
|
let bridge = bridge.get().await;
|
||||||
let (tracks, current_index) = bridge.get_all_queue_tracks().await;
|
let (tracks, current_index) = bridge.get_all_queue_tracks().await;
|
||||||
Ok(AllQueueTracksResponse { tracks, current_index })
|
Ok(AllQueueTracksResponse {
|
||||||
|
tracks,
|
||||||
|
current_index,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get currently selected queue track (V2)
|
/// Get currently selected queue track (V2)
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ use qbz_core::QbzCore;
|
|||||||
use qbz_models::{
|
use qbz_models::{
|
||||||
Album, Artist, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse,
|
Album, Artist, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse,
|
||||||
GenreInfo, LabelDetail, LabelExploreResponse, LabelPageData, PageArtistResponse, Playlist,
|
GenreInfo, LabelDetail, LabelExploreResponse, LabelPageData, PageArtistResponse, Playlist,
|
||||||
PlaylistTag, Quality, QueueState,
|
PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track,
|
||||||
QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession,
|
UserSession,
|
||||||
};
|
};
|
||||||
use qbz_player::{PlaybackState, Player};
|
use qbz_player::{PlaybackState, Player};
|
||||||
|
|
||||||
@@ -107,7 +107,9 @@ impl CoreBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get all queue tracks and current index (for session persistence)
|
/// Get all queue tracks and current index (for session persistence)
|
||||||
pub async fn get_all_queue_tracks(&self) -> (Vec<qbz_models::playback::QueueTrack>, Option<usize>) {
|
pub async fn get_all_queue_tracks(
|
||||||
|
&self,
|
||||||
|
) -> (Vec<qbz_models::playback::QueueTrack>, Option<usize>) {
|
||||||
self.core.get_all_queue_tracks().await
|
self.core.get_all_queue_tracks().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -701,6 +701,8 @@ pub fn run() {
|
|||||||
qconnect_service::v2_qconnect_ask_for_renderer_state,
|
qconnect_service::v2_qconnect_ask_for_renderer_state,
|
||||||
qconnect_service::v2_qconnect_queue_snapshot,
|
qconnect_service::v2_qconnect_queue_snapshot,
|
||||||
qconnect_service::v2_qconnect_renderer_snapshot,
|
qconnect_service::v2_qconnect_renderer_snapshot,
|
||||||
|
qconnect_service::v2_qconnect_report_playback_state,
|
||||||
|
qconnect_service::v2_qconnect_report_volume,
|
||||||
commands_v2::v2_is_logged_in,
|
commands_v2::v2_is_logged_in,
|
||||||
commands_v2::v2_login,
|
commands_v2::v2_login,
|
||||||
commands_v2::v2_logout,
|
commands_v2::v2_logout,
|
||||||
|
|||||||
@@ -1119,11 +1119,17 @@ pub async fn library_scan_folder_impl(
|
|||||||
};
|
};
|
||||||
let missing_ids: Vec<i64> = tracks
|
let missing_ids: Vec<i64> = tracks
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, path)| path.starts_with(&folder_prefix) && !std::path::Path::new(path).exists())
|
.filter(|(_, path)| {
|
||||||
|
path.starts_with(&folder_prefix) && !std::path::Path::new(path).exists()
|
||||||
|
})
|
||||||
.map(|(id, _)| *id)
|
.map(|(id, _)| *id)
|
||||||
.collect();
|
.collect();
|
||||||
if !missing_ids.is_empty() {
|
if !missing_ids.is_empty() {
|
||||||
log::info!("Removing {} tracks with missing files from folder {}", missing_ids.len(), folder.path);
|
log::info!(
|
||||||
|
"Removing {} tracks with missing files from folder {}",
|
||||||
|
missing_ids.len(),
|
||||||
|
folder.path
|
||||||
|
);
|
||||||
for chunk in missing_ids.chunks(500) {
|
for chunk in missing_ids.chunks(500) {
|
||||||
if let Err(e) = db.delete_tracks_by_ids(chunk) {
|
if let Err(e) = db.delete_tracks_by_ids(chunk) {
|
||||||
log::error!("Failed to delete missing tracks: {}", e);
|
log::error!("Failed to delete missing tracks: {}", e);
|
||||||
|
|||||||
@@ -25,9 +25,15 @@ pub enum MusicResource {
|
|||||||
/// A native Qobuz URL — resolve directly.
|
/// A native Qobuz URL — resolve directly.
|
||||||
Qobuz,
|
Qobuz,
|
||||||
/// A single track on a third-party platform.
|
/// A single track on a third-party platform.
|
||||||
Track { provider: MusicProvider, url: String },
|
Track {
|
||||||
|
provider: MusicProvider,
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
/// An album on a third-party platform.
|
/// An album on a third-party platform.
|
||||||
Album { provider: MusicProvider, url: String },
|
Album {
|
||||||
|
provider: MusicProvider,
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
/// A playlist — should be redirected to the Playlist Importer.
|
/// A playlist — should be redirected to the Playlist Importer.
|
||||||
Playlist { provider: MusicProvider },
|
Playlist { provider: MusicProvider },
|
||||||
/// A song.link / album.link / odesli.co URL — resolve via Odesli API.
|
/// A song.link / album.link / odesli.co URL — resolve via Odesli API.
|
||||||
@@ -50,11 +56,11 @@ pub fn detect_music_resource(url: &str) -> Option<MusicResource> {
|
|||||||
|
|
||||||
// 2. song.link / album.link / odesli.co URLs
|
// 2. song.link / album.link / odesli.co URLs
|
||||||
let lower = url.to_ascii_lowercase();
|
let lower = url.to_ascii_lowercase();
|
||||||
if lower.contains("song.link/")
|
if lower.contains("song.link/") || lower.contains("album.link/") || lower.contains("odesli.co/")
|
||||||
|| lower.contains("album.link/")
|
|
||||||
|| lower.contains("odesli.co/")
|
|
||||||
{
|
{
|
||||||
return Some(MusicResource::SongLink { url: url.to_string() });
|
return Some(MusicResource::SongLink {
|
||||||
|
url: url.to_string(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Per-provider detection (track/album/playlist)
|
// 3. Per-provider detection (track/album/playlist)
|
||||||
|
|||||||
@@ -306,7 +306,23 @@ struct QconnectRemoteSyncState {
|
|||||||
impl QconnectEventSink for TauriQconnectEventSink {
|
impl QconnectEventSink for TauriQconnectEventSink {
|
||||||
async fn on_event(&self, event: QconnectAppEvent) {
|
async fn on_event(&self, event: QconnectAppEvent) {
|
||||||
match &event {
|
match &event {
|
||||||
|
QconnectAppEvent::SessionManagementEvent {
|
||||||
|
message_type,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
log::info!(
|
||||||
|
"[QConnect] Session management: {} payload={}",
|
||||||
|
message_type,
|
||||||
|
serde_json::to_string(payload).unwrap_or_else(|_| "?".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
QconnectAppEvent::RendererUpdated(renderer_state) => {
|
QconnectAppEvent::RendererUpdated(renderer_state) => {
|
||||||
|
log::info!(
|
||||||
|
"[QConnect] Renderer updated: playing_state={:?} volume={:?} position={:?}",
|
||||||
|
renderer_state.playing_state,
|
||||||
|
renderer_state.volume,
|
||||||
|
renderer_state.current_position_ms,
|
||||||
|
);
|
||||||
let mut sync_state = self.sync_state.lock().await;
|
let mut sync_state = self.sync_state.lock().await;
|
||||||
sync_state.last_renderer_queue_item_id = renderer_state
|
sync_state.last_renderer_queue_item_id = renderer_state
|
||||||
.current_track
|
.current_track
|
||||||
@@ -331,6 +347,10 @@ impl QconnectEventSink for TauriQconnectEventSink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
QconnectAppEvent::RendererCommandApplied { command, state } => {
|
QconnectAppEvent::RendererCommandApplied { command, state } => {
|
||||||
|
log::info!(
|
||||||
|
"[QConnect] Renderer command applied: {:?}",
|
||||||
|
command
|
||||||
|
);
|
||||||
if let Err(err) =
|
if let Err(err) =
|
||||||
apply_renderer_command_to_corebridge(&self.core_bridge, command, state).await
|
apply_renderer_command_to_corebridge(&self.core_bridge, command, state).await
|
||||||
{
|
{
|
||||||
@@ -392,6 +412,21 @@ impl QconnectServiceState {
|
|||||||
loop {
|
loop {
|
||||||
match transport_rx.recv().await {
|
match transport_rx.recv().await {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
|
match &event {
|
||||||
|
qconnect_transport_ws::TransportEvent::InboundQueueServerEvent(evt) => {
|
||||||
|
log::info!("[QConnect] <-- Inbound queue event: {}", evt.message_type());
|
||||||
|
}
|
||||||
|
qconnect_transport_ws::TransportEvent::InboundRendererServerCommand(cmd) => {
|
||||||
|
log::info!("[QConnect] <-- Inbound renderer command: {} payload={}", cmd.message_type(), cmd.payload);
|
||||||
|
}
|
||||||
|
qconnect_transport_ws::TransportEvent::InboundFrameDecoded { cloud_message_type, payload_size } => {
|
||||||
|
log::debug!("[QConnect] <-- Frame decoded: type={} size={}", cloud_message_type, payload_size);
|
||||||
|
}
|
||||||
|
qconnect_transport_ws::TransportEvent::TransportError { stage, message } => {
|
||||||
|
log::error!("[QConnect] Transport error: stage={} message={}", stage, message);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
if let Err(err) = app_for_loop.handle_transport_event(event).await {
|
if let Err(err) = app_for_loop.handle_transport_event(event).await {
|
||||||
let message = format!("qconnect app transport handling error: {err}");
|
let message = format!("qconnect app transport handling error: {err}");
|
||||||
log::error!("{message}");
|
log::error!("{message}");
|
||||||
@@ -520,6 +555,38 @@ impl QconnectServiceState {
|
|||||||
|
|
||||||
Ok(app.renderer_state_snapshot().await)
|
Ok(app.renderer_state_snapshot().await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn send_renderer_report(
|
||||||
|
&self,
|
||||||
|
report: RendererReport,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app = {
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
guard
|
||||||
|
.runtime
|
||||||
|
.as_ref()
|
||||||
|
.map(|runtime| Arc::clone(&runtime.app))
|
||||||
|
.ok_or_else(|| "QConnect service is not running".to_string())?
|
||||||
|
};
|
||||||
|
|
||||||
|
app.send_renderer_report_command(report)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("send renderer report failed: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_active(&self) -> bool {
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
guard.runtime.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_queue_version(&self) -> qconnect_app::QueueVersion {
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
if let Some(runtime) = &guard.runtime {
|
||||||
|
runtime.app.queue_state_snapshot().await.version
|
||||||
|
} else {
|
||||||
|
qconnect_app::QueueVersion::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for QconnectServiceState {
|
impl Default for QconnectServiceState {
|
||||||
@@ -1274,6 +1341,73 @@ pub async fn v2_qconnect_renderer_snapshot(
|
|||||||
.map_err(RuntimeError::Internal)
|
.map_err(RuntimeError::Internal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Report current playback state to QConnect server.
|
||||||
|
/// Called by playback commands (play, pause, resume, stop, seek, track change).
|
||||||
|
/// Fire-and-forget: errors are logged but do not block playback.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn v2_qconnect_report_playback_state(
|
||||||
|
playing_state: i32,
|
||||||
|
current_position: Option<i32>,
|
||||||
|
duration: Option<i32>,
|
||||||
|
current_queue_item_id: Option<i32>,
|
||||||
|
next_queue_item_id: Option<i32>,
|
||||||
|
service: State<'_, QconnectServiceState>,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
if !service.is_active().await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let queue_version = service.get_queue_version().await;
|
||||||
|
let report = RendererReport::new(
|
||||||
|
RendererReportType::RndrSrvrStateUpdated,
|
||||||
|
Uuid::new_v4().to_string(),
|
||||||
|
queue_version,
|
||||||
|
serde_json::json!({
|
||||||
|
"playing_state": playing_state,
|
||||||
|
"buffer_state": BUFFER_STATE_OK,
|
||||||
|
"current_position": current_position,
|
||||||
|
"duration": duration,
|
||||||
|
"current_queue_item_id": current_queue_item_id,
|
||||||
|
"next_queue_item_id": next_queue_item_id,
|
||||||
|
"queue_version": {
|
||||||
|
"major": queue_version.major,
|
||||||
|
"minor": queue_version.minor
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(err) = service.send_renderer_report(report).await {
|
||||||
|
log::warn!("[QConnect] Failed to report playback state: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report volume change to QConnect server.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn v2_qconnect_report_volume(
|
||||||
|
volume: i32,
|
||||||
|
service: State<'_, QconnectServiceState>,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
if !service.is_active().await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let queue_version = service.get_queue_version().await;
|
||||||
|
let report = RendererReport::new(
|
||||||
|
RendererReportType::RndrSrvrVolumeChanged,
|
||||||
|
Uuid::new_v4().to_string(),
|
||||||
|
queue_version,
|
||||||
|
serde_json::json!({ "volume": volume }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(err) = service.send_renderer_report(report).await {
|
||||||
|
log::warn!("[QConnect] Failed to report volume change: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn resolve_transport_config(
|
async fn resolve_transport_config(
|
||||||
options: QconnectConnectOptions,
|
options: QconnectConnectOptions,
|
||||||
app_state: &AppState,
|
app_state: &AppState,
|
||||||
|
|||||||
Reference in new issue
Block a user