mirror of
https://github.com/librespot-org/librespot.git
synced 2025-10-03 09:49:31 +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:
parent
f497806fb1
commit
09b4aa41e5
14 changed files with 429 additions and 209 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -4,5 +4,6 @@ spotify_appkey.key
|
|||
.vagrant/
|
||||
.project
|
||||
.history
|
||||
.cache
|
||||
*.save
|
||||
*.*~
|
||||
|
|
12
CHANGELOG.md
12
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<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
|
||||
|
||||
### 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
|
||||
|
|
63
connect/README.md
Normal file
63
connect/README.md
Normal 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(())
|
||||
}
|
||||
```
|
|
@ -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::*;
|
||||
|
|
|
@ -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<LoadContextOptions>,
|
||||
/// 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<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)]
|
||||
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),
|
||||
}
|
||||
|
||||
|
|
|
@ -46,13 +46,6 @@ impl<T> From<Vec<T>> for 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) {
|
||||
self.shuffle_with_rng(SmallRng::seed_from_u64(seed))
|
||||
}
|
||||
|
|
|
@ -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<SpircError> 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<SpircCommand>,
|
||||
}
|
||||
|
||||
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<Player>,
|
||||
|
@ -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<Context>,
|
||||
) -> 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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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<StateError> 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()
|
||||
|
|
|
@ -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<String, String>;
|
||||
|
||||
fn metadata_mut(&mut self) -> &mut HashMap<String, String>;
|
||||
|
||||
fn get_bool(&self, entry: &str) -> bool {
|
||||
|
|
|
@ -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<R: Read>(stream: &mut R) -> io::Result<Vec<u8>> {
|
||||
|
|
|
@ -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:<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
|
||||
/// ## 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<Context, Error> {
|
||||
let uri = format!("/context-resolve/v1/{uri}");
|
||||
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
12
src/main.rs
12
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<Cache>,
|
||||
player_config: PlayerConfig,
|
||||
session_config: SessionConfig,
|
||||
connect_config: ConnectStateConfig,
|
||||
connect_config: ConnectConfig,
|
||||
mixer_config: MixerConfig,
|
||||
credentials: Option<Credentials>,
|
||||
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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue