mirror of
https://github.com/librespot-org/librespot.git
synced 2024-12-18 17:11:53 +00:00
Retrieve autoplay contexts over HTTPS and fix repeat/prev/next
Repeat, previous and next used to start playback regardless of the actual playback state. They now start playback only if we were already playing.
This commit is contained in:
parent
6dc7a11b09
commit
bfb7d5689c
4 changed files with 143 additions and 149 deletions
|
@ -40,6 +40,9 @@ https://github.com/librespot-org/librespot
|
|||
- [audio] Improve file opening and seeking performance (breaking)
|
||||
- [chore] MSRV is now 1.61 (breaking)
|
||||
- [connect] `DeviceType` moved out of `connect` into `core` (breaking)
|
||||
- [connect] Update and expose all `spirc` context fields (breaking)
|
||||
- [connect] Add `Clone, Defaut` traits to `spirc` contexts
|
||||
- [connect] Autoplay contexts are now retrieved with the `spclient` (breaking)
|
||||
- [core] Message listeners are registered before authenticating. As a result
|
||||
there now is a separate `Session::new` and subsequent `session.connect`.
|
||||
(breaking)
|
||||
|
@ -96,6 +99,8 @@ https://github.com/librespot-org/librespot
|
|||
`LoadingPause` in `spirc.rs`
|
||||
- [connect] Handle attempts to play local files better by basically ignoring
|
||||
attempts to load them in `handle_remote_update` in `spirc.rs`
|
||||
- [connect] Loading previous or next tracks, or looping back on repeat, will
|
||||
only start playback when we were already playing
|
||||
- [connect, playback] Clean up and de-noise events and event firing
|
||||
- [playback] Handle invalid track start positions by just starting the track
|
||||
from the beginning
|
||||
|
|
|
@ -8,67 +8,89 @@ use serde::{
|
|||
Deserialize,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Deserialize, Debug, Default, Clone)]
|
||||
pub struct StationContext {
|
||||
pub uri: Option<String>,
|
||||
pub next_page_url: String,
|
||||
#[serde(deserialize_with = "deserialize_protobuf_TrackRef")]
|
||||
pub tracks: Vec<TrackRef>,
|
||||
// Not required for core functionality
|
||||
// pub seeds: Vec<String>,
|
||||
// #[serde(rename = "imageUri")]
|
||||
// pub image_uri: String,
|
||||
// pub subtitle: Option<String>,
|
||||
// pub subtitles: Vec<String>,
|
||||
// #[serde(rename = "subtitleUri")]
|
||||
// pub subtitle_uri: Option<String>,
|
||||
// pub title: String,
|
||||
// #[serde(rename = "titleUri")]
|
||||
// pub title_uri: String,
|
||||
// pub related_artists: Vec<ArtistContext>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct PageContext {
|
||||
pub uri: String,
|
||||
pub next_page_url: 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>,
|
||||
// Not required for core functionality
|
||||
// pub url: String,
|
||||
// // pub restrictions:
|
||||
pub next_page_url: String,
|
||||
pub correlation_id: String,
|
||||
pub related_artists: Vec<ArtistContext>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[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 {
|
||||
#[serde(rename = "original_gid")]
|
||||
pub gid: String,
|
||||
pub uri: String,
|
||||
pub uid: String,
|
||||
// Not required for core functionality
|
||||
// pub album_uri: String,
|
||||
// pub artist_uri: String,
|
||||
// pub metadata: MetadataContext,
|
||||
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)]
|
||||
#[derive(Deserialize, Debug, Default, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArtistContext {
|
||||
#[serde(rename = "artistName")]
|
||||
artist_name: String,
|
||||
artist_uri: String,
|
||||
#[serde(rename = "imageUri")]
|
||||
image_uri: String,
|
||||
#[serde(rename = "artistUri")]
|
||||
artist_uri: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Deserialize, Debug, Default, Clone)]
|
||||
pub struct MetadataContext {
|
||||
album_title: String,
|
||||
artist_name: String,
|
||||
artist_uri: String,
|
||||
image_url: String,
|
||||
title: String,
|
||||
uid: 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)]
|
||||
|
|
|
@ -6,11 +6,7 @@ use std::{
|
|||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use futures_util::{
|
||||
future::{self, FusedFuture},
|
||||
stream::FusedStream,
|
||||
FutureExt, StreamExt,
|
||||
};
|
||||
use futures_util::{stream::FusedStream, FutureExt, StreamExt};
|
||||
|
||||
use protobuf::{self, Message};
|
||||
use rand::seq::SliceRandom;
|
||||
|
@ -20,13 +16,10 @@ use tokio_stream::wrappers::UnboundedReceiverStream;
|
|||
|
||||
use crate::{
|
||||
config::ConnectConfig,
|
||||
context::StationContext,
|
||||
context::{PageContext, StationContext},
|
||||
core::{
|
||||
authentication::Credentials,
|
||||
mercury::{MercuryError, MercurySender},
|
||||
session::UserAttributes,
|
||||
util::SeqGenerator,
|
||||
version, Error, Session, SpotifyId,
|
||||
authentication::Credentials, mercury::MercurySender, session::UserAttributes,
|
||||
util::SeqGenerator, version, Error, Session, SpotifyId,
|
||||
},
|
||||
playback::{
|
||||
mixer::Mixer,
|
||||
|
@ -81,7 +74,6 @@ enum SpircPlayStatus {
|
|||
},
|
||||
}
|
||||
|
||||
type BoxedFuture<T> = Pin<Box<dyn FusedFuture<Output = T> + Send>>;
|
||||
type BoxedStream<T> = Pin<Box<dyn FusedStream<Item = T> + Send>>;
|
||||
|
||||
struct SpircTask {
|
||||
|
@ -106,8 +98,8 @@ struct SpircTask {
|
|||
|
||||
shutdown: bool,
|
||||
session: Session,
|
||||
context_fut: BoxedFuture<Result<serde_json::Value, Error>>,
|
||||
autoplay_fut: BoxedFuture<Result<String, Error>>,
|
||||
resolve_context: Option<String>,
|
||||
autoplay_context: bool,
|
||||
context: Option<StationContext>,
|
||||
|
||||
spirc_id: usize,
|
||||
|
@ -132,6 +124,7 @@ pub enum SpircCommand {
|
|||
SetVolume(u16),
|
||||
}
|
||||
|
||||
const CONTEXT_TRACKS_COUNT: usize = 50;
|
||||
const CONTEXT_TRACKS_HISTORY: usize = 10;
|
||||
const CONTEXT_FETCH_THRESHOLD: u32 = 5;
|
||||
|
||||
|
@ -376,8 +369,8 @@ impl Spirc {
|
|||
shutdown: false,
|
||||
session,
|
||||
|
||||
context_fut: Box::pin(future::pending()),
|
||||
autoplay_fut: Box::pin(future::pending()),
|
||||
resolve_context: None,
|
||||
autoplay_context: false,
|
||||
context: None,
|
||||
|
||||
spirc_id,
|
||||
|
@ -504,10 +497,35 @@ impl SpircTask {
|
|||
error!("Cannot flush spirc event sender.");
|
||||
break;
|
||||
},
|
||||
context = &mut self.context_fut, if !self.context_fut.is_terminated() => {
|
||||
context_uri = async { self.resolve_context.take() }, if self.resolve_context.is_some() => {
|
||||
let context_uri = context_uri.unwrap();
|
||||
let is_next_page = context_uri.starts_with("hm://");
|
||||
|
||||
let context = if is_next_page {
|
||||
self.session.spclient().get_next_page(&context_uri).await
|
||||
} else {
|
||||
let previous_tracks = self.state.get_track().iter().filter_map(|t| SpotifyId::try_from(t).ok()).collect();
|
||||
self.session.spclient().get_apollo_station(&context_uri, CONTEXT_TRACKS_COUNT, previous_tracks, self.autoplay_context).await
|
||||
};
|
||||
|
||||
match context {
|
||||
Ok(value) => {
|
||||
let r_context = serde_json::from_value::<StationContext>(value);
|
||||
let r_context = if is_next_page {
|
||||
match serde_json::from_slice::<PageContext>(&value) {
|
||||
Ok(page_context) => {
|
||||
// page contexts don't have the stations full metadata, so decorate it
|
||||
let mut station_context = self.context.clone().unwrap_or_default();
|
||||
station_context.tracks = page_context.tracks;
|
||||
station_context.next_page_url = page_context.next_page_url;
|
||||
station_context.correlation_id = page_context.correlation_id;
|
||||
Ok(station_context)
|
||||
},
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
} else {
|
||||
serde_json::from_slice::<StationContext>(&value)
|
||||
};
|
||||
|
||||
self.context = match r_context {
|
||||
Ok(context) => {
|
||||
info!(
|
||||
|
@ -522,28 +540,12 @@ impl SpircTask {
|
|||
None
|
||||
}
|
||||
};
|
||||
// It needn't be so verbose - can be as simple as
|
||||
// if let Some(ref context) = r_context {
|
||||
// info!("Got {:?} tracks from <{}>", context.tracks.len(), context.uri);
|
||||
// }
|
||||
// self.context = r_context;
|
||||
},
|
||||
Err(err) => {
|
||||
error!("ContextError: {:?}", err)
|
||||
}
|
||||
}
|
||||
},
|
||||
autoplay = &mut self.autoplay_fut, if !self.autoplay_fut.is_terminated() => {
|
||||
match autoplay {
|
||||
Ok(autoplay_station_uri) => {
|
||||
info!("Autoplay uri resolved to <{:?}>", autoplay_station_uri);
|
||||
self.context_fut = self.resolve_station(&autoplay_station_uri);
|
||||
},
|
||||
Err(err) => {
|
||||
error!("AutoplayError: {:?}", err)
|
||||
}
|
||||
}
|
||||
},
|
||||
else => break
|
||||
}
|
||||
}
|
||||
|
@ -953,7 +955,7 @@ impl SpircTask {
|
|||
MessageType::kMessageTypeShuffle => {
|
||||
let shuffle = update.get_state().get_shuffle();
|
||||
self.state.set_shuffle(shuffle);
|
||||
if self.state.get_shuffle() {
|
||||
if shuffle {
|
||||
let current_index = self.state.get_playing_track_index();
|
||||
let tracks = self.state.mut_track();
|
||||
if !tracks.is_empty() {
|
||||
|
@ -964,11 +966,7 @@ impl SpircTask {
|
|||
}
|
||||
self.state.set_playing_track_index(0);
|
||||
}
|
||||
} else {
|
||||
let context = self.state.get_context_uri();
|
||||
debug!("{:?}", context);
|
||||
}
|
||||
|
||||
self.player.emit_shuffle_changed_event(shuffle);
|
||||
|
||||
self.notify(None)
|
||||
|
@ -1191,40 +1189,41 @@ impl SpircTask {
|
|||
}
|
||||
|
||||
fn handle_next(&mut self) {
|
||||
let context_uri = self.state.get_context_uri().to_owned();
|
||||
let mut tracks_len = self.state.get_track().len() as u32;
|
||||
let mut new_index = self.consume_queued_track() as u32;
|
||||
let mut continue_playing = true;
|
||||
let tracks_len = self.state.get_track().len() as u32;
|
||||
let mut continue_playing = self.state.get_status() == PlayStatus::kPlayStatusPlay;
|
||||
|
||||
let update_tracks =
|
||||
self.autoplay_context && tracks_len - new_index < CONTEXT_FETCH_THRESHOLD;
|
||||
|
||||
debug!(
|
||||
"At track {:?} of {:?} <{:?}> update [{}]",
|
||||
new_index + 1,
|
||||
tracks_len,
|
||||
self.state.get_context_uri(),
|
||||
tracks_len - new_index < CONTEXT_FETCH_THRESHOLD
|
||||
context_uri,
|
||||
update_tracks,
|
||||
);
|
||||
let context_uri = self.state.get_context_uri().to_owned();
|
||||
if (context_uri.starts_with("spotify:station:")
|
||||
|| context_uri.starts_with("spotify:dailymix:")
|
||||
// spotify:user:xxx:collection
|
||||
|| context_uri.starts_with(&format!("spotify:user:{}:collection",url_encode(&self.session.username()))))
|
||||
&& ((self.state.get_track().len() as u32) - new_index) < CONTEXT_FETCH_THRESHOLD
|
||||
{
|
||||
self.context_fut = self.resolve_station(&context_uri);
|
||||
|
||||
// When in autoplay, keep topping up the playlist when it nears the end
|
||||
if update_tracks {
|
||||
self.update_tracks_from_context();
|
||||
new_index = self.state.get_playing_track_index();
|
||||
tracks_len = self.state.get_track().len() as u32;
|
||||
}
|
||||
|
||||
// When not in autoplay, either start autoplay or loop back to the start
|
||||
if new_index >= tracks_len {
|
||||
if self.session.autoplay() {
|
||||
// Extend the playlist
|
||||
debug!("Extending playlist <{}>", context_uri);
|
||||
debug!("Starting autoplay for <{}>", context_uri);
|
||||
self.autoplay_context = true;
|
||||
self.update_tracks_from_context();
|
||||
self.player.set_auto_normalise_as_album(false);
|
||||
} else {
|
||||
new_index = 0;
|
||||
continue_playing = self.state.get_repeat();
|
||||
debug!(
|
||||
"Looping around back to start, repeat is {}",
|
||||
continue_playing
|
||||
);
|
||||
continue_playing &= self.state.get_repeat();
|
||||
debug!("Looping back to start, repeat is {}", continue_playing);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1271,7 +1270,8 @@ impl SpircTask {
|
|||
|
||||
self.state.set_playing_track_index(new_index);
|
||||
|
||||
self.load_track(true, 0);
|
||||
let start_playing = self.state.get_status() == PlayStatus::kPlayStatusPlay;
|
||||
self.load_track(start_playing, 0);
|
||||
} else {
|
||||
self.handle_seek(0);
|
||||
}
|
||||
|
@ -1304,50 +1304,16 @@ impl SpircTask {
|
|||
}
|
||||
}
|
||||
|
||||
fn resolve_station(&self, uri: &str) -> BoxedFuture<Result<serde_json::Value, Error>> {
|
||||
let radio_uri = format!("hm://radio-apollo/v3/stations/{}", uri);
|
||||
|
||||
self.resolve_uri(&radio_uri)
|
||||
}
|
||||
|
||||
fn resolve_autoplay_uri(&self, uri: &str) -> BoxedFuture<Result<String, Error>> {
|
||||
let query_uri = format!("hm://autoplay-enabled/query?uri={}", uri);
|
||||
let request = self.session.mercury().get(query_uri);
|
||||
Box::pin(
|
||||
async {
|
||||
let response = request?.await?;
|
||||
|
||||
if response.status_code == 200 {
|
||||
let data = response.payload.first().ok_or(SpircError::NoData)?.to_vec();
|
||||
Ok(String::from_utf8(data)?)
|
||||
} else {
|
||||
warn!("No autoplay_uri found");
|
||||
Err(MercuryError::Response(response).into())
|
||||
}
|
||||
}
|
||||
.fuse(),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_uri(&self, uri: &str) -> BoxedFuture<Result<serde_json::Value, Error>> {
|
||||
let request = self.session.mercury().get(uri);
|
||||
|
||||
Box::pin(
|
||||
async move {
|
||||
let response = request?.await?;
|
||||
|
||||
let data = response.payload.first().ok_or(SpircError::NoData)?;
|
||||
let response: serde_json::Value = serde_json::from_slice(data)?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
.fuse(),
|
||||
)
|
||||
}
|
||||
|
||||
fn update_tracks_from_context(&mut self) {
|
||||
if let Some(ref context) = self.context {
|
||||
self.context_fut = self.resolve_uri(&context.next_page_url);
|
||||
self.resolve_context =
|
||||
if !self.autoplay_context || context.next_page_url.contains("autoplay=true") {
|
||||
Some(context.next_page_url.to_owned())
|
||||
} else {
|
||||
// this arm means: we need to resolve for autoplay,
|
||||
// and were previously resolving for the original context
|
||||
Some(context.uri.to_owned())
|
||||
};
|
||||
|
||||
let new_tracks = &context.tracks;
|
||||
debug!("Adding {:?} tracks from context to frame", new_tracks.len());
|
||||
|
@ -1381,15 +1347,10 @@ impl SpircTask {
|
|||
|
||||
trace!("Frame has {:?} tracks", tracks.len());
|
||||
|
||||
if context_uri.starts_with("spotify:station:")
|
||||
|| context_uri.starts_with("spotify:dailymix:")
|
||||
{
|
||||
self.context_fut = self.resolve_station(&context_uri);
|
||||
} else if self.session.autoplay() {
|
||||
info!("Fetching autoplay context uri");
|
||||
// Get autoplay_station_uri for regular playlists
|
||||
self.autoplay_fut = self.resolve_autoplay_uri(&context_uri);
|
||||
}
|
||||
// First the tracks from the requested context, without autoplay.
|
||||
// We will transition into autoplay after the latest track of this context.
|
||||
self.autoplay_context = false;
|
||||
self.resolve_context = Some(context_uri.clone());
|
||||
|
||||
self.player
|
||||
.set_auto_normalise_as_album(context_uri.starts_with("spotify:album:"));
|
||||
|
|
|
@ -616,7 +616,7 @@ impl SpClient {
|
|||
pub async fn get_radio_for_track(&self, track_id: &SpotifyId) -> SpClientResult {
|
||||
let endpoint = format!(
|
||||
"/inspiredby-mix/v2/seed_to_playlist/{}?response-format=json",
|
||||
track_id.to_uri()?
|
||||
track_id.to_base62()?
|
||||
);
|
||||
|
||||
self.request_as_json(&Method::GET, &endpoint, None, None)
|
||||
|
@ -626,13 +626,13 @@ impl SpClient {
|
|||
pub async fn get_apollo_station(
|
||||
&self,
|
||||
context_uri: &str,
|
||||
count: u32,
|
||||
previous_tracks: Vec<&SpotifyId>,
|
||||
count: usize,
|
||||
previous_tracks: Vec<SpotifyId>,
|
||||
autoplay: bool,
|
||||
) -> SpClientResult {
|
||||
let previous_track_str = previous_tracks
|
||||
.iter()
|
||||
.map(|track| track.to_uri())
|
||||
.map(|track| track.to_base62())
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join(",");
|
||||
let endpoint = format!(
|
||||
|
@ -644,6 +644,12 @@ impl SpClient {
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn get_next_page(&self, next_page_uri: &str) -> SpClientResult {
|
||||
let endpoint = next_page_uri.trim_start_matches("hm:/");
|
||||
self.request_as_json(&Method::GET, endpoint, None, None)
|
||||
.await
|
||||
}
|
||||
|
||||
// TODO: Find endpoint for newer canvas.proto and upgrade to that.
|
||||
pub async fn get_canvases(&self, request: EntityCanvazRequest) -> SpClientResult {
|
||||
let endpoint = "/canvaz-cache/v0/canvases";
|
||||
|
|
Loading…
Reference in a new issue