Add support for custom sublists

This commit is contained in:
2023-09-23 15:44:36 +02:00
parent ec5daba300
commit 1092860b42
6 changed files with 194 additions and 51 deletions

View File

@ -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"

View File

@ -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,15 +107,11 @@ pub fn init(_url: Url, orders: &mut impl Orders<Msg>) -> Model {
}
}
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg {
Msg::Search(query) => {
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();
model.query = query;
if model.query.is_empty() {
model.filter_duets = false;
model.filter_video = false;
@ -98,9 +121,16 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
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);
let new_score = song.fuzzy_compare(&query, &model.custom_lists);
if new_score < Default::default() {
model.hidden_songs += 1;
}
@ -109,6 +139,35 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
}
model.songs.sort_unstable();
}
}
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg {
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.custom_lists.entry(list).or_default() = Loading::Loaded(song_hashes);
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);
@ -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
View 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 })
}

View File

@ -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);
}

View File

@ -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(())
}

View File

@ -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();