My personal site cherry.computer
htmx tailwind axum askama

feat: push scrobbles rather than pull them via SSE

cherry.computer 0f0fe58c a04492e3

verified
+97 -19
+11
frontend/package-lock.json
··· 9 9 "version": "1.0.0", 10 10 "license": "MIT", 11 11 "dependencies": { 12 + "htmx-ext-sse": "^2.2.2", 12 13 "htmx.org": "^2.0.4", 13 14 "normalize.css": "^8.0.1", 14 15 "three": "^0.172.0" ··· 1732 1733 "node": ">=8" 1733 1734 } 1734 1735 }, 1736 + "node_modules/htmx-ext-sse": { 1737 + "version": "2.2.2", 1738 + "resolved": "https://registry.npmjs.org/htmx-ext-sse/-/htmx-ext-sse-2.2.2.tgz", 1739 + "integrity": "sha512-MTnKkBzA2t4sI8gOXrRiPaceTlkUbrw3+3qOy1BfuBNIPBalsJiT4qxUGd6W48ggOkfe2akOnB8uxICJKw+Dsg==" 1740 + }, 1735 1741 "node_modules/htmx.org": { 1736 1742 "version": "2.0.4", 1737 1743 "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz", ··· 3413 3419 "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 3414 3420 "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 3415 3421 "dev": true 3422 + }, 3423 + "htmx-ext-sse": { 3424 + "version": "2.2.2", 3425 + "resolved": "https://registry.npmjs.org/htmx-ext-sse/-/htmx-ext-sse-2.2.2.tgz", 3426 + "integrity": "sha512-MTnKkBzA2t4sI8gOXrRiPaceTlkUbrw3+3qOy1BfuBNIPBalsJiT4qxUGd6W48ggOkfe2akOnB8uxICJKw+Dsg==" 3416 3427 }, 3417 3428 "htmx.org": { 3418 3429 "version": "2.0.4",
+1
frontend/package.json
··· 36 36 }, 37 37 "dependencies": { 38 38 "htmx.org": "^2.0.4", 39 + "htmx-ext-sse": "^2.2.2", 39 40 "normalize.css": "^8.0.1", 40 41 "three": "^0.172.0" 41 42 },
+9
frontend/src/ts/htmx.ts
··· 1 + import htmx from "htmx.org"; 2 + 3 + declare global { 4 + interface Window { 5 + htmx: typeof htmx; 6 + } 7 + } 8 + 9 + window.htmx = htmx;
+3 -1
frontend/src/ts/main.ts
··· 1 1 import "../css/main.css"; 2 2 3 - import "htmx.org"; 3 + // need to load htmx as a global variable before importing extensions 4 + import "./htmx"; 5 + import "htmx-ext-sse"; 4 6 5 7 import "./header"; 6 8 import "./logo";
+35
server/Cargo.lock
··· 95 95 ] 96 96 97 97 [[package]] 98 + name = "async-stream" 99 + version = "0.3.6" 100 + source = "registry+https://github.com/rust-lang/crates.io-index" 101 + checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" 102 + dependencies = [ 103 + "async-stream-impl", 104 + "futures-core", 105 + "pin-project-lite", 106 + ] 107 + 108 + [[package]] 109 + name = "async-stream-impl" 110 + version = "0.3.6" 111 + source = "registry+https://github.com/rust-lang/crates.io-index" 112 + checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" 113 + dependencies = [ 114 + "proc-macro2", 115 + "quote", 116 + "syn", 117 + ] 118 + 119 + [[package]] 98 120 name = "atomic-waker" 99 121 version = "1.1.2" 100 122 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 854 876 dependencies = [ 855 877 "anyhow", 856 878 "askama", 879 + "async-stream", 857 880 "axum", 858 881 "reqwest", 859 882 "serde", 860 883 "tokio", 884 + "tokio-stream", 861 885 "tower", 862 886 "tower-http", 863 887 "tracing", ··· 1469 1493 checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" 1470 1494 dependencies = [ 1471 1495 "rustls", 1496 + "tokio", 1497 + ] 1498 + 1499 + [[package]] 1500 + name = "tokio-stream" 1501 + version = "0.1.17" 1502 + source = "registry+https://github.com/rust-lang/crates.io-index" 1503 + checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" 1504 + dependencies = [ 1505 + "futures-core", 1506 + "pin-project-lite", 1472 1507 "tokio", 1473 1508 ] 1474 1509
+2
server/Cargo.toml
··· 8 8 [dependencies] 9 9 anyhow = "1.0.57" 10 10 askama = { version = "0.13.0", git="https://github.com/rinja-rs/askama.git" } 11 + async-stream = "0.3.6" 11 12 axum = "0.8.1" 12 13 reqwest = { version = "0.12.12", features = ["json"] } 13 14 serde = { version = "1.0.217", features = ["derive"] } 14 15 tokio = { version = "1.18.2", features = ["full"] } 16 + tokio-stream = "0.1.17" 15 17 tower = "0.5.2" 16 18 tower-http = { version = "0.6.2", features = ["compression-full", "fs", "trace", "set-header"] } 17 19 tracing = "0.1.34"
+33 -11
server/src/main.rs
··· 2 2 mod scrobble; 3 3 mod scrobble_monitor; 4 4 5 - use std::{env, net::SocketAddr}; 5 + use std::{convert::Infallible, env, net::SocketAddr, time::Duration}; 6 6 7 7 use crate::index::get_index; 8 8 use crate::scrobble_monitor::ScrobbleMonitor; 9 9 10 10 use askama::Template; 11 + use async_stream::stream; 11 12 use axum::{ 12 13 http::{HeaderName, HeaderValue, StatusCode}, 13 - response::{Html, IntoResponse}, 14 + response::{sse, Html, IntoResponse, Sse}, 14 15 routing::{get, get_service}, 15 16 Extension, Router, 16 17 }; 18 + use tokio::time::{self, MissedTickBehavior}; 19 + use tokio_stream::Stream; 17 20 use tower::ServiceBuilder; 18 21 use tower_http::{ 19 22 compression::CompressionLayer, services::ServeDir, set_header::SetResponseHeaderLayer, ··· 57 60 }) 58 61 } 59 62 60 - async fn get_scrobble(Extension(mut monitor): Extension<ScrobbleMonitor>) -> impl IntoResponse { 61 - let template = monitor.get_scrobble().await.map_err(|err| { 62 - tracing::error!("failed to get data from last.fm: {err:?}"); 63 - StatusCode::BAD_GATEWAY 64 - })?; 65 - template.render().map(Html).map_err(|err| { 66 - tracing::error!("failed to render scrobble: {err:?}"); 67 - StatusCode::INTERNAL_SERVER_ERROR 68 - }) 63 + async fn get_scrobble( 64 + Extension(mut monitor): Extension<ScrobbleMonitor>, 65 + ) -> Sse<impl Stream<Item = Result<sse::Event, Infallible>>> { 66 + let stream = stream! { 67 + let mut interval = time::interval(Duration::from_secs(30)); 68 + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); 69 + interval.tick().await; 70 + loop { 71 + interval.tick().await; 72 + let template = match monitor.get_scrobble().await { 73 + Ok(template) => template, 74 + Err(err) => { 75 + tracing::error!("failed to get data from last.fm: {err:?}"); 76 + continue; 77 + } 78 + }; 79 + let data = match template.render() { 80 + Ok(data) => data, 81 + Err(err) => { 82 + tracing::error!("failed to render scrobble: {err:?}"); 83 + break; 84 + } 85 + }; 86 + yield Ok(sse::Event::default().event("scrobble").data(data)); 87 + } 88 + }; 89 + 90 + Sse::new(stream) 69 91 }
+3 -7
server/templates/index.html
··· 88 88 </div> 89 89 <div 90 90 class="scrobble-bar" 91 - hx-get="/scrobbles" 92 - hx-trigger= 93 - {%- if let Some(_) = scrobble -%} 94 - "every 30s" 95 - {%- else -%} 96 - "load, every 30s" 97 - {%- endif -%} 91 + hx-ext="sse" 92 + sse-connect="/scrobbles" 93 + sse-swap="scrobble" 98 94 > 99 95 {%- match scrobble %} {%- when Some with (ScrobblesTemplate {intro, now_playing, image, srcset}) %} 100 96 {% include "scrobble.html" %}