Add support for custom sublists
This commit is contained in:
@ -4,9 +4,6 @@ version = "0.2.2"
|
||||
authors = ["Joakim Hulthe <joakim@hulthe.net"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
seed = "0.8.0"
|
||||
#wasm-bindgen = "0.2.70"
|
||||
|
||||
155
src/app.rs
155
src/app.rs
@ -1,4 +1,5 @@
|
||||
use crate::css::C;
|
||||
use crate::custom_list::{fetch_custom_song_list, fetch_custom_song_list_index, CustomLists};
|
||||
use crate::fuzzy::FuzzyScore;
|
||||
use crate::query::ParsedQuery;
|
||||
use crate::song::Song;
|
||||
@ -7,27 +8,31 @@ use rand::seq::SliceRandom;
|
||||
use rand::thread_rng;
|
||||
use seed::app::cmds::timeout;
|
||||
use seed::browser::util::document;
|
||||
use seed::prelude::*;
|
||||
use seed::{attrs, button, div, empty, error, img, input, p, span, C, IF};
|
||||
use seed::{log, prelude::*};
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashSet;
|
||||
use web_sys::Element;
|
||||
|
||||
pub struct Model {
|
||||
songs: Vec<(Reverse<FuzzyScore>, 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<CmdHandle>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub enum Loading<T> {
|
||||
/// 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<Song>),
|
||||
|
||||
/// Loaded custom song index.
|
||||
CustomSongLists(Vec<String>),
|
||||
|
||||
/// Loaded custom song list.
|
||||
CustomSongList {
|
||||
list: String,
|
||||
song_hashes: HashSet<String>,
|
||||
},
|
||||
|
||||
/// 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<Msg>) -> Model {
|
||||
let mut songs: Vec<Song> =
|
||||
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<Msg>) -> Model {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_song_list(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
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<Msg>) {
|
||||
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<Node<Msg>> {
|
||||
]
|
||||
}
|
||||
|
||||
async fn fetch_songs() -> Option<Msg> {
|
||||
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<Song> = 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<Msg>) {
|
||||
let (_, song) = &model.songs[0];
|
||||
model.query_placeholder = ParsedQuery::random(song, &mut thread_rng()).to_string();
|
||||
|
||||
50
src/custom_list.rs
Normal file
50
src/custom_list.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use seed::{log, prelude::fetch};
|
||||
|
||||
use crate::app::{Loading, Msg};
|
||||
|
||||
pub type CustomLists = HashMap<String, Loading<HashSet<String>>>;
|
||||
|
||||
pub async fn fetch_custom_song_list_index() -> Option<Msg> {
|
||||
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<String> = 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<Msg> {
|
||||
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<String> = match response.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log!("error parsing custom song list", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(Msg::CustomSongList { list, song_hashes })
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
14
src/query.rs
14
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<R: Rng>(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<bool> {
|
||||
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<Cow<str>> {
|
||||
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<Item = (&str, &str)> {
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
18
src/song.rs
18
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();
|
||||
|
||||
Reference in New Issue
Block a user