demo filtering

This commit is contained in:
Robin Appelman 2023-04-13 23:33:20 +02:00
commit 57d034159b
14 changed files with 402 additions and 35 deletions

View file

@ -1,10 +1,20 @@
use crate::data::chat::Chat;
use crate::data::player::Player;
use crate::data::schema::{ArrayAgg, CleanMapName, Demos, Players};
use crate::data::steam_id::SteamId;
use crate::Result;
use maud::Render;
use sea_query::extension::postgres::PgExpr;
use sea_query::{
Alias, Expr, Func, Order, PostgresQueryBuilder, Query, SelectStatement, SimpleExpr,
};
use sea_query_binder::SqlxBinder;
use serde::{Deserialize, Deserializer};
use sqlx::{query_as, Executor, FromRow, Postgres};
use std::borrow::Cow;
use std::fmt::Write;
use std::ops::Range;
use std::str::FromStr;
use time::format_description::well_known::Iso8601;
use time::{OffsetDateTime, PrimitiveDateTime, UtcOffset};
use tracing::instrument;
@ -156,19 +166,9 @@ impl ListDemo {
#[instrument(skip(connection))]
pub async fn list(
connection: impl Executor<'_, Database = Postgres>,
before: Option<u32>,
filter: Filter,
) -> Result<Vec<Self>> {
if let Some(before) = before {
Ok(query_as!(
ListDemo,
r#"SELECT
id, name, map, red, blu, duration, created_at, server, "playerCount" as player_count
FROM demos WHERE deleted_at IS NULL and id < $1 ORDER BY id DESC LIMIT 50"#,
before as i32
)
.fetch_all(connection)
.await?)
} else {
if filter.is_empty() {
Ok(query_as!(
ListDemo,
r#"SELECT
@ -177,6 +177,31 @@ impl ListDemo {
)
.fetch_all(connection)
.await?)
} else {
let mut query = Query::select();
query
.column((Demos::Table, Demos::Id))
.column((Demos::Table, Demos::Name))
.columns([
Demos::Map,
Demos::Red,
Demos::Blu,
Demos::Duration,
Demos::Server,
Demos::CreatedAt,
])
.expr_as(Expr::col(Demos::PlayerCount), Alias::new("player_count"))
.from(Demos::Table)
.and_where(Expr::col(Demos::DeletedAt).is_null())
.order_by(Demos::Id, Order::Desc)
.limit(50);
filter.apply(&mut query);
let (sql, values) = query.build_sqlx(PostgresQueryBuilder);
Ok(sqlx::query_as_with::<_, ListDemo, _>(&sql, values)
.fetch_all(connection)
.await?)
}
}
@ -314,3 +339,101 @@ impl Render for RelativeDate {
}
}
}
#[derive(Debug, Default, Deserialize, Eq, PartialEq)]
pub enum GameMode {
#[serde(rename = "4v4")]
Fours,
#[serde(rename = "6v6")]
Sixes,
#[serde(rename = "prolander")]
Prolander,
#[serde(rename = "highlander")]
HighLander,
#[default]
#[serde(other)]
Any,
}
impl GameMode {
fn player_count(&self) -> Option<Range<i32>> {
match self {
GameMode::Fours => Some(7..9),
GameMode::Sixes => Some(11..13),
GameMode::Prolander => Some(14..15),
GameMode::HighLander => Some(17..19),
GameMode::Any => None,
}
}
}
#[derive(Default, Debug, Deserialize)]
pub struct Filter {
#[serde(default)]
mode: GameMode,
#[serde(default)]
map: String,
#[serde(default)]
#[serde(deserialize_with = "deserialize_array")]
players: Vec<i32>,
#[serde(default)]
before: Option<i32>,
}
fn deserialize_array<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de> + FromStr,
{
let s = <Cow<str>>::deserialize(deserializer)?;
Ok(s.split(",").map(T::from_str).flatten().collect())
}
impl Filter {
fn is_empty(&self) -> bool {
self.mode != GameMode::default()
&& self.map.is_empty()
&& self.before.is_none()
&& self.players.is_empty()
}
fn apply(&self, query: &mut SelectStatement) {
if let Some(count) = self.mode.player_count() {
query.and_where(Expr::col(Demos::PlayerCount).between(count.start, count.end));
}
if !self.map.is_empty() {
let val = Expr::value(&self.map);
query.and_where(
Expr::col(Demos::Map).eq(val.clone()).or(SimpleExpr::from(
Func::cust(CleanMapName).arg(Expr::col(Demos::Map)),
)
.eq(val)),
);
}
if let Some(before) = &self.before {
query.and_where(Expr::col(Demos::Id).lt(*before));
}
if !self.players.is_empty() && self.players.len() < 19 {
let mut player_iter = self.players.iter();
let mut players = format!("array[{}", player_iter.next().unwrap());
for player in player_iter {
write!(&mut players, ",{}", player).unwrap();
}
players.push_str("]");
// query.and_where(Expr::cust(&players).contained(sub_array));
query
.inner_join(
Players::Table,
Expr::col((Demos::Table, Demos::Id)).equals((Players::Table, Players::DemoId)),
)
.and_where(Expr::col(Players::UserId).is_in(self.players.clone()));
query.group_by_col((Demos::Table, Players::Id));
query.and_having(
Expr::cust(&players).contained(
Func::cust(ArrayAgg).arg(Expr::col((Players::Table, Players::UserId))),
),
);
}
}
}

View file

@ -2,5 +2,6 @@ pub mod chat;
pub mod demo;
pub mod maps;
pub mod player;
mod schema;
pub mod steam_id;
pub mod user;

94
src/data/schema.rs Normal file
View file

@ -0,0 +1,94 @@
use sea_query::Iden;
use std::fmt::Write;
#[allow(dead_code)]
#[derive(Iden)]
pub enum Demos {
Table,
#[iden = "id"]
Id,
#[iden = "name"]
Name,
#[iden = "url"]
Url,
#[iden = "map"]
Map,
#[iden = "red"]
Red,
#[iden = "blu"]
Blu,
#[iden = "uploader"]
Uploader,
#[iden = "duration"]
Duration,
#[iden = "created_at"]
CreatedAt,
#[iden = "updated_at"]
UpdatedAt,
#[iden = "backend"]
Backend,
#[iden = "path"]
Path,
#[iden = "scoreBlue"]
ScoreBlue,
#[iden = "scoreRed"]
ScoreRed,
#[iden = "version"]
Version,
#[iden = "server"]
Server,
#[iden = "nick"]
Nick,
#[iden = "deleted_at"]
DeletedAt,
#[iden = "playerCount"]
PlayerCount,
#[iden = "hash"]
Hash,
#[iden = "blue_team_id"]
BlueTeamId,
#[iden = "red_team_id"]
RedTeamId,
}
pub struct CleanMapName;
impl Iden for CleanMapName {
fn unquoted(&self, s: &mut dyn Write) {
write!(s, "clean_map_name").unwrap()
}
}
#[derive(Iden)]
#[iden = "ARRAY"]
pub struct ArrayFunc;
#[derive(Iden)]
#[iden = "array_agg"]
pub struct ArrayAgg;
#[allow(dead_code)]
#[derive(Iden)]
pub enum Players {
Table,
#[iden = "id"]
Id,
#[iden = "demo_id"]
DemoId,
#[iden = "demo_user_id"]
DemoUserId,
#[iden = "user_id"]
UserId,
#[iden = "name"]
Name,
#[iden = "team"]
Team,
#[iden = "class"]
Class,
#[iden = "kills"]
Kills,
#[iden = "assists"]
Assists,
#[iden = "deaths"]
Deaths,
}

View file

@ -4,8 +4,10 @@ use sqlx::database::HasValueRef;
use sqlx::error::BoxDynError;
use sqlx::{Database, Decode, Type};
use std::borrow::Cow;
use std::convert::Infallible;
use std::fmt::{Debug, Formatter};
use std::fmt::{Display, Write};
use std::str::FromStr;
use steamid_ng::SteamID;
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@ -90,15 +92,7 @@ where
{
fn decode(value: <DB as HasValueRef<'r>>::ValueRef) -> Result<Self, BoxDynError> {
let str = <&str as Decode<DB>>::decode(value)?;
if let Ok(id) = str.parse() {
Ok(Self::Id(id))
} else if str == "serveme.tf" {
Ok(Self::Raw("serveme.tf".into()))
} else if str == "essentialstf" {
Ok(Self::Raw("essentialstf".into()))
} else {
Ok(Self::Raw(str.to_string().into()))
}
Ok(str.parse().unwrap())
}
}
@ -144,3 +138,19 @@ impl SteamId {
ProfileLink(self)
}
}
impl FromStr for SteamId {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Ok(id) = s.parse() {
Ok(Self::Id(id))
} else if s == "serveme.tf" {
Ok(Self::Raw("serveme.tf".into()))
} else if s == "essentialstf" {
Ok(Self::Raw("essentialstf".into()))
} else {
Ok(Self::Raw(s.to_string().into()))
}
}
}

View file

@ -28,7 +28,7 @@ impl IntoResponse for Error {
fn into_response(self) -> Response {
match self {
Error::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(),
_ => todo!(),
e => format!("{:#}", e).into_response(),
}
}
}

View file

@ -0,0 +1,24 @@
use crate::data::demo::ListDemo;
use maud::{html, Markup, Render};
pub struct DemoList {
pub demos: Vec<ListDemo>,
}
impl Render for DemoList {
fn render(&self) -> Markup {
html! {
@for demo in &self.demos {
tr {
td .title {
a href = (demo.url()) { (demo.server) " - " (demo.red) " vs " (demo.blu) }
}
td .format { (demo.format()) }
td .map { (demo.map) }
td .duration { (demo.duration()) }
td .date title = (demo.date()) { (demo.relative_date()) }
}
}
}
}
}

1
src/fragments/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod demo_list;

View file

@ -2,16 +2,18 @@ mod asset;
mod config;
mod data;
mod error;
mod fragments;
mod pages;
mod session;
use crate::asset::serve_asset;
pub use crate::config::Config;
use crate::config::Listen;
use crate::data::demo::{Demo, ListDemo};
use crate::data::demo::{Demo, Filter, ListDemo};
use crate::data::maps::map_list;
use crate::data::steam_id::SteamId;
use crate::data::user::User;
use crate::fragments::demo_list::DemoList;
use crate::pages::about::AboutPage;
use crate::pages::demo::DemoPage;
use crate::pages::index::{DemoListScript, Index};
@ -19,7 +21,7 @@ use crate::pages::upload::{UploadPage, UploadScript};
use crate::pages::{render, GlobalStyle};
use crate::session::{SessionData, COOKIE_NAME};
use async_session::{MemoryStore, Session, SessionStore};
use axum::extract::{MatchedPath, Path, RawQuery};
use axum::extract::{MatchedPath, Path, Query, RawQuery};
use axum::headers::Cookie;
use axum::http::header::{LOCATION, SET_COOKIE};
use axum::http::{HeaderValue, Request, StatusCode};
@ -28,7 +30,7 @@ use axum::{extract::State, routing::get, Router, Server, TypedHeader};
use demostf_build::Asset;
pub use error::Error;
use hyperlocal::UnixServerExt;
use maud::Markup;
use maud::{Markup, Render};
use sqlx::PgPool;
use std::env::{args, var};
use std::fs::{remove_file, set_permissions, Permissions};
@ -88,6 +90,7 @@ async fn main() -> Result<()> {
.route(DemoListScript::route(), get(serve_asset::<DemoListScript>))
.route(LogoPng::route(), get(serve_asset::<LogoPng>))
.route(LogoSvg::route(), get(serve_asset::<LogoSvg>))
.route("/fragments/demo-list", get(demo_list))
.route("/about", get(about))
.route("/login/callback", get(login_callback))
.route("/login", get(login))
@ -134,8 +137,14 @@ async fn main() -> Result<()> {
Ok(())
}
async fn index(State(app): State<Arc<App>>, session: SessionData) -> Result<Markup> {
let demos = ListDemo::list(&app.connection, None).await?;
#[axum::debug_handler]
async fn index(
State(app): State<Arc<App>>,
session: SessionData,
filter: Option<Query<Filter>>,
) -> Result<Markup> {
let filter = filter.map(|filter| filter.0).unwrap_or_default();
let demos = ListDemo::list(&app.connection, filter).await?;
let maps = map_list(&app.connection).await?.collect();
Ok(render(
Index {
@ -258,6 +267,13 @@ async fn upload(State(app): State<Arc<App>>, session: SessionData) -> impl IntoR
}
}
#[axum::debug_handler]
async fn demo_list(State(app): State<Arc<App>>, filter: Option<Query<Filter>>) -> Result<Markup> {
let filter = filter.map(|filter| filter.0).unwrap_or_default();
let demos = ListDemo::list(&app.connection, filter).await?;
Ok(DemoList { demos }.render())
}
async fn handler_404() -> impl IntoResponse {
Error::NotFound
}