Spirc: Replace Mecury with Dealer (#1356)

This was a huge effort by photovoltex@gmail.com with help from the community.
Over 140 commits were squashed. Below, their commit messages are kept unchanged.

---

* dealer wrapper for ease of use

* improve sending protobuf requests

* replace connect config with connect_state config

* start integrating dealer into spirc

* payload handling, gzip support

* put connect state consistent

* formatting

* request payload handling, gzip support

* expose dealer::protocol, move request in own file

* integrate handle of connect-state commands

* spirc: remove ident field

* transfer playing state better

* spirc: remove remote_update stream

* spirc: replace command sender with connect state update

* spirc: remove device state and remaining unused methods

* spirc: remove mercury sender

* add repeat track state

* ConnectState: add methods to replace state in spirc

* spirc: move context into connect_state, update load and next

* spirc: remove state, adjust remaining methods

* spirc: handle more dealer request commands

* revert rustfmt.toml

* spirc: impl shuffle

- impl shuffle again
- extracted fill up of next tracks in own method
- moved queue revision update into next track fill up
- removed unused method `set_playing_track_index`
- added option to specify index when resetting the playback context
- reshuffle after repeat context

* spirc: handle device became inactive

* dealer: adjust payload handling

* spirc: better set volume handling

* dealer: box PlayCommand (clippy warning)

* dealer: always respect queued tracks

* spirc: update duration of track

* ConnectState: update more restrictions

* cleanup

* spirc: handle queue requests

* spirc: skip next with track

* proto: exclude spirc.proto
- move "deserialize_with" functions into own file
- replace TrackRef with ProvidedTrack

* spirc: stabilize transfer/context handling

* core: cleanup some remains

* connect: improvements to code structure and performance

- use VecDeque for next and prev tracks

* connect: delayed volume update

* connect: move context resolve into own function

* connect: load context asynchronous

* connect: handle reconnect

- might currently steal the active devices playback

* connect: some fixes and adjustments

- fix wrong offset when transferring playback
- fix missing displayed context in web-player
- remove access_token from log
- send correct state reason when updating volume
- queue track correctly
- fix wrong assumption for skip_to

* connect: replace error case with option

* connect: use own context state

* connect: more stabilising

- handle SkipTo having no Index
- handle no transferred restrictions
- handle no transferred index
- update state before shutdown, for smoother reacquiring

* connect: working autoplay

* connect: handle repeat context/track

* connect: some quick fixes

- found self-named uid in collection after reconnecting

* connect: handle add_to_queue via set_queue

* fix clippy warnings

* fix check errors, fix/update example

* fix 1.75 specific error

* connect: position update improvements

* connect: handle unavailable

* connect: fix incorrect status handling for desktop and mobile

* core: fix dealer reconnect

- actually acquire new token
- use login5 token retrieval

* connect: split state into multiple files

* connect: encapsulate provider logic

* connect: remove public access to next and prev tracks

* connect: remove public access to player

* connect: move state only commands into own file

* connect: improve logging

* connect: handle transferred queue again

* connect: fix all-features specific error

* connect: extract transfer  handling into own file

* connect: remove old context model

* connect: handle more transfer cases correctly

* connect: do auth_token pre-acquiring earlier

* connect: handle play with skip_to by uid

* connect: simplified cluster update log

* core/connect: add remaining set value commands

* connect: position update workaround/fix

* connect: some queue cleanups

* connect: add uid to queue

* connect: duration as volume delay const

* connect: some adjustments and todo cleanups

- send volume update before general update
- simplify queue revision to use the track uri
- argument why copying the prev/next tracks is fine

* connect: handle shuffle from set_options

* connect: handle context update

* connect: move other structs into model.rs

* connect: reduce SpircCommand visibility

* connect: fix visibility of model

* connect: fix: shuffle on startup isn't applied

* connect: prevent loading a context with no tracks

* connect: use the first page of a context

* connect: improve context resolving

- support multiple pages
- support page_url of context
- handle single track

* connect: prevent integer underflow

* connect: rename method for better clarity

* connect: handle mutate and update messages

* connect: fix 1.75 problems

* connect: fill, instead of replace next page

* connect: reduce context update to single method

* connect: remove unused SpircError, handle local files

* connect: reduce nesting, adjust initial transfer handling

* connect: don't update volume initially

* core: disable trace logging of handled mercury responses

* core/connect: prevent takeover from other clients, handle session-update

* connect: add queue-uid for set_queue command

* connect: adjust fields for PlayCommand

* connect: preserve context position after update_context

* connect: unify metadata modification

- only handle `is_queued` `true` items for queue

* connect: polish request command handling

 - reply to all request endpoints
 - adjust some naming
 - add some docs

* connect: add uid to tracks without

* connect: simpler update of current index

* core/connect: update log msg, fix wrong behavior

- handle became inactive separately
- remove duplicate stop
- adjust docs for websocket request

* core: add option to request without metrics and salt

* core/context: adjust context requests and update

- search should now return the expected context
- removed workaround for single track playback
- move local playback check into update_context
- check track uri for invalid characters
- early return with `?`

* connect: handle possible search context uri

* connect: remove logout support

- handle logout command
- disable support for logout
- add todos for logout

* connect: adjust detailed tracks/context handling

- always allow next
- handle no prev track available
- separate active and fill up context

* connect: adjust context resolve handling, again

* connect: add autoplay metadata to tracks

- transfer into autoplay again

* core/connect: cleanup session after spirc stops

* update CHANGELOG.md

* playback: fix clippy warnings

* connect: adjust metadata

- unify naming
- move more metadata infos into metadata.rs

* connect: add delimiter between context and autoplay playback

* connect: stop and resume correctly

* connect: adjust context resolving

- improved certain logging parts
- preload autoplay when autoplay attribute mutates
- fix transfer context uri
- fix typo
- handle empty strings for resolve uri
- fix unexpected stop of playback

* connect: ignore failure during stop

* connect: revert resolve_uri changes

* connect: correct context reset

* connect: reduce boiler code

* connect: fix some incorrect states

- uid getting replaced by empty value
- shuffle/repeat clearing autoplay context
- fill_up updating and using incorrect index

* core: adjust incorrect separator

* connect: move `add_to_queue` and `mark_unavailable` into tracks.rs

* connect: refactor - directly modify PutStateRequest

- replace `next_tracks`, `prev_tracks`, `player` and `device` with `request`
- provide helper methods for the removed fields

* connect: adjust handling of context metadata/restrictions

* connect: fix incorrect context states

* connect: become inactive when no cluster is reported

* update CHANGELOG.md

* core/playback: preemptively fix clippy warnings

* connect: minor adjustment to session changed

* connect: change return type changing active context

* connect: handle unavailable contexts

* connect: fix previous restrictions blocking load with shuffle

* connect: update comments and logging

* core/connect: reduce some more duplicate code

* more docs around the dealer
This commit is contained in:
Felix Prillwitz 2024-12-10 20:36:09 +01:00 committed by GitHub
parent f646ef2b5a
commit 5839b36192
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 4229 additions and 1283 deletions

View file

@ -9,8 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- [connect] Replaced `ConnectConfig` with `ConnectStateConfig` (breaking)
- [connect] Replaced `playing_track_index` field of `SpircLoadCommand` with `playing_track` (breaking)
- [connect] Replaced Mercury usage in `Spirc` with Dealer
### Added ### Added
- [connect] Add `seek_to` field to `SpircLoadCommand` (breaking)
- [connect] Add `repeat_track` field to `SpircLoadCommand` (breaking)
- [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking)
- [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient`
### Fixed ### Fixed
- [core] Fix "no native root CA certificates found" on platforms unsupported - [core] Fix "no native root CA certificates found" on platforms unsupported

35
Cargo.lock generated
View file

@ -630,6 +630,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crc32fast"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.20" version = "0.8.20"
@ -894,6 +903,16 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "flate2"
version = "1.0.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -1941,7 +1960,6 @@ dependencies = [
name = "librespot-connect" name = "librespot-connect"
version = "0.6.0-dev" version = "0.6.0-dev"
dependencies = [ dependencies = [
"form_urlencoded",
"futures-util", "futures-util",
"librespot-core", "librespot-core",
"librespot-playback", "librespot-playback",
@ -1949,11 +1967,11 @@ dependencies = [
"log", "log",
"protobuf", "protobuf",
"rand", "rand",
"serde",
"serde_json", "serde_json",
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"uuid",
] ]
[[package]] [[package]]
@ -1965,6 +1983,7 @@ dependencies = [
"byteorder", "byteorder",
"bytes", "bytes",
"data-encoding", "data-encoding",
"flate2",
"form_urlencoded", "form_urlencoded",
"futures-core", "futures-core",
"futures-util", "futures-util",
@ -1991,6 +2010,7 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"priority-queue", "priority-queue",
"protobuf", "protobuf",
"protobuf-json-mapping",
"quick-xml", "quick-xml",
"rand", "rand",
"rsa", "rsa",
@ -2744,6 +2764,17 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "protobuf-json-mapping"
version = "3.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b445cf83c9303695e6c423d269759e139b6182d2f1171e18afda7078a764336"
dependencies = [
"protobuf",
"protobuf-support",
"thiserror",
]
[[package]] [[package]]
name = "protobuf-parse" name = "protobuf-parse"
version = "3.7.1" version = "3.7.1"

View file

@ -9,16 +9,15 @@ repository = "https://github.com/librespot-org/librespot"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
form_urlencoded = "1.0"
futures-util = "0.3" futures-util = "0.3"
log = "0.4" log = "0.4"
protobuf = "3.5" protobuf = "3.5"
rand = "0.8" rand = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
thiserror = "1.0" thiserror = "1.0"
tokio = { version = "1", features = ["macros", "parking_lot", "sync"] } tokio = { version = "1", features = ["macros", "parking_lot", "sync"] }
tokio-stream = "0.1" tokio-stream = "0.1"
uuid = { version = "1.11.0", features = ["v4"] }
[dependencies.librespot-core] [dependencies.librespot-core]
path = "../core" path = "../core"

View file

@ -1,22 +0,0 @@
use crate::core::config::DeviceType;
#[derive(Clone, Debug)]
pub struct ConnectConfig {
pub name: String,
pub device_type: DeviceType,
pub is_group: bool,
pub initial_volume: Option<u16>,
pub has_volume_ctrl: bool,
}
impl Default for ConnectConfig {
fn default() -> ConnectConfig {
ConnectConfig {
name: "Librespot".to_string(),
device_type: DeviceType::default(),
is_group: false,
initial_volume: Some(50),
has_volume_ctrl: true,
}
}
}

View file

@ -1,121 +0,0 @@
// TODO : move to metadata
use crate::core::spotify_id::SpotifyId;
use crate::protocol::spirc::TrackRef;
use serde::{
de::{Error, Unexpected},
Deserialize,
};
#[derive(Deserialize, Debug, Default, Clone)]
pub struct StationContext {
pub uri: String,
pub title: String,
#[serde(rename = "titleUri")]
pub title_uri: String,
pub subtitles: Vec<SubtitleContext>,
#[serde(rename = "imageUri")]
pub image_uri: String,
pub seeds: Vec<String>,
#[serde(deserialize_with = "deserialize_protobuf_TrackRef")]
pub tracks: Vec<TrackRef>,
pub next_page_url: String,
pub correlation_id: String,
pub related_artists: Vec<ArtistContext>,
}
#[derive(Deserialize, Debug, Default, Clone)]
pub struct PageContext {
#[serde(deserialize_with = "deserialize_protobuf_TrackRef")]
pub tracks: Vec<TrackRef>,
pub next_page_url: String,
pub correlation_id: String,
}
#[derive(Deserialize, Debug, Default, Clone)]
pub struct TrackContext {
pub uri: String,
pub uid: String,
pub artist_uri: String,
pub album_uri: String,
#[serde(rename = "original_gid")]
pub gid: String,
pub metadata: MetadataContext,
pub name: String,
}
#[allow(dead_code)]
#[derive(Deserialize, Debug, Default, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ArtistContext {
#[serde(rename = "artistName")]
artist_name: String,
#[serde(rename = "imageUri")]
image_uri: String,
#[serde(rename = "artistUri")]
artist_uri: String,
}
#[allow(dead_code)]
#[derive(Deserialize, Debug, Default, Clone)]
pub struct MetadataContext {
album_title: String,
artist_name: String,
artist_uri: String,
image_url: String,
title: String,
#[serde(deserialize_with = "bool_from_string")]
is_explicit: bool,
#[serde(deserialize_with = "bool_from_string")]
is_promotional: bool,
decision_id: String,
}
#[allow(dead_code)]
#[derive(Deserialize, Debug, Default, Clone)]
pub struct SubtitleContext {
name: String,
uri: String,
}
fn bool_from_string<'de, D>(de: D) -> Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
match String::deserialize(de)?.as_ref() {
"true" => Ok(true),
"false" => Ok(false),
other => Err(D::Error::invalid_value(
Unexpected::Str(other),
&"true or false",
)),
}
}
#[allow(non_snake_case)]
fn deserialize_protobuf_TrackRef<'d, D>(de: D) -> Result<Vec<TrackRef>, D::Error>
where
D: serde::Deserializer<'d>,
{
let v: Vec<TrackContext> = serde::Deserialize::deserialize(de)?;
v.iter()
.map(|v| {
let mut t = TrackRef::new();
// This has got to be the most round about way of doing this.
t.set_gid(
SpotifyId::from_base62(&v.gid)
.map_err(|_| {
D::Error::invalid_value(
Unexpected::Str(&v.gid),
&"a Base-62 encoded Spotify ID",
)
})?
.to_raw()
.to_vec(),
);
t.set_uri(v.uri.to_owned());
Ok(t)
})
.collect::<Result<Vec<TrackRef>, D::Error>>()
}

View file

@ -5,6 +5,6 @@ use librespot_core as core;
use librespot_playback as playback; use librespot_playback as playback;
use librespot_protocol as protocol; use librespot_protocol as protocol;
pub mod config; mod model;
pub mod context;
pub mod spirc; pub mod spirc;
pub mod state;

188
connect/src/model.rs Normal file
View file

@ -0,0 +1,188 @@
use crate::state::ConnectState;
use librespot_core::dealer::protocol::SkipTo;
use librespot_protocol::player::Context;
use std::fmt::{Display, Formatter};
use std::hash::{Hash, Hasher};
#[derive(Debug)]
pub struct SpircLoadCommand {
pub context_uri: String,
/// Whether the given tracks should immediately start playing, or just be initially loaded.
pub start_playing: bool,
pub seek_to: u32,
pub shuffle: bool,
pub repeat: bool,
pub repeat_track: bool,
pub playing_track: PlayingTrack,
}
#[derive(Debug)]
pub enum PlayingTrack {
Index(u32),
Uri(String),
Uid(String),
}
impl From<SkipTo> for PlayingTrack {
fn from(value: SkipTo) -> Self {
// order of checks is important, as the index can be 0, but still has an uid or uri provided,
// so we only use the index as last resort
if let Some(uri) = value.track_uri {
PlayingTrack::Uri(uri)
} else if let Some(uid) = value.track_uid {
PlayingTrack::Uid(uid)
} else {
PlayingTrack::Index(value.track_index.unwrap_or_else(|| {
warn!("SkipTo didn't provided any point to skip to, falling back to index 0");
0
}))
}
}
}
#[derive(Debug)]
pub(super) enum SpircPlayStatus {
Stopped,
LoadingPlay {
position_ms: u32,
},
LoadingPause {
position_ms: u32,
},
Playing {
nominal_start_time: i64,
preloading_of_next_track_triggered: bool,
},
Paused {
position_ms: u32,
preloading_of_next_track_triggered: bool,
},
}
#[derive(Debug, Clone)]
pub(super) struct ResolveContext {
context: Context,
fallback: Option<String>,
autoplay: bool,
/// if `true` updates the entire context, otherwise only fills the context from the next
/// retrieve page, it is usually used when loading the next page of an already established context
///
/// like for example:
/// - playing an artists profile
update: bool,
}
impl ResolveContext {
pub fn from_uri(uri: impl Into<String>, fallback: impl Into<String>, autoplay: bool) -> Self {
let fallback_uri = fallback.into();
Self {
context: Context {
uri: uri.into(),
..Default::default()
},
fallback: (!fallback_uri.is_empty()).then_some(fallback_uri),
autoplay,
update: true,
}
}
pub fn from_context(context: Context, autoplay: bool) -> Self {
Self {
context,
fallback: None,
autoplay,
update: true,
}
}
// expected page_url: hm://artistplaycontext/v1/page/spotify/album/5LFzwirfFwBKXJQGfwmiMY/km_artist
pub fn from_page_url(page_url: String) -> Self {
let split = if let Some(rest) = page_url.strip_prefix("hm://") {
rest.split('/')
} else {
warn!("page_url didn't started with hm://. got page_url: {page_url}");
page_url.split('/')
};
let uri = split
.skip_while(|s| s != &"spotify")
.take(3)
.collect::<Vec<&str>>()
.join(":");
trace!("created an ResolveContext from page_url <{page_url}> as uri <{uri}>");
Self {
context: Context {
uri,
..Default::default()
},
fallback: None,
update: false,
autoplay: false,
}
}
/// the uri which should be used to resolve the context, might not be the context uri
pub fn resolve_uri(&self) -> Option<&String> {
// it's important to call this always, or at least for every ResolveContext
// otherwise we might not even check if we need to fallback and just use the fallback uri
ConnectState::get_context_uri_from_context(&self.context)
.and_then(|s| (!s.is_empty()).then_some(s))
.or(self.fallback.as_ref())
}
/// the actual context uri
pub fn context_uri(&self) -> &str {
&self.context.uri
}
pub fn autoplay(&self) -> bool {
self.autoplay
}
pub fn update(&self) -> bool {
self.update
}
}
impl Display for ResolveContext {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"resolve_uri: <{:?}>, context_uri: <{}>, autoplay: <{}>, update: <{}>",
self.resolve_uri(),
self.context.uri,
self.autoplay,
self.update
)
}
}
impl PartialEq for ResolveContext {
fn eq(&self, other: &Self) -> bool {
let eq_context = self.context_uri() == other.context_uri();
let eq_resolve = self.resolve_uri() == other.resolve_uri();
let eq_autoplay = self.autoplay == other.autoplay;
let eq_update = self.update == other.update;
eq_context && eq_resolve && eq_autoplay && eq_update
}
}
impl Eq for ResolveContext {}
impl Hash for ResolveContext {
fn hash<H: Hasher>(&self, state: &mut H) {
self.context_uri().hash(state);
self.resolve_uri().hash(state);
self.autoplay.hash(state);
self.update.hash(state);
}
}
impl From<ResolveContext> for Context {
fn from(value: ResolveContext) -> Self {
value.context
}
}

File diff suppressed because it is too large Load diff

448
connect/src/state.rs Normal file
View file

@ -0,0 +1,448 @@
pub(super) mod context;
mod handle;
pub mod metadata;
mod options;
pub(super) mod provider;
mod restrictions;
mod tracks;
mod transfer;
use crate::model::SpircPlayStatus;
use crate::state::{
context::{ContextType, ResetContext, StateContext},
provider::{IsProvider, Provider},
};
use librespot_core::{
config::DeviceType, date::Date, dealer::protocol::Request, spclient::SpClientResult, version,
Error, Session,
};
use librespot_protocol::connect::{
Capabilities, Device, DeviceInfo, MemberType, PutStateReason, PutStateRequest,
};
use librespot_protocol::player::{
ContextIndex, ContextPage, ContextPlayerOptions, PlayOrigin, PlayerState, ProvidedTrack,
Suppressions,
};
use log::LevelFilter;
use protobuf::{EnumOrUnknown, MessageField};
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use thiserror::Error;
// these limitations are essential, otherwise to many tracks will overload the web-player
const SPOTIFY_MAX_PREV_TRACKS_SIZE: usize = 10;
const SPOTIFY_MAX_NEXT_TRACKS_SIZE: usize = 80;
#[derive(Debug, Error)]
pub enum StateError {
#[error("the current track couldn't be resolved from the transfer state")]
CouldNotResolveTrackFromTransfer,
#[error("message field {0} was not available")]
MessageFieldNone(String),
#[error("context is not available. type: {0:?}")]
NoContext(ContextType),
#[error("could not find track {0:?} in context of {1}")]
CanNotFindTrackInContext(Option<usize>, usize),
#[error("currently {action} is not allowed because {reason}")]
CurrentlyDisallowed { action: String, reason: String },
#[error("the provided context has no tracks")]
ContextHasNoTracks,
#[error("playback of local files is not supported")]
UnsupportedLocalPlayBack,
#[error("track uri <{0}> contains invalid characters")]
InvalidTrackUri(String),
}
impl From<StateError> for Error {
fn from(err: StateError) -> Self {
use StateError::*;
match err {
CouldNotResolveTrackFromTransfer
| MessageFieldNone(_)
| NoContext(_)
| CanNotFindTrackInContext(_, _)
| ContextHasNoTracks
| InvalidTrackUri(_) => Error::failed_precondition(err),
CurrentlyDisallowed { .. } | UnsupportedLocalPlayBack => Error::unavailable(err),
}
}
}
#[derive(Debug, Clone)]
pub struct ConnectStateConfig {
pub session_id: String,
pub initial_volume: u32,
pub name: String,
pub device_type: DeviceType,
pub volume_steps: i32,
pub is_group: bool,
}
impl Default for ConnectStateConfig {
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,
}
}
}
#[derive(Default, Debug)]
pub struct ConnectState {
/// the entire state that is updated to the remote server
request: PutStateRequest,
unavailable_uri: Vec<String>,
pub active_since: Option<SystemTime>,
queue_count: u64,
// separation is necessary because we could have already loaded
// the autoplay context but are still playing from the default context
/// to update the active context use [switch_active_context](ConnectState::set_active_context)
pub active_context: ContextType,
pub fill_up_context: ContextType,
/// the context from which we play, is used to top up prev and next tracks
pub context: Option<StateContext>,
/// upcoming contexts, directly provided by the context-resolver
next_contexts: Vec<ContextPage>,
/// a context to keep track of our shuffled context,
/// should be only available when `player.option.shuffling_context` is true
shuffle_context: Option<StateContext>,
/// a context to keep track of the autoplay context
autoplay_context: Option<StateContext>,
}
impl ConnectState {
pub fn new(cfg: ConnectStateConfig, session: &Session) -> Self {
let device_info = DeviceInfo {
can_play: true,
volume: cfg.initial_volume,
name: cfg.name,
device_id: session.device_id().to_string(),
device_type: EnumOrUnknown::new(cfg.device_type.into()),
device_software_version: version::SEMVER.to_string(),
spirc_version: version::SPOTIFY_SPIRC_VERSION.to_string(),
client_id: session.client_id(),
is_group: cfg.is_group,
capabilities: MessageField::some(Capabilities {
volume_steps: cfg.volume_steps,
hidden: false, // could be exposed later to only observe the playback
gaia_eq_connect_id: true,
can_be_player: true,
needs_full_player_state: true,
is_observable: true,
is_controllable: true,
supports_gzip_pushes: true,
// todo: enable after logout handling is implemented, see spirc logout_request
supports_logout: false,
supported_types: vec!["audio/episode".into(), "audio/track".into()],
supports_playlist_v2: true,
supports_transfer_command: true,
supports_command_request: true,
supports_set_options_command: true,
is_voice_enabled: false,
restrict_to_local: false,
disable_volume: false,
connect_disabled: false,
supports_rename: false,
supports_external_episodes: false,
supports_set_backend_metadata: false,
supports_hifi: MessageField::none(),
command_acks: true,
..Default::default()
}),
..Default::default()
};
let mut state = Self {
request: PutStateRequest {
member_type: EnumOrUnknown::new(MemberType::CONNECT_STATE),
put_state_reason: EnumOrUnknown::new(PutStateReason::PLAYER_STATE_CHANGED),
device: MessageField::some(Device {
device_info: MessageField::some(device_info),
player_state: MessageField::some(PlayerState {
session_id: cfg.session_id,
..Default::default()
}),
..Default::default()
}),
..Default::default()
},
..Default::default()
};
state.reset();
state
}
fn reset(&mut self) {
self.set_active(false);
self.queue_count = 0;
// preserve the session_id
let session_id = self.player().session_id.clone();
self.device_mut().player_state = MessageField::some(PlayerState {
session_id,
is_system_initiated: true,
playback_speed: 1.,
play_origin: MessageField::some(PlayOrigin::new()),
suppressions: MessageField::some(Suppressions::new()),
options: MessageField::some(ContextPlayerOptions::new()),
// + 1, so that we have a buffer where we can swap elements
prev_tracks: Vec::with_capacity(SPOTIFY_MAX_PREV_TRACKS_SIZE + 1),
next_tracks: Vec::with_capacity(SPOTIFY_MAX_NEXT_TRACKS_SIZE + 1),
..Default::default()
});
}
fn device_mut(&mut self) -> &mut Device {
self.request
.device
.as_mut()
.expect("the request is always available")
}
fn player_mut(&mut self) -> &mut PlayerState {
self.device_mut()
.player_state
.as_mut()
.expect("the player_state has to be always given")
}
pub fn device_info(&self) -> &DeviceInfo {
&self.request.device.device_info
}
pub fn player(&self) -> &PlayerState {
&self.request.device.player_state
}
pub fn is_active(&self) -> bool {
self.request.is_active
}
pub fn set_volume(&mut self, volume: u32) {
self.device_mut()
.device_info
.as_mut()
.expect("the device_info has to be always given")
.volume = volume;
}
pub fn set_last_command(&mut self, command: Request) {
self.request.last_command_message_id = command.message_id;
self.request.last_command_sent_by_device_id = command.sent_by_device_id;
}
pub fn set_now(&mut self, now: u64) {
self.request.client_side_timestamp = now;
if let Some(active_since) = self.active_since {
if let Ok(active_since_duration) = active_since.duration_since(UNIX_EPOCH) {
match active_since_duration.as_millis().try_into() {
Ok(active_since_ms) => self.request.started_playing_at = active_since_ms,
Err(why) => warn!("couldn't update active since because {why}"),
}
}
}
}
pub fn set_active(&mut self, value: bool) {
if value {
if self.request.is_active {
return;
}
self.request.is_active = true;
self.active_since = Some(SystemTime::now())
} else {
self.request.is_active = false;
self.active_since = None
}
}
pub fn set_origin(&mut self, origin: PlayOrigin) {
self.player_mut().play_origin = MessageField::some(origin)
}
pub fn set_session_id(&mut self, session_id: String) {
self.player_mut().session_id = session_id;
}
pub(crate) fn set_status(&mut self, status: &SpircPlayStatus) {
let player = self.player_mut();
player.is_paused = matches!(
status,
SpircPlayStatus::LoadingPause { .. }
| SpircPlayStatus::Paused { .. }
| SpircPlayStatus::Stopped
);
// desktop and mobile require all 'states' set to true, when we are paused,
// otherwise the play button (desktop) is grayed out or the preview (mobile) can't be opened
player.is_buffering = player.is_paused
|| matches!(
status,
SpircPlayStatus::LoadingPause { .. } | SpircPlayStatus::LoadingPlay { .. }
);
player.is_playing = player.is_paused
|| matches!(
status,
SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::Playing { .. }
);
debug!(
"updated connect play status playing: {}, paused: {}, buffering: {}",
player.is_playing, player.is_paused, player.is_buffering
);
self.update_restrictions()
}
/// index is 0 based, so the first track is index 0
pub fn update_current_index(&mut self, f: impl Fn(&mut ContextIndex)) {
match self.player_mut().index.as_mut() {
Some(player_index) => f(player_index),
None => {
let mut new_index = ContextIndex::new();
f(&mut new_index);
self.player_mut().index = MessageField::some(new_index)
}
}
}
pub fn update_position(&mut self, position_ms: u32, timestamp: i64) {
let player = self.player_mut();
player.position_as_of_timestamp = position_ms.into();
player.timestamp = timestamp;
}
pub fn update_duration(&mut self, duration: u32) {
self.player_mut().duration = duration.into()
}
pub fn update_queue_revision(&mut self) {
let mut state = DefaultHasher::new();
self.next_tracks()
.iter()
.for_each(|t| t.uri.hash(&mut state));
self.player_mut().queue_revision = state.finish().to_string()
}
pub fn reset_playback_to_position(&mut self, new_index: Option<usize>) -> Result<(), Error> {
let new_index = new_index.unwrap_or(0);
self.update_current_index(|i| i.track = new_index as u32);
self.update_context_index(self.active_context, new_index + 1)?;
if !self.current_track(|t| t.is_queue()) {
self.set_current_track(new_index)?;
}
self.clear_prev_track();
if new_index > 0 {
let context = self.get_context(&self.active_context)?;
let before_new_track = context.tracks.len() - new_index;
self.player_mut().prev_tracks = context
.tracks
.iter()
.rev()
.skip(before_new_track)
.take(SPOTIFY_MAX_PREV_TRACKS_SIZE)
.rev()
.cloned()
.collect();
debug!("has {} prev tracks", self.prev_tracks().len())
}
self.clear_next_tracks(true);
self.fill_up_next_tracks()?;
self.update_restrictions();
Ok(())
}
fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, uri: &str) {
if track.uri == uri {
debug!("Marked <{}:{}> as unavailable", track.provider, track.uri);
track.set_provider(Provider::Unavailable);
}
}
pub fn update_position_in_relation(&mut self, timestamp: i64) {
let player = self.player_mut();
let diff = timestamp - player.timestamp;
player.position_as_of_timestamp += diff;
if log::max_level() >= LevelFilter::Debug {
let pos = Duration::from_millis(player.position_as_of_timestamp as u64);
let time = Date::from_timestamp_ms(timestamp)
.map(|d| d.time().to_string())
.unwrap_or_else(|_| timestamp.to_string());
let sec = pos.as_secs();
let (min, sec) = (sec / 60, sec % 60);
debug!("update position to {min}:{sec:0>2} at {time}");
}
player.timestamp = timestamp;
}
pub async fn became_inactive(&mut self, session: &Session) -> SpClientResult {
self.reset();
self.reset_context(ResetContext::Completely);
session.spclient().put_connect_state_inactive(false).await
}
async fn send_with_reason(
&mut self,
session: &Session,
reason: PutStateReason,
) -> SpClientResult {
let prev_reason = self.request.put_state_reason;
self.request.put_state_reason = EnumOrUnknown::new(reason);
let res = self.send_state(session).await;
self.request.put_state_reason = prev_reason;
res
}
/// Notifies the remote server about a new device
pub async fn notify_new_device_appeared(&mut self, session: &Session) -> SpClientResult {
self.send_with_reason(session, PutStateReason::NEW_DEVICE)
.await
}
/// Notifies the remote server about a new volume
pub async fn notify_volume_changed(&mut self, session: &Session) -> SpClientResult {
self.send_with_reason(session, PutStateReason::VOLUME_CHANGED)
.await
}
/// Sends the connect state for the connect session to the remote server
pub async fn send_state(&self, session: &Session) -> SpClientResult {
session
.spclient()
.put_connect_state_request(&self.request)
.await
}
}

View file

@ -0,0 +1,415 @@
use crate::state::{metadata::Metadata, provider::Provider, ConnectState, StateError};
use librespot_core::{Error, SpotifyId};
use librespot_protocol::player::{
Context, ContextIndex, ContextPage, ContextTrack, ProvidedTrack, Restrictions,
};
use protobuf::MessageField;
use std::collections::HashMap;
use uuid::Uuid;
const LOCAL_FILES_IDENTIFIER: &str = "spotify:local-files";
const SEARCH_IDENTIFIER: &str = "spotify:search";
#[derive(Debug, Clone)]
pub struct StateContext {
pub tracks: Vec<ProvidedTrack>,
pub metadata: HashMap<String, String>,
pub restrictions: Option<Restrictions>,
/// is used to keep track which tracks are already loaded into the next_tracks
pub index: ContextIndex,
}
#[derive(Default, Debug, Copy, Clone)]
pub enum ContextType {
#[default]
Default,
Shuffle,
Autoplay,
}
pub enum LoadNext {
Done,
PageUrl(String),
Empty,
}
#[derive(Debug)]
pub enum UpdateContext {
Default,
Autoplay,
}
pub enum ResetContext<'s> {
Completely,
DefaultIndex,
WhenDifferent(&'s str),
}
impl ConnectState {
pub fn find_index_in_context<F: Fn(&ProvidedTrack) -> bool>(
context: Option<&StateContext>,
f: F,
) -> Result<usize, StateError> {
let ctx = context
.as_ref()
.ok_or(StateError::NoContext(ContextType::Default))?;
ctx.tracks
.iter()
.position(f)
.ok_or(StateError::CanNotFindTrackInContext(None, ctx.tracks.len()))
}
pub(super) fn get_context(&self, ty: &ContextType) -> Result<&StateContext, StateError> {
match ty {
ContextType::Default => self.context.as_ref(),
ContextType::Shuffle => self.shuffle_context.as_ref(),
ContextType::Autoplay => self.autoplay_context.as_ref(),
}
.ok_or(StateError::NoContext(*ty))
}
pub fn context_uri(&self) -> &String {
&self.player().context_uri
}
pub fn reset_context(&mut self, mut reset_as: ResetContext) {
self.set_active_context(ContextType::Default);
self.fill_up_context = ContextType::Default;
if matches!(reset_as, ResetContext::WhenDifferent(ctx) if self.context_uri() != ctx) {
reset_as = ResetContext::Completely
}
self.shuffle_context = None;
match reset_as {
ResetContext::Completely => {
self.context = None;
self.autoplay_context = None;
self.next_contexts.clear();
}
ResetContext::WhenDifferent(_) => debug!("context didn't change, no reset"),
ResetContext::DefaultIndex => {
for ctx in [self.context.as_mut(), self.autoplay_context.as_mut()]
.into_iter()
.flatten()
{
ctx.index.track = 0;
ctx.index.page = 0;
}
}
}
self.update_restrictions()
}
pub fn get_context_uri_from_context(context: &Context) -> Option<&String> {
if !context.uri.starts_with(SEARCH_IDENTIFIER) {
return Some(&context.uri);
}
context
.pages
.first()
.and_then(|p| p.tracks.first().map(|t| &t.uri))
}
pub fn set_active_context(&mut self, new_context: ContextType) {
self.active_context = new_context;
let ctx = match self.get_context(&new_context) {
Err(why) => {
debug!("couldn't load context info because: {why}");
return;
}
Ok(ctx) => ctx,
};
let mut restrictions = ctx.restrictions.clone();
let metadata = ctx.metadata.clone();
let player = self.player_mut();
player.context_metadata.clear();
player.restrictions.clear();
if let Some(restrictions) = restrictions.take() {
player.restrictions = MessageField::some(restrictions);
}
for (key, value) in metadata {
player.context_metadata.insert(key, value);
}
}
pub fn update_context(&mut self, mut context: Context, ty: UpdateContext) -> Result<(), Error> {
if context.pages.iter().all(|p| p.tracks.is_empty()) {
error!("context didn't have any tracks: {context:#?}");
return Err(StateError::ContextHasNoTracks.into());
} else if context.uri.starts_with(LOCAL_FILES_IDENTIFIER) {
return Err(StateError::UnsupportedLocalPlayBack.into());
}
if matches!(ty, UpdateContext::Default) {
self.next_contexts.clear();
}
let mut first_page = None;
for page in context.pages {
if first_page.is_none() && !page.tracks.is_empty() {
first_page = Some(page);
} else {
self.next_contexts.push(page)
}
}
let page = match first_page {
None => Err(StateError::ContextHasNoTracks)?,
Some(p) => p,
};
let prev_context = match ty {
UpdateContext::Default => self.context.as_ref(),
UpdateContext::Autoplay => self.autoplay_context.as_ref(),
};
debug!(
"updated context {ty:?} from <{}> ({} tracks) to <{}> ({} tracks)",
self.context_uri(),
prev_context
.map(|c| c.tracks.len().to_string())
.unwrap_or_else(|| "-".to_string()),
context.uri,
page.tracks.len()
);
match ty {
UpdateContext::Default => {
let mut new_context = self.state_context_from_page(
page,
context.restrictions.take(),
Some(&context.uri),
None,
);
// when we update the same context, we should try to preserve the previous position
// otherwise we might load the entire context twice
if !self.context_uri().contains(SEARCH_IDENTIFIER)
&& self.context_uri() == &context.uri
{
match Self::find_index_in_context(Some(&new_context), |t| {
self.current_track(|t| &t.uri) == &t.uri
}) {
Ok(new_pos) => {
debug!("found new index of current track, updating new_context index to {new_pos}");
new_context.index.track = (new_pos + 1) as u32;
}
// the track isn't anymore in the context
Err(_) if matches!(self.active_context, ContextType::Default) => {
warn!("current track was removed, setting pos to last known index");
new_context.index.track = self.player().index.track
}
Err(_) => {}
}
// enforce reloading the context
self.clear_next_tracks(true);
}
self.context = Some(new_context);
if !context.url.contains(SEARCH_IDENTIFIER) {
self.player_mut().context_url = context.url;
} else {
self.player_mut().context_url.clear()
}
self.player_mut().context_uri = context.uri;
}
UpdateContext::Autoplay => {
self.autoplay_context = Some(self.state_context_from_page(
page,
context.restrictions.take(),
Some(&context.uri),
Some(Provider::Autoplay),
))
}
}
Ok(())
}
fn state_context_from_page(
&mut self,
page: ContextPage,
restrictions: Option<Restrictions>,
new_context_uri: Option<&str>,
provider: Option<Provider>,
) -> StateContext {
let new_context_uri = new_context_uri.unwrap_or(self.context_uri());
let tracks = page
.tracks
.iter()
.flat_map(|track| {
match self.context_to_provided_track(track, Some(new_context_uri), provider.clone())
{
Ok(t) => Some(t),
Err(why) => {
error!("couldn't convert {track:#?} into ProvidedTrack: {why}");
None
}
}
})
.collect::<Vec<_>>();
StateContext {
tracks,
restrictions,
metadata: page.metadata,
index: ContextIndex::new(),
}
}
pub fn merge_context(&mut self, context: Option<Context>) -> Option<()> {
let mut context = context?;
if self.context_uri() != &context.uri {
return None;
}
let current_context = self.context.as_mut()?;
let new_page = context.pages.pop()?;
for new_track in new_page.tracks {
if new_track.uri.is_empty() {
continue;
}
if let Ok(position) =
Self::find_index_in_context(Some(current_context), |t| t.uri == new_track.uri)
{
let context_track = current_context.tracks.get_mut(position)?;
for (key, value) in new_track.metadata {
warn!("merging metadata {key} {value}");
context_track.metadata.insert(key, value);
}
// the uid provided from another context might be actual uid of an item
if !new_track.uid.is_empty() {
context_track.uid = new_track.uid;
}
}
}
Some(())
}
pub(super) fn update_context_index(
&mut self,
ty: ContextType,
new_index: usize,
) -> Result<(), StateError> {
let context = match ty {
ContextType::Default => self.context.as_mut(),
ContextType::Shuffle => self.shuffle_context.as_mut(),
ContextType::Autoplay => self.autoplay_context.as_mut(),
}
.ok_or(StateError::NoContext(ty))?;
context.index.track = new_index as u32;
Ok(())
}
pub fn context_to_provided_track(
&self,
ctx_track: &ContextTrack,
context_uri: Option<&str>,
provider: Option<Provider>,
) -> Result<ProvidedTrack, Error> {
let id = if !ctx_track.uri.is_empty() {
if ctx_track.uri.contains(['?', '%']) {
Err(StateError::InvalidTrackUri(ctx_track.uri.clone()))?
}
SpotifyId::from_uri(&ctx_track.uri)?
} else if !ctx_track.gid.is_empty() {
SpotifyId::from_raw(&ctx_track.gid)?
} else {
Err(StateError::InvalidTrackUri(String::new()))?
};
let provider = if self.unavailable_uri.contains(&ctx_track.uri) {
Provider::Unavailable
} else {
provider.unwrap_or(Provider::Context)
};
// assumption: the uid is used as unique-id of any item
// - queue resorting is done by each client and orients itself by the given uid
// - if no uid is present, resorting doesn't work or behaves not as intended
let uid = if ctx_track.uid.is_empty() {
// so setting providing a unique id should allow to resort the queue
Uuid::new_v4().as_simple().to_string()
} else {
ctx_track.uid.to_string()
};
let mut metadata = HashMap::new();
for (k, v) in &ctx_track.metadata {
metadata.insert(k.to_string(), v.to_string());
}
let mut track = ProvidedTrack {
uri: id.to_uri()?.replace("unknown", "track"),
uid,
metadata,
provider: provider.to_string(),
..Default::default()
};
if let Some(context_uri) = context_uri {
track.set_context_uri(context_uri.to_string());
track.set_entity_uri(context_uri.to_string());
}
if matches!(provider, Provider::Autoplay) {
track.set_autoplay(true)
}
Ok(track)
}
pub fn fill_context_from_page(&mut self, page: ContextPage) -> Result<(), Error> {
let context = self.state_context_from_page(page, None, None, None);
let ctx = self
.context
.as_mut()
.ok_or(StateError::NoContext(ContextType::Default))?;
for t in context.tracks {
ctx.tracks.push(t)
}
Ok(())
}
pub fn try_load_next_context(&mut self) -> Result<LoadNext, Error> {
let next = match self.next_contexts.first() {
None => return Ok(LoadNext::Empty),
Some(_) => self.next_contexts.remove(0),
};
if next.tracks.is_empty() {
if next.page_url.is_empty() {
Err(StateError::NoContext(ContextType::Default))?
}
self.update_current_index(|i| i.page += 1);
return Ok(LoadNext::PageUrl(next.page_url));
}
self.fill_context_from_page(next)?;
self.fill_up_next_tracks()?;
Ok(LoadNext::Done)
}
}

View file

@ -0,0 +1,65 @@
use crate::state::{context::ResetContext, ConnectState};
use librespot_core::{dealer::protocol::SetQueueCommand, Error};
use protobuf::MessageField;
impl ConnectState {
pub fn handle_shuffle(&mut self, shuffle: bool) -> Result<(), Error> {
self.set_shuffle(shuffle);
if shuffle {
return self.shuffle();
}
self.reset_context(ResetContext::DefaultIndex);
if self.current_track(MessageField::is_none) {
return Ok(());
}
let ctx = self.context.as_ref();
let current_index =
ConnectState::find_index_in_context(ctx, |c| self.current_track(|t| c.uri == t.uri))?;
self.reset_playback_to_position(Some(current_index))
}
pub fn handle_set_queue(&mut self, set_queue: SetQueueCommand) {
self.set_next_tracks(set_queue.next_tracks);
self.set_prev_tracks(set_queue.prev_tracks);
self.update_queue_revision();
}
pub fn handle_set_repeat(
&mut self,
context: Option<bool>,
track: Option<bool>,
) -> Result<(), Error> {
// doesn't need any state updates, because it should only change how the current song is played
if let Some(track) = track {
self.set_repeat_track(track);
}
if matches!(context, Some(context) if self.repeat_context() == context) || context.is_none()
{
return Ok(());
}
if let Some(context) = context {
self.set_repeat_context(context);
}
if self.repeat_context() {
self.set_shuffle(false);
self.reset_context(ResetContext::DefaultIndex);
let ctx = self.context.as_ref();
let current_track = ConnectState::find_index_in_context(ctx, |t| {
self.current_track(|t| &t.uri) == &t.uri
})?;
self.reset_playback_to_position(Some(current_track))
} else {
self.update_restrictions();
Ok(())
}
}
}

View file

@ -0,0 +1,84 @@
use librespot_protocol::player::{ContextTrack, ProvidedTrack};
use std::collections::HashMap;
const CONTEXT_URI: &str = "context_uri";
const ENTITY_URI: &str = "entity_uri";
const IS_QUEUED: &str = "is_queued";
const IS_AUTOPLAY: &str = "autoplay.is_autoplay";
const HIDDEN: &str = "hidden";
const ITERATION: &str = "iteration";
#[allow(dead_code)]
pub trait Metadata {
fn metadata(&self) -> &HashMap<String, String>;
fn metadata_mut(&mut self) -> &mut HashMap<String, String>;
fn is_from_queue(&self) -> bool {
matches!(self.metadata().get(IS_QUEUED), Some(is_queued) if is_queued.eq("true"))
}
fn is_from_autoplay(&self) -> bool {
matches!(self.metadata().get(IS_AUTOPLAY), Some(is_autoplay) if is_autoplay.eq("true"))
}
fn is_hidden(&self) -> bool {
matches!(self.metadata().get(HIDDEN), Some(is_hidden) if is_hidden.eq("true"))
}
fn get_context_uri(&self) -> Option<&String> {
self.metadata().get(CONTEXT_URI)
}
fn get_iteration(&self) -> Option<&String> {
self.metadata().get(ITERATION)
}
fn set_queued(&mut self, queued: bool) {
self.metadata_mut()
.insert(IS_QUEUED.to_string(), queued.to_string());
}
fn set_autoplay(&mut self, autoplay: bool) {
self.metadata_mut()
.insert(IS_AUTOPLAY.to_string(), autoplay.to_string());
}
fn set_hidden(&mut self, hidden: bool) {
self.metadata_mut()
.insert(HIDDEN.to_string(), hidden.to_string());
}
fn set_context_uri(&mut self, uri: String) {
self.metadata_mut().insert(CONTEXT_URI.to_string(), uri);
}
fn set_entity_uri(&mut self, uri: String) {
self.metadata_mut().insert(ENTITY_URI.to_string(), uri);
}
fn add_iteration(&mut self, iter: i64) {
self.metadata_mut()
.insert(ITERATION.to_string(), iter.to_string());
}
}
impl Metadata for ContextTrack {
fn metadata(&self) -> &HashMap<String, String> {
&self.metadata
}
fn metadata_mut(&mut self) -> &mut HashMap<String, String> {
&mut self.metadata
}
}
impl Metadata for ProvidedTrack {
fn metadata(&self) -> &HashMap<String, String> {
&self.metadata
}
fn metadata_mut(&mut self) -> &mut HashMap<String, String> {
&mut self.metadata
}
}

View file

@ -0,0 +1,88 @@
use crate::state::context::ContextType;
use crate::state::{ConnectState, StateError};
use librespot_core::Error;
use librespot_protocol::player::{ContextIndex, ContextPlayerOptions};
use protobuf::MessageField;
use rand::prelude::SliceRandom;
impl ConnectState {
fn add_options_if_empty(&mut self) {
if self.player().options.is_none() {
self.player_mut().options = MessageField::some(ContextPlayerOptions::new())
}
}
pub fn set_repeat_context(&mut self, repeat: bool) {
self.add_options_if_empty();
if let Some(options) = self.player_mut().options.as_mut() {
options.repeating_context = repeat;
}
}
pub fn set_repeat_track(&mut self, repeat: bool) {
self.add_options_if_empty();
if let Some(options) = self.player_mut().options.as_mut() {
options.repeating_track = repeat;
}
}
pub fn set_shuffle(&mut self, shuffle: bool) {
self.add_options_if_empty();
if let Some(options) = self.player_mut().options.as_mut() {
options.shuffling_context = shuffle;
}
}
pub fn shuffle(&mut self) -> Result<(), Error> {
if let Some(reason) = self
.player()
.restrictions
.disallow_toggling_shuffle_reasons
.first()
{
Err(StateError::CurrentlyDisallowed {
action: "shuffle".to_string(),
reason: reason.clone(),
})?
}
self.clear_prev_track();
self.clear_next_tracks(true);
let current_uri = self.current_track(|t| &t.uri);
let ctx = self
.context
.as_ref()
.ok_or(StateError::NoContext(ContextType::Default))?;
let current_track = Self::find_index_in_context(Some(ctx), |t| &t.uri == current_uri)?;
let mut shuffle_context = ctx.clone();
// we don't need to include the current track, because it is already being played
shuffle_context.tracks.remove(current_track);
let mut rng = rand::thread_rng();
shuffle_context.tracks.shuffle(&mut rng);
shuffle_context.index = ContextIndex::new();
self.shuffle_context = Some(shuffle_context);
self.set_active_context(ContextType::Shuffle);
self.fill_up_context = ContextType::Shuffle;
self.fill_up_next_tracks()?;
Ok(())
}
pub fn shuffling_context(&self) -> bool {
self.player().options.shuffling_context
}
pub fn repeat_context(&self) -> bool {
self.player().options.repeating_context
}
pub fn repeat_track(&self) -> bool {
self.player().options.repeating_track
}
}

View file

@ -0,0 +1,66 @@
use librespot_protocol::player::ProvidedTrack;
use std::fmt::{Display, Formatter};
// providers used by spotify
const PROVIDER_CONTEXT: &str = "context";
const PROVIDER_QUEUE: &str = "queue";
const PROVIDER_AUTOPLAY: &str = "autoplay";
// custom providers, used to identify certain states that we can't handle preemptively, yet
/// it seems like spotify just knows that the track isn't available, currently we don't have an
/// option to do the same, so we stay with the old solution for now
const PROVIDER_UNAVAILABLE: &str = "unavailable";
#[derive(Debug, Clone)]
pub enum Provider {
Context,
Queue,
Autoplay,
Unavailable,
}
impl Display for Provider {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
Provider::Context => PROVIDER_CONTEXT,
Provider::Queue => PROVIDER_QUEUE,
Provider::Autoplay => PROVIDER_AUTOPLAY,
Provider::Unavailable => PROVIDER_UNAVAILABLE,
}
)
}
}
pub trait IsProvider {
fn is_autoplay(&self) -> bool;
fn is_context(&self) -> bool;
fn is_queue(&self) -> bool;
fn is_unavailable(&self) -> bool;
fn set_provider(&mut self, provider: Provider);
}
impl IsProvider for ProvidedTrack {
fn is_autoplay(&self) -> bool {
self.provider == PROVIDER_AUTOPLAY
}
fn is_context(&self) -> bool {
self.provider == PROVIDER_CONTEXT
}
fn is_queue(&self) -> bool {
self.provider == PROVIDER_QUEUE
}
fn is_unavailable(&self) -> bool {
self.provider == PROVIDER_UNAVAILABLE
}
fn set_provider(&mut self, provider: Provider) {
self.provider = provider.to_string()
}
}

View file

@ -0,0 +1,61 @@
use crate::state::provider::IsProvider;
use crate::state::ConnectState;
use librespot_protocol::player::Restrictions;
use protobuf::MessageField;
impl ConnectState {
pub fn clear_restrictions(&mut self) {
let player = self.player_mut();
player.restrictions.clear();
player.context_restrictions.clear();
}
pub fn update_restrictions(&mut self) {
const NO_PREV: &str = "no previous tracks";
const AUTOPLAY: &str = "autoplay";
const ENDLESS_CONTEXT: &str = "endless_context";
let prev_tracks_is_empty = self.prev_tracks().is_empty();
let player = self.player_mut();
if let Some(restrictions) = player.restrictions.as_mut() {
if player.is_playing {
restrictions.disallow_pausing_reasons.clear();
restrictions.disallow_resuming_reasons = vec!["not_paused".to_string()]
}
if player.is_paused {
restrictions.disallow_resuming_reasons.clear();
restrictions.disallow_pausing_reasons = vec!["not_playing".to_string()]
}
}
if player.restrictions.is_none() {
player.restrictions = MessageField::some(Restrictions::new())
}
if let Some(restrictions) = player.restrictions.as_mut() {
if prev_tracks_is_empty {
restrictions.disallow_peeking_prev_reasons = vec![NO_PREV.to_string()];
restrictions.disallow_skipping_prev_reasons = vec![NO_PREV.to_string()];
} else {
restrictions.disallow_peeking_prev_reasons.clear();
restrictions.disallow_skipping_prev_reasons.clear();
}
if player.track.is_autoplay() {
restrictions.disallow_toggling_shuffle_reasons = vec![AUTOPLAY.to_string()];
restrictions.disallow_toggling_repeat_context_reasons = vec![AUTOPLAY.to_string()];
restrictions.disallow_toggling_repeat_track_reasons = vec![AUTOPLAY.to_string()];
} else if player.options.repeating_context {
restrictions.disallow_toggling_shuffle_reasons = vec![ENDLESS_CONTEXT.to_string()]
} else {
restrictions.disallow_toggling_shuffle_reasons.clear();
restrictions
.disallow_toggling_repeat_context_reasons
.clear();
restrictions.disallow_toggling_repeat_track_reasons.clear();
}
}
}
}

422
connect/src/state/tracks.rs Normal file
View file

@ -0,0 +1,422 @@
use crate::state::{
context::ContextType,
metadata::Metadata,
provider::{IsProvider, Provider},
ConnectState, StateError, SPOTIFY_MAX_NEXT_TRACKS_SIZE, SPOTIFY_MAX_PREV_TRACKS_SIZE,
};
use librespot_core::{Error, SpotifyId};
use librespot_protocol::player::ProvidedTrack;
use protobuf::MessageField;
// identifier used as part of the uid
pub const IDENTIFIER_DELIMITER: &str = "delimiter";
impl<'ct> ConnectState {
fn new_delimiter(iteration: i64) -> ProvidedTrack {
let mut delimiter = ProvidedTrack {
uri: format!("spotify:{IDENTIFIER_DELIMITER}"),
uid: format!("{IDENTIFIER_DELIMITER}{iteration}"),
provider: Provider::Context.to_string(),
..Default::default()
};
delimiter.set_hidden(true);
delimiter.add_iteration(iteration);
delimiter
}
fn push_prev(&mut self, prev: ProvidedTrack) {
let prev_tracks = self.prev_tracks_mut();
// add prev track, while preserving a length of 10
if prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE {
// todo: O(n), but technically only maximal O(SPOTIFY_MAX_PREV_TRACKS_SIZE) aka O(10)
let _ = prev_tracks.remove(0);
}
prev_tracks.push(prev)
}
fn get_next_track(&mut self) -> Option<ProvidedTrack> {
if self.next_tracks().is_empty() {
None
} else {
// todo: O(n), but technically only maximal O(SPOTIFY_MAX_NEXT_TRACKS_SIZE) aka O(80)
Some(self.next_tracks_mut().remove(0))
}
}
/// bottom => top, aka the last track of the list is the prev track
fn prev_tracks_mut(&mut self) -> &mut Vec<ProvidedTrack> {
&mut self.player_mut().prev_tracks
}
/// bottom => top, aka the last track of the list is the prev track
pub(super) fn prev_tracks(&self) -> &Vec<ProvidedTrack> {
&self.player().prev_tracks
}
/// top => bottom, aka the first track of the list is the next track
fn next_tracks_mut(&mut self) -> &mut Vec<ProvidedTrack> {
&mut self.player_mut().next_tracks
}
/// top => bottom, aka the first track of the list is the next track
pub(super) fn next_tracks(&self) -> &Vec<ProvidedTrack> {
&self.player().next_tracks
}
pub fn set_current_track(&mut self, index: usize) -> Result<(), Error> {
let context = self.get_context(&self.active_context)?;
let new_track = context
.tracks
.get(index)
.ok_or(StateError::CanNotFindTrackInContext(
Some(index),
context.tracks.len(),
))?;
debug!(
"set track to: {} at {} of {} tracks",
index,
new_track.uri,
context.tracks.len()
);
self.set_track(new_track.clone());
self.update_current_index(|i| i.track = index as u32);
Ok(())
}
/// Move to the next track
///
/// Updates the current track to the next track. Adds the old track
/// to prev tracks and fills up the next tracks from the current context
pub fn next_track(&mut self) -> Result<Option<u32>, Error> {
// when we skip in repeat track, we don't repeat the current track anymore
if self.repeat_track() {
self.set_repeat_track(false);
}
let old_track = self.player_mut().track.take();
if let Some(old_track) = old_track {
// only add songs from our context to our previous tracks
if old_track.is_context() || old_track.is_autoplay() {
self.push_prev(old_track)
}
}
let new_track = loop {
match self.get_next_track() {
Some(next) if next.uid.starts_with(IDENTIFIER_DELIMITER) => {
self.push_prev(next);
continue;
}
Some(next) if next.is_unavailable() => continue,
other => break other,
};
};
let new_track = match new_track {
None => return Ok(None),
Some(t) => t,
};
self.fill_up_next_tracks()?;
let update_index = if new_track.is_queue() {
None
} else if new_track.is_autoplay() {
self.set_active_context(ContextType::Autoplay);
None
} else {
let ctx = self.context.as_ref();
let new_index = Self::find_index_in_context(ctx, |c| c.uri == new_track.uri);
match new_index {
Ok(new_index) => Some(new_index as u32),
Err(why) => {
error!("didn't find the track in the current context: {why}");
None
}
}
};
if let Some(update_index) = update_index {
self.update_current_index(|i| i.track = update_index)
} else {
self.player_mut().index.clear()
}
self.set_track(new_track);
self.update_restrictions();
Ok(Some(self.player().index.track))
}
/// Move to the prev track
///
/// Updates the current track to the prev track. Adds the old track
/// to next tracks (when from the context) and fills up the prev tracks from the
/// current context
pub fn prev_track(&mut self) -> Result<Option<&MessageField<ProvidedTrack>>, Error> {
let old_track = self.player_mut().track.take();
if let Some(old_track) = old_track {
if old_track.is_context() || old_track.is_autoplay() {
// todo: O(n)
self.next_tracks_mut().insert(0, old_track);
}
}
// handle possible delimiter
if matches!(self.prev_tracks().last(), Some(prev) if prev.uid.starts_with(IDENTIFIER_DELIMITER))
{
let delimiter = self
.prev_tracks_mut()
.pop()
.expect("item that was prechecked");
let next_tracks = self.next_tracks_mut();
if next_tracks.len() >= SPOTIFY_MAX_NEXT_TRACKS_SIZE {
let _ = next_tracks.pop();
}
// todo: O(n)
next_tracks.insert(0, delimiter)
}
while self.next_tracks().len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE {
let _ = self.next_tracks_mut().pop();
}
let new_track = match self.prev_tracks_mut().pop() {
None => return Ok(None),
Some(t) => t,
};
if matches!(self.active_context, ContextType::Autoplay if new_track.is_context()) {
// transition back to default context
self.set_active_context(ContextType::Default);
}
self.fill_up_next_tracks()?;
self.set_track(new_track);
if self.player().index.track == 0 {
warn!("prev: trying to skip into negative, index update skipped")
} else {
self.update_current_index(|i| i.track -= 1)
}
self.update_restrictions();
Ok(Some(self.current_track(|t| t)))
}
pub fn current_track<F: Fn(&'ct MessageField<ProvidedTrack>) -> R, R>(
&'ct self,
access: F,
) -> R {
access(&self.player().track)
}
pub fn set_track(&mut self, track: ProvidedTrack) {
self.player_mut().track = MessageField::some(track)
}
pub fn set_next_tracks(&mut self, mut tracks: Vec<ProvidedTrack>) {
// mobile only sends a set_queue command instead of an add_to_queue command
// in addition to handling the mobile add_to_queue handling, this should also handle
// a mass queue addition
tracks
.iter_mut()
.filter(|t| t.is_from_queue())
.for_each(|t| {
t.set_provider(Provider::Queue);
// technically we could preserve the queue-uid here,
// but it seems to work without that, so we just override it
t.uid = format!("q{}", self.queue_count);
self.queue_count += 1;
});
self.player_mut().next_tracks = tracks;
}
pub fn set_prev_tracks(&mut self, tracks: Vec<ProvidedTrack>) {
self.player_mut().prev_tracks = tracks;
}
pub fn clear_prev_track(&mut self) {
self.prev_tracks_mut().clear()
}
pub fn clear_next_tracks(&mut self, keep_queued: bool) {
if !keep_queued {
self.next_tracks_mut().clear();
return;
}
// respect queued track and don't throw them out of our next played tracks
let first_non_queued_track = self
.next_tracks()
.iter()
.enumerate()
.find(|(_, track)| !track.is_queue());
if let Some((non_queued_track, _)) = first_non_queued_track {
while self.next_tracks().len() > non_queued_track
&& self.next_tracks_mut().pop().is_some()
{}
}
}
pub fn fill_up_next_tracks(&mut self) -> Result<(), StateError> {
let ctx = self.get_context(&self.fill_up_context)?;
let mut new_index = ctx.index.track as usize;
let mut iteration = ctx.index.page;
while self.next_tracks().len() < SPOTIFY_MAX_NEXT_TRACKS_SIZE {
let ctx = self.get_context(&self.fill_up_context)?;
let track = match ctx.tracks.get(new_index) {
None if self.repeat_context() => {
let delimiter = Self::new_delimiter(iteration.into());
iteration += 1;
new_index = 0;
delimiter
}
None if !matches!(self.fill_up_context, ContextType::Autoplay)
&& self.autoplay_context.is_some() =>
{
self.update_context_index(self.fill_up_context, new_index)?;
// transition to autoplay as fill up context
self.fill_up_context = ContextType::Autoplay;
new_index = self.get_context(&ContextType::Autoplay)?.index.track as usize;
// add delimiter to only display the current context
Self::new_delimiter(iteration.into())
}
None if self.autoplay_context.is_some() => {
match self
.get_context(&ContextType::Autoplay)?
.tracks
.get(new_index)
{
None => break,
Some(ct) => {
new_index += 1;
ct.clone()
}
}
}
None => break,
Some(ct) if ct.is_unavailable() => {
new_index += 1;
continue;
}
Some(ct) => {
new_index += 1;
ct.clone()
}
};
self.next_tracks_mut().push(track);
}
self.update_context_index(self.fill_up_context, new_index)?;
// the web-player needs a revision update, otherwise the queue isn't updated in the ui
self.update_queue_revision();
Ok(())
}
pub fn preview_next_track(&mut self) -> Option<SpotifyId> {
let next = if self.repeat_track() {
self.current_track(|t| &t.uri)
} else {
&self.next_tracks().first()?.uri
};
SpotifyId::from_uri(next).ok()
}
pub fn has_next_tracks(&self, min: Option<usize>) -> bool {
if let Some(min) = min {
self.next_tracks().len() >= min
} else {
!self.next_tracks().is_empty()
}
}
pub fn prev_autoplay_track_uris(&self) -> Vec<String> {
let mut prev = self
.prev_tracks()
.iter()
.flat_map(|t| t.is_autoplay().then_some(t.uri.clone()))
.collect::<Vec<_>>();
if self.current_track(|t| t.is_autoplay()) {
prev.push(self.current_track(|t| t.uri.clone()));
}
prev
}
pub fn mark_unavailable(&mut self, id: SpotifyId) -> Result<(), Error> {
let uri = id.to_uri()?;
debug!("marking {uri} as unavailable");
let next_tracks = self.next_tracks_mut();
while let Some(pos) = next_tracks.iter().position(|t| t.uri == uri) {
let _ = next_tracks.remove(pos);
}
for next_track in next_tracks {
Self::mark_as_unavailable_for_match(next_track, &uri)
}
let prev_tracks = self.prev_tracks_mut();
while let Some(pos) = prev_tracks.iter().position(|t| t.uri == uri) {
let _ = prev_tracks.remove(pos);
}
for prev_track in prev_tracks {
Self::mark_as_unavailable_for_match(prev_track, &uri)
}
self.unavailable_uri.push(uri);
self.fill_up_next_tracks()?;
self.update_queue_revision();
Ok(())
}
pub fn add_to_queue(&mut self, mut track: ProvidedTrack, rev_update: bool) {
track.uid = format!("q{}", self.queue_count);
self.queue_count += 1;
track.set_provider(Provider::Queue);
if !track.is_from_queue() {
track.set_queued(true);
}
let next_tracks = self.next_tracks_mut();
if let Some(next_not_queued_track) = next_tracks.iter().position(|t| !t.is_queue()) {
next_tracks.insert(next_not_queued_track, track);
} else {
next_tracks.push(track)
}
while next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE {
next_tracks.pop();
}
if rev_update {
self.update_queue_revision();
}
self.update_restrictions();
}
}

View file

@ -0,0 +1,146 @@
use crate::state::context::ContextType;
use crate::state::metadata::Metadata;
use crate::state::provider::{IsProvider, Provider};
use crate::state::{ConnectState, StateError};
use librespot_core::Error;
use librespot_protocol::player::{ProvidedTrack, TransferState};
use protobuf::MessageField;
impl ConnectState {
pub fn current_track_from_transfer(
&self,
transfer: &TransferState,
) -> Result<ProvidedTrack, Error> {
let track = if transfer.queue.is_playing_queue {
transfer.queue.tracks.first()
} else {
transfer.playback.current_track.as_ref()
}
.ok_or(StateError::CouldNotResolveTrackFromTransfer)?;
self.context_to_provided_track(
track,
Some(&transfer.current_session.context.uri),
transfer.queue.is_playing_queue.then_some(Provider::Queue),
)
}
/// handles the initially transferable data
pub fn handle_initial_transfer(&mut self, transfer: &mut TransferState) {
let current_context_metadata = self.context.as_ref().map(|c| c.metadata.clone());
let player = self.player_mut();
player.is_buffering = false;
if let Some(options) = transfer.options.take() {
player.options = MessageField::some(options);
}
player.is_paused = transfer.playback.is_paused;
player.is_playing = !transfer.playback.is_paused;
if transfer.playback.playback_speed != 0. {
player.playback_speed = transfer.playback.playback_speed
} else {
player.playback_speed = 1.;
}
if let Some(session) = transfer.current_session.as_mut() {
player.play_origin = session.play_origin.take().into();
player.suppressions = session.suppressions.take().into();
if let Some(mut ctx) = session.context.take() {
player.restrictions = ctx.restrictions.take().into();
for (key, value) in ctx.metadata {
player.context_metadata.insert(key, value);
}
}
}
player.context_url.clear();
player.context_uri.clear();
if let Some(metadata) = current_context_metadata {
for (key, value) in metadata {
player.context_metadata.insert(key, value);
}
}
self.clear_prev_track();
self.clear_next_tracks(false);
}
/// completes the transfer, loading the queue and updating metadata
pub fn finish_transfer(&mut self, transfer: TransferState) -> Result<(), Error> {
let track = match self.player().track.as_ref() {
None => self.current_track_from_transfer(&transfer)?,
Some(track) => track.clone(),
};
let context_ty = if self.current_track(|t| t.is_from_autoplay()) {
ContextType::Autoplay
} else {
ContextType::Default
};
self.set_active_context(context_ty);
self.fill_up_context = context_ty;
let ctx = self.get_context(&self.active_context).ok();
let current_index = if track.is_queue() {
Self::find_index_in_context(ctx, |c| c.uid == transfer.current_session.current_uid)
.map(|i| if i > 0 { i - 1 } else { i })
} else {
Self::find_index_in_context(ctx, |c| c.uri == track.uri || c.uid == track.uid)
};
debug!(
"active track is <{}> with index {current_index:?} in {:?} context, has {} tracks",
track.uri,
self.active_context,
ctx.map(|c| c.tracks.len()).unwrap_or_default()
);
if self.player().track.is_none() {
self.set_track(track);
}
let current_index = current_index.ok();
if let Some(current_index) = current_index {
self.update_current_index(|i| i.track = current_index as u32);
}
debug!(
"setting up next and prev: index is at {current_index:?} while shuffle {}",
self.shuffling_context()
);
for (i, track) in transfer.queue.tracks.iter().enumerate() {
if transfer.queue.is_playing_queue && i == 0 {
// if we are currently playing from the queue,
// don't add the first queued item, because we are currently playing that item
continue;
}
if let Ok(queued_track) = self.context_to_provided_track(
track,
Some(self.context_uri()),
Some(Provider::Queue),
) {
self.add_to_queue(queued_track, false);
}
}
if self.shuffling_context() {
self.set_current_track(current_index.unwrap_or_default())?;
self.set_shuffle(true);
self.shuffle()?;
} else {
self.reset_playback_to_position(current_index)?;
}
self.update_restrictions();
Ok(())
}
}

View file

@ -60,6 +60,8 @@ tokio-util = { version = "0.7", features = ["codec"] }
url = "2" url = "2"
uuid = { version = "1", default-features = false, features = ["fast-rng", "v4"] } uuid = { version = "1", default-features = false, features = ["fast-rng", "v4"] }
data-encoding = "2.5" data-encoding = "2.5"
flate2 = "1.0.33"
protobuf-json-mapping = "3.5"
# Eventually, this should use rustls-platform-verifier to unify the platform-specific dependencies # Eventually, this should use rustls-platform-verifier to unify the platform-specific dependencies
# but currently, hyper-proxy2 and tokio-tungstenite do not support it. # but currently, hyper-proxy2 and tokio-tungstenite do not support it.

View file

@ -1,5 +1,6 @@
use std::{fmt, path::PathBuf, str::FromStr}; use std::{fmt, path::PathBuf, str::FromStr};
use librespot_protocol::devices::DeviceType as ProtoDeviceType;
use url::Url; use url::Url;
pub(crate) const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; pub(crate) const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd";
@ -146,3 +147,29 @@ impl fmt::Display for DeviceType {
f.write_str(str) f.write_str(str)
} }
} }
impl From<DeviceType> for ProtoDeviceType {
fn from(value: DeviceType) -> Self {
match value {
DeviceType::Unknown => ProtoDeviceType::UNKNOWN,
DeviceType::Computer => ProtoDeviceType::COMPUTER,
DeviceType::Tablet => ProtoDeviceType::TABLET,
DeviceType::Smartphone => ProtoDeviceType::SMARTPHONE,
DeviceType::Speaker => ProtoDeviceType::SPEAKER,
DeviceType::Tv => ProtoDeviceType::TV,
DeviceType::Avr => ProtoDeviceType::AVR,
DeviceType::Stb => ProtoDeviceType::STB,
DeviceType::AudioDongle => ProtoDeviceType::AUDIO_DONGLE,
DeviceType::GameConsole => ProtoDeviceType::GAME_CONSOLE,
DeviceType::CastAudio => ProtoDeviceType::CAST_VIDEO,
DeviceType::CastVideo => ProtoDeviceType::CAST_AUDIO,
DeviceType::Automobile => ProtoDeviceType::AUTOMOBILE,
DeviceType::Smartwatch => ProtoDeviceType::SMARTWATCH,
DeviceType::Chromebook => ProtoDeviceType::CHROMEBOOK,
DeviceType::UnknownSpotify => ProtoDeviceType::UNKNOWN_SPOTIFY,
DeviceType::CarThing => ProtoDeviceType::CAR_THING,
DeviceType::Observer => ProtoDeviceType::OBSERVER,
DeviceType::HomeThing => ProtoDeviceType::HOME_THING,
}
}
}

View file

@ -233,8 +233,8 @@ where
Ok(message) Ok(message)
} }
async fn read_into_accumulator<'a, 'b, T: AsyncRead + Unpin>( async fn read_into_accumulator<'b, T: AsyncRead + Unpin>(
connection: &'a mut T, connection: &mut T,
size: usize, size: usize,
acc: &'b mut Vec<u8>, acc: &'b mut Vec<u8>,
) -> io::Result<&'b mut [u8]> { ) -> io::Result<&'b mut [u8]> {

174
core/src/dealer/manager.rs Normal file
View file

@ -0,0 +1,174 @@
use futures_core::Stream;
use futures_util::StreamExt;
use std::{cell::OnceCell, pin::Pin, str::FromStr};
use thiserror::Error;
use tokio::sync::mpsc;
use tokio_stream::wrappers::UnboundedReceiverStream;
use url::Url;
use super::{
protocol::Message, Builder, Dealer, GetUrlResult, Request, RequestHandler, Responder, Response,
Subscription,
};
use crate::{Error, Session};
component! {
DealerManager: DealerManagerInner {
builder: OnceCell<Builder> = OnceCell::from(Builder::new()),
dealer: OnceCell<Dealer> = OnceCell::new(),
}
}
pub type BoxedStream<T> = Pin<Box<dyn Stream<Item = T> + Send>>;
pub type BoxedStreamResult<T> = BoxedStream<Result<T, Error>>;
#[derive(Error, Debug)]
enum DealerError {
#[error("Builder wasn't available")]
BuilderNotAvailable,
#[error("Websocket couldn't be started because: {0}")]
LaunchFailure(Error),
#[error("Failed to set dealer")]
CouldNotSetDealer,
}
impl From<DealerError> for Error {
fn from(err: DealerError) -> Self {
Error::failed_precondition(err)
}
}
#[derive(Debug)]
pub enum Reply {
Success,
Failure,
Unanswered,
}
pub type RequestReply = (Request, mpsc::UnboundedSender<Reply>);
type RequestReceiver = mpsc::UnboundedReceiver<RequestReply>;
type RequestSender = mpsc::UnboundedSender<RequestReply>;
struct DealerRequestHandler(RequestSender);
impl DealerRequestHandler {
pub fn new() -> (Self, RequestReceiver) {
let (tx, rx) = mpsc::unbounded_channel();
(DealerRequestHandler(tx), rx)
}
}
impl RequestHandler for DealerRequestHandler {
fn handle_request(&self, request: Request, responder: Responder) {
let (tx, mut rx) = mpsc::unbounded_channel();
if let Err(why) = self.0.send((request, tx)) {
error!("failed sending dealer request {why}");
responder.send(Response { success: false });
return;
}
tokio::spawn(async move {
let reply = rx.recv().await.unwrap_or(Reply::Failure);
debug!("replying to ws request: {reply:?}");
match reply {
Reply::Unanswered => responder.force_unanswered(),
Reply::Success | Reply::Failure => responder.send(Response {
success: matches!(reply, Reply::Success),
}),
}
});
}
}
impl DealerManager {
async fn get_url(session: Session) -> GetUrlResult {
let (host, port) = session.apresolver().resolve("dealer").await?;
let token = session.login5().auth_token().await?.access_token;
let url = format!("wss://{host}:{port}/?access_token={token}");
let url = Url::from_str(&url)?;
Ok(url)
}
pub fn add_listen_for(&self, url: impl Into<String>) -> Result<Subscription, Error> {
let url = url.into();
self.lock(|inner| {
if let Some(dealer) = inner.dealer.get() {
dealer.subscribe(&[&url])
} else if let Some(builder) = inner.builder.get_mut() {
builder.subscribe(&[&url])
} else {
Err(DealerError::BuilderNotAvailable.into())
}
})
}
pub fn listen_for<T>(
&self,
uri: impl Into<String>,
t: impl Fn(Message) -> Result<T, Error> + Send + 'static,
) -> Result<BoxedStreamResult<T>, Error> {
Ok(Box::pin(self.add_listen_for(uri)?.map(t)))
}
pub fn add_handle_for(&self, url: impl Into<String>) -> Result<RequestReceiver, Error> {
let url = url.into();
let (handler, receiver) = DealerRequestHandler::new();
self.lock(|inner| {
if let Some(dealer) = inner.dealer.get() {
dealer.add_handler(&url, handler).map(|_| receiver)
} else if let Some(builder) = inner.builder.get_mut() {
builder.add_handler(&url, handler).map(|_| receiver)
} else {
Err(DealerError::BuilderNotAvailable.into())
}
})
}
pub fn handle_for(&self, uri: impl Into<String>) -> Result<BoxedStream<RequestReply>, Error> {
Ok(Box::pin(
self.add_handle_for(uri).map(UnboundedReceiverStream::new)?,
))
}
pub fn handles(&self, uri: &str) -> bool {
self.lock(|inner| {
if let Some(dealer) = inner.dealer.get() {
dealer.handles(uri)
} else if let Some(builder) = inner.builder.get() {
builder.handles(uri)
} else {
false
}
})
}
pub async fn start(&self) -> Result<(), Error> {
debug!("Launching dealer");
let session = self.session();
// the url has to be a function that can retrieve a new url,
// otherwise when we later try to reconnect with the initial url/token
// and the token is expired we will just get 401 error
let get_url = move || Self::get_url(session.clone());
let dealer = self
.lock(move |inner| inner.builder.take())
.ok_or(DealerError::BuilderNotAvailable)?
.launch(get_url, None)
.await
.map_err(DealerError::LaunchFailure)?;
self.lock(|inner| inner.dealer.set(dealer))
.map_err(|_| DealerError::CouldNotSetDealer)?;
Ok(())
}
pub async fn close(&self) {
if let Some(dealer) = self.lock(|inner| inner.dealer.take()) {
dealer.close().await
}
}
}

View file

@ -1,8 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use thiserror::Error;
use crate::Error; use crate::Error;
use thiserror::Error;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum HandlerMapError { pub enum HandlerMapError {
@ -28,6 +27,10 @@ impl<T> Default for HandlerMap<T> {
} }
impl<T> HandlerMap<T> { impl<T> HandlerMap<T> {
pub fn contains(&self, path: &str) -> bool {
matches!(self, HandlerMap::Branch(map) if map.contains_key(path))
}
pub fn insert<'a>( pub fn insert<'a>(
&mut self, &mut self,
mut path: impl Iterator<Item = &'a str>, mut path: impl Iterator<Item = &'a str>,
@ -107,6 +110,22 @@ impl<T> SubscriberMap<T> {
} }
} }
pub fn contains<'a>(&self, mut path: impl Iterator<Item = &'a str>) -> bool {
if !self.subscribed.is_empty() {
return true;
}
if let Some(next) = path.next() {
if let Some(next_map) = self.children.get(next) {
return next_map.contains(path);
}
} else {
return !self.is_empty();
}
false
}
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.children.is_empty() && self.subscribed.is_empty() self.children.is_empty() && self.subscribed.is_empty()
} }
@ -115,16 +134,22 @@ impl<T> SubscriberMap<T> {
&mut self, &mut self,
mut path: impl Iterator<Item = &'a str>, mut path: impl Iterator<Item = &'a str>,
fun: &mut impl FnMut(&T) -> bool, fun: &mut impl FnMut(&T) -> bool,
) { ) -> bool {
self.subscribed.retain(|x| fun(x)); let mut handled_by_any = false;
self.subscribed.retain(|x| {
handled_by_any = true;
fun(x)
});
if let Some(next) = path.next() { if let Some(next) = path.next() {
if let Some(y) = self.children.get_mut(next) { if let Some(y) = self.children.get_mut(next) {
y.retain(path, fun); handled_by_any = handled_by_any || y.retain(path, fun);
if y.is_empty() { if y.is_empty() {
self.children.remove(next); self.children.remove(next);
} }
} }
} }
handled_by_any
} }
} }

View file

@ -1,3 +1,4 @@
pub mod manager;
mod maps; mod maps;
pub mod protocol; pub mod protocol;
@ -28,8 +29,10 @@ use tokio_tungstenite::tungstenite;
use tungstenite::error::UrlError; use tungstenite::error::UrlError;
use url::Url; use url::Url;
use self::maps::*; use self::{
use self::protocol::*; maps::*,
protocol::{Message, MessageOrRequest, Request, WebsocketMessage, WebsocketRequest},
};
use crate::{ use crate::{
socket, socket,
@ -39,7 +42,14 @@ use crate::{
type WsMessage = tungstenite::Message; type WsMessage = tungstenite::Message;
type WsError = tungstenite::Error; type WsError = tungstenite::Error;
type WsResult<T> = Result<T, tungstenite::Error>; type WsResult<T> = Result<T, Error>;
type GetUrlResult = Result<Url, Error>;
impl From<WsError> for Error {
fn from(err: WsError) -> Self {
Error::failed_precondition(err)
}
}
const WEBSOCKET_CLOSE_TIMEOUT: Duration = Duration::from_secs(3); const WEBSOCKET_CLOSE_TIMEOUT: Duration = Duration::from_secs(3);
@ -48,11 +58,11 @@ const PING_TIMEOUT: Duration = Duration::from_secs(3);
const RECONNECT_INTERVAL: Duration = Duration::from_secs(10); const RECONNECT_INTERVAL: Duration = Duration::from_secs(10);
pub struct Response { struct Response {
pub success: bool, pub success: bool,
} }
pub struct Responder { struct Responder {
key: String, key: String,
tx: mpsc::UnboundedSender<WsMessage>, tx: mpsc::UnboundedSender<WsMessage>,
sent: bool, sent: bool,
@ -101,7 +111,7 @@ impl Drop for Responder {
} }
} }
pub trait IntoResponse { trait IntoResponse {
fn respond(self, responder: Responder); fn respond(self, responder: Responder);
} }
@ -132,7 +142,7 @@ where
} }
} }
pub trait RequestHandler: Send + 'static { trait RequestHandler: Send + 'static {
fn handle_request(&self, request: Request, responder: Responder); fn handle_request(&self, request: Request, responder: Responder);
} }
@ -156,8 +166,10 @@ impl Stream for Subscription {
fn split_uri(s: &str) -> Option<impl Iterator<Item = &'_ str>> { fn split_uri(s: &str) -> Option<impl Iterator<Item = &'_ str>> {
let (scheme, sep, rest) = if let Some(rest) = s.strip_prefix("hm://") { let (scheme, sep, rest) = if let Some(rest) = s.strip_prefix("hm://") {
("hm", '/', rest) ("hm", '/', rest)
} else if let Some(rest) = s.strip_suffix("spotify:") { } else if let Some(rest) = s.strip_prefix("spotify:") {
("spotify", ':', rest) ("spotify", ':', rest)
} else if s.contains('/') {
("", '/', s)
} else { } else {
return None; return None;
}; };
@ -169,7 +181,7 @@ fn split_uri(s: &str) -> Option<impl Iterator<Item = &'_ str>> {
} }
#[derive(Debug, Clone, Error)] #[derive(Debug, Clone, Error)]
pub enum AddHandlerError { enum AddHandlerError {
#[error("There is already a handler for the given uri")] #[error("There is already a handler for the given uri")]
AlreadyHandled, AlreadyHandled,
#[error("The specified uri {0} is invalid")] #[error("The specified uri {0} is invalid")]
@ -186,7 +198,7 @@ impl From<AddHandlerError> for Error {
} }
#[derive(Debug, Clone, Error)] #[derive(Debug, Clone, Error)]
pub enum SubscriptionError { enum SubscriptionError {
#[error("The specified uri is invalid")] #[error("The specified uri is invalid")]
InvalidUri(String), InvalidUri(String),
} }
@ -224,8 +236,23 @@ fn subscribe(
Ok(Subscription(rx)) Ok(Subscription(rx))
} }
fn handles(
req_map: &HandlerMap<Box<dyn RequestHandler>>,
msg_map: &SubscriberMap<MessageHandler>,
uri: &str,
) -> bool {
if req_map.contains(uri) {
return true;
}
match split_uri(uri) {
None => false,
Some(mut split) => msg_map.contains(&mut split),
}
}
#[derive(Default)] #[derive(Default)]
pub struct Builder { struct Builder {
message_handlers: SubscriberMap<MessageHandler>, message_handlers: SubscriberMap<MessageHandler>,
request_handlers: HandlerMap<Box<dyn RequestHandler>>, request_handlers: HandlerMap<Box<dyn RequestHandler>>,
} }
@ -267,22 +294,26 @@ impl Builder {
subscribe(&mut self.message_handlers, uris) subscribe(&mut self.message_handlers, uris)
} }
pub fn handles(&self, uri: &str) -> bool {
handles(&self.request_handlers, &self.message_handlers, uri)
}
pub fn launch_in_background<Fut, F>(self, get_url: F, proxy: Option<Url>) -> Dealer pub fn launch_in_background<Fut, F>(self, get_url: F, proxy: Option<Url>) -> Dealer
where where
Fut: Future<Output = Url> + Send + 'static, Fut: Future<Output = GetUrlResult> + Send + 'static,
F: (FnMut() -> Fut) + Send + 'static, F: (Fn() -> Fut) + Send + 'static,
{ {
create_dealer!(self, shared -> run(shared, None, get_url, proxy)) create_dealer!(self, shared -> run(shared, None, get_url, proxy))
} }
pub async fn launch<Fut, F>(self, mut get_url: F, proxy: Option<Url>) -> WsResult<Dealer> pub async fn launch<Fut, F>(self, get_url: F, proxy: Option<Url>) -> WsResult<Dealer>
where where
Fut: Future<Output = Url> + Send + 'static, Fut: Future<Output = GetUrlResult> + Send + 'static,
F: (FnMut() -> Fut) + Send + 'static, F: (Fn() -> Fut) + Send + 'static,
{ {
let dealer = create_dealer!(self, shared -> { let dealer = create_dealer!(self, shared -> {
// Try to connect. // Try to connect.
let url = get_url().await; let url = get_url().await?;
let tasks = connect(&url, proxy.as_ref(), &shared).await?; let tasks = connect(&url, proxy.as_ref(), &shared).await?;
// If a connection is established, continue in a background task. // If a connection is established, continue in a background task.
@ -303,15 +334,47 @@ struct DealerShared {
} }
impl DealerShared { impl DealerShared {
fn dispatch_message(&self, msg: Message) { fn dispatch_message(&self, mut msg: WebsocketMessage) {
let msg = match msg.handle_payload() {
Ok(value) => Message {
headers: msg.headers,
payload: value,
uri: msg.uri,
},
Err(why) => {
warn!("failure during data parsing for {}: {why}", msg.uri);
return;
}
};
if let Some(split) = split_uri(&msg.uri) { if let Some(split) = split_uri(&msg.uri) {
self.message_handlers if self
.message_handlers
.lock() .lock()
.retain(split, &mut |tx| tx.send(msg.clone()).is_ok()); .retain(split, &mut |tx| tx.send(msg.clone()).is_ok())
{
return;
}
} }
warn!("No subscriber for msg.uri: {}", msg.uri);
} }
fn dispatch_request(&self, request: Request, send_tx: &mpsc::UnboundedSender<WsMessage>) { fn dispatch_request(
&self,
request: WebsocketRequest,
send_tx: &mpsc::UnboundedSender<WsMessage>,
) {
trace!("dealer request {}", &request.message_ident);
let payload_request = match request.handle_payload() {
Ok(payload) => payload,
Err(why) => {
warn!("request payload handling failed because of {why}");
return;
}
};
// ResponseSender will automatically send "success: false" if it is dropped without an answer. // ResponseSender will automatically send "success: false" if it is dropped without an answer.
let responder = Responder::new(request.key.clone(), send_tx.clone()); let responder = Responder::new(request.key.clone(), send_tx.clone());
@ -325,13 +388,11 @@ impl DealerShared {
return; return;
}; };
{ let handler_map = self.request_handlers.lock();
let handler_map = self.request_handlers.lock();
if let Some(handler) = handler_map.get(split) { if let Some(handler) = handler_map.get(split) {
handler.handle_request(request, responder); handler.handle_request(payload_request, responder);
return; return;
}
} }
warn!("No handler for message_ident: {}", &request.message_ident); warn!("No handler for message_ident: {}", &request.message_ident);
@ -355,9 +416,9 @@ impl DealerShared {
} }
} }
pub struct Dealer { struct Dealer {
shared: Arc<DealerShared>, shared: Arc<DealerShared>,
handle: TimeoutOnDrop<()>, handle: TimeoutOnDrop<Result<(), Error>>,
} }
impl Dealer { impl Dealer {
@ -376,6 +437,14 @@ impl Dealer {
subscribe(&mut self.shared.message_handlers.lock(), uris) subscribe(&mut self.shared.message_handlers.lock(), uris)
} }
pub fn handles(&self, uri: &str) -> bool {
handles(
&self.shared.request_handlers.lock(),
&self.shared.message_handlers.lock(),
uri,
)
}
pub async fn close(mut self) { pub async fn close(mut self) {
debug!("closing dealer"); debug!("closing dealer");
@ -402,7 +471,7 @@ async fn connect(
let default_port = match address.scheme() { let default_port = match address.scheme() {
"ws" => 80, "ws" => 80,
"wss" => 443, "wss" => 443,
_ => return Err(WsError::Url(UrlError::UnsupportedUrlScheme)), _ => return Err(WsError::Url(UrlError::UnsupportedUrlScheme).into()),
}; };
let port = address.port().unwrap_or(default_port); let port = address.port().unwrap_or(default_port);
@ -484,13 +553,13 @@ async fn connect(
Some(Ok(msg)) => match msg { Some(Ok(msg)) => match msg {
WsMessage::Text(t) => match serde_json::from_str(&t) { WsMessage::Text(t) => match serde_json::from_str(&t) {
Ok(m) => shared.dispatch(m, &send_tx), Ok(m) => shared.dispatch(m, &send_tx),
Err(e) => info!("Received invalid message: {}", e), Err(e) => warn!("Message couldn't be parsed: {e}. Message was {t}"),
}, },
WsMessage::Binary(_) => { WsMessage::Binary(_) => {
info!("Received invalid binary message"); info!("Received invalid binary message");
} }
WsMessage::Pong(_) => { WsMessage::Pong(_) => {
debug!("Received pong"); trace!("Received pong");
pong_received.store(true, atomic::Ordering::Relaxed); pong_received.store(true, atomic::Ordering::Relaxed);
} }
_ => (), // tungstenite handles Close and Ping automatically _ => (), // tungstenite handles Close and Ping automatically
@ -522,7 +591,7 @@ async fn connect(
break; break;
} }
debug!("Sent ping"); trace!("Sent ping");
sleep(PING_TIMEOUT).await; sleep(PING_TIMEOUT).await;
@ -556,8 +625,9 @@ async fn run<F, Fut>(
initial_tasks: Option<(JoinHandle<()>, JoinHandle<()>)>, initial_tasks: Option<(JoinHandle<()>, JoinHandle<()>)>,
mut get_url: F, mut get_url: F,
proxy: Option<Url>, proxy: Option<Url>,
) where ) -> Result<(), Error>
Fut: Future<Output = Url> + Send + 'static, where
Fut: Future<Output = GetUrlResult> + Send + 'static,
F: (FnMut() -> Fut) + Send + 'static, F: (FnMut() -> Fut) + Send + 'static,
{ {
let init_task = |t| Some(TimeoutOnDrop::new(t, WEBSOCKET_CLOSE_TIMEOUT)); let init_task = |t| Some(TimeoutOnDrop::new(t, WEBSOCKET_CLOSE_TIMEOUT));
@ -593,7 +663,7 @@ async fn run<F, Fut>(
break break
}, },
e = get_url() => e e = get_url() => e
}; }?;
match connect(&url, proxy.as_ref(), &shared).await { match connect(&url, proxy.as_ref(), &shared).await {
Ok((s, r)) => tasks = (init_task(s), init_task(r)), Ok((s, r)) => tasks = (init_task(s), init_task(r)),
@ -609,4 +679,6 @@ async fn run<F, Fut>(
let tasks = tasks.0.into_iter().chain(tasks.1); let tasks = tasks.0.into_iter().chain(tasks.1);
let _ = join_all(tasks).await; let _ = join_all(tasks).await;
Ok(())
} }

View file

@ -1,19 +1,58 @@
pub mod request;
pub use request::*;
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{Error as IoError, Read};
use crate::Error;
use base64::prelude::BASE64_STANDARD;
use base64::{DecodeError, Engine};
use flate2::read::GzDecoder;
use log::LevelFilter;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Error as SerdeError;
use thiserror::Error;
pub type JsonValue = serde_json::Value; const IGNORE_UNKNOWN: protobuf_json_mapping::ParseOptions = protobuf_json_mapping::ParseOptions {
pub type JsonObject = serde_json::Map<String, JsonValue>; ignore_unknown_fields: true,
_future_options: (),
};
#[derive(Clone, Debug, Deserialize)] type JsonValue = serde_json::Value;
pub struct Payload {
pub message_id: i32, #[derive(Debug, Error)]
pub sent_by_device_id: String, enum ProtocolError {
pub command: JsonObject, #[error("base64 decoding failed: {0}")]
Base64(DecodeError),
#[error("gzip decoding failed: {0}")]
GZip(IoError),
#[error("deserialization failed: {0}")]
Deserialization(SerdeError),
#[error("payload had more then one value. had {0} values")]
MoreThenOneValue(usize),
#[error("received unexpected data {0:#?}")]
UnexpectedData(PayloadValue),
#[error("payload was empty")]
Empty,
}
impl From<ProtocolError> for Error {
fn from(err: ProtocolError) -> Self {
match err {
ProtocolError::UnexpectedData(_) => Error::unavailable(err),
_ => Error::failed_precondition(err),
}
}
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct Request { pub(super) struct Payload {
pub compressed: String,
}
#[derive(Clone, Debug, Deserialize)]
pub(super) struct WebsocketRequest {
#[serde(default)] #[serde(default)]
pub headers: HashMap<String, String>, pub headers: HashMap<String, String>,
pub message_ident: String, pub message_ident: String,
@ -22,18 +61,133 @@ pub struct Request {
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct Message { pub(super) struct WebsocketMessage {
#[serde(default)] #[serde(default)]
pub headers: HashMap<String, String>, pub headers: HashMap<String, String>,
pub method: Option<String>, pub method: Option<String>,
#[serde(default)] #[serde(default)]
pub payloads: Vec<JsonValue>, pub payloads: Vec<MessagePayloadValue>,
pub uri: String, pub uri: String,
} }
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub enum MessagePayloadValue {
String(String),
Bytes(Vec<u8>),
Json(JsonValue),
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub(super) enum MessageOrRequest { pub(super) enum MessageOrRequest {
Message(Message), Message(WebsocketMessage),
Request(Request), Request(WebsocketRequest),
}
#[derive(Clone, Debug)]
pub enum PayloadValue {
Empty,
Raw(Vec<u8>),
Json(String),
}
#[derive(Clone, Debug)]
pub struct Message {
pub headers: HashMap<String, String>,
pub payload: PayloadValue,
pub uri: String,
}
impl Message {
pub fn from_json<M: protobuf::MessageFull>(value: Self) -> Result<M, Error> {
use protobuf_json_mapping::*;
match value.payload {
PayloadValue::Json(json) => match parse_from_str::<M>(&json) {
Ok(message) => Ok(message),
Err(_) => match parse_from_str_with_options(&json, &IGNORE_UNKNOWN) {
Ok(message) => Ok(message),
Err(why) => Err(Error::failed_precondition(why)),
},
},
other => Err(ProtocolError::UnexpectedData(other).into()),
}
}
pub fn from_raw<M: protobuf::Message>(value: Self) -> Result<M, Error> {
match value.payload {
PayloadValue::Raw(bytes) => {
M::parse_from_bytes(&bytes).map_err(Error::failed_precondition)
}
other => Err(ProtocolError::UnexpectedData(other).into()),
}
}
}
impl WebsocketMessage {
pub fn handle_payload(&mut self) -> Result<PayloadValue, Error> {
if self.payloads.is_empty() {
return Ok(PayloadValue::Empty);
} else if self.payloads.len() > 1 {
return Err(ProtocolError::MoreThenOneValue(self.payloads.len()).into());
}
let payload = self.payloads.pop().ok_or(ProtocolError::Empty)?;
let bytes = match payload {
MessagePayloadValue::String(string) => BASE64_STANDARD
.decode(string)
.map_err(ProtocolError::Base64)?,
MessagePayloadValue::Bytes(bytes) => bytes,
MessagePayloadValue::Json(json) => return Ok(PayloadValue::Json(json.to_string())),
};
handle_transfer_encoding(&self.headers, bytes).map(PayloadValue::Raw)
}
}
impl WebsocketRequest {
pub fn handle_payload(&self) -> Result<Request, Error> {
let payload_bytes = BASE64_STANDARD
.decode(&self.payload.compressed)
.map_err(ProtocolError::Base64)?;
let payload = handle_transfer_encoding(&self.headers, payload_bytes)?;
let payload = String::from_utf8(payload)?;
if log::max_level() >= LevelFilter::Trace {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&payload) {
trace!("websocket request: {json:#?}");
} else {
trace!("websocket request: {payload}");
}
}
serde_json::from_str(&payload)
.map_err(ProtocolError::Deserialization)
.map_err(Into::into)
}
}
fn handle_transfer_encoding(
headers: &HashMap<String, String>,
data: Vec<u8>,
) -> Result<Vec<u8>, Error> {
let encoding = headers.get("Transfer-Encoding").map(String::as_str);
if let Some(encoding) = encoding {
trace!("message was send with {encoding} encoding ");
}
if !matches!(encoding, Some("gzip")) {
return Ok(data);
}
let mut gz = GzDecoder::new(&data[..]);
let mut bytes = vec![];
match gz.read_to_end(&mut bytes) {
Ok(i) if i == bytes.len() => Ok(bytes),
Ok(_) => Err(Error::failed_precondition(
"read bytes mismatched with expected bytes",
)),
Err(why) => Err(ProtocolError::GZip(why).into()),
}
} }

View file

@ -0,0 +1,208 @@
use crate::deserialize_with::*;
use librespot_protocol::player::{
Context, ContextPlayerOptionOverrides, PlayOrigin, ProvidedTrack, TransferState,
};
use serde::Deserialize;
use serde_json::Value;
use std::fmt::{Display, Formatter};
#[derive(Clone, Debug, Deserialize)]
pub struct Request {
pub message_id: u32,
// todo: did only send target_alias_id: null so far, maybe we just ignore it, will see
// pub target_alias_id: Option<()>,
pub sent_by_device_id: String,
pub command: Command,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(tag = "endpoint", rename_all = "snake_case")]
pub enum Command {
Transfer(TransferCommand),
#[serde(deserialize_with = "boxed")]
Play(Box<PlayCommand>),
Pause(PauseCommand),
SeekTo(SeekToCommand),
SetShufflingContext(SetValueCommand),
SetRepeatingTrack(SetValueCommand),
SetRepeatingContext(SetValueCommand),
AddToQueue(AddToQueueCommand),
SetQueue(SetQueueCommand),
SetOptions(SetOptionsCommand),
UpdateContext(UpdateContextCommand),
SkipNext(SkipNextCommand),
// commands that don't send any context (at least not usually...)
SkipPrev(GenericCommand),
Resume(GenericCommand),
// catch unknown commands, so that we can implement them later
#[serde(untagged)]
Unknown(Value),
}
impl Display for Command {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
use Command::*;
write!(
f,
"endpoint: {}{}",
matches!(self, Unknown(_))
.then_some("unknown ")
.unwrap_or_default(),
match self {
Transfer(_) => "transfer",
Play(_) => "play",
Pause(_) => "pause",
SeekTo(_) => "seek_to",
SetShufflingContext(_) => "set_shuffling_context",
SetRepeatingContext(_) => "set_repeating_context",
SetRepeatingTrack(_) => "set_repeating_track",
AddToQueue(_) => "add_to_queue",
SetQueue(_) => "set_queue",
SetOptions(_) => "set_options",
UpdateContext(_) => "update_context",
SkipNext(_) => "skip_next",
SkipPrev(_) => "skip_prev",
Resume(_) => "resume",
Unknown(json) => {
json.as_object()
.and_then(|obj| obj.get("endpoint").map(|v| v.as_str()))
.flatten()
.unwrap_or("???")
}
}
)
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct TransferCommand {
#[serde(default, deserialize_with = "base64_proto")]
pub data: Option<TransferState>,
pub options: TransferOptions,
pub from_device_identifier: String,
pub logging_params: LoggingParams,
}
#[derive(Clone, Debug, Deserialize)]
pub struct PlayCommand {
#[serde(deserialize_with = "json_proto")]
pub context: Context,
#[serde(deserialize_with = "json_proto")]
pub play_origin: PlayOrigin,
pub options: PlayOptions,
pub logging_params: LoggingParams,
}
#[derive(Clone, Debug, Deserialize)]
pub struct PauseCommand {
// does send options with it, but seems to be empty, investigate which options are send here
pub logging_params: LoggingParams,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SeekToCommand {
pub value: u32,
pub position: u32,
pub logging_params: LoggingParams,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SkipNextCommand {
#[serde(default, deserialize_with = "option_json_proto")]
pub track: Option<ProvidedTrack>,
pub logging_params: LoggingParams,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SetValueCommand {
pub value: bool,
pub logging_params: LoggingParams,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AddToQueueCommand {
#[serde(deserialize_with = "json_proto")]
pub track: ProvidedTrack,
pub logging_params: LoggingParams,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SetQueueCommand {
#[serde(deserialize_with = "vec_json_proto")]
pub next_tracks: Vec<ProvidedTrack>,
#[serde(deserialize_with = "vec_json_proto")]
pub prev_tracks: Vec<ProvidedTrack>,
// this queue revision is actually the last revision, so using it will not update the web ui
// might be that internally they use the last revision to create the next revision
pub queue_revision: String,
pub logging_params: LoggingParams,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SetOptionsCommand {
pub shuffling_context: Option<bool>,
pub repeating_context: Option<bool>,
pub repeating_track: Option<bool>,
pub options: Option<OptionsOptions>,
pub logging_params: LoggingParams,
}
#[derive(Clone, Debug, Deserialize)]
pub struct UpdateContextCommand {
#[serde(deserialize_with = "json_proto")]
pub context: Context,
pub session_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GenericCommand {
pub logging_params: LoggingParams,
}
#[derive(Clone, Debug, Deserialize)]
pub struct TransferOptions {
pub restore_paused: String,
pub restore_position: String,
pub restore_track: String,
pub retain_session: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct PlayOptions {
pub skip_to: SkipTo,
#[serde(default, deserialize_with = "option_json_proto")]
pub player_options_override: Option<ContextPlayerOptionOverrides>,
pub license: Option<String>,
// possible to send wie web-api
pub seek_to: Option<u32>,
// mobile
pub always_play_something: Option<bool>,
pub audio_stream: Option<String>,
pub initially_paused: Option<bool>,
pub prefetch_level: Option<String>,
pub system_initiated: Option<bool>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct OptionsOptions {
only_for_local_device: bool,
override_restrictions: bool,
system_initiated: bool,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SkipTo {
pub track_uid: Option<String>,
pub track_uri: Option<String>,
pub track_index: Option<u32>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct LoggingParams {
pub interaction_ids: Option<Vec<String>>,
pub device_identifier: Option<String>,
pub command_initiated_time: Option<i64>,
pub page_instance_ids: Option<Vec<String>>,
pub command_id: Option<String>,
}

View file

@ -0,0 +1,94 @@
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use protobuf::MessageFull;
use serde::de::{Error, Unexpected};
use serde::{Deserialize, Deserializer};
use serde_json::Value;
const IGNORE_UNKNOWN: protobuf_json_mapping::ParseOptions = protobuf_json_mapping::ParseOptions {
ignore_unknown_fields: true,
_future_options: (),
};
fn parse_value_to_msg<T: MessageFull>(
value: &Value,
) -> Result<T, protobuf_json_mapping::ParseError> {
protobuf_json_mapping::parse_from_str_with_options::<T>(&value.to_string(), &IGNORE_UNKNOWN)
}
pub fn base64_proto<'de, T, D>(de: D) -> Result<Option<T>, D::Error>
where
T: MessageFull,
D: Deserializer<'de>,
{
let v: String = Deserialize::deserialize(de)?;
let bytes = BASE64_STANDARD
.decode(v)
.map_err(|e| Error::custom(e.to_string()))?;
T::parse_from_bytes(&bytes).map(Some).map_err(Error::custom)
}
pub fn json_proto<'de, T, D>(de: D) -> Result<T, D::Error>
where
T: MessageFull,
D: Deserializer<'de>,
{
let v: Value = Deserialize::deserialize(de)?;
parse_value_to_msg(&v).map_err(|why| {
warn!("deserialize_json_proto: {v}");
error!("deserialize_json_proto: {why}");
Error::custom(why)
})
}
pub fn option_json_proto<'de, T, D>(de: D) -> Result<Option<T>, D::Error>
where
T: MessageFull,
D: Deserializer<'de>,
{
let v: Value = Deserialize::deserialize(de)?;
parse_value_to_msg(&v).map(Some).map_err(Error::custom)
}
pub fn vec_json_proto<'de, T, D>(de: D) -> Result<Vec<T>, D::Error>
where
T: MessageFull,
D: Deserializer<'de>,
{
let v: Value = Deserialize::deserialize(de)?;
let array = match v {
Value::Array(array) => array,
_ => return Err(Error::custom("the value wasn't an array")),
};
let res = array
.iter()
.flat_map(parse_value_to_msg)
.collect::<Vec<T>>();
Ok(res)
}
pub fn boxed<'de, T, D>(de: D) -> Result<Box<T>, D::Error>
where
T: Deserialize<'de>,
D: Deserializer<'de>,
{
let v: T = Deserialize::deserialize(de)?;
Ok(Box::new(v))
}
pub fn bool_from_string<'de, D>(de: D) -> Result<bool, D::Error>
where
D: Deserializer<'de>,
{
match String::deserialize(de)?.as_ref() {
"true" => Ok(true),
"false" => Ok(false),
other => Err(Error::invalid_value(
Unexpected::Str(other),
&"true or false",
)),
}
}

View file

@ -499,3 +499,9 @@ impl From<Utf8Error> for Error {
Self::new(ErrorKind::FailedPrecondition, err) Self::new(ErrorKind::FailedPrecondition, err)
} }
} }
impl From<protobuf_json_mapping::ParseError> for Error {
fn from(err: protobuf_json_mapping::ParseError) -> Self {
Self::failed_precondition(err)
}
}

View file

@ -208,7 +208,7 @@ impl HttpClient {
} }
} }
if code != StatusCode::OK { if !code.is_success() {
return Err(HttpClientError::StatusCode(code).into()); return Err(HttpClientError::StatusCode(code).into());
} }
} }

View file

@ -16,7 +16,8 @@ pub mod config;
mod connection; mod connection;
pub mod date; pub mod date;
#[allow(dead_code)] #[allow(dead_code)]
mod dealer; pub mod dealer;
pub mod deserialize_with;
#[doc(hidden)] #[doc(hidden)]
pub mod diffie_hellman; pub mod diffie_hellman;
pub mod error; pub mod error;

View file

@ -276,12 +276,15 @@ impl MercuryManager {
}); });
}); });
if !found { if found {
Ok(())
} else if self.session().dealer().handles(&response.uri) {
trace!("mercury response <{}> is handled by dealer", response.uri);
Ok(())
} else {
debug!("unknown subscription uri={}", &response.uri); debug!("unknown subscription uri={}", &response.uri);
trace!("response pushed over Mercury: {:?}", response); trace!("response pushed over Mercury: {:?}", response);
Err(MercuryError::Response(response).into()) Err(MercuryError::Response(response).into())
} else {
Ok(())
} }
} else if let Some(cb) = pending.callback { } else if let Some(cb) = pending.callback {
cb.send(Ok(response)).map_err(|_| MercuryError::Channel)?; cb.send(Ok(response)).map_err(|_| MercuryError::Channel)?;

View file

@ -9,23 +9,7 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH}, time::{Duration, SystemTime, UNIX_EPOCH},
}; };
use byteorder::{BigEndian, ByteOrder}; use crate::dealer::manager::DealerManager;
use bytes::Bytes;
use futures_core::TryStream;
use futures_util::StreamExt;
use librespot_protocol::authentication::AuthenticationType;
use num_traits::FromPrimitive;
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use pin_project_lite::pin_project;
use quick_xml::events::Event;
use thiserror::Error;
use tokio::{
sync::mpsc,
time::{sleep, Duration as TokioDuration, Instant as TokioInstant, Sleep},
};
use tokio_stream::wrappers::UnboundedReceiverStream;
use crate::{ use crate::{
apresolve::{ApResolver, SocketAddress}, apresolve::{ApResolver, SocketAddress},
audio_key::AudioKeyManager, audio_key::AudioKeyManager,
@ -43,6 +27,23 @@ use crate::{
token::TokenProvider, token::TokenProvider,
Error, Error,
}; };
use byteorder::{BigEndian, ByteOrder};
use bytes::Bytes;
use futures_core::TryStream;
use futures_util::StreamExt;
use librespot_protocol::authentication::AuthenticationType;
use num_traits::FromPrimitive;
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use pin_project_lite::pin_project;
use quick_xml::events::Event;
use thiserror::Error;
use tokio::{
sync::mpsc,
time::{sleep, Duration as TokioDuration, Instant as TokioInstant, Sleep},
};
use tokio_stream::wrappers::UnboundedReceiverStream;
use uuid::Uuid;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum SessionError { pub enum SessionError {
@ -78,6 +79,7 @@ pub struct UserData {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
struct SessionData { struct SessionData {
session_id: String,
client_id: String, client_id: String,
client_name: String, client_name: String,
client_brand_name: String, client_brand_name: String,
@ -100,6 +102,7 @@ struct SessionInternal {
audio_key: OnceCell<AudioKeyManager>, audio_key: OnceCell<AudioKeyManager>,
channel: OnceCell<ChannelManager>, channel: OnceCell<ChannelManager>,
mercury: OnceCell<MercuryManager>, mercury: OnceCell<MercuryManager>,
dealer: OnceCell<DealerManager>,
spclient: OnceCell<SpClient>, spclient: OnceCell<SpClient>,
token_provider: OnceCell<TokenProvider>, token_provider: OnceCell<TokenProvider>,
login5: OnceCell<Login5Manager>, login5: OnceCell<Login5Manager>,
@ -128,6 +131,8 @@ impl Session {
let session_data = SessionData { let session_data = SessionData {
client_id: config.client_id.clone(), client_id: config.client_id.clone(),
// can be any guid, doesn't need to be simple
session_id: Uuid::new_v4().as_simple().to_string(),
..SessionData::default() ..SessionData::default()
}; };
@ -141,6 +146,7 @@ impl Session {
audio_key: OnceCell::new(), audio_key: OnceCell::new(),
channel: OnceCell::new(), channel: OnceCell::new(),
mercury: OnceCell::new(), mercury: OnceCell::new(),
dealer: OnceCell::new(),
spclient: OnceCell::new(), spclient: OnceCell::new(),
token_provider: OnceCell::new(), token_provider: OnceCell::new(),
login5: OnceCell::new(), login5: OnceCell::new(),
@ -303,6 +309,12 @@ impl Session {
.get_or_init(|| MercuryManager::new(self.weak())) .get_or_init(|| MercuryManager::new(self.weak()))
} }
pub fn dealer(&self) -> &DealerManager {
self.0
.dealer
.get_or_init(|| DealerManager::new(self.weak()))
}
pub fn spclient(&self) -> &SpClient { pub fn spclient(&self) -> &SpClient {
self.0.spclient.get_or_init(|| SpClient::new(self.weak())) self.0.spclient.get_or_init(|| SpClient::new(self.weak()))
} }
@ -373,6 +385,14 @@ impl Session {
self.0.data.read().user_data.clone() self.0.data.read().user_data.clone()
} }
pub fn session_id(&self) -> String {
self.0.data.read().session_id.clone()
}
pub fn set_session_id(&self, session_id: String) {
self.0.data.write().session_id = session_id.to_owned();
}
pub fn device_id(&self) -> &str { pub fn device_id(&self) -> &str {
&self.config().device_id &self.config().device_id
} }

View file

@ -3,20 +3,6 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use bytes::Bytes;
use data_encoding::HEXUPPER_PERMISSIVE;
use futures_util::future::IntoStream;
use http::header::HeaderValue;
use hyper::{
header::{HeaderName, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RANGE},
HeaderMap, Method, Request,
};
use hyper_util::client::legacy::ResponseFuture;
use protobuf::{Enum, Message, MessageFull};
use rand::RngCore;
use sysinfo::System;
use thiserror::Error;
use crate::config::{os_version, OS}; use crate::config::{os_version, OS};
use crate::{ use crate::{
apresolve::SocketAddress, apresolve::SocketAddress,
@ -37,6 +23,20 @@ use crate::{
version::spotify_semantic_version, version::spotify_semantic_version,
Error, FileId, SpotifyId, Error, FileId, SpotifyId,
}; };
use bytes::Bytes;
use data_encoding::HEXUPPER_PERMISSIVE;
use futures_util::future::IntoStream;
use http::header::HeaderValue;
use hyper::{
header::{HeaderName, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RANGE},
HeaderMap, Method, Request,
};
use hyper_util::client::legacy::ResponseFuture;
use librespot_protocol::{autoplay_context_request::AutoplayContextRequest, player::Context};
use protobuf::{Enum, Message, MessageFull};
use rand::RngCore;
use sysinfo::System;
use thiserror::Error;
component! { component! {
SpClient : SpClientInner { SpClient : SpClientInner {
@ -50,11 +50,20 @@ pub type SpClientResult = Result<Bytes, Error>;
#[allow(clippy::declare_interior_mutable_const)] #[allow(clippy::declare_interior_mutable_const)]
pub const CLIENT_TOKEN: HeaderName = HeaderName::from_static("client-token"); pub const CLIENT_TOKEN: HeaderName = HeaderName::from_static("client-token");
#[allow(clippy::declare_interior_mutable_const)]
const CONNECTION_ID: HeaderName = HeaderName::from_static("x-spotify-connection-id");
const NO_METRICS_AND_SALT: RequestOptions = RequestOptions {
metrics: false,
salt: false,
};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum SpClientError { pub enum SpClientError {
#[error("missing attribute {0}")] #[error("missing attribute {0}")]
Attribute(String), Attribute(String),
#[error("expected data but received none")]
NoData,
} }
impl From<SpClientError> for Error { impl From<SpClientError> for Error {
@ -75,6 +84,20 @@ impl Default for RequestStrategy {
} }
} }
pub struct RequestOptions {
metrics: bool,
salt: bool,
}
impl Default for RequestOptions {
fn default() -> Self {
Self {
metrics: true,
salt: true,
}
}
}
impl SpClient { impl SpClient {
pub fn set_strategy(&self, strategy: RequestStrategy) { pub fn set_strategy(&self, strategy: RequestStrategy) {
self.lock(|inner| inner.strategy = strategy) self.lock(|inner| inner.strategy = strategy)
@ -354,7 +377,25 @@ impl SpClient {
headers: Option<HeaderMap>, headers: Option<HeaderMap>,
message: &M, message: &M,
) -> SpClientResult { ) -> SpClientResult {
let body = protobuf::text_format::print_to_string(message); self.request_with_protobuf_and_options(
method,
endpoint,
headers,
message,
&Default::default(),
)
.await
}
pub async fn request_with_protobuf_and_options<M: Message + MessageFull>(
&self,
method: &Method,
endpoint: &str,
headers: Option<HeaderMap>,
message: &M,
options: &RequestOptions,
) -> SpClientResult {
let body = message.write_to_bytes()?;
let mut headers = headers.unwrap_or_default(); let mut headers = headers.unwrap_or_default();
headers.insert( headers.insert(
@ -362,7 +403,7 @@ impl SpClient {
HeaderValue::from_static("application/x-protobuf"), HeaderValue::from_static("application/x-protobuf"),
); );
self.request(method, endpoint, Some(headers), Some(&body)) self.request_with_options(method, endpoint, Some(headers), Some(&body), options)
.await .await
} }
@ -376,7 +417,8 @@ impl SpClient {
let mut headers = headers.unwrap_or_default(); let mut headers = headers.unwrap_or_default();
headers.insert(ACCEPT, HeaderValue::from_static("application/json")); headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
self.request(method, endpoint, Some(headers), body).await self.request(method, endpoint, Some(headers), body.map(|s| s.as_bytes()))
.await
} }
pub async fn request( pub async fn request(
@ -384,7 +426,19 @@ impl SpClient {
method: &Method, method: &Method,
endpoint: &str, endpoint: &str,
headers: Option<HeaderMap>, headers: Option<HeaderMap>,
body: Option<&str>, body: Option<&[u8]>,
) -> SpClientResult {
self.request_with_options(method, endpoint, headers, body, &Default::default())
.await
}
pub async fn request_with_options(
&self,
method: &Method,
endpoint: &str,
headers: Option<HeaderMap>,
body: Option<&[u8]>,
options: &RequestOptions,
) -> SpClientResult { ) -> SpClientResult {
let mut tries: usize = 0; let mut tries: usize = 0;
let mut last_response; let mut last_response;
@ -399,31 +453,33 @@ impl SpClient {
let mut url = self.base_url().await?; let mut url = self.base_url().await?;
url.push_str(endpoint); url.push_str(endpoint);
let separator = match url.find('?') {
Some(_) => "&",
None => "?",
};
// Add metrics. There is also an optional `partner` key with a value like // Add metrics. There is also an optional `partner` key with a value like
// `vodafone-uk` but we've yet to discover how we can find that value. // `vodafone-uk` but we've yet to discover how we can find that value.
// For the sake of documentation you could also do "product=free" but // For the sake of documentation you could also do "product=free" but
// we only support premium anyway. // we only support premium anyway.
let _ = write!( if options.metrics && !url.contains("product=0") {
url, let _ = write!(
"{}product=0&country={}", url,
separator, "{}product=0&country={}",
self.session().country() util::get_next_query_separator(&url),
); self.session().country()
);
}
// Defeat caches. Spotify-generated URLs already contain this. // Defeat caches. Spotify-generated URLs already contain this.
if !url.contains("salt=") { if options.salt && !url.contains("salt=") {
let _ = write!(url, "&salt={}", rand::thread_rng().next_u32()); let _ = write!(
url,
"{}salt={}",
util::get_next_query_separator(&url),
rand::thread_rng().next_u32()
);
} }
let mut request = Request::builder() let mut request = Request::builder()
.method(method) .method(method)
.uri(url) .uri(url)
.body(body.to_owned().into())?; .body(Bytes::copy_from_slice(body))?;
// Reconnection logic: keep getting (cached) tokens because they might have expired. // Reconnection logic: keep getting (cached) tokens because they might have expired.
let token = self.session().login5().auth_token().await?; let token = self.session().login5().auth_token().await?;
@ -481,20 +537,34 @@ impl SpClient {
last_response last_response
} }
pub async fn put_connect_state( pub async fn put_connect_state_request(&self, state: &PutStateRequest) -> SpClientResult {
&self,
connection_id: &str,
state: &PutStateRequest,
) -> SpClientResult {
let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id()); let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id());
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("X-Spotify-Connection-Id", connection_id.parse()?); headers.insert(CONNECTION_ID, self.session().connection_id().parse()?);
self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), state) self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), state)
.await .await
} }
pub async fn delete_connect_state_request(&self) -> SpClientResult {
let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id());
self.request(&Method::DELETE, &endpoint, None, None).await
}
pub async fn put_connect_state_inactive(&self, notify: bool) -> SpClientResult {
let endpoint = format!(
"/connect-state/v1/devices/{}/inactive?notify={notify}",
self.session().device_id()
);
let mut headers = HeaderMap::new();
headers.insert(CONNECTION_ID, self.session().connection_id().parse()?);
self.request(&Method::PUT, &endpoint, Some(headers), None)
.await
}
pub async fn get_metadata(&self, scope: &str, id: &SpotifyId) -> SpClientResult { pub async fn get_metadata(&self, scope: &str, id: &SpotifyId) -> SpClientResult {
let endpoint = format!("/metadata/4/{}/{}", scope, id.to_base16()?); let endpoint = format!("/metadata/4/{}/{}", scope, id.to_base16()?);
self.request(&Method::GET, &endpoint, None, None).await self.request(&Method::GET, &endpoint, None, None).await
@ -738,4 +808,76 @@ impl SpClient {
self.request_url(&url).await self.request_url(&url).await
} }
/// Request the context for an uri
///
/// ## Query entry found in the wild:
/// - include_video=true
/// ## Remarks:
/// - 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
/// - returns 2 pages with tracks: 10 most popular tracks and latest/popular album
/// - remaining pages are albums of the artists and are only provided as page_url
/// - 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"
pub async fn get_context(&self, uri: &str) -> Result<Context, Error> {
let uri = format!("/context-resolve/v1/{uri}");
let res = self
.request_with_options(&Method::GET, &uri, None, None, &NO_METRICS_AND_SALT)
.await?;
let ctx_json = String::from_utf8(res.to_vec())?;
if ctx_json.is_empty() {
Err(SpClientError::NoData)?
}
let ctx = protobuf_json_mapping::parse_from_str::<Context>(&ctx_json);
if ctx.is_err() {
trace!("failed parsing context: {ctx_json}")
}
Ok(ctx?)
}
pub async fn get_autoplay_context(
&self,
context_request: &AutoplayContextRequest,
) -> Result<Context, Error> {
let res = self
.request_with_protobuf_and_options(
&Method::POST,
"/context-resolve/v1/autoplay",
None,
context_request,
&NO_METRICS_AND_SALT,
)
.await?;
let ctx_json = String::from_utf8(res.to_vec())?;
if ctx_json.is_empty() {
Err(SpClientError::NoData)?
}
let ctx = protobuf_json_mapping::parse_from_str::<Context>(&ctx_json);
if ctx.is_err() {
trace!("failed parsing context: {ctx_json}")
}
Ok(ctx?)
}
pub async fn get_rootlist(&self, from: usize, length: Option<usize>) -> SpClientResult {
let length = length.unwrap_or(120);
let user = self.session().username();
let endpoint = format!("/playlist/v2/user/{user}/rootlist?decorate=revision,attributes,length,owner,capabilities,status_code&from={from}&length={length}");
self.request(&Method::GET, &endpoint, None, None).await
}
} }

View file

@ -423,19 +423,6 @@ impl TryFrom<&Vec<u8>> for SpotifyId {
} }
} }
impl TryFrom<&protocol::spirc::TrackRef> for SpotifyId {
type Error = crate::Error;
fn try_from(track: &protocol::spirc::TrackRef) -> Result<Self, Self::Error> {
match SpotifyId::from_raw(track.gid()) {
Ok(mut id) => {
id.item_type = SpotifyItemType::Track;
Ok(id)
}
Err(_) => SpotifyId::from_uri(track.uri()),
}
}
}
impl TryFrom<&protocol::metadata::Album> for SpotifyId { impl TryFrom<&protocol::metadata::Album> for SpotifyId {
type Error = crate::Error; type Error = crate::Error;
fn try_from(album: &protocol::metadata::Album) -> Result<Self, Self::Error> { fn try_from(album: &protocol::metadata::Album) -> Result<Self, Self::Error> {

View file

@ -165,3 +165,10 @@ pub fn solve_hash_cash(
Ok(now.elapsed()) Ok(now.elapsed())
} }
pub fn get_next_query_separator(url: &str) -> &'static str {
match url.find('?') {
Some(_) => "&",
None => "?",
}
}

View file

@ -25,6 +25,9 @@ pub const SPOTIFY_SEMANTIC_VERSION: &str = "1.2.31.1205.g4d59ad7c";
/// The protocol version of the Spotify mobile app. /// The protocol version of the Spotify mobile app.
pub const SPOTIFY_MOBILE_VERSION: &str = "8.6.84"; pub const SPOTIFY_MOBILE_VERSION: &str = "8.6.84";
/// The general spirc version
pub const SPOTIFY_SPIRC_VERSION: &str = "3.2.6";
/// The user agent to fall back to, if one could not be determined dynamically. /// The user agent to fall back to, if one could not be determined dynamically.
pub const FALLBACK_USER_AGENT: &str = "Spotify/117300517 Linux/0 (librespot)"; pub const FALLBACK_USER_AGENT: &str = "Spotify/117300517 Linux/0 (librespot)";

79
docs/dealer.md Normal file
View file

@ -0,0 +1,79 @@
# Dealer
When talking about the dealer, we are speaking about a websocket that represents the player as
spotify-connect device. The dealer is primarily used to receive updates and not to update the
state.
## Messages and Requests
There are two types of messages that are received via the dealer, Messages and Requests.
Messages are fire-and-forget and don't need a responses, while request expect a reply if the
request was processed successfully or failed.
Because we publish our device with support for gzip, the message payload might be BASE64 encoded
and gzip compressed. If that is the case, the related headers send an entry for "Transfer-Encoding"
with the value of "gzip".
### Messages
Most messages librespot handles send bytes that can be easily converted into their respective
protobuf definition. Some outliers send json that can be usually mapped to an existing protobuf
definition. We use `protobuf-json-mapping` to a similar protobuf definition
> Note: The json sometimes doesn't map exactly and can provide more fields than the protobuf
> definition expects. For messages, we usually ignore unknown fields.
There are two types of messages, "informational" and "fire and forget commands".
**Informational:**
Informational messages send any changes done by the current user or of a client where the current user
is logged in. These messages contain for example changes to a own playlist, additions to the liked songs
or any update that a client sends.
**Fire and Forget commands:**
These are messages that send information that are requests to the current player. These are only send to
the active player. Volume update requests and the logout request are send as fire-forget-commands.
### Requests
The request payload is sent as json. There are almost usable protobuf definitions (see
files named like `es_<command in snakecase>(_request).proto`) for the commands, but they don't
align up with the expected values and are missing some major information we need for handling some
commands. Because of that we have our own model for the specific commands, see
[core/src/dealer/protocol/request.rs](../core/src/dealer/protocol/request.rs).
All request modify the player-state.
## Details
This sections is for details and special hiccups in regards to handling that isn't completely intuitive.
### UIDs
A spotify item is identifiable by their uri. The `ContextTrack` and `ProvidedTrack` both have a `uid`
field. When we receive a context via the `context-resolver` it can return items (`ContextTrack`) that
may have their respective uid set. Some context like the collection and albums don't provide this
information.
When a `uid` is missing, resorting the next tracks in an official client gets confused and sends
incorrect data via the `set_queue` request. To prevent this behavior we generate a uid for each
track that doesn't have an uid. Queue items become a "queue-uid" which is just a `q` with an
incrementing number.
### Metadata
For some client's (especially mobile) the metadata of a track is very important to display the
context correct. For example the "autoplay" metadata is relevant to display the correct context
info.
Metadata can also be used to store data like the iteration when repeating a context.
### Repeat
The context repeating implementation is partly mimicked from the official client. The official
client allows skipping into negative iterations, this is currently not supported.
Repeating is realized by filling the next tracks with multiple contexts separated by delimiters.
By that we only have to handle the delimiter when skipping to the next and previous track.

View file

@ -9,13 +9,13 @@ use librespot::{
player::Player, player::Player,
}, },
}; };
use librespot_connect::spirc::PlayingTrack;
use librespot_connect::{ use librespot_connect::{
config::ConnectConfig,
spirc::{Spirc, SpircLoadCommand}, spirc::{Spirc, SpircLoadCommand},
state::ConnectStateConfig,
}; };
use librespot_metadata::{Album, Metadata}; use librespot_metadata::{Album, Metadata};
use librespot_playback::mixer::{softmixer::SoftMixer, Mixer, MixerConfig}; use librespot_playback::mixer::{softmixer::SoftMixer, Mixer, MixerConfig};
use librespot_protocol::spirc::TrackRef;
use std::env; use std::env;
use std::sync::Arc; use std::sync::Arc;
use tokio::join; use tokio::join;
@ -25,7 +25,7 @@ async fn main() {
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 = ConnectConfig::default(); let connect_config = ConnectStateConfig::default();
let mut args: Vec<_> = env::args().collect(); let mut args: Vec<_> = env::args().collect();
let context_uri = if args.len() == 3 { let context_uri = if args.len() == 3 {
@ -64,14 +64,6 @@ async fn main() {
let album = Album::get(&session, &SpotifyId::from_uri(&context_uri).unwrap()) let album = Album::get(&session, &SpotifyId::from_uri(&context_uri).unwrap())
.await .await
.unwrap(); .unwrap();
let tracks = album
.tracks()
.map(|track_id| {
let mut track = TrackRef::new();
track.set_gid(Vec::from(track_id.to_raw()));
track
})
.collect();
println!( println!(
"Playing album: {} by {}", "Playing album: {} by {}",
@ -87,10 +79,12 @@ async fn main() {
.load(SpircLoadCommand { .load(SpircLoadCommand {
context_uri, context_uri,
start_playing: true, start_playing: true,
seek_to: 0,
shuffle: false, shuffle: false,
repeat: false, repeat: false,
playing_track_index: 0, // the index specifies which track in the context starts playing, in this case the first in the album repeat_track: false,
tracks, // the index specifies which track in the context starts playing, in this case the first in the album
playing_track: PlayingTrack::Index(0),
}) })
.unwrap(); .unwrap();
}); });

View file

@ -94,7 +94,7 @@ impl<'a> Open for PortAudioSink<'a> {
} }
} }
impl<'a> Sink for PortAudioSink<'a> { impl Sink for PortAudioSink<'_> {
fn start(&mut self) -> SinkResult<()> { fn start(&mut self) -> SinkResult<()> {
macro_rules! start_sink { macro_rules! start_sink {
(ref mut $stream: ident, ref $parameters: ident) => {{ (ref mut $stream: ident, ref $parameters: ident) => {{
@ -175,12 +175,12 @@ impl<'a> Sink for PortAudioSink<'a> {
} }
} }
impl<'a> Drop for PortAudioSink<'a> { impl Drop for PortAudioSink<'_> {
fn drop(&mut self) { fn drop(&mut self) {
portaudio_rs::terminate().unwrap(); portaudio_rs::terminate().unwrap();
} }
} }
impl<'a> PortAudioSink<'a> { impl PortAudioSink<'_> {
pub const NAME: &'static str = "portaudio"; pub const NAME: &'static str = "portaudio";
} }

View file

@ -145,7 +145,7 @@ fn create_sink(
}, },
Some(device_name) => { Some(device_name) => {
host.output_devices()? host.output_devices()?
.find(|d| d.name().ok().map_or(false, |name| name == device_name)) // Ignore devices for which getting name fails .find(|d| d.name().ok().is_some_and(|name| name == device_name)) // Ignore devices for which getting name fails
.ok_or_else(|| RodioError::DeviceNotAvailable(device_name.to_string()))? .ok_or_else(|| RodioError::DeviceNotAvailable(device_name.to_string()))?
} }
None => host None => host

View file

@ -123,7 +123,10 @@ enum PlayerCommand {
}, },
EmitFilterExplicitContentChangedEvent(bool), EmitFilterExplicitContentChangedEvent(bool),
EmitShuffleChangedEvent(bool), EmitShuffleChangedEvent(bool),
EmitRepeatChangedEvent(bool), EmitRepeatChangedEvent {
context: bool,
track: bool,
},
EmitAutoPlayChangedEvent(bool), EmitAutoPlayChangedEvent(bool),
} }
@ -218,7 +221,8 @@ pub enum PlayerEvent {
shuffle: bool, shuffle: bool,
}, },
RepeatChanged { RepeatChanged {
repeat: bool, context: bool,
track: bool,
}, },
AutoPlayChanged { AutoPlayChanged {
auto_play: bool, auto_play: bool,
@ -607,8 +611,8 @@ impl Player {
self.command(PlayerCommand::EmitShuffleChangedEvent(shuffle)); self.command(PlayerCommand::EmitShuffleChangedEvent(shuffle));
} }
pub fn emit_repeat_changed_event(&self, repeat: bool) { pub fn emit_repeat_changed_event(&self, context: bool, track: bool) {
self.command(PlayerCommand::EmitRepeatChangedEvent(repeat)); self.command(PlayerCommand::EmitRepeatChangedEvent { context, track });
} }
pub fn emit_auto_play_changed_event(&self, auto_play: bool) { pub fn emit_auto_play_changed_event(&self, auto_play: bool) {
@ -2104,8 +2108,8 @@ impl PlayerInternal {
self.send_event(PlayerEvent::VolumeChanged { volume }) self.send_event(PlayerEvent::VolumeChanged { volume })
} }
PlayerCommand::EmitRepeatChangedEvent(repeat) => { PlayerCommand::EmitRepeatChangedEvent { context, track } => {
self.send_event(PlayerEvent::RepeatChanged { repeat }) self.send_event(PlayerEvent::RepeatChanged { context, track })
} }
PlayerCommand::EmitShuffleChangedEvent(shuffle) => { PlayerCommand::EmitShuffleChangedEvent(shuffle) => {
@ -2336,9 +2340,10 @@ impl fmt::Debug for PlayerCommand {
.debug_tuple("EmitShuffleChangedEvent") .debug_tuple("EmitShuffleChangedEvent")
.field(&shuffle) .field(&shuffle)
.finish(), .finish(),
PlayerCommand::EmitRepeatChangedEvent(repeat) => f PlayerCommand::EmitRepeatChangedEvent { context, track } => f
.debug_tuple("EmitRepeatChangedEvent") .debug_tuple("EmitRepeatChangedEvent")
.field(&repeat) .field(&context)
.field(&track)
.finish(), .finish(),
PlayerCommand::EmitAutoPlayChangedEvent(auto_play) => f PlayerCommand::EmitAutoPlayChangedEvent(auto_play) => f
.debug_tuple("EmitAutoPlayChangedEvent") .debug_tuple("EmitAutoPlayChangedEvent")

View file

@ -37,6 +37,8 @@ fn compile() {
proto_dir.join("spotify/login5/v3/user_info.proto"), proto_dir.join("spotify/login5/v3/user_info.proto"),
proto_dir.join("storage-resolve.proto"), proto_dir.join("storage-resolve.proto"),
proto_dir.join("user_attributes.proto"), proto_dir.join("user_attributes.proto"),
proto_dir.join("autoplay_context_request.proto"),
proto_dir.join("social_connect_v2.proto"),
// TODO: remove these legacy protobufs when we are on the new API completely // TODO: remove these legacy protobufs when we are on the new API completely
proto_dir.join("authentication.proto"), proto_dir.join("authentication.proto"),
proto_dir.join("canvaz.proto"), proto_dir.join("canvaz.proto"),
@ -45,7 +47,6 @@ fn compile() {
proto_dir.join("keyexchange.proto"), proto_dir.join("keyexchange.proto"),
proto_dir.join("mercury.proto"), proto_dir.join("mercury.proto"),
proto_dir.join("pubsub.proto"), proto_dir.join("pubsub.proto"),
proto_dir.join("spirc.proto"),
]; ];
let slices = files.iter().map(Deref::deref).collect::<Vec<_>>(); let slices = files.iter().map(Deref::deref).collect::<Vec<_>>();

View file

@ -1,7 +1,3 @@
use data_encoding::HEXLOWER;
use futures_util::StreamExt;
use log::{debug, error, info, trace, warn};
use sha1::{Digest, Sha1};
use std::{ use std::{
env, env,
fs::create_dir_all, fs::create_dir_all,
@ -12,12 +8,13 @@ use std::{
str::FromStr, str::FromStr,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use sysinfo::{ProcessesToUpdate, System};
use thiserror::Error;
use url::Url;
use data_encoding::HEXLOWER;
use futures_util::StreamExt;
#[cfg(feature = "alsa-backend")]
use librespot::playback::mixer::alsamixer::AlsaMixer;
use librespot::{ use librespot::{
connect::{config::ConnectConfig, spirc::Spirc}, connect::{spirc::Spirc, state::ConnectStateConfig},
core::{ core::{
authentication::Credentials, cache::Cache, config::DeviceType, version, Session, authentication::Credentials, cache::Cache, config::DeviceType, version, Session,
SessionConfig, SessionConfig,
@ -33,9 +30,11 @@ use librespot::{
player::{coefficient_to_duration, duration_to_coefficient, Player}, player::{coefficient_to_duration, duration_to_coefficient, Player},
}, },
}; };
use log::{debug, error, info, trace, warn};
#[cfg(feature = "alsa-backend")] use sha1::{Digest, Sha1};
use librespot::playback::mixer::alsamixer::AlsaMixer; use sysinfo::{ProcessesToUpdate, System};
use thiserror::Error;
use url::Url;
mod player_event_handler; mod player_event_handler;
use player_event_handler::{run_program_on_sink_events, EventHandler}; use player_event_handler::{run_program_on_sink_events, EventHandler};
@ -208,7 +207,7 @@ struct Setup {
cache: Option<Cache>, cache: Option<Cache>,
player_config: PlayerConfig, player_config: PlayerConfig,
session_config: SessionConfig, session_config: SessionConfig,
connect_config: ConnectConfig, connect_config: ConnectStateConfig,
mixer_config: MixerConfig, mixer_config: MixerConfig,
credentials: Option<Credentials>, credentials: Option<Credentials>,
enable_oauth: bool, enable_oauth: bool,
@ -1371,7 +1370,7 @@ fn get_setup() -> Setup {
}); });
let connect_config = { let connect_config = {
let connect_default_config = ConnectConfig::default(); let connect_default_config = ConnectStateConfig::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());
@ -1431,14 +1430,11 @@ fn get_setup() -> Setup {
#[cfg(feature = "alsa-backend")] #[cfg(feature = "alsa-backend")]
let default_value = &format!( let default_value = &format!(
"{}, or the current value when the alsa mixer is used.", "{}, or the current value when the alsa mixer is used.",
connect_default_config.initial_volume.unwrap_or_default() connect_default_config.initial_volume
); );
#[cfg(not(feature = "alsa-backend"))] #[cfg(not(feature = "alsa-backend"))]
let default_value = &connect_default_config let default_value = &connect_default_config.initial_volume.to_string();
.initial_volume
.unwrap_or_default()
.to_string();
invalid_error_msg( invalid_error_msg(
INITIAL_VOLUME, INITIAL_VOLUME,
@ -1485,14 +1481,21 @@ fn get_setup() -> Setup {
let is_group = opt_present(DEVICE_IS_GROUP); let is_group = opt_present(DEVICE_IS_GROUP);
let has_volume_ctrl = !matches!(mixer_config.volume_ctrl, VolumeCtrl::Fixed); 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()
has_volume_ctrl, }
} else {
ConnectStateConfig {
name,
device_type,
is_group,
..Default::default()
}
} }
}; };

View file

@ -226,9 +226,10 @@ impl EventHandler {
env_vars.insert("PLAYER_EVENT", "shuffle_changed".to_string()); env_vars.insert("PLAYER_EVENT", "shuffle_changed".to_string());
env_vars.insert("SHUFFLE", shuffle.to_string()); env_vars.insert("SHUFFLE", shuffle.to_string());
} }
PlayerEvent::RepeatChanged { repeat } => { PlayerEvent::RepeatChanged { context, track } => {
env_vars.insert("PLAYER_EVENT", "repeat_changed".to_string()); env_vars.insert("PLAYER_EVENT", "repeat_changed".to_string());
env_vars.insert("REPEAT", repeat.to_string()); env_vars.insert("REPEAT", context.to_string());
env_vars.insert("REPEAT_TRACK", track.to_string());
} }
PlayerEvent::AutoPlayChanged { auto_play } => { PlayerEvent::AutoPlayChanged { auto_play } => {
env_vars.insert("PLAYER_EVENT", "auto_play_changed".to_string()); env_vars.insert("PLAYER_EVENT", "auto_play_changed".to_string());