From 1092860b4236573cca0ecb842abc6a5efaf6a009 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Sat, 23 Sep 2023 15:44:36 +0200 Subject: [PATCH] Add support for custom sublists --- Cargo.toml | 3 - src/app.rs | 155 ++++++++++++++++++++++++++++++---------- src/custom_list.rs | 50 +++++++++++++ src/{lib.rs => main.rs} | 5 +- src/query.rs | 14 ++-- src/song.rs | 18 ++++- 6 files changed, 194 insertions(+), 51 deletions(-) create mode 100644 src/custom_list.rs rename src/{lib.rs => main.rs} (63%) diff --git a/Cargo.toml b/Cargo.toml index a3eb610..e150700 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,6 @@ version = "0.2.2" authors = ["Joakim Hulthe , Song)>, - /// The search string + /// Custom song lists, lazily loaded. + custom_lists: CustomLists, + + /// The search string. query: String, - /// The number of songs currently in view. Goes up when the user scrolls down. + /// The number of songs currently in the dom. Goes up when the user scrolls down. shown_songs: usize, - /// The number of songs that didn't match the search critera + /// The number of songs that didn't match the search critera. hidden_songs: usize, - /// Whether we're filtering by video + /// Whether we're filtering by video. filter_video: bool, - /// Whether we're filtering by duets + /// Whether we're filtering by duets. filter_duets: bool, query_placeholder: String, @@ -36,10 +41,35 @@ pub struct Model { autotyper: Option, } +#[derive(Default)] +pub enum Loading { + /// The resource has not started loading. + #[default] + NotLoaded, + + /// The resource is currently loading. + InProgress, + + /// The resource has loaded. + Loaded(T), +} + const SCROLL_THRESHOLD: usize = 50; const INITIAL_ELEM_COUNT: usize = 100; pub enum Msg { + /// Loaded songs. + Songs(Vec), + + /// Loaded custom song index. + CustomSongLists(Vec), + + /// Loaded custom song list. + CustomSongList { + list: String, + song_hashes: HashSet, + }, + /// The user entered something into the search field Search(String), @@ -60,15 +90,12 @@ pub enum Msg { } pub fn init(_url: Url, orders: &mut impl Orders) -> Model { - let mut songs: Vec = - serde_json::from_str(include_str!("../static/songs.json")).expect("parse songs"); - songs.shuffle(&mut thread_rng()); + orders.perform_cmd(fetch_songs()); + orders.perform_cmd(fetch_custom_song_list_index()); Model { - songs: songs - .into_iter() - .map(|song| (Default::default(), song)) - .collect(), + songs: vec![], + custom_lists: Default::default(), query: String::new(), hidden_songs: 0, shown_songs: INITIAL_ELEM_COUNT, @@ -80,36 +107,68 @@ pub fn init(_url: Url, orders: &mut impl Orders) -> Model { } } +fn update_song_list(model: &mut Model, orders: &mut impl Orders) { + model.hidden_songs = 0; + model.shown_songs = INITIAL_ELEM_COUNT; + scroll_to_top(); + + if model.query.is_empty() { + model.filter_duets = false; + model.filter_video = false; + update(Msg::Shuffle, model, orders); + } else { + let query = ParsedQuery::parse(&model.query); + model.filter_duets = query.duet == Some(true); + model.filter_video = query.video == Some(true); + + if let Some(name) = query.list { + if let Some(l @ Loading::NotLoaded) = model.custom_lists.get_mut(name) { + orders.perform_cmd(fetch_custom_song_list(name.to_string())); + *l = Loading::InProgress; + } + } + + // calculate search scores & sort list + for (score, song) in model.songs.iter_mut() { + let new_score = song.fuzzy_compare(&query, &model.custom_lists); + if new_score < Default::default() { + model.hidden_songs += 1; + } + + *score = Reverse(new_score); + } + model.songs.sort_unstable(); + } +} + pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { match msg { - Msg::Search(query) => { - model.hidden_songs = 0; - model.shown_songs = INITIAL_ELEM_COUNT; - scroll_to_top(); + Msg::Songs(songs) => { + model.songs = songs + .into_iter() + .map(|song| (Default::default(), song)) + .collect(); + } + Msg::CustomSongLists(lists) => { + model.custom_lists = lists + .into_iter() + .map(|list| (list, Loading::NotLoaded)) + .collect(); + } + Msg::CustomSongList { list, song_hashes } => { + let query = ParsedQuery::parse(&model.query); + let update_list = query.list == Some(&list); - model.query = query; + *model.custom_lists.entry(list).or_default() = Loading::Loaded(song_hashes); - if model.query.is_empty() { - model.filter_duets = false; - model.filter_video = false; - update(Msg::Shuffle, model, orders); - } else { - let query = ParsedQuery::parse(&model.query); - model.filter_duets = query.duet == Some(true); - model.filter_video = query.video == Some(true); - - // calculate search scores & sort list - for (score, song) in model.songs.iter_mut() { - let new_score = song.fuzzy_compare(&query); - if new_score < Default::default() { - model.hidden_songs += 1; - } - - *score = Reverse(new_score); - } - model.songs.sort_unstable(); + if update_list { + update_song_list(model, orders); } } + Msg::Search(query) => { + model.query = query; + update_song_list(model, orders); + } Msg::ToggleVideo => { let mut query = ParsedQuery::parse(&model.query); query.video = match query.video { @@ -284,6 +343,28 @@ pub fn view(model: &Model) -> Vec> { ] } +async fn fetch_songs() -> Option { + let response = match fetch("/songs").await.and_then(|r| r.check_status()) { + Ok(response) => response, + Err(e) => { + log!("error fetching songs", e); + return None; + } + }; + + let mut songs: Vec = match response.json().await { + Ok(v) => v, + Err(e) => { + log!("error parsing songs", e); + return None; + } + }; + + songs.shuffle(&mut thread_rng()); + + Some(Msg::Songs(songs)) +} + pub fn autotype_song(model: &mut Model, orders: &mut impl Orders) { let (_, song) = &model.songs[0]; model.query_placeholder = ParsedQuery::random(song, &mut thread_rng()).to_string(); diff --git a/src/custom_list.rs b/src/custom_list.rs new file mode 100644 index 0000000..b41af4a --- /dev/null +++ b/src/custom_list.rs @@ -0,0 +1,50 @@ +use std::collections::{HashMap, HashSet}; + +use seed::{log, prelude::fetch}; + +use crate::app::{Loading, Msg}; + +pub type CustomLists = HashMap>>; + +pub async fn fetch_custom_song_list_index() -> Option { + let response = match fetch("/custom/lists").await.and_then(|r| r.check_status()) { + Ok(response) => response, + Err(e) => { + log!("error fetching custom song list index", e); + return None; + } + }; + + let custom_lists: Vec = match response.json().await { + Ok(v) => v, + Err(e) => { + log!("error parsing custom song list index", e); + return None; + } + }; + + Some(Msg::CustomSongLists(custom_lists)) +} + +pub async fn fetch_custom_song_list(list: String) -> Option { + let response = match fetch(format!("/custom/list/{list}")) + .await + .and_then(|r| r.check_status()) + { + Ok(response) => response, + Err(e) => { + log!("error fetching custom song list", e); + return None; + } + }; + + let song_hashes: HashSet = match response.json().await { + Ok(v) => v, + Err(e) => { + log!("error parsing custom song list", e); + return None; + } + }; + + Some(Msg::CustomSongList { list, song_hashes }) +} diff --git a/src/lib.rs b/src/main.rs similarity index 63% rename from src/lib.rs rename to src/main.rs index ecb7e0c..8d7c8dc 100644 --- a/src/lib.rs +++ b/src/main.rs @@ -1,13 +1,12 @@ mod app; mod css; +mod custom_list; mod fuzzy; mod query; mod song; -use seed::prelude::wasm_bindgen; use seed::App; -#[wasm_bindgen(start)] -pub fn start() { +fn main() { App::start("app", app::init, app::update, app::view); } diff --git a/src/query.rs b/src/query.rs index b21f150..b3363f0 100644 --- a/src/query.rs +++ b/src/query.rs @@ -30,6 +30,9 @@ pub struct ParsedQuery<'a> { /// Query from a specifc year pub year: Option<&'a str>, + + /// Query songs from the specified custom list. + pub list: Option<&'a str>, } impl<'a> ParsedQuery<'a> { @@ -50,6 +53,7 @@ impl<'a> ParsedQuery<'a> { "lang" => parsed.language = Some(v), "genre" => parsed.genre = Some(v), "year" => parsed.year = Some(v), + "list" => parsed.list = Some(v), _ => {} } } @@ -59,8 +63,7 @@ impl<'a> ParsedQuery<'a> { /// Generate a parsed query with a few random fields matching a song pub fn random(song: &'a Song, rng: &mut R) -> Self { - let until_space = - |s: &'a str| -> &'a str { s.trim().split_whitespace().next().unwrap_or("") }; + let until_space = |s: &'a str| -> &'a str { s.split_whitespace().next().unwrap_or("") }; let join_spaces = |s: &'a str| -> Cow<'a, str> { let s = s.trim(); @@ -119,8 +122,8 @@ impl<'a> ParsedQuery<'a> { fn parse_bool(s: &str) -> Option { match s { - "true" | "yes" => Some(true), - "false" | "no" => Some(false), + "true" | "yes" | "y" => Some(true), + "false" | "no" | "n" => Some(false), _ => None, } } @@ -137,7 +140,7 @@ fn extract_plain(s: &str) -> Option> { a }); - plain.is_empty().not().then(|| Cow::Owned(plain)) + plain.is_empty().not().then_some(Cow::Owned(plain)) } fn extract_key_values(s: &str) -> impl Iterator { @@ -173,6 +176,7 @@ impl Display for ParsedQuery<'_> { w("lang:", display(&self.language))?; w("genre:", display(&self.genre))?; w("year:", display(&self.year))?; + w("list:", display(&self.list))?; Ok(()) } diff --git a/src/song.rs b/src/song.rs index 0b30ed4..7236d7a 100644 --- a/src/song.rs +++ b/src/song.rs @@ -1,3 +1,5 @@ +use crate::app::Loading; +use crate::custom_list::CustomLists; use crate::fuzzy::{self, FuzzyScore}; use crate::query::ParsedQuery; use serde::Deserialize; @@ -27,8 +29,8 @@ impl Song { .zip(self.duet_singer_2.as_deref()) } - pub fn fuzzy_compare(&self, query: &ParsedQuery) -> FuzzyScore { - let bad = || -1; + pub fn fuzzy_compare(&self, query: &ParsedQuery, custom_lists: &CustomLists) -> FuzzyScore { + let bad: FuzzyScore = -1; let filter_strs = |query: Option<&str>, item: Option<&str>| { if let Some(query) = query { @@ -56,7 +58,17 @@ impl Song { ]; if !filters.iter().all(|f| f()) { - return bad(); + return bad; + } + + if let Some(list) = query.list { + let Some(Loading::Loaded(list)) = custom_lists.get(list) else { + return bad; + }; + + if !list.contains(&self.song_hash) { + return bad; + } } let mut score = FuzzyScore::default();