1
0
Fork 0
mirror of https://github.com/librespot-org/librespot.git synced 2025-10-03 17:59:24 +02:00

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
This commit is contained in:
Felix Prillwitz 2025-02-22 23:39:16 +01:00 committed by GitHub
parent f497806fb1
commit 09b4aa41e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 429 additions and 209 deletions

1
.gitignore vendored
View file

@ -4,5 +4,6 @@ spotify_appkey.key
.vagrant/ .vagrant/
.project .project
.history .history
.cache
*.save *.save
*.*~ *.*~

View file

@ -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] MSRV is now 1.81 (breaking)
- [core] AP connect and handshake have a combined 5 second timeout. - [core] AP connect and handshake have a combined 5 second timeout.
- [connect] Replaced `ConnectConfig` with `ConnectStateConfig` (breaking) - [connect] Replaced `has_volume_ctrl` with `disable_volume` in `ConnectConfig` (breaking)
- [connect] Replaced `playing_track_index` field of `SpircLoadCommand` with `playing_track` (breaking) - [connect] Changed `initial_volume` from `Option<u16>` 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 - [connect] Replaced Mercury usage in `Spirc` with Dealer
### Added ### Added
- [connect] Add `seek_to` field to `SpircLoadCommand` (breaking) - [connect] Add support for `seek_to`, `repeat_track` and `autoplay` for `Spirc` loading
- [connect] Add `repeat_track` field to `SpircLoadCommand` (breaking)
- [connect] Add `autoplay` field to `SpircLoadCommand` (breaking)
- [connect] Add `pause` parameter to `Spirc::disconnect` method (breaking) - [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) - [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking)
- [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient` - [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 - [oauth] Add `OAuthClient` and `OAuthClientBuilder` structs to achieve a more customizable login process

63
connect/README.md Normal file
View file

@ -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(())
}
```

View file

@ -1,3 +1,6 @@
#![warn(missing_docs)]
#![doc=include_str!("../README.md")]
#[macro_use] #[macro_use]
extern crate log; extern crate log;
@ -7,6 +10,10 @@ use librespot_protocol as protocol;
mod context_resolver; mod context_resolver;
mod model; mod model;
pub mod shuffle_vec; mod shuffle_vec;
pub mod spirc; mod spirc;
pub mod state; mod state;
pub use model::*;
pub use spirc::*;
pub use state::*;

View file

@ -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)] #[derive(Debug)]
pub struct SpircLoadCommand { pub struct LoadRequest {
pub context_uri: String, 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. /// Whether the given tracks should immediately start playing, or just be initially loaded.
pub start_playing: bool, 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 seek_to: u32,
pub shuffle: bool, /// Options that decide how the context starts playing
pub repeat: bool, pub context_options: Option<LoadContextOptions>,
pub repeat_track: bool, /// Decides the starting position in the given context.
/// Decides if the context or the autoplay of the context is played
/// ///
/// ## Remarks: /// If the provided item doesn't exist or is out of range,
/// If `true` is provided, the option values (`shuffle`, `repeat` and `repeat_track`) are ignored /// the playback starts at the beginning of the context.
pub autoplay: bool,
/// Decides the starting position in the given context
/// ///
/// ## Remarks:
/// If `None` is provided and `shuffle` is `true`, a random track is played, otherwise the first /// If `None` is provided and `shuffle` is `true`, a random track is played, otherwise the first
pub playing_track: Option<PlayingTrack>, pub playing_track: Option<PlayingTrack>,
} }
/// 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<ContextPlayerOptionOverrides> 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)] #[derive(Debug)]
pub enum PlayingTrack { pub enum PlayingTrack {
/// Represent the track at a given index.
Index(u32), Index(u32),
/// Represent the uri of a track.
Uri(String), 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), Uid(String),
} }

View file

@ -46,13 +46,6 @@ impl<T> From<Vec<T>> for ShuffleVec<T> {
} }
impl<T> ShuffleVec<T> { impl<T> ShuffleVec<T> {
pub fn new() -> Self {
Self {
vec: Vec::new(),
indices: None,
}
}
pub fn shuffle_with_seed(&mut self, seed: u64) { pub fn shuffle_with_seed(&mut self, seed: u64) {
self.shuffle_with_rng(SmallRng::seed_from_u64(seed)) self.shuffle_with_rng(SmallRng::seed_from_u64(seed))
} }

View file

@ -1,4 +1,3 @@
pub use crate::model::{PlayingTrack, SpircLoadCommand};
use crate::{ use crate::{
context_resolver::{ContextAction, ContextResolver, ResolveContext}, context_resolver::{ContextAction, ContextResolver, ResolveContext},
core::{ core::{
@ -10,7 +9,7 @@ use crate::{
session::UserAttributes, session::UserAttributes,
Error, Session, SpotifyId, Error, Session, SpotifyId,
}, },
model::SpircPlayStatus, model::{LoadRequest, PlayingTrack, SpircPlayStatus},
playback::{ playback::{
mixer::Mixer, mixer::Mixer,
player::{Player, PlayerEvent, PlayerEventChannel}, player::{Player, PlayerEvent, PlayerEventChannel},
@ -26,10 +25,10 @@ use crate::{
}, },
state::{ state::{
context::{ContextType, ResetContext}, context::{ContextType, ResetContext},
metadata::Metadata,
provider::IsProvider, provider::IsProvider,
{ConnectState, ConnectStateConfig}, {ConnectConfig, ConnectState},
}, },
LoadContextOptions, LoadRequestOptions,
}; };
use futures_util::StreamExt; use futures_util::StreamExt;
use protobuf::MessageField; use protobuf::MessageField;
@ -43,15 +42,13 @@ use thiserror::Error;
use tokio::{sync::mpsc, time::sleep}; use tokio::{sync::mpsc, time::sleep};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum SpircError { enum SpircError {
#[error("response payload empty")] #[error("response payload empty")]
NoData, NoData,
#[error("{0} had no uri")] #[error("{0} had no uri")]
NoUri(&'static str), NoUri(&'static str),
#[error("message pushed for another URI")] #[error("message pushed for another URI")]
InvalidUri(String), InvalidUri(String),
#[error("tried resolving not allowed context: {0:?}")]
NotAllowedContext(String),
#[error("failed to put connect state for new device")] #[error("failed to put connect state for new device")]
FailedDealerSetup, FailedDealerSetup,
#[error("unknown endpoint: {0:#?}")] #[error("unknown endpoint: {0:#?}")]
@ -62,7 +59,7 @@ impl From<SpircError> for Error {
fn from(err: SpircError) -> Self { fn from(err: SpircError) -> Self {
use SpircError::*; use SpircError::*;
match err { match err {
NoData | NoUri(_) | NotAllowedContext(_) => Error::unavailable(err), NoData | NoUri(_) => Error::unavailable(err),
InvalidUri(_) | FailedDealerSetup => Error::aborted(err), InvalidUri(_) | FailedDealerSetup => Error::aborted(err),
UnknownEndpoint(_) => Error::unimplemented(err), UnknownEndpoint(_) => Error::unimplemented(err),
} }
@ -130,25 +127,30 @@ enum SpircCommand {
SetPosition(u32), SetPosition(u32),
SetVolume(u16), SetVolume(u16),
Activate, Activate,
Load(SpircLoadCommand), Load(LoadRequest),
} }
const CONTEXT_FETCH_THRESHOLD: usize = 2; 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 // delay to update volume after a certain amount of time, instead on each update request
const VOLUME_UPDATE_DELAY: Duration = Duration::from_secs(2); 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 // 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); const UPDATE_STATE_DELAY: Duration = Duration::from_millis(200);
/// The spotify connect handle
pub struct Spirc { pub struct Spirc {
commands: mpsc::UnboundedSender<SpircCommand>, commands: mpsc::UnboundedSender<SpircCommand>,
} }
impl Spirc { 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( pub async fn new(
config: ConnectStateConfig, config: ConnectConfig,
session: Session, session: Session,
credentials: Credentials, credentials: Credentials,
player: Arc<Player>, player: Arc<Player>,
@ -268,54 +270,132 @@ impl Spirc {
Ok((spirc, task.run())) Ok((spirc, task.run()))
} }
pub fn play(&self) -> Result<(), Error> { /// Safely shutdowns the spirc.
Ok(self.commands.send(SpircCommand::Play)?) ///
} /// This pauses the playback, disconnects the connect device and
pub fn play_pause(&self) -> Result<(), Error> { /// bring the future initially returned to an end.
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)?)
}
pub fn shutdown(&self) -> Result<(), Error> { pub fn shutdown(&self) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Shutdown)?) 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> { pub fn shuffle(&self, shuffle: bool) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Shuffle(shuffle))?) 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> { pub fn repeat(&self, repeat: bool) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Repeat(repeat))?) 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> { pub fn repeat_track(&self, repeat: bool) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::RepeatTrack(repeat))?) 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> { pub fn set_volume(&self, volume: u16) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::SetVolume(volume))?) 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> { pub fn set_position_ms(&self, position_ms: u32) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::SetPosition(position_ms))?) 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> { pub fn disconnect(&self, pause: bool) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Disconnect { pause })?) 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> { pub fn activate(&self) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Activate)?) Ok(self.commands.send(SpircCommand::Activate)?)
} }
pub fn load(&self, command: SpircLoadCommand) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Load(command))?)
}
} }
impl SpircTask { impl SpircTask {
@ -529,6 +609,7 @@ impl SpircTask {
match cmd { match cmd {
SpircCommand::Shutdown => { SpircCommand::Shutdown => {
trace!("Received SpircCommand::Shutdown"); trace!("Received SpircCommand::Shutdown");
self.handle_pause();
self.handle_disconnect().await?; self.handle_disconnect().await?;
self.shutdown = true; self.shutdown = true;
if let Some(rx) = self.commands.as_mut() { if let Some(rx) = self.commands.as_mut() {
@ -895,42 +976,28 @@ impl SpircTask {
return self.notify().await; return self.notify().await;
} }
Play(play) => { 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 let context_uri = play
.context .context
.uri .uri
.clone() .clone()
.ok_or(SpircError::NoUri("context"))?; .ok_or(SpircError::NoUri("context"))?;
let context_options = play
.options
.player_options_override
.map(Into::into)
.map(LoadContextOptions::Options);
self.handle_load( self.handle_load(
SpircLoadCommand { LoadRequest::from_context_uri(
context_uri, context_uri,
LoadRequestOptions {
start_playing: true, start_playing: true,
seek_to: play.options.seek_to.unwrap_or_default(), seek_to: play.options.seek_to.unwrap_or_default(),
playing_track: play.options.skip_to.and_then(|s| s.try_into().ok()), playing_track: play.options.skip_to.and_then(|s| s.try_into().ok()),
shuffle, context_options,
repeat,
repeat_track,
autoplay: false,
}, },
),
Some(play.context), Some(play.context),
) )
.await?; .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 { if autoplay {
ctx_uri = ctx_uri.replace("station:", ""); ctx_uri = ctx_uri.replace("station:", "");
} }
@ -1111,7 +1178,7 @@ impl SpircTask {
async fn handle_load( async fn handle_load(
&mut self, &mut self,
cmd: SpircLoadCommand, cmd: LoadRequest,
context: Option<Context>, context: Option<Context>,
) -> Result<(), Error> { ) -> Result<(), Error> {
self.connect_state self.connect_state
@ -1132,7 +1199,7 @@ impl SpircTask {
&cmd.context_uri &cmd.context_uri
}; };
let update_context = if cmd.autoplay { let update_context = if matches!(cmd.context_options, Some(LoadContextOptions::Autoplay)) {
ContextType::Autoplay ContextType::Autoplay
} else { } else {
ContextType::Default ContextType::Default
@ -1168,31 +1235,31 @@ impl SpircTask {
let index = match cmd.playing_track { let index = match cmd.playing_track {
None => None, None => None,
Some(playing_track) => Some(match playing_track { Some(ref playing_track) => Some(match playing_track {
PlayingTrack::Index(i) => i as usize, PlayingTrack::Index(i) => *i as usize,
PlayingTrack::Uri(uri) => { PlayingTrack::Uri(uri) => {
let ctx = self.connect_state.get_context(ContextType::Default)?; 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) => { PlayingTrack::Uid(uid) => {
let ctx = self.connect_state.get_context(ContextType::Default)?; 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)?
} }
}), }),
}; };
if let Some(LoadContextOptions::Options(ref options)) = cmd.context_options {
debug!( debug!(
"loading with shuffle: <{}>, repeat track: <{}> context: <{}>", "loading with shuffle: <{}>, repeat track: <{}> context: <{}>",
cmd.shuffle, cmd.repeat, cmd.repeat_track options.shuffle, options.repeat, options.repeat_track
); );
self.connect_state.set_shuffle(!cmd.autoplay && cmd.shuffle); self.connect_state.set_shuffle(options.shuffle);
self.connect_state self.connect_state.set_repeat_context(options.repeat);
.set_repeat_context(!cmd.autoplay && cmd.repeat); self.connect_state.set_repeat_track(options.repeat_track);
self.connect_state }
.set_repeat_track(!cmd.autoplay && cmd.repeat_track);
if cmd.shuffle { if matches!(cmd.context_options, Some(LoadContextOptions::Options(ref o)) if o.shuffle) {
if let Some(index) = index { if let Some(index) = index {
self.connect_state.set_current_track(index)?; self.connect_state.set_current_track(index)?;
} else { } else {
@ -1285,6 +1352,12 @@ impl SpircTask {
} }
fn handle_seek(&mut self, position_ms: u32) { 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 self.connect_state
.update_position(position_ms, self.now_ms()); .update_position(position_ms, self.now_ms());
self.player.seek(position_ms); self.player.seek(position_ms);
@ -1422,14 +1495,16 @@ impl SpircTask {
} }
fn handle_volume_up(&mut self) { fn handle_volume_up(&mut self) {
let volume = let volume_steps = self.connect_state.device_info().capabilities.volume_steps as u16;
(self.connect_state.device_info().volume as u16).saturating_add(VOLUME_STEP_SIZE);
let volume = (self.connect_state.device_info().volume as u16).saturating_add(volume_steps);
self.set_volume(volume); self.set_volume(volume);
} }
fn handle_volume_down(&mut self) { fn handle_volume_down(&mut self) {
let volume = let volume_steps = self.connect_state.device_info().capabilities.volume_steps as u16;
(self.connect_state.device_info().volume as u16).saturating_sub(VOLUME_STEP_SIZE);
let volume = (self.connect_state.device_info().volume as u16).saturating_sub(volume_steps);
self.set_volume(volume); self.set_volume(volume);
} }

View file

@ -1,6 +1,6 @@
pub(super) mod context; pub(super) mod context;
mod handle; mod handle;
pub mod metadata; mod metadata;
mod options; mod options;
pub(super) mod provider; pub(super) mod provider;
mod restrictions; mod restrictions;
@ -40,7 +40,7 @@ const SPOTIFY_MAX_PREV_TRACKS_SIZE: usize = 10;
const SPOTIFY_MAX_NEXT_TRACKS_SIZE: usize = 80; const SPOTIFY_MAX_NEXT_TRACKS_SIZE: usize = 80;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum StateError { pub(super) enum StateError {
#[error("the current track couldn't be resolved from the transfer state")] #[error("the current track couldn't be resolved from the transfer state")]
CouldNotResolveTrackFromTransfer, CouldNotResolveTrackFromTransfer,
#[error("context is not available. type: {0:?}")] #[error("context is not available. type: {0:?}")]
@ -74,33 +74,38 @@ impl From<StateError> for Error {
} }
} }
/// Configuration of the connect device
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ConnectStateConfig { pub struct ConnectConfig {
pub session_id: String, /// The name of the connect device (default: librespot)
pub initial_volume: u32,
pub name: String, pub name: String,
/// The icon type of the connect device (default: [DeviceType::Speaker])
pub device_type: DeviceType, 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, 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, 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 { fn default() -> Self {
Self { Self {
session_id: String::new(),
initial_volume: u32::from(u16::MAX) / 2,
name: "librespot".to_string(), name: "librespot".to_string(),
device_type: DeviceType::Speaker, device_type: DeviceType::Speaker,
volume_steps: 64,
is_group: false, is_group: false,
initial_volume: u16::MAX / 2,
disable_volume: false, disable_volume: false,
volume_steps: 1024,
} }
} }
} }
#[derive(Default, Debug)] #[derive(Default, Debug)]
pub struct ConnectState { pub(super) struct ConnectState {
/// the entire state that is updated to the remote server /// the entire state that is updated to the remote server
request: PutStateRequest, request: PutStateRequest,
@ -125,10 +130,10 @@ pub struct ConnectState {
} }
impl ConnectState { impl ConnectState {
pub fn new(cfg: ConnectStateConfig, session: &Session) -> Self { pub fn new(cfg: ConnectConfig, session: &Session) -> Self {
let device_info = DeviceInfo { let device_info = DeviceInfo {
can_play: true, can_play: true,
volume: cfg.initial_volume, volume: cfg.initial_volume.into(),
name: cfg.name, name: cfg.name,
device_id: session.device_id().to_string(), device_id: session.device_id().to_string(),
device_type: EnumOrUnknown::new(cfg.device_type.into()), device_type: EnumOrUnknown::new(cfg.device_type.into()),
@ -137,7 +142,7 @@ impl ConnectState {
client_id: session.client_id(), client_id: session.client_id(),
is_group: cfg.is_group, is_group: cfg.is_group,
capabilities: MessageField::some(Capabilities { capabilities: MessageField::some(Capabilities {
volume_steps: cfg.volume_steps, volume_steps: cfg.volume_steps.into(),
disable_volume: cfg.disable_volume, disable_volume: cfg.disable_volume,
gaia_eq_connect_id: true, gaia_eq_connect_id: true,
@ -183,7 +188,7 @@ impl ConnectState {
device: MessageField::some(Device { device: MessageField::some(Device {
device_info: MessageField::some(device_info), device_info: MessageField::some(device_info),
player_state: MessageField::some(PlayerState { player_state: MessageField::some(PlayerState {
session_id: cfg.session_id, session_id: session.session_id(),
..Default::default() ..Default::default()
}), }),
..Default::default() ..Default::default()

View file

@ -24,6 +24,7 @@ macro_rules! metadata_entry {
self.$get($entry) self.$get($entry)
} }
fn $set (&mut self, $key: impl Display) { fn $set (&mut self, $key: impl Display) {
self.metadata_mut().insert($entry.to_string(), $key.to_string()); 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)] #[allow(dead_code)]
pub trait Metadata { pub(super) trait Metadata {
fn metadata(&self) -> &HashMap<String, String>; fn metadata(&self) -> &HashMap<String, String>;
fn metadata_mut(&mut self) -> &mut HashMap<String, String>; fn metadata_mut(&mut self) -> &mut HashMap<String, String>;
fn get_bool(&self, entry: &str) -> bool { fn get_bool(&self, entry: &str) -> bool {

View file

@ -84,7 +84,7 @@ impl Credentials {
} }
let hi = read_u8(stream)? as u32; let hi = read_u8(stream)? as u32;
Ok(lo & 0x7f | hi << 7) Ok(lo & 0x7f | (hi << 7))
} }
fn read_bytes<R: Read>(stream: &mut R) -> io::Result<Vec<u8>> { fn read_bytes<R: Read>(stream: &mut R) -> io::Result<Vec<u8>> {

View file

@ -804,20 +804,27 @@ impl SpClient {
/// Request the context for an uri /// 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:<user_id>:collection`
/// - of artist: `spotify:user:<user_id>:collection:artist:<artist_id>`
/// - search: `spotify:search:<search+query>` (whitespaces are replaced with `+`)
///
/// ## Query params found in the wild:
/// - include_video=true /// - include_video=true
/// ## Remarks: ///
/// - track /// ## Known results of uri types:
/// - uris of type `track`
/// - returns a single page with a single track /// - returns a single page with a single track
/// - when requesting a single track with a query in the request, the returned track uri /// - when requesting a single track with a query in the request, the returned track uri
/// **will** contain the query /// **will** contain the query
/// - artists /// - uris of type `artist`
/// - returns 2 pages with tracks: 10 most popular tracks and latest/popular album /// - 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) /// - 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 /// - is massively influenced by the provided query
/// - the query result shown by the search expects no query at all /// - 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<Context, Error> { pub async fn get_context(&self, uri: &str) -> Result<Context, Error> {
let uri = format!("/context-resolve/v1/{uri}"); let uri = format!("/context-resolve/v1/{uri}");

View file

@ -1,92 +1,77 @@
use librespot::{ use librespot::{
connect::{ConnectConfig, LoadRequest, LoadRequestOptions, Spirc},
core::{ core::{
authentication::Credentials, config::SessionConfig, session::Session, spotify_id::SpotifyId, authentication::Credentials, cache::Cache, config::SessionConfig, session::Session, Error,
}, },
playback::mixer::MixerConfig,
playback::{ playback::{
audio_backend, audio_backend,
config::{AudioFormat, PlayerConfig}, config::{AudioFormat, PlayerConfig},
mixer::NoOpVolume, mixer,
player::Player, player::Player,
}, },
}; };
use librespot_connect::spirc::PlayingTrack;
use librespot_connect::{ use log::LevelFilter;
spirc::{Spirc, SpircLoadCommand},
state::ConnectStateConfig, const CACHE: &str = ".cache";
}; const CACHE_FILES: &str = ".cache/files";
use librespot_metadata::{Album, Metadata};
use librespot_playback::mixer::{softmixer::SoftMixer, Mixer, MixerConfig};
use std::env;
use std::sync::Arc;
use tokio::join;
#[tokio::main] #[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 session_config = SessionConfig::default();
let player_config = PlayerConfig::default(); let player_config = PlayerConfig::default();
let audio_format = AudioFormat::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 sink_builder = audio_backend::find(None).unwrap();
let context_uri = if args.len() == 3 { let mixer_builder = mixer::find(None).unwrap();
args.pop().unwrap()
} else if args.len() == 2 {
String::from("spotify:album:79dL7FLiJFOO0EoehUHQBv")
} else {
eprintln!("Usage: {} ACCESS_TOKEN (ALBUM URI)", args[0]);
return;
};
let credentials = Credentials::with_access_token(&args[1]); let cache = Cache::new(Some(CACHE), Some(CACHE), Some(CACHE_FILES), None)?;
let backend = audio_backend::find(None).unwrap(); 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, Some(cache));
let session = Session::new(session_config, None); let mixer = mixer_builder(mixer_config);
let player = Player::new( let player = Player::new(
player_config, player_config,
session.clone(), session.clone(),
Box::new(NoOpVolume), mixer.get_soft_volume(),
move || backend(None, audio_format), move || sink_builder(None, audio_format),
); );
let (spirc, spirc_task) = Spirc::new( let (spirc, spirc_task) =
connect_config, Spirc::new(connect_config, session.clone(), credentials, player, mixer).await?;
session.clone(),
credentials,
player,
Arc::new(SoftMixer::open(MixerConfig::default())),
)
.await
.unwrap();
join!(spirc_task, async { // these calls can be seen as "queued"
let album = Album::get(&session, &SpotifyId::from_uri(&context_uri).unwrap()) spirc.activate()?;
.await spirc.load(LoadRequest::from_context_uri(
.unwrap(); format!("spotify:user:{}:collection", session.username()),
request_options,
))?;
spirc.play()?;
println!( // starting the connect device and processing the previously "queued" calls
"Playing album: {} by {}", spirc_task.await;
&album.name,
album
.artists
.first()
.map_or("unknown", |artist| &artist.name)
);
spirc.activate().unwrap(); Ok(())
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();
});
} }

View file

@ -2238,9 +2238,7 @@ impl PlayerInternal {
let wait_for_data_length = let wait_for_data_length =
(read_ahead_during_playback.as_secs_f32() * bytes_per_second as f32) as usize; (read_ahead_during_playback.as_secs_f32() * bytes_per_second as f32) as usize;
stream_loader_controller stream_loader_controller.fetch_next_and_wait(request_data_length, wait_for_data_length)
.fetch_next_and_wait(request_data_length, wait_for_data_length)
.map_err(Into::into)
} else { } else {
Ok(()) Ok(())
} }

View file

@ -14,7 +14,7 @@ use futures_util::StreamExt;
#[cfg(feature = "alsa-backend")] #[cfg(feature = "alsa-backend")]
use librespot::playback::mixer::alsamixer::AlsaMixer; use librespot::playback::mixer::alsamixer::AlsaMixer;
use librespot::{ use librespot::{
connect::{spirc::Spirc, state::ConnectStateConfig}, connect::{ConnectConfig, Spirc},
core::{ core::{
authentication::Credentials, cache::Cache, config::DeviceType, version, Session, authentication::Credentials, cache::Cache, config::DeviceType, version, Session,
SessionConfig, SessionConfig,
@ -208,7 +208,7 @@ struct Setup {
cache: Option<Cache>, cache: Option<Cache>,
player_config: PlayerConfig, player_config: PlayerConfig,
session_config: SessionConfig, session_config: SessionConfig,
connect_config: ConnectStateConfig, connect_config: ConnectConfig,
mixer_config: MixerConfig, mixer_config: MixerConfig,
credentials: Option<Credentials>, credentials: Option<Credentials>,
enable_oauth: bool, enable_oauth: bool,
@ -1371,7 +1371,7 @@ fn get_setup() -> Setup {
}); });
let connect_config = { 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()); 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); let is_group = opt_present(DEVICE_IS_GROUP);
if let Some(initial_volume) = initial_volume { if let Some(initial_volume) = initial_volume {
ConnectStateConfig { ConnectConfig {
name, name,
device_type, device_type,
is_group, is_group,
initial_volume: initial_volume.into(), initial_volume,
..Default::default() ..Default::default()
} }
} else { } else {
ConnectStateConfig { ConnectConfig {
name, name,
device_type, device_type,
is_group, is_group,