Skip to content

Commit f10b8f6

Browse files
committed
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.
1 parent bfb7d56 commit f10b8f6

2 files changed

Lines changed: 139 additions & 65 deletions

File tree

connect/src/spirc.rs

Lines changed: 93 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream;
1616

1717
use crate::{
1818
config::ConnectConfig,
19-
context::{PageContext, StationContext},
19+
context::PageContext,
2020
core::{
2121
authentication::Credentials, mercury::MercurySender, session::UserAttributes,
2222
util::SeqGenerator, version, Error, Session, SpotifyId,
@@ -100,7 +100,7 @@ struct SpircTask {
100100
session: Session,
101101
resolve_context: Option<String>,
102102
autoplay_context: bool,
103-
context: Option<StationContext>,
103+
context: Option<PageContext>,
104104

105105
spirc_id: usize,
106106
}
@@ -124,7 +124,6 @@ pub enum SpircCommand {
124124
SetVolume(u16),
125125
}
126126

127-
const CONTEXT_TRACKS_COUNT: usize = 50;
128127
const CONTEXT_TRACKS_HISTORY: usize = 10;
129128
const CONTEXT_FETCH_THRESHOLD: u32 = 5;
130129

@@ -184,6 +183,7 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState {
184183
};
185184
{
186185
let msg = repeated.push_default();
186+
// TODO: implement logout
187187
msg.set_typ(protocol::spirc::CapabilityType::kSupportsLogout);
188188
{
189189
let repeated = msg.mut_intValue();
@@ -224,17 +224,51 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState {
224224
};
225225
{
226226
let msg = repeated.push_default();
227-
msg.set_typ(protocol::spirc::CapabilityType::kSupportedContexts);
227+
msg.set_typ(protocol::spirc::CapabilityType::kSupportsExternalEpisodes);
228228
{
229-
let repeated = msg.mut_stringValue();
230-
repeated.push(::std::convert::Into::into("album"));
231-
repeated.push(::std::convert::Into::into("playlist"));
232-
repeated.push(::std::convert::Into::into("search"));
233-
repeated.push(::std::convert::Into::into("inbox"));
234-
repeated.push(::std::convert::Into::into("toplist"));
235-
repeated.push(::std::convert::Into::into("starred"));
236-
repeated.push(::std::convert::Into::into("publishedstarred"));
237-
repeated.push(::std::convert::Into::into("track"))
229+
let repeated = msg.mut_intValue();
230+
repeated.push(1)
231+
};
232+
msg
233+
};
234+
{
235+
let msg = repeated.push_default();
236+
// TODO: how would such a rename command be triggered? Handle it.
237+
msg.set_typ(protocol::spirc::CapabilityType::kSupportsRename);
238+
{
239+
let repeated = msg.mut_intValue();
240+
repeated.push(1)
241+
};
242+
msg
243+
};
244+
{
245+
let msg = repeated.push_default();
246+
msg.set_typ(protocol::spirc::CapabilityType::kCommandAcks);
247+
{
248+
let repeated = msg.mut_intValue();
249+
repeated.push(0)
250+
};
251+
msg
252+
};
253+
{
254+
let msg = repeated.push_default();
255+
// TODO: does this mean local files or the local network?
256+
// LAN may be an interesting privacy toggle.
257+
msg.set_typ(protocol::spirc::CapabilityType::kRestrictToLocal);
258+
{
259+
let repeated = msg.mut_intValue();
260+
repeated.push(0)
261+
};
262+
msg
263+
};
264+
{
265+
let msg = repeated.push_default();
266+
// TODO: what does this hide, or who do we hide from?
267+
// May be an interesting privacy toggle.
268+
msg.set_typ(protocol::spirc::CapabilityType::kHidden);
269+
{
270+
let repeated = msg.mut_intValue();
271+
repeated.push(0)
238272
};
239273
msg
240274
};
@@ -243,9 +277,15 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState {
243277
msg.set_typ(protocol::spirc::CapabilityType::kSupportedTypes);
244278
{
245279
let repeated = msg.mut_stringValue();
246-
repeated.push(::std::convert::Into::into("audio/track"));
247-
repeated.push(::std::convert::Into::into("audio/episode"));
248-
repeated.push(::std::convert::Into::into("track"))
280+
repeated.push("audio/episode".to_string());
281+
repeated.push("audio/episode+track".to_string());
282+
repeated.push("audio/track".to_string());
283+
// other known types:
284+
// - "audio/ad"
285+
// - "audio/interruption"
286+
// - "audio/local"
287+
// - "video/ad"
288+
// - "video/episode"
249289
};
250290
msg
251291
};
@@ -498,35 +538,30 @@ impl SpircTask {
498538
break;
499539
},
500540
context_uri = async { self.resolve_context.take() }, if self.resolve_context.is_some() => {
501-
let context_uri = context_uri.unwrap();
502-
let is_next_page = context_uri.starts_with("hm://");
541+
let context_uri = context_uri.unwrap(); // guaranteed above
542+
if context_uri.contains("spotify:show:") || context_uri.contains("spotify:episode:") {
543+
continue; // not supported by apollo stations
544+
}
503545

504-
let context = if is_next_page {
546+
let context = if context_uri.starts_with("hm://") {
505547
self.session.spclient().get_next_page(&context_uri).await
506548
} else {
507-
let previous_tracks = self.state.get_track().iter().filter_map(|t| SpotifyId::try_from(t).ok()).collect();
508-
self.session.spclient().get_apollo_station(&context_uri, CONTEXT_TRACKS_COUNT, previous_tracks, self.autoplay_context).await
549+
// only send previous tracks that were before the current playback position
550+
let current_position = self.state.get_playing_track_index() as usize;
551+
let previous_tracks = self.state.get_track()[..current_position].iter().filter_map(|t| SpotifyId::try_from(t).ok()).collect();
552+
553+
let scope = if self.autoplay_context {
554+
"stations" // this returns a `StationContext` but we deserialize it into a `PageContext`
555+
} else {
556+
"tracks" // this returns a `PageContext`
557+
};
558+
559+
self.session.spclient().get_apollo_station(scope, &context_uri, None, previous_tracks, self.autoplay_context).await
509560
};
510561

511562
match context {
512563
Ok(value) => {
513-
let r_context = if is_next_page {
514-
match serde_json::from_slice::<PageContext>(&value) {
515-
Ok(page_context) => {
516-
// page contexts don't have the stations full metadata, so decorate it
517-
let mut station_context = self.context.clone().unwrap_or_default();
518-
station_context.tracks = page_context.tracks;
519-
station_context.next_page_url = page_context.next_page_url;
520-
station_context.correlation_id = page_context.correlation_id;
521-
Ok(station_context)
522-
},
523-
Err(e) => Err(e),
524-
}
525-
} else {
526-
serde_json::from_slice::<StationContext>(&value)
527-
};
528-
529-
self.context = match r_context {
564+
self.context = match serde_json::from_slice::<PageContext>(&value) {
530565
Ok(context) => {
531566
info!(
532567
"Resolved {:?} tracks from <{:?}>",
@@ -829,7 +864,7 @@ impl SpircTask {
829864

830865
for entry in update.get_device_state().get_metadata().iter() {
831866
match entry.get_field_type() {
832-
"client-id" => self.session.set_client_id(entry.get_metadata()),
867+
"client_id" => self.session.set_client_id(entry.get_metadata()),
833868
"brand_display_name" => self.session.set_client_brand_name(entry.get_metadata()),
834869
"model_display_name" => self.session.set_client_model_name(entry.get_metadata()),
835870
_ => (),
@@ -1207,17 +1242,23 @@ impl SpircTask {
12071242

12081243
// When in autoplay, keep topping up the playlist when it nears the end
12091244
if update_tracks {
1210-
self.update_tracks_from_context();
1211-
new_index = self.state.get_playing_track_index();
1212-
tracks_len = self.state.get_track().len() as u32;
1245+
if let Some(ref context) = self.context {
1246+
self.resolve_context = Some(context.next_page_url.to_owned());
1247+
self.update_tracks_from_context();
1248+
tracks_len = self.state.get_track().len() as u32;
1249+
}
12131250
}
12141251

12151252
// When not in autoplay, either start autoplay or loop back to the start
12161253
if new_index >= tracks_len {
1217-
if self.session.autoplay() {
1254+
// for some contexts there is no autoplay, such as shows and episodes
1255+
// in such cases there is no context in librespot.
1256+
if self.context.is_some() && self.session.autoplay() {
12181257
// Extend the playlist
12191258
debug!("Starting autoplay for <{}>", context_uri);
1259+
// force reloading the current context with an autoplay context
12201260
self.autoplay_context = true;
1261+
self.resolve_context = Some(self.state.get_context_uri().to_owned());
12211262
self.update_tracks_from_context();
12221263
self.player.set_auto_normalise_as_album(false);
12231264
} else {
@@ -1306,17 +1347,10 @@ impl SpircTask {
13061347

13071348
fn update_tracks_from_context(&mut self) {
13081349
if let Some(ref context) = self.context {
1309-
self.resolve_context =
1310-
if !self.autoplay_context || context.next_page_url.contains("autoplay=true") {
1311-
Some(context.next_page_url.to_owned())
1312-
} else {
1313-
// this arm means: we need to resolve for autoplay,
1314-
// and were previously resolving for the original context
1315-
Some(context.uri.to_owned())
1316-
};
1317-
13181350
let new_tracks = &context.tracks;
1351+
13191352
debug!("Adding {:?} tracks from context to frame", new_tracks.len());
1353+
13201354
let mut track_vec = self.state.take_track().into_vec();
13211355
if let Some(head) = track_vec.len().checked_sub(CONTEXT_TRACKS_HISTORY) {
13221356
track_vec.drain(0..head);
@@ -1342,22 +1376,22 @@ impl SpircTask {
13421376
trace!("State: {:#?}", frame.get_state());
13431377

13441378
let index = frame.get_state().get_playing_track_index();
1345-
let context_uri = frame.get_state().get_context_uri().to_owned();
1379+
let context_uri = frame.get_state().get_context_uri();
13461380
let tracks = frame.get_state().get_track();
13471381

13481382
trace!("Frame has {:?} tracks", tracks.len());
13491383

13501384
// First the tracks from the requested context, without autoplay.
13511385
// We will transition into autoplay after the latest track of this context.
13521386
self.autoplay_context = false;
1353-
self.resolve_context = Some(context_uri.clone());
1387+
self.resolve_context = Some(context_uri.to_owned());
13541388

13551389
self.player
13561390
.set_auto_normalise_as_album(context_uri.starts_with("spotify:album:"));
13571391

13581392
self.state.set_playing_track_index(index);
13591393
self.state.set_track(tracks.iter().cloned().collect());
1360-
self.state.set_context_uri(context_uri);
1394+
self.state.set_context_uri(context_uri.to_owned());
13611395
// has_shuffle/repeat seem to always be true in these replace msgs,
13621396
// but to replicate the behaviour of the Android client we have to
13631397
// ignore false values.
@@ -1517,8 +1551,11 @@ struct CommandSender<'a> {
15171551
impl<'a> CommandSender<'a> {
15181552
fn new(spirc: &'a mut SpircTask, cmd: MessageType) -> CommandSender<'_> {
15191553
let mut frame = protocol::spirc::Frame::new();
1554+
// frame version
15201555
frame.set_version(1);
1521-
frame.set_protocol_version(::std::convert::Into::into("2.0.0"));
1556+
// Latest known Spirc version is 3.2.6, but we need another interface to announce support for Spirc V3.
1557+
// Setting anything higher than 2.0.0 here just seems to limit it to 2.0.0.
1558+
frame.set_protocol_version("2.0.0".to_string());
15221559
frame.set_ident(spirc.ident.clone());
15231560
frame.set_seq_nr(spirc.sequence.get());
15241561
frame.set_typ(cmd);

core/src/spclient.rs

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use hyper::{
1515
Body, HeaderMap, Method, Request,
1616
};
1717
use protobuf::{Message, ProtobufEnum};
18+
use rand::RngCore;
1819
use sha1::{Digest, Sha1};
1920
use sysinfo::{System, SystemExt};
2021
use thiserror::Error;
@@ -435,13 +436,26 @@ impl SpClient {
435436
let mut url = self.base_url().await?;
436437
url.push_str(endpoint);
437438

438-
// Add metrics. There is also an optional `partner` key with a value like
439-
// `vodafone-uk` but we've yet to discover how we can find that value.
440439
let separator = match url.find('?') {
441440
Some(_) => "&",
442441
None => "?",
443442
};
444-
let _ = write!(url, "{}product=0", separator);
443+
444+
// Add metrics. There is also an optional `partner` key with a value like
445+
// `vodafone-uk` but we've yet to discover how we can find that value.
446+
// For the sake of documentation you could also do "product=free" but
447+
// we only support premium anyway.
448+
let _ = write!(
449+
url,
450+
"{}product=0&country={}",
451+
separator,
452+
self.session().country()
453+
);
454+
455+
// Defeat caches. Spotify-generated URLs already contain this.
456+
if !url.contains("salt=") {
457+
let _ = write!(url, "&salt={}", rand::thread_rng().next_u32());
458+
}
445459

446460
let mut request = Request::builder()
447461
.method(method)
@@ -616,29 +630,49 @@ impl SpClient {
616630
pub async fn get_radio_for_track(&self, track_id: &SpotifyId) -> SpClientResult {
617631
let endpoint = format!(
618632
"/inspiredby-mix/v2/seed_to_playlist/{}?response-format=json",
619-
track_id.to_base62()?
633+
track_id.to_uri()?
620634
);
621635

622636
self.request_as_json(&Method::GET, &endpoint, None, None)
623637
.await
624638
}
625639

640+
// Known working scopes: stations, tracks
641+
// For others see: https://gist.github.com/roderickvd/62df5b74d2179a12de6817a37bb474f9
642+
//
643+
// Seen-in-the-wild but unimplemented query parameters:
644+
// - image_style=gradient_overlay
645+
// - excludeClusters=true
646+
// - language=en
647+
// - count_tracks=0
648+
// - market=from_token
626649
pub async fn get_apollo_station(
627650
&self,
651+
scope: &str,
628652
context_uri: &str,
629-
count: usize,
653+
count: Option<usize>,
630654
previous_tracks: Vec<SpotifyId>,
631655
autoplay: bool,
632656
) -> SpClientResult {
657+
let mut endpoint = format!(
658+
"/radio-apollo/v3/{}/{}?autoplay={}",
659+
scope, context_uri, autoplay,
660+
);
661+
662+
// Spotify has a default of 50
663+
if let Some(count) = count {
664+
let _ = write!(endpoint, "&count={}", count);
665+
}
666+
633667
let previous_track_str = previous_tracks
634668
.iter()
635669
.map(|track| track.to_base62())
636670
.collect::<Result<Vec<_>, _>>()?
637671
.join(",");
638-
let endpoint = format!(
639-
"/radio-apollo/v3/stations/{}?count={}&prev_tracks={}&autoplay={}",
640-
context_uri, count, previous_track_str, autoplay,
641-
);
672+
// better than checking `previous_tracks.len() > 0` because the `filter_map` could still return 0 items
673+
if !previous_track_str.is_empty() {
674+
let _ = write!(endpoint, "&prev_tracks={}", previous_track_str);
675+
}
642676

643677
self.request_as_json(&Method::GET, &endpoint, None, None)
644678
.await
@@ -650,6 +684,9 @@ impl SpClient {
650684
.await
651685
}
652686

687+
// TODO: Seen-in-the-wild but unimplemented endpoints
688+
// - /presence-view/v1/buddylist
689+
653690
// TODO: Find endpoint for newer canvas.proto and upgrade to that.
654691
pub async fn get_canvases(&self, request: EntityCanvazRequest) -> SpClientResult {
655692
let endpoint = "/canvaz-cache/v0/canvases";

0 commit comments

Comments
 (0)