Add support for custom sublists
This commit is contained in:
@ -4,9 +4,6 @@ version = "0.2.2"
|
|||||||
authors = ["Joakim Hulthe <joakim@hulthe.net"]
|
authors = ["Joakim Hulthe <joakim@hulthe.net"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
seed = "0.8.0"
|
seed = "0.8.0"
|
||||||
#wasm-bindgen = "0.2.70"
|
#wasm-bindgen = "0.2.70"
|
||||||
|
|||||||
119
src/app.rs
119
src/app.rs
@ -1,4 +1,5 @@
|
|||||||
use crate::css::C;
|
use crate::css::C;
|
||||||
|
use crate::custom_list::{fetch_custom_song_list, fetch_custom_song_list_index, CustomLists};
|
||||||
use crate::fuzzy::FuzzyScore;
|
use crate::fuzzy::FuzzyScore;
|
||||||
use crate::query::ParsedQuery;
|
use crate::query::ParsedQuery;
|
||||||
use crate::song::Song;
|
use crate::song::Song;
|
||||||
@ -7,27 +8,31 @@ use rand::seq::SliceRandom;
|
|||||||
use rand::thread_rng;
|
use rand::thread_rng;
|
||||||
use seed::app::cmds::timeout;
|
use seed::app::cmds::timeout;
|
||||||
use seed::browser::util::document;
|
use seed::browser::util::document;
|
||||||
use seed::prelude::*;
|
|
||||||
use seed::{attrs, button, div, empty, error, img, input, p, span, C, IF};
|
use seed::{attrs, button, div, empty, error, img, input, p, span, C, IF};
|
||||||
|
use seed::{log, prelude::*};
|
||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
|
use std::collections::HashSet;
|
||||||
use web_sys::Element;
|
use web_sys::Element;
|
||||||
|
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
songs: Vec<(Reverse<FuzzyScore>, Song)>,
|
songs: Vec<(Reverse<FuzzyScore>, Song)>,
|
||||||
|
|
||||||
/// The search string
|
/// Custom song lists, lazily loaded.
|
||||||
|
custom_lists: CustomLists,
|
||||||
|
|
||||||
|
/// The search string.
|
||||||
query: 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,
|
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,
|
hidden_songs: usize,
|
||||||
|
|
||||||
/// Whether we're filtering by video
|
/// Whether we're filtering by video.
|
||||||
filter_video: bool,
|
filter_video: bool,
|
||||||
|
|
||||||
/// Whether we're filtering by duets
|
/// Whether we're filtering by duets.
|
||||||
filter_duets: bool,
|
filter_duets: bool,
|
||||||
|
|
||||||
query_placeholder: String,
|
query_placeholder: String,
|
||||||
@ -36,10 +41,35 @@ pub struct Model {
|
|||||||
autotyper: Option<CmdHandle>,
|
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 SCROLL_THRESHOLD: usize = 50;
|
||||||
const INITIAL_ELEM_COUNT: usize = 100;
|
const INITIAL_ELEM_COUNT: usize = 100;
|
||||||
|
|
||||||
pub enum Msg {
|
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
|
/// The user entered something into the search field
|
||||||
Search(String),
|
Search(String),
|
||||||
|
|
||||||
@ -60,15 +90,12 @@ pub enum Msg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(_url: Url, orders: &mut impl Orders<Msg>) -> Model {
|
pub fn init(_url: Url, orders: &mut impl Orders<Msg>) -> Model {
|
||||||
let mut songs: Vec<Song> =
|
orders.perform_cmd(fetch_songs());
|
||||||
serde_json::from_str(include_str!("../static/songs.json")).expect("parse songs");
|
orders.perform_cmd(fetch_custom_song_list_index());
|
||||||
songs.shuffle(&mut thread_rng());
|
|
||||||
|
|
||||||
Model {
|
Model {
|
||||||
songs: songs
|
songs: vec![],
|
||||||
.into_iter()
|
custom_lists: Default::default(),
|
||||||
.map(|song| (Default::default(), song))
|
|
||||||
.collect(),
|
|
||||||
query: String::new(),
|
query: String::new(),
|
||||||
hidden_songs: 0,
|
hidden_songs: 0,
|
||||||
shown_songs: INITIAL_ELEM_COUNT,
|
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>) {
|
fn update_song_list(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||||
match msg {
|
|
||||||
Msg::Search(query) => {
|
|
||||||
model.hidden_songs = 0;
|
model.hidden_songs = 0;
|
||||||
model.shown_songs = INITIAL_ELEM_COUNT;
|
model.shown_songs = INITIAL_ELEM_COUNT;
|
||||||
scroll_to_top();
|
scroll_to_top();
|
||||||
|
|
||||||
model.query = query;
|
|
||||||
|
|
||||||
if model.query.is_empty() {
|
if model.query.is_empty() {
|
||||||
model.filter_duets = false;
|
model.filter_duets = false;
|
||||||
model.filter_video = 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_duets = query.duet == Some(true);
|
||||||
model.filter_video = query.video == 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
|
// calculate search scores & sort list
|
||||||
for (score, song) in model.songs.iter_mut() {
|
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() {
|
if new_score < Default::default() {
|
||||||
model.hidden_songs += 1;
|
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();
|
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 => {
|
Msg::ToggleVideo => {
|
||||||
let mut query = ParsedQuery::parse(&model.query);
|
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>) {
|
pub fn autotype_song(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||||
let (_, song) = &model.songs[0];
|
let (_, song) = &model.songs[0];
|
||||||
model.query_placeholder = ParsedQuery::random(song, &mut thread_rng()).to_string();
|
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 app;
|
||||||
mod css;
|
mod css;
|
||||||
|
mod custom_list;
|
||||||
mod fuzzy;
|
mod fuzzy;
|
||||||
mod query;
|
mod query;
|
||||||
mod song;
|
mod song;
|
||||||
|
|
||||||
use seed::prelude::wasm_bindgen;
|
|
||||||
use seed::App;
|
use seed::App;
|
||||||
|
|
||||||
#[wasm_bindgen(start)]
|
fn main() {
|
||||||
pub fn start() {
|
|
||||||
App::start("app", app::init, app::update, app::view);
|
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
|
/// Query from a specifc year
|
||||||
pub year: Option<&'a str>,
|
pub year: Option<&'a str>,
|
||||||
|
|
||||||
|
/// Query songs from the specified custom list.
|
||||||
|
pub list: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ParsedQuery<'a> {
|
impl<'a> ParsedQuery<'a> {
|
||||||
@ -50,6 +53,7 @@ impl<'a> ParsedQuery<'a> {
|
|||||||
"lang" => parsed.language = Some(v),
|
"lang" => parsed.language = Some(v),
|
||||||
"genre" => parsed.genre = Some(v),
|
"genre" => parsed.genre = Some(v),
|
||||||
"year" => parsed.year = 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
|
/// Generate a parsed query with a few random fields matching a song
|
||||||
pub fn random<R: Rng>(song: &'a Song, rng: &mut R) -> Self {
|
pub fn random<R: Rng>(song: &'a Song, rng: &mut R) -> Self {
|
||||||
let until_space =
|
let until_space = |s: &'a str| -> &'a str { s.split_whitespace().next().unwrap_or("") };
|
||||||
|s: &'a str| -> &'a str { s.trim().split_whitespace().next().unwrap_or("") };
|
|
||||||
|
|
||||||
let join_spaces = |s: &'a str| -> Cow<'a, str> {
|
let join_spaces = |s: &'a str| -> Cow<'a, str> {
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
@ -119,8 +122,8 @@ impl<'a> ParsedQuery<'a> {
|
|||||||
|
|
||||||
fn parse_bool(s: &str) -> Option<bool> {
|
fn parse_bool(s: &str) -> Option<bool> {
|
||||||
match s {
|
match s {
|
||||||
"true" | "yes" => Some(true),
|
"true" | "yes" | "y" => Some(true),
|
||||||
"false" | "no" => Some(false),
|
"false" | "no" | "n" => Some(false),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -137,7 +140,7 @@ fn extract_plain(s: &str) -> Option<Cow<str>> {
|
|||||||
a
|
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)> {
|
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("lang:", display(&self.language))?;
|
||||||
w("genre:", display(&self.genre))?;
|
w("genre:", display(&self.genre))?;
|
||||||
w("year:", display(&self.year))?;
|
w("year:", display(&self.year))?;
|
||||||
|
w("list:", display(&self.list))?;
|
||||||
|
|
||||||
Ok(())
|
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::fuzzy::{self, FuzzyScore};
|
||||||
use crate::query::ParsedQuery;
|
use crate::query::ParsedQuery;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@ -27,8 +29,8 @@ impl Song {
|
|||||||
.zip(self.duet_singer_2.as_deref())
|
.zip(self.duet_singer_2.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fuzzy_compare(&self, query: &ParsedQuery) -> FuzzyScore {
|
pub fn fuzzy_compare(&self, query: &ParsedQuery, custom_lists: &CustomLists) -> FuzzyScore {
|
||||||
let bad = || -1;
|
let bad: FuzzyScore = -1;
|
||||||
|
|
||||||
let filter_strs = |query: Option<&str>, item: Option<&str>| {
|
let filter_strs = |query: Option<&str>, item: Option<&str>| {
|
||||||
if let Some(query) = query {
|
if let Some(query) = query {
|
||||||
@ -56,7 +58,17 @@ impl Song {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if !filters.iter().all(|f| f()) {
|
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();
|
let mut score = FuzzyScore::default();
|
||||||
|
|||||||
Reference in New Issue
Block a user