Improvements towards supporting pagination

Not there yet, as Apollo stations always return autoplay
recommendations even if you set autoplay to false. Along the way
as an effort to bring the protocol up to spec:

- And support for and use different Apollo station scopes depending
  on whether we are using autoplay or not. For autoplay, get a
  "stations" scope and follow the "tracks" pages from there. Otherwise
  use "tracks" immediately for the active scope (playlist, album).

- For the above point we only need the fields from `PageContext`
  so use that instead of a `StationContext`.

- Add some documentation from API reverse engineering: things seen
  in the wild, some of them to do, others documented for posterity's
  sake.

- Update the Spirc device state based on what the latest desktop
  client puts out. Unfortunately none of it seems to change the
  behavior necessary to support external episodes, shows, but
  at least we're doing the right thing.

- Add a salt to HTTPS queries to defeat any caching.

- Add country metrics to HTTPS queries.

- Fix `get_radio_for_track` to use the right Spotify ID format.

- Fix a bug from the previous commit, where the playback position
  might not advance when hitting next and the autoplay context
  is loaded initially.
This commit is contained in:
Roderick van Domburg 2022-10-01 23:01:17 +02:00
parent bfb7d5689c
commit f10b8f69f8
No known key found for this signature in database
GPG key ID: FE2585E713F9F30A
2 changed files with 139 additions and 65 deletions

View file

@ -16,7 +16,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream;
use crate::{ use crate::{
config::ConnectConfig, config::ConnectConfig,
context::{PageContext, StationContext}, context::PageContext,
core::{ core::{
authentication::Credentials, mercury::MercurySender, session::UserAttributes, authentication::Credentials, mercury::MercurySender, session::UserAttributes,
util::SeqGenerator, version, Error, Session, SpotifyId, util::SeqGenerator, version, Error, Session, SpotifyId,
@ -100,7 +100,7 @@ struct SpircTask {
session: Session, session: Session,
resolve_context: Option<String>, resolve_context: Option<String>,
autoplay_context: bool, autoplay_context: bool,
context: Option<StationContext>, context: Option<PageContext>,
spirc_id: usize, spirc_id: usize,
} }
@ -124,7 +124,6 @@ pub enum SpircCommand {
SetVolume(u16), SetVolume(u16),
} }
const CONTEXT_TRACKS_COUNT: usize = 50;
const CONTEXT_TRACKS_HISTORY: usize = 10; const CONTEXT_TRACKS_HISTORY: usize = 10;
const CONTEXT_FETCH_THRESHOLD: u32 = 5; const CONTEXT_FETCH_THRESHOLD: u32 = 5;
@ -184,6 +183,7 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState {
}; };
{ {
let msg = repeated.push_default(); let msg = repeated.push_default();
// TODO: implement logout
msg.set_typ(protocol::spirc::CapabilityType::kSupportsLogout); msg.set_typ(protocol::spirc::CapabilityType::kSupportsLogout);
{ {
let repeated = msg.mut_intValue(); let repeated = msg.mut_intValue();
@ -224,17 +224,51 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState {
}; };
{ {
let msg = repeated.push_default(); let msg = repeated.push_default();
msg.set_typ(protocol::spirc::CapabilityType::kSupportedContexts); msg.set_typ(protocol::spirc::CapabilityType::kSupportsExternalEpisodes);
{ {
let repeated = msg.mut_stringValue(); let repeated = msg.mut_intValue();
repeated.push(::std::convert::Into::into("album")); repeated.push(1)
repeated.push(::std::convert::Into::into("playlist")); };
repeated.push(::std::convert::Into::into("search")); msg
repeated.push(::std::convert::Into::into("inbox")); };
repeated.push(::std::convert::Into::into("toplist")); {
repeated.push(::std::convert::Into::into("starred")); let msg = repeated.push_default();
repeated.push(::std::convert::Into::into("publishedstarred")); // TODO: how would such a rename command be triggered? Handle it.
repeated.push(::std::convert::Into::into("track")) msg.set_typ(protocol::spirc::CapabilityType::kSupportsRename);
{
let repeated = msg.mut_intValue();
repeated.push(1)
};
msg
};
{
let msg = repeated.push_default();
msg.set_typ(protocol::spirc::CapabilityType::kCommandAcks);
{
let repeated = msg.mut_intValue();
repeated.push(0)
};
msg
};
{
let msg = repeated.push_default();
// TODO: does this mean local files or the local network?
// LAN may be an interesting privacy toggle.
msg.set_typ(protocol::spirc::CapabilityType::kRestrictToLocal);
{
let repeated = msg.mut_intValue();
repeated.push(0)
};
msg
};
{
let msg = repeated.push_default();
// TODO: what does this hide, or who do we hide from?
// May be an interesting privacy toggle.
msg.set_typ(protocol::spirc::CapabilityType::kHidden);
{
let repeated = msg.mut_intValue();
repeated.push(0)
}; };
msg msg
}; };
@ -243,9 +277,15 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState {
msg.set_typ(protocol::spirc::CapabilityType::kSupportedTypes); msg.set_typ(protocol::spirc::CapabilityType::kSupportedTypes);
{ {
let repeated = msg.mut_stringValue(); let repeated = msg.mut_stringValue();
repeated.push(::std::convert::Into::into("audio/track")); repeated.push("audio/episode".to_string());
repeated.push(::std::convert::Into::into("audio/episode")); repeated.push("audio/episode+track".to_string());
repeated.push(::std::convert::Into::into("track")) repeated.push("audio/track".to_string());
// other known types:
// - "audio/ad"
// - "audio/interruption"
// - "audio/local"
// - "video/ad"
// - "video/episode"
}; };
msg msg
}; };
@ -498,35 +538,30 @@ impl SpircTask {
break; break;
}, },
context_uri = async { self.resolve_context.take() }, if self.resolve_context.is_some() => { context_uri = async { self.resolve_context.take() }, if self.resolve_context.is_some() => {
let context_uri = context_uri.unwrap(); let context_uri = context_uri.unwrap(); // guaranteed above
let is_next_page = context_uri.starts_with("hm://"); if context_uri.contains("spotify:show:") || context_uri.contains("spotify:episode:") {
continue; // not supported by apollo stations
}
let context = if is_next_page { let context = if context_uri.starts_with("hm://") {
self.session.spclient().get_next_page(&context_uri).await self.session.spclient().get_next_page(&context_uri).await
} else { } else {
let previous_tracks = self.state.get_track().iter().filter_map(|t| SpotifyId::try_from(t).ok()).collect(); // only send previous tracks that were before the current playback position
self.session.spclient().get_apollo_station(&context_uri, CONTEXT_TRACKS_COUNT, previous_tracks, self.autoplay_context).await let current_position = self.state.get_playing_track_index() as usize;
let previous_tracks = self.state.get_track()[..current_position].iter().filter_map(|t| SpotifyId::try_from(t).ok()).collect();
let scope = if self.autoplay_context {
"stations" // this returns a `StationContext` but we deserialize it into a `PageContext`
} else {
"tracks" // this returns a `PageContext`
};
self.session.spclient().get_apollo_station(scope, &context_uri, None, previous_tracks, self.autoplay_context).await
}; };
match context { match context {
Ok(value) => { Ok(value) => {
let r_context = if is_next_page { self.context = match serde_json::from_slice::<PageContext>(&value) {
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) => { Ok(context) => {
info!( info!(
"Resolved {:?} tracks from <{:?}>", "Resolved {:?} tracks from <{:?}>",
@ -829,7 +864,7 @@ impl SpircTask {
for entry in update.get_device_state().get_metadata().iter() { for entry in update.get_device_state().get_metadata().iter() {
match entry.get_field_type() { match entry.get_field_type() {
"client-id" => self.session.set_client_id(entry.get_metadata()), "client_id" => self.session.set_client_id(entry.get_metadata()),
"brand_display_name" => self.session.set_client_brand_name(entry.get_metadata()), "brand_display_name" => self.session.set_client_brand_name(entry.get_metadata()),
"model_display_name" => self.session.set_client_model_name(entry.get_metadata()), "model_display_name" => self.session.set_client_model_name(entry.get_metadata()),
_ => (), _ => (),
@ -1207,17 +1242,23 @@ impl SpircTask {
// When in autoplay, keep topping up the playlist when it nears the end // When in autoplay, keep topping up the playlist when it nears the end
if update_tracks { if update_tracks {
if let Some(ref context) = self.context {
self.resolve_context = Some(context.next_page_url.to_owned());
self.update_tracks_from_context(); self.update_tracks_from_context();
new_index = self.state.get_playing_track_index();
tracks_len = self.state.get_track().len() as u32; tracks_len = self.state.get_track().len() as u32;
} }
}
// When not in autoplay, either start autoplay or loop back to the start // When not in autoplay, either start autoplay or loop back to the start
if new_index >= tracks_len { if new_index >= tracks_len {
if self.session.autoplay() { // for some contexts there is no autoplay, such as shows and episodes
// in such cases there is no context in librespot.
if self.context.is_some() && self.session.autoplay() {
// Extend the playlist // Extend the playlist
debug!("Starting autoplay for <{}>", context_uri); debug!("Starting autoplay for <{}>", context_uri);
// force reloading the current context with an autoplay context
self.autoplay_context = true; self.autoplay_context = true;
self.resolve_context = Some(self.state.get_context_uri().to_owned());
self.update_tracks_from_context(); self.update_tracks_from_context();
self.player.set_auto_normalise_as_album(false); self.player.set_auto_normalise_as_album(false);
} else { } else {
@ -1306,17 +1347,10 @@ impl SpircTask {
fn update_tracks_from_context(&mut self) { fn update_tracks_from_context(&mut self) {
if let Some(ref context) = self.context { if let Some(ref context) = self.context {
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; let new_tracks = &context.tracks;
debug!("Adding {:?} tracks from context to frame", new_tracks.len()); debug!("Adding {:?} tracks from context to frame", new_tracks.len());
let mut track_vec = self.state.take_track().into_vec(); let mut track_vec = self.state.take_track().into_vec();
if let Some(head) = track_vec.len().checked_sub(CONTEXT_TRACKS_HISTORY) { if let Some(head) = track_vec.len().checked_sub(CONTEXT_TRACKS_HISTORY) {
track_vec.drain(0..head); track_vec.drain(0..head);
@ -1342,7 +1376,7 @@ impl SpircTask {
trace!("State: {:#?}", frame.get_state()); trace!("State: {:#?}", frame.get_state());
let index = frame.get_state().get_playing_track_index(); let index = frame.get_state().get_playing_track_index();
let context_uri = frame.get_state().get_context_uri().to_owned(); let context_uri = frame.get_state().get_context_uri();
let tracks = frame.get_state().get_track(); let tracks = frame.get_state().get_track();
trace!("Frame has {:?} tracks", tracks.len()); trace!("Frame has {:?} tracks", tracks.len());
@ -1350,14 +1384,14 @@ impl SpircTask {
// First the tracks from the requested context, without autoplay. // First the tracks from the requested context, without autoplay.
// We will transition into autoplay after the latest track of this context. // We will transition into autoplay after the latest track of this context.
self.autoplay_context = false; self.autoplay_context = false;
self.resolve_context = Some(context_uri.clone()); self.resolve_context = Some(context_uri.to_owned());
self.player self.player
.set_auto_normalise_as_album(context_uri.starts_with("spotify:album:")); .set_auto_normalise_as_album(context_uri.starts_with("spotify:album:"));
self.state.set_playing_track_index(index); self.state.set_playing_track_index(index);
self.state.set_track(tracks.iter().cloned().collect()); self.state.set_track(tracks.iter().cloned().collect());
self.state.set_context_uri(context_uri); self.state.set_context_uri(context_uri.to_owned());
// has_shuffle/repeat seem to always be true in these replace msgs, // has_shuffle/repeat seem to always be true in these replace msgs,
// but to replicate the behaviour of the Android client we have to // but to replicate the behaviour of the Android client we have to
// ignore false values. // ignore false values.
@ -1517,8 +1551,11 @@ struct CommandSender<'a> {
impl<'a> CommandSender<'a> { impl<'a> CommandSender<'a> {
fn new(spirc: &'a mut SpircTask, cmd: MessageType) -> CommandSender<'_> { fn new(spirc: &'a mut SpircTask, cmd: MessageType) -> CommandSender<'_> {
let mut frame = protocol::spirc::Frame::new(); let mut frame = protocol::spirc::Frame::new();
// frame version
frame.set_version(1); frame.set_version(1);
frame.set_protocol_version(::std::convert::Into::into("2.0.0")); // Latest known Spirc version is 3.2.6, but we need another interface to announce support for Spirc V3.
// Setting anything higher than 2.0.0 here just seems to limit it to 2.0.0.
frame.set_protocol_version("2.0.0".to_string());
frame.set_ident(spirc.ident.clone()); frame.set_ident(spirc.ident.clone());
frame.set_seq_nr(spirc.sequence.get()); frame.set_seq_nr(spirc.sequence.get());
frame.set_typ(cmd); frame.set_typ(cmd);

View file

@ -15,6 +15,7 @@ use hyper::{
Body, HeaderMap, Method, Request, Body, HeaderMap, Method, Request,
}; };
use protobuf::{Message, ProtobufEnum}; use protobuf::{Message, ProtobufEnum};
use rand::RngCore;
use sha1::{Digest, Sha1}; use sha1::{Digest, Sha1};
use sysinfo::{System, SystemExt}; use sysinfo::{System, SystemExt};
use thiserror::Error; use thiserror::Error;
@ -435,13 +436,26 @@ impl SpClient {
let mut url = self.base_url().await?; let mut url = self.base_url().await?;
url.push_str(endpoint); url.push_str(endpoint);
// 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.
let separator = match url.find('?') { let separator = match url.find('?') {
Some(_) => "&", Some(_) => "&",
None => "?", None => "?",
}; };
let _ = write!(url, "{}product=0", separator);
// 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.
// For the sake of documentation you could also do "product=free" but
// we only support premium anyway.
let _ = write!(
url,
"{}product=0&country={}",
separator,
self.session().country()
);
// Defeat caches. Spotify-generated URLs already contain this.
if !url.contains("salt=") {
let _ = write!(url, "&salt={}", rand::thread_rng().next_u32());
}
let mut request = Request::builder() let mut request = Request::builder()
.method(method) .method(method)
@ -616,29 +630,49 @@ impl SpClient {
pub async fn get_radio_for_track(&self, track_id: &SpotifyId) -> SpClientResult { pub async fn get_radio_for_track(&self, track_id: &SpotifyId) -> SpClientResult {
let endpoint = format!( let endpoint = format!(
"/inspiredby-mix/v2/seed_to_playlist/{}?response-format=json", "/inspiredby-mix/v2/seed_to_playlist/{}?response-format=json",
track_id.to_base62()? track_id.to_uri()?
); );
self.request_as_json(&Method::GET, &endpoint, None, None) self.request_as_json(&Method::GET, &endpoint, None, None)
.await .await
} }
// Known working scopes: stations, tracks
// For others see: https://gist.github.com/roderickvd/62df5b74d2179a12de6817a37bb474f9
//
// Seen-in-the-wild but unimplemented query parameters:
// - image_style=gradient_overlay
// - excludeClusters=true
// - language=en
// - count_tracks=0
// - market=from_token
pub async fn get_apollo_station( pub async fn get_apollo_station(
&self, &self,
scope: &str,
context_uri: &str, context_uri: &str,
count: usize, count: Option<usize>,
previous_tracks: Vec<SpotifyId>, previous_tracks: Vec<SpotifyId>,
autoplay: bool, autoplay: bool,
) -> SpClientResult { ) -> SpClientResult {
let mut endpoint = format!(
"/radio-apollo/v3/{}/{}?autoplay={}",
scope, context_uri, autoplay,
);
// Spotify has a default of 50
if let Some(count) = count {
let _ = write!(endpoint, "&count={}", count);
}
let previous_track_str = previous_tracks let previous_track_str = previous_tracks
.iter() .iter()
.map(|track| track.to_base62()) .map(|track| track.to_base62())
.collect::<Result<Vec<_>, _>>()? .collect::<Result<Vec<_>, _>>()?
.join(","); .join(",");
let endpoint = format!( // better than checking `previous_tracks.len() > 0` because the `filter_map` could still return 0 items
"/radio-apollo/v3/stations/{}?count={}&prev_tracks={}&autoplay={}", if !previous_track_str.is_empty() {
context_uri, count, previous_track_str, autoplay, let _ = write!(endpoint, "&prev_tracks={}", previous_track_str);
); }
self.request_as_json(&Method::GET, &endpoint, None, None) self.request_as_json(&Method::GET, &endpoint, None, None)
.await .await
@ -650,6 +684,9 @@ impl SpClient {
.await .await
} }
// TODO: Seen-in-the-wild but unimplemented endpoints
// - /presence-view/v1/buddylist
// TODO: Find endpoint for newer canvas.proto and upgrade to that. // TODO: Find endpoint for newer canvas.proto and upgrade to that.
pub async fn get_canvases(&self, request: EntityCanvazRequest) -> SpClientResult { pub async fn get_canvases(&self, request: EntityCanvazRequest) -> SpClientResult {
let endpoint = "/canvaz-cache/v0/canvases"; let endpoint = "/canvaz-cache/v0/canvases";