mirror of
https://github.com/librespot-org/librespot.git
synced 2024-12-18 17:11:53 +00:00
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:
parent
bfb7d5689c
commit
f10b8f69f8
2 changed files with 139 additions and 65 deletions
|
@ -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);
|
||||||
|
|
|
@ -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";
|
||||||
|
|
Loading…
Reference in a new issue