Gracefully handle spotify errors
Diff
shalom/src/pages/room/listen.rs | 266 ++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
shalom/src/pages/room/listen/search.rs | 52 ++++++++++++++++++++++++++++++++++++++++------------
2 files changed, 181 insertions(+), 137 deletions(-)
@@ -1,9 +1,9 @@
mod search;
use std::{borrow::Cow, convert::identity, sync::Arc, time::Duration};
use std::{borrow::Cow, convert::identity, iter, sync::Arc, time::Duration};
use iced::{
futures::{future, stream, stream::FuturesUnordered, FutureExt, StreamExt},
futures::{future, future::Either, stream, stream::FuturesUnordered, FutureExt, StreamExt},
subscription,
widget::{container, image::Handle, lazy, Column, Text},
Element, Length, Renderer, Subscription, Theme,
@@ -179,38 +179,36 @@
self.search = if v {
SearchState::Open {
search: String::new(),
results: vec![],
needs_result: false,
waiting_for_result: false,
results_search: String::new(),
results: Ok(vec![]),
}
} else {
SearchState::Closed
};
None
}
Message::SpotifySearchResult(res) => {
if let SearchState::Open {
results,
needs_result,
..
} = &mut self.search
{
if *needs_result {
results.clear();
*needs_result = false;
}
Message::SpotifySearchResult((res, search)) => {
if self.search.search() != Some(&search) {
return None;
}
results.push(res);
if let SearchState::Open { results, .. } = &mut self.search {
if let Ok(results) = results {
results.push(res);
} else {
*results = Ok(vec![res]);
}
}
None
}
Message::SpotifySearchResultDone => {
if let SearchState::Open {
waiting_for_result, ..
} = &mut self.search
{
*waiting_for_result = false;
Message::SpotifySearchResultError((res, search)) => {
if self.search.search() != Some(&search) {
return None;
}
if let SearchState::Open { results, .. } = &mut self.search {
*results = Err(res);
}
None
@@ -317,13 +315,8 @@
Subscription::none()
};
let spotify_result = if let SearchState::Open {
search,
waiting_for_result: true,
..
} = &self.search
{
search_spotify(search.to_string(), &self.config.spotify.token)
let spotify_result = if let SearchState::Open { search, .. } = &self.search {
search_spotify(search, &self.config.spotify.token)
} else {
Subscription::none()
};
@@ -344,9 +337,8 @@
pub enum SearchState {
Open {
search: String,
results: Vec<SearchResult>,
needs_result: bool,
waiting_for_result: bool,
results_search: String,
results: Result<Vec<SearchResult>, String>,
},
Closed,
}
@@ -356,37 +348,39 @@
matches!(self, Self::Open { search, .. } if !search.is_empty())
}
pub fn results(&self) -> Option<&[SearchResult]> {
pub fn results(&self) -> search::SearchState<'_> {
match self {
Self::Open {
results,
needs_result,
results_search,
..
} => (!needs_result).then_some(results.as_slice()),
Self::Closed => None,
} => match results {
Ok(v) if results_search.is_empty() && v.is_empty() => search::SearchState::NotReady,
Ok(v) => search::SearchState::Ready(v.as_slice()),
Err(e) => search::SearchState::Error(e),
},
Self::Closed => search::SearchState::NotReady,
}
}
pub fn search(&self) -> Option<&str> {
match self {
SearchState::Open { search, .. } => Some(search),
SearchState::Closed => None,
Self::Open { search, .. } => Some(search),
Self::Closed => None,
}
}
pub fn open(&self, search: String) -> SearchState {
pub fn open(&self, search: String) -> Self {
match self {
Self::Open { results, .. } => Self::Open {
needs_result: !search.is_empty(),
waiting_for_result: !search.is_empty(),
Self::Open { results_search, .. } => Self::Open {
search,
results: results.clone(),
results_search: results_search.clone(),
results: Ok(vec![]),
},
Self::Closed => Self::Open {
needs_result: !search.is_empty(),
waiting_for_result: !search.is_empty(),
search,
results: vec![],
results_search: String::new(),
results: Ok(vec![]),
},
}
}
@@ -423,17 +417,24 @@
OnSpeakerPreviousTrack,
OnSearchTerm(String),
OnSearchVisibleChange(bool),
SpotifySearchResult(SearchResult),
SpotifySearchResultDone,
SpotifySearchResult((SearchResult, String)),
SpotifySearchResultError((String, String)),
OnPlayTrack(String),
}
fn search_spotify(search: String, token: &str) -> Subscription<Message> {
fn search_spotify(search_param: &str, token: &str) -> Subscription<Message> {
if search_param.is_empty() {
return Subscription::none();
}
let token = token.to_string();
let search = search_param.to_string();
subscription::run_with_id(
format!("search-{search}"),
stream::once(async move {
eprintln!("sending search {search}");
let mut url = Url::parse("https://api.spotify.com/v1/search").unwrap();
url.query_pairs_mut()
.append_pair("q", &search)
@@ -451,78 +452,90 @@
.await
.unwrap();
eprintln!("{}", std::str::from_utf8(res.as_ref()).unwrap());
eprintln!("{search} - {}", std::str::from_utf8(res.as_ref()).unwrap());
Yoke::attach_to_cart(res, |s| serde_json::from_str(s).unwrap())
(
Yoke::attach_to_cart(res, |s| serde_json::from_str(s).unwrap()),
search,
)
})
.flat_map(|res: Yoke<SpotifySearchResult<'static>, String>| {
let res = res.get();
let results = FuturesUnordered::new();
for track in &res.tracks.items {
let image_url = track.album.images.last().map(|v| v.url.to_string());
let track_name = track.name.to_string();
let artist_name = track.artists.iter().map(|v| &v.name).join(", ");
let uri = track.uri.to_string();
results.push(tokio::spawn(
async move {
let image = load_album_art(image_url).await;
SearchResult::track(image, track_name, artist_name, uri)
}
.boxed(),
));
}
.flat_map(
|(res, search): (Yoke<SpotifySearchResult<'static>, String>, String)| {
let res = res.get();
if let Some(error) = &res.error {
return Either::Left(stream::iter(iter::once(
Message::SpotifySearchResultError((error.message.to_string(), search)),
)));
}
for artist in &res.artists.items {
let image_url = artist.images.last().map(|v| v.url.to_string());
let artist_name = artist.name.to_string();
let uri = artist.uri.to_string();
results.push(tokio::spawn(
async move {
let image = load_album_art(image_url).await;
SearchResult::artist(image, artist_name, uri)
}
.boxed(),
));
}
let results = FuturesUnordered::new();
for track in &res.tracks.items {
let image_url = track.album.images.last().map(|v| v.url.to_string());
let track_name = track.name.to_string();
let artist_name = track.artists.iter().map(|v| &v.name).join(", ");
let uri = track.uri.to_string();
results.push(tokio::spawn(
async move {
let image = load_album_art(image_url).await;
SearchResult::track(image, track_name, artist_name, uri)
}
.boxed(),
));
}
for albums in &res.albums.items {
let image_url = albums.images.last().map(|v| v.url.to_string());
let album_name = albums.name.to_string();
let uri = albums.uri.to_string();
results.push(tokio::spawn(
async move {
let image = load_album_art(image_url).await;
SearchResult::album(image, album_name, uri)
}
.boxed(),
));
}
for artist in &res.artists.items {
let image_url = artist.images.last().map(|v| v.url.to_string());
let artist_name = artist.name.to_string();
let uri = artist.uri.to_string();
results.push(tokio::spawn(
async move {
let image = load_album_art(image_url).await;
SearchResult::artist(image, artist_name, uri)
}
.boxed(),
));
}
for playlist in &res.playlists.items {
let image_url = playlist.images.last().map(|v| v.url.to_string());
let playlist_name = playlist.name.to_string();
let uri = playlist.uri.to_string();
results.push(tokio::spawn(
async move {
let image = load_album_art(image_url).await;
SearchResult::playlist(image, playlist_name, uri)
}
.boxed(),
));
}
for albums in &res.albums.items {
let image_url = albums.images.last().map(|v| v.url.to_string());
let album_name = albums.name.to_string();
let uri = albums.uri.to_string();
results.push(tokio::spawn(
async move {
let image = load_album_art(image_url).await;
SearchResult::album(image, album_name, uri)
}
.boxed(),
));
}
results
.map(Result::unwrap)
.map(Message::SpotifySearchResult)
})
.chain(stream::once(future::ready(
Message::SpotifySearchResultDone,
))),
for playlist in &res.playlists.items {
let image_url = playlist.images.last().map(|v| v.url.to_string());
let playlist_name = playlist.name.to_string();
let uri = playlist.uri.to_string();
results.push(tokio::spawn(
async move {
let image = load_album_art(image_url).await;
SearchResult::playlist(image, playlist_name, uri)
}
.boxed(),
));
}
Either::Right(
results
.filter_map(|v| future::ready(v.ok()))
.zip(stream::repeat(search))
.map(Message::SpotifySearchResult),
)
},
),
)
}
@@ -536,19 +549,32 @@
#[derive(Deserialize, Yokeable)]
pub struct SpotifySearchResult<'a> {
#[serde(borrow)]
#[serde(borrow, default)]
tracks: SpotifySearchResultWrapper<SpotifyTrack<'a>>,
#[serde(borrow)]
#[serde(borrow, default)]
artists: SpotifySearchResultWrapper<SpotifyArtist<'a>>,
#[serde(borrow)]
#[serde(borrow, default)]
albums: SpotifySearchResultWrapper<SpotifyAlbum<'a>>,
#[serde(borrow)]
#[serde(borrow, default)]
playlists: SpotifySearchResultWrapper<SpotifyPlaylist<'a>>,
#[serde(borrow, default)]
error: Option<SpotifyError<'a>>,
}
#[derive(Deserialize)]
pub struct SpotifyError<'a> {
message: &'a str,
}
#[derive(Deserialize)]
pub struct SpotifySearchResultWrapper<T> {
items: Vec<T>,
}
impl<T> Default for SpotifySearchResultWrapper<T> {
fn default() -> Self {
Self { items: Vec::new() }
}
}
#[allow(dead_code)]
@@ -12,7 +12,7 @@
use crate::widgets::{mouse_area::mouse_area, spinner::CupertinoSpinner};
pub fn search<M: Clone + 'static>(theme: Theme, results: Option<&[SearchResult]>) -> Search<'_, M> {
pub fn search<M: Clone + 'static>(theme: Theme, results: SearchState<'_>) -> Search<'_, M> {
Search {
on_track_press: None,
theme,
@@ -23,7 +23,7 @@
pub struct Search<'a, M> {
on_track_press: Option<fn(String) -> M>,
theme: Theme,
results: Option<&'a [SearchResult]>,
results: SearchState<'a>,
}
impl<M> Search<'_, M> {
@@ -44,27 +44,38 @@
}
fn view(&self, _state: &Self::State) -> Element<'_, Self::Event, Renderer> {
let col = if let Some(results) = self.results {
let mut col = Column::new();
for (i, result) in results.iter().enumerate() {
if i != 0 {
col = col.push(hr());
let col = match self.results {
SearchState::Ready(results) if !results.is_empty() => {
let mut col = Column::new();
for (i, result) in results.iter().enumerate() {
if i != 0 {
col = col.push(hr());
}
let track = mouse_area(search_item_container(result_card(result, &self.theme)))
.on_press(Event::OnTrackPress(result.uri.to_string()));
col = col.push(track);
}
let track = mouse_area(search_item_container(result_card(result, &self.theme)))
.on_press(Event::OnTrackPress(result.uri.to_string()));
col = col.push(track);
Element::from(scrollable(col.spacing(10)))
}
Element::from(scrollable(col.spacing(10)))
} else {
Element::from(
SearchState::Ready(_) => Element::from(
container(text("No results found"))
.width(Length::Fill)
.align_x(Horizontal::Center),
),
SearchState::Error(error) => Element::from(
container(text(error))
.width(Length::Fill)
.align_x(Horizontal::Center),
),
SearchState::NotReady => Element::from(
container(CupertinoSpinner::new().width(40.into()).height(40.into()))
.width(Length::Fill)
.align_x(Horizontal::Center),
)
),
};
search_container(col)
@@ -133,6 +144,13 @@
border_color: Color::default(),
}
}
}
#[allow(clippy::module_name_repetitions)]
pub enum SearchState<'a> {
NotReady,
Ready(&'a [SearchResult]),
Error(&'a str),
}
#[allow(clippy::module_name_repetitions)]