Skip to content

Commit

Permalink
Support searching by Spotify share URLs and URIs analogously to the d…
Browse files Browse the repository at this point in the history
…esktop client (#623)

* Generalize input processing to both URLs and URIs

Unfortunately, I would have preferred the small refactor of adding
process_input() to be in a separate commit, but I quite genuinely just
forgot to commit my work, so these guys are mushed together here.

* Add the boilerplate code for supporting tracks, playlists and podcasts

* Introduce a spotify_resource_id() closure to reduce some duplication

* Support searching playlists by URL/Spotify-URI

* Support searching shows by URL/Spotify-URI

* Support searching individual tracks by URL/Spotify-URI

* Add initial parsing tests and required refactors

* Add full suite of happy path test cases

* Verify that we fail to match on invalid strings

* Remove debug prints

* Correct seek to track index on track search

Albeit, not very cleanly. Want to try getting it nicer before I put this
up...

* Handle query parameters in URIs

* Always push a GetPlaylist to the navigation stack to avoid UI inconsistency

* Clear the playlist selection no matter what kind of search we do

* Remove debug prints

* Remove redundant clone

* Prefer unwrap_or_else()
  • Loading branch information
Utagai authored Oct 22, 2020
1 parent 4debdd6 commit ed0bf03
Show file tree
Hide file tree
Showing 2 changed files with 212 additions and 31 deletions.
202 changes: 174 additions & 28 deletions src/handlers/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,36 +69,9 @@ pub fn handler(key: Key, app: &mut App) {
app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));
}
Key::Enter => {
let user_country = app.get_user_country();
let input_str: String = app.input.iter().collect();

// Don't do anything if there is no input
if input_str.is_empty() {
return;
}

let album_url_prefix = "https://open.spotify.com/album/";

if input_str.starts_with(album_url_prefix) {
let album_id = input_str.trim_start_matches(album_url_prefix);
app.dispatch(IoEvent::GetAlbum(album_id.to_string()));
return;
}

let artist_url_prefix = "https://open.spotify.com/artist/";

if input_str.starts_with(artist_url_prefix) {
let artist_id = input_str.trim_start_matches(artist_url_prefix);
app.get_artist(artist_id.to_string(), "".to_string());
app.push_navigation_stack(RouteId::Artist, ActiveBlock::ArtistBlock);
return;
}

app.dispatch(IoEvent::GetSearchResults(input_str, user_country));

// On searching for a track, clear the playlist selection
app.selected_playlist_index = Some(0);
app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock);
process_input(app, input_str);
}
Key::Char(c) => {
app.input.insert(app.input_idx, c);
Expand All @@ -121,6 +94,74 @@ pub fn handler(key: Key, app: &mut App) {
}
}

fn process_input(app: &mut App, input: String) {
// Don't do anything if there is no input
if input.is_empty() {
return;
}

// On searching for a track, clear the playlist selection
app.selected_playlist_index = Some(0);

if attempt_process_uri(app, &input, "https://open.spotify.com/", "/")
|| attempt_process_uri(app, &input, "spotify:", ":")
{
return;
}

// Default fallback behavior: treat the input as a raw search phrase.
app.dispatch(IoEvent::GetSearchResults(input, app.get_user_country()));
app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock);
}

fn spotify_resource_id(base: &str, uri: &str, sep: &str, resource_type: &str) -> (String, bool) {
let uri_prefix = format!("{}{}{}", base, resource_type, sep);
let id_string_with_query_params = uri.trim_start_matches(&uri_prefix);
let query_idx = id_string_with_query_params
.find('?')
.unwrap_or_else(|| id_string_with_query_params.len());
let id_string = id_string_with_query_params[0..query_idx].to_string();
// If the lengths aren't equal, we must have found a match.
let matched = id_string_with_query_params.len() != uri.len() && id_string.len() != uri.len();
(id_string, matched)
}

// Returns true if the input was successfully processed as a Spotify URI.
fn attempt_process_uri(app: &mut App, input: &str, base: &str, sep: &str) -> bool {
let (album_id, matched) = spotify_resource_id(base, input, sep, "album");
if matched {
app.dispatch(IoEvent::GetAlbum(album_id));
return true;
}

let (artist_id, matched) = spotify_resource_id(base, input, sep, "artist");
if matched {
app.get_artist(artist_id, "".to_string());
app.push_navigation_stack(RouteId::Artist, ActiveBlock::ArtistBlock);
return true;
}

let (track_id, matched) = spotify_resource_id(base, input, sep, "track");
if matched {
app.dispatch(IoEvent::GetAlbumForTrack(track_id));
return true;
}

let (playlist_id, matched) = spotify_resource_id(base, input, sep, "playlist");
if matched {
app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, 0));
return true;
}

let (show_id, matched) = spotify_resource_id(base, input, sep, "show");
if matched {
app.dispatch(IoEvent::GetShowEpisodes(show_id));
return true;
}

false
}

fn compute_character_width(character: char) -> u16 {
UnicodeWidthChar::width(character)
.unwrap()
Expand Down Expand Up @@ -357,4 +398,109 @@ mod tests {
assert_eq!(app.input_idx, 2);
assert_eq!(app.input_cursor_position, 4);
}

mod test_uri_parsing {
use super::*;

const URI_BASE: &str = "spotify:";
const URL_BASE: &str = "https://open.spotify.com/";

fn check_uri_parse(expected_id: &str, parsed: (String, bool)) {
assert_eq!(parsed.1, true);
assert_eq!(parsed.0, expected_id);
}

fn run_test_for_id_and_resource_type(id: &str, resource_type: &str) {
check_uri_parse(
id,
spotify_resource_id(
URI_BASE,
&format!("spotify:{}:{}", resource_type, id),
":",
resource_type,
),
);
check_uri_parse(
id,
spotify_resource_id(
URL_BASE,
&format!("https://open.spotify.com/{}/{}", resource_type, id),
"/",
resource_type,
),
)
}

#[test]
fn artist() {
let expected_artist_id = "2ye2Wgw4gimLv2eAKyk1NB";
run_test_for_id_and_resource_type(expected_artist_id, "artist");
}

#[test]
fn album() {
let expected_album_id = "5gzLOflH95LkKYE6XSXE9k";
run_test_for_id_and_resource_type(expected_album_id, "album");
}

#[test]
fn playlist() {
let expected_playlist_id = "1cJ6lPBYj2fscs0kqBHsVV";
run_test_for_id_and_resource_type(expected_playlist_id, "playlist");
}

#[test]
fn show() {
let expected_show_id = "3aNsrV6lkzmcU1w8u8kA7N";
run_test_for_id_and_resource_type(expected_show_id, "show");
}

#[test]
fn track() {
let expected_track_id = "10igKaIKsSB6ZnWxPxPvKO";
run_test_for_id_and_resource_type(expected_track_id, "track");
}

#[test]
fn invalid_format_doesnt_match() {
let swapped = "show:spotify:3aNsrV6lkzmcU1w8u8kA7N";
let totally_wrong = "hehe-haha-3aNsrV6lkzmcU1w8u8kA7N";
let random = "random string";
let (_, matched) = spotify_resource_id(URI_BASE, swapped, ":", "track");
assert_eq!(matched, false);
let (_, matched) = spotify_resource_id(URI_BASE, totally_wrong, ":", "track");
assert_eq!(matched, false);
let (_, matched) = spotify_resource_id(URL_BASE, totally_wrong, "/", "track");
assert_eq!(matched, false);
let (_, matched) = spotify_resource_id(URL_BASE, random, "/", "track");
assert_eq!(matched, false);
}

#[test]
fn parse_with_query_parameters() {
// If this test ever fails due to some change to the parsing logic, it is likely a sign we
// should just integrate the url crate instead of trying to do things ourselves.
let playlist_url_with_query =
"https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV?si=OdwuJsbsSeuUAOadehng3A";
let playlist_url = "https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV";
let expected_id = "1cJ6lPBYj2fscs0kqBHsVV";

let (actual_id, matched) = spotify_resource_id(URL_BASE, playlist_url, "/", "playlist");
assert_eq!(matched, true);
assert_eq!(actual_id, expected_id);

let (actual_id, matched) =
spotify_resource_id(URL_BASE, playlist_url_with_query, "/", "playlist");
assert_eq!(matched, true);
assert_eq!(actual_id, expected_id);
}

#[test]
fn mismatched_resource_types_do_not_match() {
let playlist_url =
"https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV?si=OdwuJsbsSeuUAOadehng3A";
let (_, matched) = spotify_resource_id(URL_BASE, playlist_url, "/", "album");
assert_eq!(matched, false);
}
}
}
41 changes: 38 additions & 3 deletions src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ pub enum IoEvent {
UserArtistFollowCheck(Vec<String>),
GetAlbum(String),
TransferPlaybackToDevice(String),
GetAlbumForTrack(String),
CurrentUserSavedTracksContains(Vec<String>),
GetShowEpisodes(String),
AddItemToQueue(String),
Expand Down Expand Up @@ -260,6 +261,9 @@ impl<'a> Network<'a> {
IoEvent::TransferPlaybackToDevice(device_id) => {
self.transfert_playback_to_device(device_id).await;
}
IoEvent::GetAlbumForTrack(track_id) => {
self.get_album_for_track(track_id).await;
}
IoEvent::Shuffle(shuffle_state) => {
self.shuffle(shuffle_state).await;
}
Expand Down Expand Up @@ -377,9 +381,7 @@ impl<'a> Network<'a> {

let mut app = self.app.lock().await;
app.playlist_tracks = Some(playlist_tracks);
if app.get_current_route().id != RouteId::TrackTable {
app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable);
};
app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable);
};
}

Expand Down Expand Up @@ -1255,6 +1257,39 @@ impl<'a> Network<'a> {
}
}

async fn get_album_for_track(&mut self, track_id: String) {
match self.spotify.track(&track_id).await {
Ok(track) => {
// It is unclear when the id can ever be None, but perhaps a track can be album-less. If
// so, there isn't much to do here anyways, since we're looking for the parent album.
let album_id = match track.album.id {
Some(id) => id,
None => return,
};

if let Ok(album) = self.spotify.album(&album_id).await {
// The way we map to the UI is zero-indexed, but Spotify is 1-indexed.
let zero_indexed_track_number = track.track_number - 1;
let selected_album = SelectedFullAlbum {
album,
// Overflow should be essentially impossible here, so we prefer the cleaner 'as'.
selected_index: zero_indexed_track_number as usize,
};

let mut app = self.app.lock().await;

app.selected_album_full = Some(selected_album.clone());
app.saved_album_tracks_index = selected_album.selected_index;
app.album_table_context = AlbumTableContext::Full;
app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks);
}
}
Err(e) => {
self.handle_error(anyhow!(e)).await;
}
}
}

async fn transfert_playback_to_device(&mut self, device_id: String) {
match self.spotify.transfer_playback(&device_id, true).await {
Ok(()) => {
Expand Down

0 comments on commit ed0bf03

Please sign in to comment.