From 09b4aa41e5da73281408491cefb29ed2d8eb35a0 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 22 Feb 2025 23:39:16 +0100 Subject: [PATCH] Add rustdoc to connect crate (#1455) * restructure connect and add initial docs * replace inline crate rustdoc with README.md * connect: make metadata trait less visible * connect: add some more docs * chore: remove clippy warning * update CHANGELOG.md * revert unrelated changes * enforce separation of autoplay and options * hide and improve docs of uid * remove/rename remarks * update connect example and link in docs * fixup rebase and clippy warnings --- .gitignore | 1 + CHANGELOG.md | 12 +- connect/README.md | 63 +++++++++ connect/src/lib.rs | 13 +- connect/src/model.rs | 105 +++++++++++++-- connect/src/shuffle_vec.rs | 7 - connect/src/spirc.rs | 245 ++++++++++++++++++++++------------ connect/src/state.rs | 35 ++--- connect/src/state/metadata.rs | 5 +- core/src/authentication.rs | 2 +- core/src/spclient.rs | 19 ++- examples/play_connect.rs | 115 +++++++--------- playback/src/player.rs | 4 +- src/main.rs | 12 +- 14 files changed, 429 insertions(+), 209 deletions(-) create mode 100644 connect/README.md diff --git a/.gitignore b/.gitignore index eebf401d..c9a8b06b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ spotify_appkey.key .vagrant/ .project .history +.cache *.save *.*~ diff --git a/CHANGELOG.md b/CHANGELOG.md index 878999be..3e8682f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,16 +11,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [core] MSRV is now 1.81 (breaking) - [core] AP connect and handshake have a combined 5 second timeout. -- [connect] Replaced `ConnectConfig` with `ConnectStateConfig` (breaking) -- [connect] Replaced `playing_track_index` field of `SpircLoadCommand` with `playing_track` (breaking) +- [connect] Replaced `has_volume_ctrl` with `disable_volume` in `ConnectConfig` (breaking) +- [connect] Changed `initial_volume` from `Option` to `u16` in `ConnectConfig` (breaking) +- [connect] Replaced `SpircLoadCommand` with `LoadRequest`, `LoadRequestOptions` and `LoadContextOptions` (breaking) +- [connect] Moved all public items to the highest level (breaking) - [connect] Replaced Mercury usage in `Spirc` with Dealer ### Added -- [connect] Add `seek_to` field to `SpircLoadCommand` (breaking) -- [connect] Add `repeat_track` field to `SpircLoadCommand` (breaking) -- [connect] Add `autoplay` field to `SpircLoadCommand` (breaking) +- [connect] Add support for `seek_to`, `repeat_track` and `autoplay` for `Spirc` loading - [connect] Add `pause` parameter to `Spirc::disconnect` method (breaking) +- [connect] Add `volume_steps` to `ConnectConfig` (breaking) +- [connect] Add and enforce rustdoc - [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking) - [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient` - [oauth] Add `OAuthClient` and `OAuthClientBuilder` structs to achieve a more customizable login process diff --git a/connect/README.md b/connect/README.md new file mode 100644 index 00000000..127474d7 --- /dev/null +++ b/connect/README.md @@ -0,0 +1,63 @@ +[//]: # (This readme is optimized for inline rustdoc, if some links don't work, they will when included in lib.rs) + +# Connect + +The connect module of librespot. Provides the option to create your own connect device +and stream to it like any other official spotify client. + +The [`Spirc`] is the entrypoint to creating your own connect device. It can be +configured with the given [`ConnectConfig`] options and requires some additional data +to start up the device. + +When creating a new [`Spirc`] it returns two items. The [`Spirc`] itself, which is can +be used as to control the local connect device. And a [`Future`](std::future::Future), +lets name it `SpircTask`, that starts and executes the event loop of the connect device +when awaited. + +A basic example in which the `Spirc` and `SpircTask` is used can be found here: +[`examples/play_connect.rs`](../examples/play_connect.rs). + +# Example + +```rust +use std::{future::Future, thread}; + +use librespot_connect::{ConnectConfig, Spirc}; +use librespot_core::{authentication::Credentials, Error, Session, SessionConfig}; +use librespot_playback::{ + audio_backend, mixer, + config::{AudioFormat, PlayerConfig}, + mixer::{MixerConfig, NoOpVolume}, + player::Player +}; + +async fn create_basic_spirc() -> Result<(), Error> { + let credentials = Credentials::with_access_token("access-token-here"); + let session = Session::new(SessionConfig::default(), None); + + let backend = audio_backend::find(None).expect("will default to rodio"); + + let player = Player::new( + PlayerConfig::default(), + session.clone(), + Box::new(NoOpVolume), + move || { + let format = AudioFormat::default(); + let device = None; + backend(device, format) + }, + ); + + let mixer = mixer::find(None).expect("will default to SoftMixer"); + + let (spirc, spirc_task): (Spirc, _) = Spirc::new( + ConnectConfig::default(), + session, + credentials, + player, + mixer(MixerConfig::default()) + ).await?; + + Ok(()) +} +``` \ No newline at end of file diff --git a/connect/src/lib.rs b/connect/src/lib.rs index ebceaaac..ba00aa4c 100644 --- a/connect/src/lib.rs +++ b/connect/src/lib.rs @@ -1,3 +1,6 @@ +#![warn(missing_docs)] +#![doc=include_str!("../README.md")] + #[macro_use] extern crate log; @@ -7,6 +10,10 @@ use librespot_protocol as protocol; mod context_resolver; mod model; -pub mod shuffle_vec; -pub mod spirc; -pub mod state; +mod shuffle_vec; +mod spirc; +mod state; + +pub use model::*; +pub use spirc::*; +pub use state::*; diff --git a/connect/src/model.rs b/connect/src/model.rs index 73f86999..5e15b01a 100644 --- a/connect/src/model.rs +++ b/connect/src/model.rs @@ -1,30 +1,111 @@ -use librespot_core::dealer::protocol::SkipTo; +use crate::{ + core::dealer::protocol::SkipTo, protocol::context_player_options::ContextPlayerOptionOverrides, +}; +use std::ops::Deref; + +/// Request for loading playback #[derive(Debug)] -pub struct SpircLoadCommand { - pub context_uri: String, +pub struct LoadRequest { + pub(super) context_uri: String, + pub(super) options: LoadRequestOptions, +} + +impl Deref for LoadRequest { + type Target = LoadRequestOptions; + + fn deref(&self) -> &Self::Target { + &self.options + } +} + +/// The parameters for creating a load request +#[derive(Debug, Default)] +pub struct LoadRequestOptions { /// Whether the given tracks should immediately start playing, or just be initially loaded. pub start_playing: bool, + /// Start the playback at a specific point of the track. + /// + /// The provided value is used as milliseconds. Providing a value greater + /// than the track duration will start the track at the beginning. pub seek_to: u32, - pub shuffle: bool, - pub repeat: bool, - pub repeat_track: bool, - /// Decides if the context or the autoplay of the context is played + /// Options that decide how the context starts playing + pub context_options: Option, + /// Decides the starting position in the given context. /// - /// ## Remarks: - /// If `true` is provided, the option values (`shuffle`, `repeat` and `repeat_track`) are ignored - pub autoplay: bool, - /// Decides the starting position in the given context + /// If the provided item doesn't exist or is out of range, + /// the playback starts at the beginning of the context. /// - /// ## Remarks: /// If `None` is provided and `shuffle` is `true`, a random track is played, otherwise the first pub playing_track: Option, } +/// The options which decide how the playback is started +/// +/// Separated into an `enum` to exclude the other variants from being used +/// simultaneously, as they are not compatible. +#[derive(Debug)] +pub enum LoadContextOptions { + /// Starts the context with options + Options(Options), + /// Starts the playback as the autoplay variant of the context + /// + /// This is the same as finishing a context and + /// automatically continuing playback of similar tracks + Autoplay, +} + +/// The available options that indicate how to start the context +#[derive(Debug, Default)] +pub struct Options { + /// Start the context in shuffle mode + pub shuffle: bool, + /// Start the context in repeat mode + pub repeat: bool, + /// Start the context, repeating the first track until skipped or manually disabled + pub repeat_track: bool, +} + +impl From for Options { + fn from(value: ContextPlayerOptionOverrides) -> Self { + Self { + shuffle: value.shuffling_context.unwrap_or_default(), + repeat: value.repeating_context.unwrap_or_default(), + repeat_track: value.repeating_track.unwrap_or_default(), + } + } +} + +impl LoadRequest { + /// Create a load request from a `context_uri` + /// + /// For supported `context_uri` see [`SpClient::get_context`](librespot_core::spclient::SpClient::get_context) + pub fn from_context_uri(context_uri: String, options: LoadRequestOptions) -> Self { + Self { + context_uri, + options, + } + } +} + +/// An item that represent a track to play #[derive(Debug)] pub enum PlayingTrack { + /// Represent the track at a given index. Index(u32), + /// Represent the uri of a track. Uri(String), + #[doc(hidden)] + /// Represent an internal identifier from spotify. + /// + /// The internal identifier is not the id contained in the uri. And rather + /// an unrelated id probably unique in spotify's internal database. But that's + /// just speculation. + /// + /// This identifier is not available by any public api. It's used for varies in + /// any spotify client, like sorting, displaying which track is currently played + /// and skipping to a track. Mobile uses it pretty intensively but also web and + /// desktop seem to make use of it. Uid(String), } diff --git a/connect/src/shuffle_vec.rs b/connect/src/shuffle_vec.rs index b7bb5f3d..84fb6b15 100644 --- a/connect/src/shuffle_vec.rs +++ b/connect/src/shuffle_vec.rs @@ -46,13 +46,6 @@ impl From> for ShuffleVec { } impl ShuffleVec { - pub fn new() -> Self { - Self { - vec: Vec::new(), - indices: None, - } - } - pub fn shuffle_with_seed(&mut self, seed: u64) { self.shuffle_with_rng(SmallRng::seed_from_u64(seed)) } diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index d1cf9e5b..bcd094ec 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,4 +1,3 @@ -pub use crate::model::{PlayingTrack, SpircLoadCommand}; use crate::{ context_resolver::{ContextAction, ContextResolver, ResolveContext}, core::{ @@ -10,7 +9,7 @@ use crate::{ session::UserAttributes, Error, Session, SpotifyId, }, - model::SpircPlayStatus, + model::{LoadRequest, PlayingTrack, SpircPlayStatus}, playback::{ mixer::Mixer, player::{Player, PlayerEvent, PlayerEventChannel}, @@ -26,10 +25,10 @@ use crate::{ }, state::{ context::{ContextType, ResetContext}, - metadata::Metadata, provider::IsProvider, - {ConnectState, ConnectStateConfig}, + {ConnectConfig, ConnectState}, }, + LoadContextOptions, LoadRequestOptions, }; use futures_util::StreamExt; use protobuf::MessageField; @@ -43,15 +42,13 @@ use thiserror::Error; use tokio::{sync::mpsc, time::sleep}; #[derive(Debug, Error)] -pub enum SpircError { +enum SpircError { #[error("response payload empty")] NoData, #[error("{0} had no uri")] NoUri(&'static str), #[error("message pushed for another URI")] InvalidUri(String), - #[error("tried resolving not allowed context: {0:?}")] - NotAllowedContext(String), #[error("failed to put connect state for new device")] FailedDealerSetup, #[error("unknown endpoint: {0:#?}")] @@ -62,7 +59,7 @@ impl From for Error { fn from(err: SpircError) -> Self { use SpircError::*; match err { - NoData | NoUri(_) | NotAllowedContext(_) => Error::unavailable(err), + NoData | NoUri(_) => Error::unavailable(err), InvalidUri(_) | FailedDealerSetup => Error::aborted(err), UnknownEndpoint(_) => Error::unimplemented(err), } @@ -130,25 +127,30 @@ enum SpircCommand { SetPosition(u32), SetVolume(u16), Activate, - Load(SpircLoadCommand), + Load(LoadRequest), } const CONTEXT_FETCH_THRESHOLD: usize = 2; -const VOLUME_STEP_SIZE: u16 = 1024; // (u16::MAX + 1) / VOLUME_STEPS - // delay to update volume after a certain amount of time, instead on each update request const VOLUME_UPDATE_DELAY: Duration = Duration::from_secs(2); // to reduce updates to remote, we group some request by waiting for a set amount of time const UPDATE_STATE_DELAY: Duration = Duration::from_millis(200); +/// The spotify connect handle pub struct Spirc { commands: mpsc::UnboundedSender, } impl Spirc { + /// Initializes a new spotify connect device + /// + /// The returned tuple consists out of a handle to the [`Spirc`] that + /// can control the local connect device when active. And a [`Future`] + /// which represents the [`Spirc`] event loop that processes the whole + /// connect device logic. pub async fn new( - config: ConnectStateConfig, + config: ConnectConfig, session: Session, credentials: Credentials, player: Arc, @@ -268,54 +270,132 @@ impl Spirc { Ok((spirc, task.run())) } - pub fn play(&self) -> Result<(), Error> { - Ok(self.commands.send(SpircCommand::Play)?) - } - pub fn play_pause(&self) -> Result<(), Error> { - Ok(self.commands.send(SpircCommand::PlayPause)?) - } - pub fn pause(&self) -> Result<(), Error> { - Ok(self.commands.send(SpircCommand::Pause)?) - } - pub fn prev(&self) -> Result<(), Error> { - Ok(self.commands.send(SpircCommand::Prev)?) - } - pub fn next(&self) -> Result<(), Error> { - Ok(self.commands.send(SpircCommand::Next)?) - } - pub fn volume_up(&self) -> Result<(), Error> { - Ok(self.commands.send(SpircCommand::VolumeUp)?) - } - pub fn volume_down(&self) -> Result<(), Error> { - Ok(self.commands.send(SpircCommand::VolumeDown)?) - } + /// Safely shutdowns the spirc. + /// + /// This pauses the playback, disconnects the connect device and + /// bring the future initially returned to an end. pub fn shutdown(&self) -> Result<(), Error> { Ok(self.commands.send(SpircCommand::Shutdown)?) } + + /// Resumes the playback + /// + /// Does nothing if we are not the active device, or it isn't paused. + pub fn play(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Play)?) + } + + /// Resumes or pauses the playback + /// + /// Does nothing if we are not the active device. + pub fn play_pause(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::PlayPause)?) + } + + /// Pauses the playback + /// + /// Does nothing if we are not the active device, or if it isn't playing. + pub fn pause(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Pause)?) + } + + /// Seeks to the beginning or skips to the previous track. + /// + /// Seeks to the beginning when the current track position + /// is greater than 3 seconds. + /// + /// Does nothing if we are not the active device. + pub fn prev(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Prev)?) + } + + /// Skips to the next track. + /// + /// Does nothing if we are not the active device. + pub fn next(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Next)?) + } + + /// Increases the volume by configured steps of [ConnectConfig]. + /// + /// Does nothing if we are not the active device. + pub fn volume_up(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::VolumeUp)?) + } + + /// Decreases the volume by configured steps of [ConnectConfig]. + /// + /// Does nothing if we are not the active device. + pub fn volume_down(&self) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::VolumeDown)?) + } + + /// Shuffles the playback according to the value. + /// + /// If true shuffles/reshuffles the playback. Otherwise, does + /// nothing (if not shuffled) or unshuffles the playback while + /// resuming at the position of the current track. + /// + /// Does nothing if we are not the active device. pub fn shuffle(&self, shuffle: bool) -> Result<(), Error> { Ok(self.commands.send(SpircCommand::Shuffle(shuffle))?) } + + /// Repeats the playback context according to the value. + /// + /// Does nothing if we are not the active device. pub fn repeat(&self, repeat: bool) -> Result<(), Error> { Ok(self.commands.send(SpircCommand::Repeat(repeat))?) } + + /// Repeats the current track if true. + /// + /// Does nothing if we are not the active device. + /// + /// Skipping to the next track disables the repeating. pub fn repeat_track(&self, repeat: bool) -> Result<(), Error> { Ok(self.commands.send(SpircCommand::RepeatTrack(repeat))?) } + + /// Update the volume to the given value. + /// + /// Does nothing if we are not the active device. pub fn set_volume(&self, volume: u16) -> Result<(), Error> { Ok(self.commands.send(SpircCommand::SetVolume(volume))?) } + + /// Updates the position to the given value. + /// + /// Does nothing if we are not the active device. + /// + /// If value is greater than the track duration, + /// the update is ignored. pub fn set_position_ms(&self, position_ms: u32) -> Result<(), Error> { Ok(self.commands.send(SpircCommand::SetPosition(position_ms))?) } + + /// Load a new context and replace the current. + /// + /// Does nothing if we are not the active device. + /// + /// Does not overwrite the queue. + pub fn load(&self, command: LoadRequest) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::Load(command))?) + } + + /// Disconnects the current device and pauses the playback according the value. + /// + /// Does nothing if we are not the active device. pub fn disconnect(&self, pause: bool) -> Result<(), Error> { Ok(self.commands.send(SpircCommand::Disconnect { pause })?) } + + /// Acquires the control as active connect device. + /// + /// Does nothing if we are not the active device. pub fn activate(&self) -> Result<(), Error> { Ok(self.commands.send(SpircCommand::Activate)?) } - pub fn load(&self, command: SpircLoadCommand) -> Result<(), Error> { - Ok(self.commands.send(SpircCommand::Load(command))?) - } } impl SpircTask { @@ -529,6 +609,7 @@ impl SpircTask { match cmd { SpircCommand::Shutdown => { trace!("Received SpircCommand::Shutdown"); + self.handle_pause(); self.handle_disconnect().await?; self.shutdown = true; if let Some(rx) = self.commands.as_mut() { @@ -895,42 +976,28 @@ impl SpircTask { return self.notify().await; } Play(play) => { - let shuffle = play - .options - .player_options_override - .as_ref() - .map(|o| o.shuffling_context.unwrap_or_default()) - .unwrap_or_default(); - let repeat = play - .options - .player_options_override - .as_ref() - .map(|o| o.repeating_context.unwrap_or_default()) - .unwrap_or_default(); - let repeat_track = play - .options - .player_options_override - .as_ref() - .map(|o| o.repeating_track.unwrap_or_default()) - .unwrap_or_default(); - let context_uri = play .context .uri .clone() .ok_or(SpircError::NoUri("context"))?; + let context_options = play + .options + .player_options_override + .map(Into::into) + .map(LoadContextOptions::Options); + self.handle_load( - SpircLoadCommand { + LoadRequest::from_context_uri( context_uri, - start_playing: true, - seek_to: play.options.seek_to.unwrap_or_default(), - playing_track: play.options.skip_to.and_then(|s| s.try_into().ok()), - shuffle, - repeat, - repeat_track, - autoplay: false, - }, + LoadRequestOptions { + start_playing: true, + seek_to: play.options.seek_to.unwrap_or_default(), + playing_track: play.options.skip_to.and_then(|s| s.try_into().ok()), + context_options, + }, + ), Some(play.context), ) .await?; @@ -991,7 +1058,7 @@ impl SpircTask { } }; - let autoplay = self.connect_state.current_track(|t| t.is_from_autoplay()); + let autoplay = self.connect_state.current_track(|t| t.is_autoplay()); if autoplay { ctx_uri = ctx_uri.replace("station:", ""); } @@ -1111,7 +1178,7 @@ impl SpircTask { async fn handle_load( &mut self, - cmd: SpircLoadCommand, + cmd: LoadRequest, context: Option, ) -> Result<(), Error> { self.connect_state @@ -1132,7 +1199,7 @@ impl SpircTask { &cmd.context_uri }; - let update_context = if cmd.autoplay { + let update_context = if matches!(cmd.context_options, Some(LoadContextOptions::Autoplay)) { ContextType::Autoplay } else { ContextType::Default @@ -1168,31 +1235,31 @@ impl SpircTask { let index = match cmd.playing_track { None => None, - Some(playing_track) => Some(match playing_track { - PlayingTrack::Index(i) => i as usize, + Some(ref playing_track) => Some(match playing_track { + PlayingTrack::Index(i) => *i as usize, PlayingTrack::Uri(uri) => { let ctx = self.connect_state.get_context(ContextType::Default)?; - ConnectState::find_index_in_context(ctx, |t| t.uri == uri)? + ConnectState::find_index_in_context(ctx, |t| &t.uri == uri)? } PlayingTrack::Uid(uid) => { let ctx = self.connect_state.get_context(ContextType::Default)?; - ConnectState::find_index_in_context(ctx, |t| t.uid == uid)? + ConnectState::find_index_in_context(ctx, |t| &t.uid == uid)? } }), }; - debug!( - "loading with shuffle: <{}>, repeat track: <{}> context: <{}>", - cmd.shuffle, cmd.repeat, cmd.repeat_track - ); + if let Some(LoadContextOptions::Options(ref options)) = cmd.context_options { + debug!( + "loading with shuffle: <{}>, repeat track: <{}> context: <{}>", + options.shuffle, options.repeat, options.repeat_track + ); - self.connect_state.set_shuffle(!cmd.autoplay && cmd.shuffle); - self.connect_state - .set_repeat_context(!cmd.autoplay && cmd.repeat); - self.connect_state - .set_repeat_track(!cmd.autoplay && cmd.repeat_track); + self.connect_state.set_shuffle(options.shuffle); + self.connect_state.set_repeat_context(options.repeat); + self.connect_state.set_repeat_track(options.repeat_track); + } - if cmd.shuffle { + if matches!(cmd.context_options, Some(LoadContextOptions::Options(ref o)) if o.shuffle) { if let Some(index) = index { self.connect_state.set_current_track(index)?; } else { @@ -1285,6 +1352,12 @@ impl SpircTask { } fn handle_seek(&mut self, position_ms: u32) { + let duration = self.connect_state.player().duration; + if i64::from(position_ms) > duration { + warn!("tried to seek to {position_ms}ms of {duration}ms"); + return; + } + self.connect_state .update_position(position_ms, self.now_ms()); self.player.seek(position_ms); @@ -1422,14 +1495,16 @@ impl SpircTask { } fn handle_volume_up(&mut self) { - let volume = - (self.connect_state.device_info().volume as u16).saturating_add(VOLUME_STEP_SIZE); + let volume_steps = self.connect_state.device_info().capabilities.volume_steps as u16; + + let volume = (self.connect_state.device_info().volume as u16).saturating_add(volume_steps); self.set_volume(volume); } fn handle_volume_down(&mut self) { - let volume = - (self.connect_state.device_info().volume as u16).saturating_sub(VOLUME_STEP_SIZE); + let volume_steps = self.connect_state.device_info().capabilities.volume_steps as u16; + + let volume = (self.connect_state.device_info().volume as u16).saturating_sub(volume_steps); self.set_volume(volume); } diff --git a/connect/src/state.rs b/connect/src/state.rs index 73010b25..047f3c8c 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,6 +1,6 @@ pub(super) mod context; mod handle; -pub mod metadata; +mod metadata; mod options; pub(super) mod provider; mod restrictions; @@ -40,7 +40,7 @@ const SPOTIFY_MAX_PREV_TRACKS_SIZE: usize = 10; const SPOTIFY_MAX_NEXT_TRACKS_SIZE: usize = 80; #[derive(Debug, Error)] -pub enum StateError { +pub(super) enum StateError { #[error("the current track couldn't be resolved from the transfer state")] CouldNotResolveTrackFromTransfer, #[error("context is not available. type: {0:?}")] @@ -74,33 +74,38 @@ impl From for Error { } } +/// Configuration of the connect device #[derive(Debug, Clone)] -pub struct ConnectStateConfig { - pub session_id: String, - pub initial_volume: u32, +pub struct ConnectConfig { + /// The name of the connect device (default: librespot) pub name: String, + /// The icon type of the connect device (default: [DeviceType::Speaker]) pub device_type: DeviceType, - pub volume_steps: i32, + /// Displays the [DeviceType] twice in the ui to show up as a group (default: false) pub is_group: bool, + /// The volume with which the connect device will be initialized (default: 50%) + pub initial_volume: u16, + /// Disables the option to control the volume remotely (default: false) pub disable_volume: bool, + /// The steps in which the volume is incremented (default: 1024) + pub volume_steps: u16, } -impl Default for ConnectStateConfig { +impl Default for ConnectConfig { fn default() -> Self { Self { - session_id: String::new(), - initial_volume: u32::from(u16::MAX) / 2, name: "librespot".to_string(), device_type: DeviceType::Speaker, - volume_steps: 64, is_group: false, + initial_volume: u16::MAX / 2, disable_volume: false, + volume_steps: 1024, } } } #[derive(Default, Debug)] -pub struct ConnectState { +pub(super) struct ConnectState { /// the entire state that is updated to the remote server request: PutStateRequest, @@ -125,10 +130,10 @@ pub struct ConnectState { } impl ConnectState { - pub fn new(cfg: ConnectStateConfig, session: &Session) -> Self { + pub fn new(cfg: ConnectConfig, session: &Session) -> Self { let device_info = DeviceInfo { can_play: true, - volume: cfg.initial_volume, + volume: cfg.initial_volume.into(), name: cfg.name, device_id: session.device_id().to_string(), device_type: EnumOrUnknown::new(cfg.device_type.into()), @@ -137,7 +142,7 @@ impl ConnectState { client_id: session.client_id(), is_group: cfg.is_group, capabilities: MessageField::some(Capabilities { - volume_steps: cfg.volume_steps, + volume_steps: cfg.volume_steps.into(), disable_volume: cfg.disable_volume, gaia_eq_connect_id: true, @@ -183,7 +188,7 @@ impl ConnectState { device: MessageField::some(Device { device_info: MessageField::some(device_info), player_state: MessageField::some(PlayerState { - session_id: cfg.session_id, + session_id: session.session_id(), ..Default::default() }), ..Default::default() diff --git a/connect/src/state/metadata.rs b/connect/src/state/metadata.rs index 763244b7..8212c276 100644 --- a/connect/src/state/metadata.rs +++ b/connect/src/state/metadata.rs @@ -24,6 +24,7 @@ macro_rules! metadata_entry { self.$get($entry) } + fn $set (&mut self, $key: impl Display) { self.metadata_mut().insert($entry.to_string(), $key.to_string()); } @@ -34,9 +35,11 @@ macro_rules! metadata_entry { }; } +/// Allows easy access of known metadata fields #[allow(dead_code)] -pub trait Metadata { +pub(super) trait Metadata { fn metadata(&self) -> &HashMap; + fn metadata_mut(&mut self) -> &mut HashMap; fn get_bool(&self, entry: &str) -> bool { diff --git a/core/src/authentication.rs b/core/src/authentication.rs index 230661ef..8dd68540 100644 --- a/core/src/authentication.rs +++ b/core/src/authentication.rs @@ -84,7 +84,7 @@ impl Credentials { } let hi = read_u8(stream)? as u32; - Ok(lo & 0x7f | hi << 7) + Ok(lo & 0x7f | (hi << 7)) } fn read_bytes(stream: &mut R) -> io::Result> { diff --git a/core/src/spclient.rs b/core/src/spclient.rs index a6680463..4377d406 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -804,20 +804,27 @@ impl SpClient { /// Request the context for an uri /// - /// ## Query entry found in the wild: + /// All [SpotifyId] uris are supported in addition to the following special uris: + /// - liked songs: + /// - all: `spotify:user::collection` + /// - of artist: `spotify:user::collection:artist:` + /// - search: `spotify:search:` (whitespaces are replaced with `+`) + /// + /// ## Query params found in the wild: /// - include_video=true - /// ## Remarks: - /// - track + /// + /// ## Known results of uri types: + /// - uris of type `track` /// - returns a single page with a single track /// - when requesting a single track with a query in the request, the returned track uri /// **will** contain the query - /// - artists + /// - uris of type `artist` /// - returns 2 pages with tracks: 10 most popular tracks and latest/popular album /// - remaining pages are artist albums sorted by popularity (only provided as page_url) - /// - search + /// - uris of type `search` /// - is massively influenced by the provided query /// - the query result shown by the search expects no query at all - /// - uri looks like "spotify:search:never+gonna" + /// - uri looks like `spotify:search:never+gonna` pub async fn get_context(&self, uri: &str) -> Result { let uri = format!("/context-resolve/v1/{uri}"); diff --git a/examples/play_connect.rs b/examples/play_connect.rs index 8ea7eaca..26e52022 100644 --- a/examples/play_connect.rs +++ b/examples/play_connect.rs @@ -1,92 +1,77 @@ use librespot::{ + connect::{ConnectConfig, LoadRequest, LoadRequestOptions, Spirc}, core::{ - authentication::Credentials, config::SessionConfig, session::Session, spotify_id::SpotifyId, + authentication::Credentials, cache::Cache, config::SessionConfig, session::Session, Error, }, + playback::mixer::MixerConfig, playback::{ audio_backend, config::{AudioFormat, PlayerConfig}, - mixer::NoOpVolume, + mixer, player::Player, }, }; -use librespot_connect::spirc::PlayingTrack; -use librespot_connect::{ - spirc::{Spirc, SpircLoadCommand}, - state::ConnectStateConfig, -}; -use librespot_metadata::{Album, Metadata}; -use librespot_playback::mixer::{softmixer::SoftMixer, Mixer, MixerConfig}; -use std::env; -use std::sync::Arc; -use tokio::join; + +use log::LevelFilter; + +const CACHE: &str = ".cache"; +const CACHE_FILES: &str = ".cache/files"; #[tokio::main] -async fn main() { +async fn main() -> Result<(), Error> { + env_logger::builder() + .filter_module("librespot", LevelFilter::Debug) + .init(); + let session_config = SessionConfig::default(); let player_config = PlayerConfig::default(); let audio_format = AudioFormat::default(); - let connect_config = ConnectStateConfig::default(); + let connect_config = ConnectConfig::default(); + let mixer_config = MixerConfig::default(); + let request_options = LoadRequestOptions::default(); - let mut args: Vec<_> = env::args().collect(); - let context_uri = if args.len() == 3 { - args.pop().unwrap() - } else if args.len() == 2 { - String::from("spotify:album:79dL7FLiJFOO0EoehUHQBv") - } else { - eprintln!("Usage: {} ACCESS_TOKEN (ALBUM URI)", args[0]); - return; - }; + let sink_builder = audio_backend::find(None).unwrap(); + let mixer_builder = mixer::find(None).unwrap(); - let credentials = Credentials::with_access_token(&args[1]); - let backend = audio_backend::find(None).unwrap(); + let cache = Cache::new(Some(CACHE), Some(CACHE), Some(CACHE_FILES), None)?; + let credentials = cache + .credentials() + .ok_or(Error::unavailable("credentials not cached")) + .or_else(|_| { + librespot_oauth::OAuthClientBuilder::new( + &session_config.client_id, + "http://127.0.0.1:8898/login", + vec!["streaming"], + ) + .open_in_browser() + .build()? + .get_access_token() + .map(|t| Credentials::with_access_token(t.access_token)) + })?; - println!("Connecting..."); - let session = Session::new(session_config, None); + let session = Session::new(session_config, Some(cache)); + let mixer = mixer_builder(mixer_config); let player = Player::new( player_config, session.clone(), - Box::new(NoOpVolume), - move || backend(None, audio_format), + mixer.get_soft_volume(), + move || sink_builder(None, audio_format), ); - let (spirc, spirc_task) = Spirc::new( - connect_config, - session.clone(), - credentials, - player, - Arc::new(SoftMixer::open(MixerConfig::default())), - ) - .await - .unwrap(); + let (spirc, spirc_task) = + Spirc::new(connect_config, session.clone(), credentials, player, mixer).await?; - join!(spirc_task, async { - let album = Album::get(&session, &SpotifyId::from_uri(&context_uri).unwrap()) - .await - .unwrap(); + // these calls can be seen as "queued" + spirc.activate()?; + spirc.load(LoadRequest::from_context_uri( + format!("spotify:user:{}:collection", session.username()), + request_options, + ))?; + spirc.play()?; - println!( - "Playing album: {} by {}", - &album.name, - album - .artists - .first() - .map_or("unknown", |artist| &artist.name) - ); + // starting the connect device and processing the previously "queued" calls + spirc_task.await; - spirc.activate().unwrap(); - spirc - .load(SpircLoadCommand { - context_uri, - start_playing: true, - seek_to: 0, - shuffle: false, - repeat: false, - repeat_track: false, - autoplay: false, - // the index specifies which track in the context starts playing, in this case the first in the album - playing_track: PlayingTrack::Index(0).into(), - }) - .unwrap(); - }); + Ok(()) } diff --git a/playback/src/player.rs b/playback/src/player.rs index e9663a70..a42bda14 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -2238,9 +2238,7 @@ impl PlayerInternal { let wait_for_data_length = (read_ahead_during_playback.as_secs_f32() * bytes_per_second as f32) as usize; - stream_loader_controller - .fetch_next_and_wait(request_data_length, wait_for_data_length) - .map_err(Into::into) + stream_loader_controller.fetch_next_and_wait(request_data_length, wait_for_data_length) } else { Ok(()) } diff --git a/src/main.rs b/src/main.rs index f1dd0271..0447bda7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,7 @@ use futures_util::StreamExt; #[cfg(feature = "alsa-backend")] use librespot::playback::mixer::alsamixer::AlsaMixer; use librespot::{ - connect::{spirc::Spirc, state::ConnectStateConfig}, + connect::{ConnectConfig, Spirc}, core::{ authentication::Credentials, cache::Cache, config::DeviceType, version, Session, SessionConfig, @@ -208,7 +208,7 @@ struct Setup { cache: Option, player_config: PlayerConfig, session_config: SessionConfig, - connect_config: ConnectStateConfig, + connect_config: ConnectConfig, mixer_config: MixerConfig, credentials: Option, enable_oauth: bool, @@ -1371,7 +1371,7 @@ fn get_setup() -> Setup { }); let connect_config = { - let connect_default_config = ConnectStateConfig::default(); + let connect_default_config = ConnectConfig::default(); let name = opt_str(NAME).unwrap_or_else(|| connect_default_config.name.clone()); @@ -1483,15 +1483,15 @@ fn get_setup() -> Setup { let is_group = opt_present(DEVICE_IS_GROUP); if let Some(initial_volume) = initial_volume { - ConnectStateConfig { + ConnectConfig { name, device_type, is_group, - initial_volume: initial_volume.into(), + initial_volume, ..Default::default() } } else { - ConnectStateConfig { + ConnectConfig { name, device_type, is_group,