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 (Rigellute#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 and lanej committed Jul 13, 2021
1 parent ffda1af commit 6b3a12c
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 6b3a12c

Please sign in to comment.