use std::fmt::Write as _;
use koan_core::db::queries::{AlbumFilter, AlbumOrder, AlbumQuery, ArtistOrder, ArtistQuery};
use serde::Deserialize;
use crate::share::escape;
#[derive(Deserialize, Default, Clone)]
#[serde(default)]
pub(super) struct Browse {
sort: String,
seed: Option<i64>,
fav: String,
lossless: String,
codec: String,
from: String,
to: String,
genre: String,
pub(super) offset: u32,
}
const ALBUM_SORTS: [(&str, &str); 5] = [
("recent", "Recently added"),
("title", "Title"),
("artist", "Artist"),
("year", "Year"),
("random", "Random"),
];
const ARTIST_SORTS: [(&str, &str); 3] = [
("name", "Name"),
("albums", "Most albums"),
("recent", "Recently added"),
];
fn set(s: &str) -> Option<&str> {
Some(s.trim()).filter(|s| !s.is_empty())
}
impl Browse {
pub(super) fn seeded(mut self) -> Self {
if self.sort == "random" && self.seed.is_none() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos() as i64 ^ d.as_secs() as i64);
self.seed = Some(now);
}
self
}
fn filter(&self) -> AlbumFilter<'_> {
let year = |s: &str| set(s).and_then(|y| y.parse().ok());
AlbumFilter {
lossless: set(&self.lossless).is_some(),
codec: set(&self.codec),
year_from: year(&self.from),
year_to: year(&self.to),
genre: set(&self.genre),
}
}
pub(super) fn albums(&self, user: i64, limit: u32) -> AlbumQuery<'_> {
let order = match self.sort.as_str() {
"title" => AlbumOrder::Title,
"artist" => AlbumOrder::ArtistThenDate,
"year" => AlbumOrder::YearDesc,
"random" => AlbumOrder::Random(self.seed.unwrap_or(0)),
_ => AlbumOrder::RecentlyAdded,
};
AlbumQuery {
order,
favourites_of: set(&self.fav).map(|_| user),
filter: self.filter(),
limit: Some(limit),
offset: self.offset,
..Default::default()
}
}
pub(super) fn artists(&self, user: i64, limit: u32) -> ArtistQuery<'_> {
let order = match self.sort.as_str() {
"albums" => ArtistOrder::AlbumCount,
"recent" => ArtistOrder::RecentlyAdded,
_ => ArtistOrder::Name,
};
ArtistQuery {
order,
favourites_of: set(&self.fav).map(|_| user),
filter: self.filter(),
limit: Some(limit),
offset: self.offset,
..Default::default()
}
}
fn active(&self) -> usize {
[&self.fav, &self.lossless, &self.codec, &self.genre]
.into_iter()
.filter(|v| set(v).is_some())
.count()
+ usize::from(set(&self.from).is_some() || set(&self.to).is_some())
}
pub(super) fn query(&self, offset: u32) -> String {
let mut q = form_urlencoded::Serializer::new(String::new());
let seed = self.seed.map(|s| s.to_string()).unwrap_or_default();
let offset = if offset > 0 {
offset.to_string()
} else {
String::new()
};
for (k, v) in [
("sort", self.sort.as_str()),
("seed", &seed),
("fav", &self.fav),
("lossless", &self.lossless),
("codec", &self.codec),
("from", &self.from),
("to", &self.to),
("genre", &self.genre),
("offset", &offset),
] {
if let Some(v) = set(v) {
q.append_pair(k, v);
}
}
q.finish()
}
}
fn select(name: &str, label: &str, options: &[(String, String)], current: &str) -> String {
let mut out = format!("<label>{label}<select name={name}>");
for (value, text) in options {
let _ = write!(
out,
"<option value=\"{v}\"{sel}>{t}</option>",
v = escape(value),
t = escape(text),
sel = if value == current { " selected" } else { "" },
);
}
out.push_str("</select></label>");
out
}
fn check(name: &str, label: &str, on: &str) -> String {
format!(
"<label class=check><input type=checkbox name={name} value=1{}>{label}</label>",
if set(on).is_some() { " checked" } else { "" }
)
}
pub(super) fn toolbar(
b: &Browse,
path: &str,
artists: bool,
codecs: &[String],
genres: &[String],
) -> String {
let sorts: Vec<(String, String)> = if artists {
&ARTIST_SORTS[..]
} else {
&ALBUM_SORTS[..]
}
.iter()
.map(|(v, t)| (v.to_string(), t.to_string()))
.collect();
let sort = if b.sort.is_empty() {
sorts[0].0.clone()
} else {
b.sort.clone()
};
let any = || vec![(String::new(), "Any".to_string())];
let mut codec_options = any();
codec_options.extend(codecs.iter().map(|c| (c.clone(), c.clone())));
let mut genre_options = any();
genre_options.extend(genres.iter().map(|g| (g.clone(), g.clone())));
for (current, options) in [
(&b.codec, &mut codec_options),
(&b.genre, &mut genre_options),
] {
if let Some(c) = set(current)
&& !options.iter().any(|(v, _)| v == c)
{
options.push((c.to_owned(), c.to_owned()));
}
}
let seed = match (sort.as_str(), b.seed) {
("random", Some(seed)) => format!("<input type=hidden name=seed value={seed}>"),
_ => String::new(),
};
let reshuffle = if sort == "random" && !artists {
let fresh = Browse {
seed: None,
..b.clone()
};
format!(
"<a class=reshuffle href=\"{path}?{}\">Reshuffle</a>",
fresh.query(0)
)
} else {
String::new()
};
let active = b.active();
let label = if active > 0 {
format!("Sort · Filter <span class=badge>{active}</span>")
} else {
"Sort · Filter".into()
};
format!(
"<details class=browse><summary>{label}</summary>\
<form class=toolbar method=get action=\"{path}\">{sort_select}{seed}{reshuffle}{fav}{lossless}{codec}\
<label class=years>Years<input name=from inputmode=numeric maxlength=4 placeholder=From value=\"{from}\" aria-label=\"From year\">\
<span>–</span><input name=to inputmode=numeric maxlength=4 placeholder=To value=\"{to}\" aria-label=\"To year\"></label>\
{genre}<div class=toolbar-actions><button class=\"primary apply\">Apply</button><a class=reset href=\"{path}\">Reset</a></div>\
</form></details>",
sort_select = select("sort", "Sort", &sorts, &sort),
fav = check("fav", "Favourites", &b.fav),
lossless = check("lossless", "Lossless", &b.lossless),
codec = select("codec", "Codec", &codec_options, &b.codec),
genre = select("genre", "Genre", &genre_options, &b.genre),
from = escape(&b.from),
to = escape(&b.to),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn browse(q: &str) -> Browse {
let uri: axum::http::Uri = format!("/albums?{q}").parse().unwrap();
axum::extract::Query::<Browse>::try_from_uri(&uri)
.unwrap()
.0
}
#[test]
fn blank_fields_are_unset_and_the_query_round_trips() {
let b = browse("sort=year&fav=&lossless=1&codec=&from=1990&to=&genre=Drum+%26+Bass");
let q = b.albums(0, 60);
assert_eq!(q.order, AlbumOrder::YearDesc);
assert!(q.favourites_of.is_none() && q.filter.lossless);
assert_eq!(
(q.filter.codec, q.filter.year_from, q.filter.year_to),
(None, Some(1990), None)
);
assert_eq!(q.filter.genre, Some("Drum & Bass"));
assert_eq!(
b.query(60),
"sort=year&lossless=1&from=1990&genre=Drum+%26+Bass&offset=60"
);
assert_eq!(b.active(), 3);
}
#[test]
fn a_random_order_keeps_its_seed_and_hostile_values_stay_encoded() {
let b = browse("sort=random").seeded();
let seed = b.seed.unwrap();
assert_eq!(b.albums(0, 1).order, AlbumOrder::Random(seed));
assert!(b.query(0).contains(&format!("seed={seed}")));
let evil = browse("genre=%27%29%3Balert(1)%2F%2F%22%3E%3C");
let q = evil.query(0);
assert!(
!q.contains('\'') && !q.contains('"') && !q.contains('<'),
"{q}"
);
}
}