diff --git a/CHANGELOG.md b/CHANGELOG.md index a11d932e4..e4b9faf3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [connect] Add method `transfer` to `Spirc` to automatically transfer the playback to ourselves +- [core] Add method `transfer` to `SpClient` - [core] Add `SpotifyUri` type to represent more types of URI than `SpotifyId` can ### Changed diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 43702d8a4..e8d01e633 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -9,6 +9,7 @@ use crate::{ protocol::{Command, FallbackWrapper, Message, Request}, }, session::UserAttributes, + spclient::TransferRequest, }, model::{LoadRequest, PlayingTrack, SpircPlayStatus}, playback::{ @@ -73,6 +74,7 @@ struct SpircTask { /// the state management object connect_state: ConnectState, + connect_established: bool, play_request_id: Option, play_status: SpircPlayStatus, @@ -128,6 +130,7 @@ enum SpircCommand { SetPosition(u32), SetVolume(u16), Activate, + Transfer(Option), Load(LoadRequest), } @@ -225,6 +228,7 @@ impl Spirc { mixer, connect_state, + connect_established: false, play_request_id: None, play_status: SpircPlayStatus::Stopped, @@ -393,10 +397,19 @@ impl Spirc { /// Acquires the control as active connect device. /// - /// Does nothing if we are not the active device. + /// Does not [Spirc::transfer] the playback. Does nothing if we are not the active device. pub fn activate(&self) -> Result<(), Error> { Ok(self.commands.send(SpircCommand::Activate)?) } + + /// Acquires the control as active connect device over the transfer flow. + /// + /// Does nothing if we are not the active device. + pub fn transfer(&self, transfer_request: Option) -> Result<(), Error> { + Ok(self + .commands + .send(SpircCommand::Transfer(transfer_request))?) + } } impl SpircTask { @@ -489,7 +502,7 @@ impl SpircTask { session_update, match |session_update| self.handle_session_update(session_update) }, - cmd = async { commands?.recv().await }, if commands.is_some() => if let Some(cmd) = cmd { + cmd = async { commands?.recv().await }, if commands.is_some() && self.connect_established => if let Some(cmd) = cmd { if let Err(e) = self.handle_command(cmd).await { debug!("could not dispatch command: {e}"); } @@ -622,12 +635,20 @@ impl SpircTask { rx.close() } } + SpircCommand::Transfer(request) if !self.connect_state.is_active() => { + let device_id = self.session.device_id(); + self.session + .spclient() + .transfer(device_id, device_id, request.as_ref()) + .await?; + return Ok(()); + } SpircCommand::Activate if !self.connect_state.is_active() => { trace!("Received SpircCommand::{cmd:?}"); self.handle_activate(); return self.notify().await; } - SpircCommand::Activate => { + SpircCommand::Transfer(..) | SpircCommand::Activate => { warn!("SpircCommand::{cmd:?} will be ignored while already active") } _ if !self.connect_state.is_active() => { @@ -812,6 +833,8 @@ impl SpircTask { self.session.device_id() ); + self.connect_established = true; + let same_session = cluster.player_state.session_id == self.session.session_id() || cluster.player_state.session_id.is_empty(); if !cluster.active_device_id.is_empty() || !same_session { diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index 86c44cf16..8125750c7 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -7,7 +7,7 @@ use crate::{ transfer_state::TransferState, }, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fmt::{Display, Formatter}; @@ -165,12 +165,16 @@ pub struct GenericCommand { pub logging_params: LoggingParams, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct TransferOptions { - pub restore_paused: String, - pub restore_position: String, - pub restore_track: String, - pub retain_session: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub restore_paused: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restore_position: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restore_track: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retain_session: Option, } #[derive(Clone, Debug, Deserialize)] diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 7bc9d0b56..51528e5a7 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -8,6 +8,7 @@ use crate::{ Error, FileId, SpotifyId, SpotifyUri, apresolve::SocketAddress, config::SessionConfig, + dealer::protocol::TransferOptions, error::ErrorKind, protocol::{ autoplay_context_request::AutoplayContextRequest, @@ -34,6 +35,7 @@ use hyper::{ use hyper_util::client::legacy::ResponseFuture; use protobuf::{Enum, Message, MessageFull}; use rand::RngCore; +use serde::Serialize; use sysinfo::System; use thiserror::Error; @@ -106,6 +108,11 @@ impl Default for RequestOptions { } } +#[derive(Debug, Serialize)] +pub struct TransferRequest { + pub transfer_options: TransferOptions, +} + impl SpClient { pub fn set_strategy(&self, strategy: RequestStrategy) { self.lock(|inner| inner.strategy = strategy) @@ -902,4 +909,28 @@ impl SpClient { self.request(&Method::GET, &endpoint, None, None).await } + + /// Triggers the transfers of the playback from one device to another + /// + /// Using the same `device_id` for `from_device_id` and `to_device_id`, initiates the transfer + /// from the currently active device. + pub async fn transfer( + &self, + from_device_id: &str, + to_device_id: &str, + transfer_request: Option<&TransferRequest>, + ) -> SpClientResult { + let body = transfer_request.map(serde_json::to_string).transpose()?; + + let endpoint = + format!("/connect-state/v1/connect/transfer/from/{from_device_id}/to/{to_device_id}"); + self.request_with_options( + &Method::POST, + &endpoint, + None, + body.as_deref().map(|s| s.as_bytes()), + &NO_METRICS_AND_SALT, + ) + .await + } }