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:
vicrodh committed 2026-02-21 15:35:55 -06:00
1 parent b160773e50
commit 8a9e08ad91
13 files changed
+950 -106

No files matched your search

+18
View File
@@ -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,
},
} }
} }
+7
View File
@@ -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]
+1 -1
View File
@@ -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;
+327 -6
View File
@@ -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>,
+55
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+5 -3
View File
@@ -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
} }
+2
View File
@@ -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,
+8 -2
View File
@@ -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);
+12 -6
View File
@@ -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)
+134
View File
@@ -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,