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 "version": "1.0.0", 10 "license": "MIT", 11 "dependencies": { 12 "htmx.org": "^2.0.4", 13 "normalize.css": "^8.0.1", 14 "three": "^0.172.0" ··· 1732 "node": ">=8" 1733 } 1734 }, 1735 "node_modules/htmx.org": { 1736 "version": "2.0.4", 1737 "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz", ··· 3413 "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 3414 "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 3415 "dev": true 3416 }, 3417 "htmx.org": { 3418 "version": "2.0.4",
··· 9 "version": "1.0.0", 10 "license": "MIT", 11 "dependencies": { 12 + "htmx-ext-sse": "^2.2.2", 13 "htmx.org": "^2.0.4", 14 "normalize.css": "^8.0.1", 15 "three": "^0.172.0" ··· 1733 "node": ">=8" 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 + }, 1741 "node_modules/htmx.org": { 1742 "version": "2.0.4", 1743 "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz", ··· 3419 "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 3420 "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 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==" 3427 }, 3428 "htmx.org": { 3429 "version": "2.0.4",
+1
frontend/package.json
··· 36 }, 37 "dependencies": { 38 "htmx.org": "^2.0.4", 39 "normalize.css": "^8.0.1", 40 "three": "^0.172.0" 41 },
··· 36 }, 37 "dependencies": { 38 "htmx.org": "^2.0.4", 39 + "htmx-ext-sse": "^2.2.2", 40 "normalize.css": "^8.0.1", 41 "three": "^0.172.0" 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 import "../css/main.css"; 2 3 - import "htmx.org"; 4 5 import "./header"; 6 import "./logo";
··· 1 import "../css/main.css"; 2 3 + // need to load htmx as a global variable before importing extensions 4 + import "./htmx"; 5 + import "htmx-ext-sse"; 6 7 import "./header"; 8 import "./logo";
+35
server/Cargo.lock
··· 95 ] 96 97 [[package]] 98 name = "atomic-waker" 99 version = "1.1.2" 100 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 854 dependencies = [ 855 "anyhow", 856 "askama", 857 "axum", 858 "reqwest", 859 "serde", 860 "tokio", 861 "tower", 862 "tower-http", 863 "tracing", ··· 1469 checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" 1470 dependencies = [ 1471 "rustls", 1472 "tokio", 1473 ] 1474
··· 95 ] 96 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]] 120 name = "atomic-waker" 121 version = "1.1.2" 122 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 876 dependencies = [ 877 "anyhow", 878 "askama", 879 + "async-stream", 880 "axum", 881 "reqwest", 882 "serde", 883 "tokio", 884 + "tokio-stream", 885 "tower", 886 "tower-http", 887 "tracing", ··· 1493 checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" 1494 dependencies = [ 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", 1507 "tokio", 1508 ] 1509
+2
server/Cargo.toml
··· 8 [dependencies] 9 anyhow = "1.0.57" 10 askama = { version = "0.13.0", git="https://github.com/rinja-rs/askama.git" } 11 axum = "0.8.1" 12 reqwest = { version = "0.12.12", features = ["json"] } 13 serde = { version = "1.0.217", features = ["derive"] } 14 tokio = { version = "1.18.2", features = ["full"] } 15 tower = "0.5.2" 16 tower-http = { version = "0.6.2", features = ["compression-full", "fs", "trace", "set-header"] } 17 tracing = "0.1.34"
··· 8 [dependencies] 9 anyhow = "1.0.57" 10 askama = { version = "0.13.0", git="https://github.com/rinja-rs/askama.git" } 11 + async-stream = "0.3.6" 12 axum = "0.8.1" 13 reqwest = { version = "0.12.12", features = ["json"] } 14 serde = { version = "1.0.217", features = ["derive"] } 15 tokio = { version = "1.18.2", features = ["full"] } 16 + tokio-stream = "0.1.17" 17 tower = "0.5.2" 18 tower-http = { version = "0.6.2", features = ["compression-full", "fs", "trace", "set-header"] } 19 tracing = "0.1.34"
+33 -11
server/src/main.rs
··· 2 mod scrobble; 3 mod scrobble_monitor; 4 5 - use std::{env, net::SocketAddr}; 6 7 use crate::index::get_index; 8 use crate::scrobble_monitor::ScrobbleMonitor; 9 10 use askama::Template; 11 use axum::{ 12 http::{HeaderName, HeaderValue, StatusCode}, 13 - response::{Html, IntoResponse}, 14 routing::{get, get_service}, 15 Extension, Router, 16 }; 17 use tower::ServiceBuilder; 18 use tower_http::{ 19 compression::CompressionLayer, services::ServeDir, set_header::SetResponseHeaderLayer, ··· 57 }) 58 } 59 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 - }) 69 }
··· 2 mod scrobble; 3 mod scrobble_monitor; 4 5 + use std::{convert::Infallible, env, net::SocketAddr, time::Duration}; 6 7 use crate::index::get_index; 8 use crate::scrobble_monitor::ScrobbleMonitor; 9 10 use askama::Template; 11 + use async_stream::stream; 12 use axum::{ 13 http::{HeaderName, HeaderValue, StatusCode}, 14 + response::{sse, Html, IntoResponse, Sse}, 15 routing::{get, get_service}, 16 Extension, Router, 17 }; 18 + use tokio::time::{self, MissedTickBehavior}; 19 + use tokio_stream::Stream; 20 use tower::ServiceBuilder; 21 use tower_http::{ 22 compression::CompressionLayer, services::ServeDir, set_header::SetResponseHeaderLayer, ··· 60 }) 61 } 62 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) 91 }
+3 -7
server/templates/index.html
··· 88 </div> 89 <div 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 -%} 98 > 99 {%- match scrobble %} {%- when Some with (ScrobblesTemplate {intro, now_playing, image, srcset}) %} 100 {% include "scrobble.html" %}
··· 88 </div> 89 <div 90 class="scrobble-bar" 91 + hx-ext="sse" 92 + sse-connect="/scrobbles" 93 + sse-swap="scrobble" 94 > 95 {%- match scrobble %} {%- when Some with (ScrobblesTemplate {intro, now_playing, image, srcset}) %} 96 {% include "scrobble.html" %}