implement game event subscriptions via rss

This commit is contained in:
yggverse 2026-03-27 06:34:37 +02:00
parent 31c31d2106
commit 28c64036ce
5 changed files with 155 additions and 20 deletions

69
crates/httpd/src/feed.rs Normal file
View file

@ -0,0 +1,69 @@
use chrono::{DateTime, Utc};
use std::net::SocketAddr;
/// Export crawl results as the RSS
pub struct Feed(String);
impl Feed {
pub fn new(title: &str, description: Option<&str>) -> Self {
let t = chrono::Utc::now().to_rfc2822();
let mut b = String::new();
b.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\"><channel>");
b.push_str("<pubDate>");
b.push_str(&t);
b.push_str("</pubDate>");
b.push_str("<lastBuildDate>");
b.push_str(&t);
b.push_str("</lastBuildDate>");
b.push_str("<title>");
b.push_str(title);
b.push_str("</title>");
if let Some(d) = description {
b.push_str("<description>");
b.push_str(d);
b.push_str("</description>")
}
Self(b)
}
/// Appends `item` to the feed `channel`
pub fn push(
&mut self,
time: &DateTime<Utc>,
address: &SocketAddr,
host: &String,
map: &String,
online: u32,
) {
let a = address.to_string(); // allocate once
self.0.push_str(&format!(
"<item><guid>{}</guid><title>{host}</title><link>udp://{a}</link>",
time.timestamp() // must be unique as the event
));
self.0.push_str("<description>");
self.0.push_str(&format!("connect: {a}\n"));
self.0.push_str(&format!("map: {map}\n"));
self.0.push_str(&format!("online: {online}\n"));
self.0.push_str("</description>");
self.0.push_str("<pubDate>");
self.0.push_str(&time.to_rfc2822());
self.0.push_str("</pubDate>");
self.0.push_str("</item>")
}
/// Write final bytes
pub fn commit(mut self) -> String {
self.0.push_str("</channel></rss>");
self.0
}
}

View file

@ -3,23 +3,31 @@ extern crate rocket;
mod argument;
mod config;
mod feed;
mod global;
mod meta;
mod scrape;
use chrono::{DateTime, Utc};
use feed::Feed;
use global::Global;
use meta::Meta;
use rocket::{
State,
form::FromForm,
http::Status,
response::content::RawXml,
tokio::{spawn, sync::RwLock, time::sleep},
};
use rocket_dyn_templates::{Template, context};
use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
struct Online {
scrape: scrape::Result,
/// Actual state container
current: scrape::Result,
/// Hold previous `current` state updated to compare and notify subscribers for online change
last: Option<scrape::Result>,
/// Snap time
update: DateTime<Utc>,
}
type Snap = Arc<RwLock<Option<Online>>>;
@ -37,12 +45,52 @@ async fn index(
masters: &global.masters,
title: &meta.title,
version: &meta.version,
servers: snap.as_ref().map(|s|s.scrape.servers.clone()),
servers: snap.as_ref().map(|s|s.current.servers.clone()),
updated: snap.as_ref().map(|s|s.update.to_rfc2822())
},
))
}
#[derive(FromForm, Debug)]
struct RssParams {
online: Option<u32>,
servers: Option<Vec<SocketAddr>>,
}
#[get("/rss?<params..>")]
async fn rss(
params: RssParams,
meta: &State<Meta>,
online: &State<Snap>,
) -> Result<RawXml<String>, Status> {
let mut f = Feed::new(
&meta.title,
None, // @TODO service description
);
if let Some(state) = online.read().await.as_ref() {
for current in &state.current.servers {
if state.last.as_ref().is_none_or(|l| {
l.servers
.iter()
.any(|last| current.address == last.address && current.numcl > last.numcl)
}) && params.online.is_none_or(|online| current.numcl >= online)
&& params
.servers
.as_ref()
.is_none_or(|servers| servers.iter().any(|address| address == &current.address))
{
f.push(
&state.update,
&current.address,
&current.host,
&current.map,
current.numcl,
)
}
}
}
Ok(RawXml(f.commit()))
}
#[launch]
async fn rocket() -> _ {
use clap::Parser;
@ -60,17 +108,18 @@ async fn rocket() -> _ {
Ok(s) => match str::from_utf8(&s.stdout) {
Ok(r) => {
if s.status.success() {
*online.write().await =
match rocket::serde::json::serde_json::from_str(r) {
Ok(scrape) => Some(Online {
scrape,
update: Utc::now(),
}),
Err(e) => {
error!("Could not decode scrape response: `{e}`");
None
}
let mut state = online.write().await;
*state = match rocket::serde::json::serde_json::from_str(r) {
Ok(current) => Some(Online {
current,
last: state.as_ref().map(|last| last.current.clone()),
update: Utc::now(),
}),
Err(e) => {
error!("Could not decode scrape response: `{e}`");
None
}
}
} else {
error!("Scrape request failed");
}
@ -103,7 +152,7 @@ async fn rocket() -> _ {
title: config.title,
version: env!("CARGO_PKG_VERSION").into(),
})
.mount("/", routes![index])
.mount("/", routes![index, rss])
}
/// Get servers online using `bin` from given `masters`

View file

@ -1,7 +1,7 @@
use rocket::serde::{Deserialize, Serialize};
use std::net::SocketAddr;
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct Result {
pub protocol: Vec<i32>,

View file

@ -2,11 +2,11 @@
{% block content %}
<h2>Game</h2>
{% if servers %}
<form name="subscribe" method="get" action="/">
<form name="rss" method="get" action="/rss">
<table>
<thead>
<tr>
{# @TODO subscribe<th></th>#}
<th></th>
<th>Address</th>
<th>Host</th>
<th>Ping</th>
@ -26,7 +26,7 @@
</tbody>
{% for server in servers %}
<tr>
{# @TODO subscribe<td><input type="checkbox" name="server[]" value="{{ server.address }}" /></td>#}
<td><input type="checkbox" name="server[]" value="{{ server.address }}" /></td>
<td>{{ server.address }}</td>
<td>{{ server.host }}</td>
<td>{{ server.ping }}</td>
@ -45,9 +45,12 @@
{% endfor %}
</tbody>
</table>
{# @TODO subscribe
<input type="number" name="online" value="1" />
<input type="submit" value="Subscribe" />#}
<fieldset>
<label>
min. online: <input type="number" name="online" min="0" value="1" />
</label>
<input type="submit" value="RSS" />
</fieldset>
</form>
{% else %}
<div>

View file

@ -94,6 +94,20 @@
max-width: var(--container-max-width);
}
main > form > fieldset {
border: 0;
margin: 16px 0;
}
main > form > fieldset input {
padding: 0 4px;
}
main > form > fieldset input[type="number"] {
margin: 0 4px;
max-width: 36px;
}
footer {
display: block;
margin: 16px auto;