My personal site cherry.computer
htmx tailwind axum askama

feat: fetch scrobbles with hypermedia/htmx

cherry.computer 70bbac01 e0298e3d

verified
+154 -86
+6 -6
frontend/index.html
··· 1 - <!DOCTYPE html> 1 + <!doctype html> 2 2 <html> 3 3 <head> 4 4 <title>Ivo's Bio</title> ··· 86 86 /> 87 87 </div> 88 88 </div> 89 - <div class="scrobble-bar"> 90 - <div class="bar-container" id="bar-container"> 91 - <p class="bar-text-intro" id="scrobblar-prefix"></p> 92 - </div> 93 - </div> 89 + <div 90 + class="scrobble-bar" 91 + hx-get="/scrobbles" 92 + hx-trigger="load, every 30s" 93 + /> 94 94 </body> 95 95 </html>
+12
frontend/package-lock.json
··· 9 9 "version": "1.0.0", 10 10 "license": "MIT", 11 11 "dependencies": { 12 + "htmx.org": "^2.0.4", 12 13 "normalize.css": "^8.0.1", 13 14 "three": "^0.172.0" 14 15 }, ··· 1731 1732 "node": ">=8" 1732 1733 } 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", 1738 + "integrity": "sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ==", 1739 + "license": "0BSD" 1740 + }, 1734 1741 "node_modules/ignore": { 1735 1742 "version": "5.3.2", 1736 1743 "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", ··· 3406 3413 "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 3407 3414 "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 3408 3415 "dev": true 3416 + }, 3417 + "htmx.org": { 3418 + "version": "2.0.4", 3419 + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz", 3420 + "integrity": "sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ==" 3409 3421 }, 3410 3422 "ignore": { 3411 3423 "version": "5.3.2",
+1
frontend/package.json
··· 35 35 "url": "https://github.com/ivomurrell/myivo.git" 36 36 }, 37 37 "dependencies": { 38 + "htmx.org": "^2.0.4", 38 39 "normalize.css": "^8.0.1", 39 40 "three": "^0.172.0" 40 41 },
+2 -1
frontend/src/ts/main.ts
··· 1 1 import "../css/main.css"; 2 2 3 + import "htmx.org"; 4 + 3 5 import "./header"; 4 6 import "./logo"; 5 - import "./scrobblar";
-66
frontend/src/ts/scrobblar.ts
··· 1 - interface Scrobbles { 2 - recenttracks: { 3 - track: { 4 - artist: { "#text": string }; 5 - image: { "#text": string }[]; 6 - name: string; 7 - "@attr"?: { nowplaying: boolean }; 8 - }[]; 9 - }; 10 - } 11 - 12 - async function pollNowListening() { 13 - const resp = await fetch("/scrobbles.json"); 14 - const data: Scrobbles = await resp.json(); 15 - const track = data.recenttracks.track[0]; 16 - const trackData = track["@attr"]; 17 - const nowPlaying = trackData?.nowplaying ?? false; 18 - const prefix = nowPlaying ? "Now playing: " : "Last played: "; 19 - const scrobblarPrefix = document.getElementById("scrobblar-prefix")!; 20 - scrobblarPrefix.textContent = prefix; 21 - 22 - const title: string = track.name; 23 - const artist: string = track.artist["#text"]; 24 - const text = `${title} - ${artist}`; 25 - 26 - const textElement = document.getElementById("scrobblar-music"); 27 - const container = document.getElementById("bar-container")!; 28 - if (!textElement) { 29 - const scrobblarMusic = document.createElement("p"); 30 - scrobblarMusic.className = "bar-text-music"; 31 - scrobblarMusic.id = "scrobblar-music"; 32 - scrobblarMusic.appendChild(document.createTextNode(text)); 33 - container.appendChild(scrobblarMusic); 34 - } else if (text !== textElement.textContent) { 35 - const textClone = textElement.cloneNode(true); 36 - textElement.remove(); 37 - textClone.textContent = text; 38 - container.appendChild(textClone); 39 - } 40 - 41 - const art: string = track.image[0]["#text"]; 42 - const art2x: string = track.image[1]["#text"]; 43 - const art3x: string = track.image[2]["#text"]; 44 - 45 - const coverElement = document.getElementById("scrobblar-art") as 46 - | HTMLImageElement 47 - | undefined; 48 - if (art === "") { 49 - coverElement?.remove(); 50 - } else if (!coverElement) { 51 - const scrobblarArt = document.createElement("img"); 52 - scrobblarArt.className = "bar-cover"; 53 - scrobblarArt.id = "scrobblar-art"; 54 - scrobblarArt.src = art; 55 - scrobblarArt.alt = "Cover art"; 56 - scrobblarArt.srcset = `${art}, ${art2x} 2x, ${art3x} 3x`; 57 - container.prepend(scrobblarArt); 58 - } else if (art !== coverElement.src) { 59 - coverElement.src = art; 60 - coverElement.srcset = `${art}, ${art2x} 2x, ${art3x} 3x`; 61 - } 62 - 63 - setTimeout(() => void pollNowListening(), 10000); 64 - } 65 - 66 - void pollNowListening();
+42
server/Cargo.lock
··· 737 737 checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 738 738 739 739 [[package]] 740 + name = "maud" 741 + version = "0.26.0" 742 + source = "git+https://github.com/lambda-fairy/maud.git#1f7620016710b07d46f1f3ef95889462e5d196d0" 743 + dependencies = [ 744 + "axum-core", 745 + "http", 746 + "itoa", 747 + "maud_macros", 748 + ] 749 + 750 + [[package]] 751 + name = "maud_macros" 752 + version = "0.26.0" 753 + source = "git+https://github.com/lambda-fairy/maud.git#1f7620016710b07d46f1f3ef95889462e5d196d0" 754 + dependencies = [ 755 + "proc-macro2", 756 + "proc-macro2-diagnostics", 757 + "quote", 758 + "syn", 759 + ] 760 + 761 + [[package]] 740 762 name = "memchr" 741 763 version = "2.7.4" 742 764 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 784 806 dependencies = [ 785 807 "anyhow", 786 808 "axum", 809 + "maud", 787 810 "reqwest", 811 + "serde", 788 812 "tokio", 789 813 "tower", 790 814 "tower-http", ··· 938 962 checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" 939 963 dependencies = [ 940 964 "unicode-ident", 965 + ] 966 + 967 + [[package]] 968 + name = "proc-macro2-diagnostics" 969 + version = "0.10.1" 970 + source = "registry+https://github.com/rust-lang/crates.io-index" 971 + checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" 972 + dependencies = [ 973 + "proc-macro2", 974 + "quote", 975 + "syn", 976 + "version_check", 941 977 ] 942 978 943 979 [[package]] ··· 1565 1601 version = "0.2.15" 1566 1602 source = "registry+https://github.com/rust-lang/crates.io-index" 1567 1603 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 1604 + 1605 + [[package]] 1606 + name = "version_check" 1607 + version = "0.9.5" 1608 + source = "registry+https://github.com/rust-lang/crates.io-index" 1609 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 1568 1610 1569 1611 [[package]] 1570 1612 name = "want"
+3 -1
server/Cargo.toml
··· 8 8 [dependencies] 9 9 anyhow = "1.0.57" 10 10 axum = "0.8.1" 11 - reqwest = "0.12.12" 11 + maud = { version = "0.26.0", git="https://github.com/lambda-fairy/maud.git", features = ["axum"] } 12 + reqwest = { version = "0.12.12", features = ["json"] } 13 + serde = { version = "1.0.217", features = ["derive"] } 12 14 tokio = { version = "1.18.2", features = ["full"] } 13 15 tower = "0.5.2" 14 16 tower-http = { version = "0.6.2", features = ["compression-full", "fs", "trace", "set-header"] }
+2 -2
server/src/main.rs
··· 23 23 let monitor = ScrobbleMonitor::new(env::var("LAST_FM_API_KEY")?); 24 24 25 25 let app = Router::new() 26 - .route("/scrobbles.json", get(get_scrobble)) 26 + .route("/scrobbles", get(get_scrobble)) 27 27 .fallback(get_service(ServeDir::new("."))) 28 28 .layer( 29 29 ServiceBuilder::new() ··· 46 46 47 47 async fn get_scrobble(Extension(mut monitor): Extension<ScrobbleMonitor>) -> impl IntoResponse { 48 48 monitor.get_scrobble().await.map_err(|err| { 49 - tracing::error!("failed to get data from last.fm: {err}"); 49 + tracing::error!("failed to get data from last.fm: {err:?}"); 50 50 StatusCode::BAD_GATEWAY 51 51 }) 52 52 }
+86 -10
server/src/scrobble_monitor.rs
··· 3 3 time::{Duration, Instant}, 4 4 }; 5 5 6 + use maud::{html, Markup}; 6 7 use reqwest::Client; 8 + use serde::Deserialize; 7 9 use tokio::sync::RwLock; 8 10 9 11 #[derive(Debug, Clone)] 10 - struct Scrobble { 11 - data: String, 12 + struct CachedScrobble { 13 + data: Markup, 12 14 fetch_time: Instant, 13 15 } 14 16 17 + #[derive(Debug, Clone, Deserialize)] 18 + struct ScrobbleArtist { 19 + #[serde(rename = "#text")] 20 + text: String, 21 + } 22 + 23 + #[derive(Debug, Clone, Deserialize)] 24 + struct ScrobbleImage { 25 + #[serde(rename = "#text")] 26 + text: String, 27 + } 28 + 29 + #[derive(Debug, Clone, Deserialize)] 30 + struct ScrobbleAttributes { 31 + #[serde(rename = "nowplaying")] 32 + now_playing: bool, 33 + } 34 + 35 + #[derive(Debug, Clone, Deserialize)] 36 + struct ScrobbleTrack { 37 + artist: ScrobbleArtist, 38 + image: Vec<ScrobbleImage>, 39 + name: String, 40 + #[serde(rename = "@attr")] 41 + attributes: Option<ScrobbleAttributes>, 42 + } 43 + 44 + #[derive(Debug, Clone, Deserialize)] 45 + struct ScrobbleRecentTracks { 46 + track: Vec<ScrobbleTrack>, 47 + } 48 + 49 + #[derive(Debug, Clone, Deserialize)] 50 + struct Scrobble { 51 + #[serde(rename = "recenttracks")] 52 + recent_tracks: ScrobbleRecentTracks, 53 + } 54 + 15 55 #[derive(Debug, Clone)] 16 56 pub struct ScrobbleMonitor { 17 57 client: Client, 18 58 api_key: String, 19 - last_scrobble: Arc<RwLock<Option<Scrobble>>>, 59 + last_scrobble: Arc<RwLock<Option<CachedScrobble>>>, 20 60 } 21 61 22 62 impl ScrobbleMonitor { ··· 28 68 } 29 69 } 30 70 31 - pub async fn get_scrobble(&mut self) -> anyhow::Result<String> { 71 + pub async fn get_scrobble(&mut self) -> anyhow::Result<Markup> { 32 72 let is_fresh = |fetch_time: &Instant| fetch_time.elapsed() < Duration::from_secs(30); 33 73 34 74 if let Some(scrobble) = &*self.last_scrobble.read().await { ··· 48 88 } 49 89 _ => { 50 90 tracing::debug!("fetching new scrobble data"); 51 - let latest = self.fetch_scrobble().await?; 52 - *last_scrobble = Some(Scrobble { 53 - data: latest.clone(), 91 + let scrobble = self.fetch_scrobble().await?; 92 + let scrobble_partial = self.scrobble_partial(&scrobble); 93 + *last_scrobble = Some(CachedScrobble { 94 + data: scrobble_partial.clone(), 54 95 fetch_time: Instant::now(), 55 96 }); 56 - Ok(latest) 97 + Ok(scrobble_partial) 57 98 } 58 99 } 59 100 } 60 101 61 - async fn fetch_scrobble(&self) -> anyhow::Result<String> { 102 + async fn fetch_scrobble(&self) -> anyhow::Result<Scrobble> { 62 103 let response = self 63 104 .client 64 105 .get("https://ws.audioscrobbler.com/2.0") ··· 71 112 ]) 72 113 .send() 73 114 .await?; 74 - Ok(response.text().await?) 115 + Ok(response.json().await?) 116 + } 117 + 118 + fn scrobble_partial(&self, scrobble: &Scrobble) -> Markup { 119 + let latest_track = &scrobble.recent_tracks.track[0]; 120 + let srcset = format!( 121 + "{}, {} 2x, {} 3x", 122 + latest_track.image[0].text, latest_track.image[1].text, latest_track.image[2].text 123 + ); 124 + let text_intro = if latest_track 125 + .attributes 126 + .as_ref() 127 + .map_or(false, |attr| attr.now_playing) 128 + { 129 + "Now playing: " 130 + } else { 131 + "Last played: " 132 + }; 133 + let now_playing = format!("{} - {}", latest_track.name, latest_track.artist.text); 134 + 135 + html! { 136 + .bar-container { 137 + img .bar-cover 138 + src=(latest_track.image[0].text) 139 + alt="Cover art" 140 + srcset=(srcset); 141 + 142 + p .bar-text-intro { 143 + (text_intro) 144 + } 145 + 146 + p .bar-text-music { 147 + (now_playing) 148 + } 149 + } 150 + } 75 151 } 76 152 }