tangled
alpha
login
or
join now
cherry.computer
/
website
My personal site
cherry.computer
htmx
tailwind
axum
askama
0
fork
atom
overview
issues
pulls
pipelines
feat: fetch scrobbles with hypermedia/htmx
cherry.computer
1 year ago
70bbac01
e0298e3d
verified
This commit was signed with the committer's
known signature
.
cherry.computer
SSH Key Fingerprint:
SHA256:SIA77Ll0IpMb8Xd3RtaGT+PBIGIePhJJg5W2r6Td7cc=
+154
-86
9 changed files
expand all
collapse all
unified
split
frontend
index.html
package-lock.json
package.json
src
ts
main.ts
scrobblar.ts
server
Cargo.lock
Cargo.toml
src
main.rs
scrobble_monitor.rs
+6
-6
frontend/index.html
···
1
1
-
<!DOCTYPE html>
1
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
89
-
<div class="scrobble-bar">
90
90
-
<div class="bar-container" id="bar-container">
91
91
-
<p class="bar-text-intro" id="scrobblar-prefix"></p>
92
92
-
</div>
93
93
-
</div>
89
89
+
<div
90
90
+
class="scrobble-bar"
91
91
+
hx-get="/scrobbles"
92
92
+
hx-trigger="load, every 30s"
93
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
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
1735
+
"node_modules/htmx.org": {
1736
1736
+
"version": "2.0.4",
1737
1737
+
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",
1738
1738
+
"integrity": "sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ==",
1739
1739
+
"license": "0BSD"
1740
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
3416
+
},
3417
3417
+
"htmx.org": {
3418
3418
+
"version": "2.0.4",
3419
3419
+
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",
3420
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
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
3
+
import "htmx.org";
4
4
+
3
5
import "./header";
4
6
import "./logo";
5
5
-
import "./scrobblar";
-66
frontend/src/ts/scrobblar.ts
···
1
1
-
interface Scrobbles {
2
2
-
recenttracks: {
3
3
-
track: {
4
4
-
artist: { "#text": string };
5
5
-
image: { "#text": string }[];
6
6
-
name: string;
7
7
-
"@attr"?: { nowplaying: boolean };
8
8
-
}[];
9
9
-
};
10
10
-
}
11
11
-
12
12
-
async function pollNowListening() {
13
13
-
const resp = await fetch("/scrobbles.json");
14
14
-
const data: Scrobbles = await resp.json();
15
15
-
const track = data.recenttracks.track[0];
16
16
-
const trackData = track["@attr"];
17
17
-
const nowPlaying = trackData?.nowplaying ?? false;
18
18
-
const prefix = nowPlaying ? "Now playing: " : "Last played: ";
19
19
-
const scrobblarPrefix = document.getElementById("scrobblar-prefix")!;
20
20
-
scrobblarPrefix.textContent = prefix;
21
21
-
22
22
-
const title: string = track.name;
23
23
-
const artist: string = track.artist["#text"];
24
24
-
const text = `${title} - ${artist}`;
25
25
-
26
26
-
const textElement = document.getElementById("scrobblar-music");
27
27
-
const container = document.getElementById("bar-container")!;
28
28
-
if (!textElement) {
29
29
-
const scrobblarMusic = document.createElement("p");
30
30
-
scrobblarMusic.className = "bar-text-music";
31
31
-
scrobblarMusic.id = "scrobblar-music";
32
32
-
scrobblarMusic.appendChild(document.createTextNode(text));
33
33
-
container.appendChild(scrobblarMusic);
34
34
-
} else if (text !== textElement.textContent) {
35
35
-
const textClone = textElement.cloneNode(true);
36
36
-
textElement.remove();
37
37
-
textClone.textContent = text;
38
38
-
container.appendChild(textClone);
39
39
-
}
40
40
-
41
41
-
const art: string = track.image[0]["#text"];
42
42
-
const art2x: string = track.image[1]["#text"];
43
43
-
const art3x: string = track.image[2]["#text"];
44
44
-
45
45
-
const coverElement = document.getElementById("scrobblar-art") as
46
46
-
| HTMLImageElement
47
47
-
| undefined;
48
48
-
if (art === "") {
49
49
-
coverElement?.remove();
50
50
-
} else if (!coverElement) {
51
51
-
const scrobblarArt = document.createElement("img");
52
52
-
scrobblarArt.className = "bar-cover";
53
53
-
scrobblarArt.id = "scrobblar-art";
54
54
-
scrobblarArt.src = art;
55
55
-
scrobblarArt.alt = "Cover art";
56
56
-
scrobblarArt.srcset = `${art}, ${art2x} 2x, ${art3x} 3x`;
57
57
-
container.prepend(scrobblarArt);
58
58
-
} else if (art !== coverElement.src) {
59
59
-
coverElement.src = art;
60
60
-
coverElement.srcset = `${art}, ${art2x} 2x, ${art3x} 3x`;
61
61
-
}
62
62
-
63
63
-
setTimeout(() => void pollNowListening(), 10000);
64
64
-
}
65
65
-
66
66
-
void pollNowListening();
+42
server/Cargo.lock
···
737
737
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
738
738
739
739
[[package]]
740
740
+
name = "maud"
741
741
+
version = "0.26.0"
742
742
+
source = "git+https://github.com/lambda-fairy/maud.git#1f7620016710b07d46f1f3ef95889462e5d196d0"
743
743
+
dependencies = [
744
744
+
"axum-core",
745
745
+
"http",
746
746
+
"itoa",
747
747
+
"maud_macros",
748
748
+
]
749
749
+
750
750
+
[[package]]
751
751
+
name = "maud_macros"
752
752
+
version = "0.26.0"
753
753
+
source = "git+https://github.com/lambda-fairy/maud.git#1f7620016710b07d46f1f3ef95889462e5d196d0"
754
754
+
dependencies = [
755
755
+
"proc-macro2",
756
756
+
"proc-macro2-diagnostics",
757
757
+
"quote",
758
758
+
"syn",
759
759
+
]
760
760
+
761
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
809
+
"maud",
787
810
"reqwest",
811
811
+
"serde",
788
812
"tokio",
789
813
"tower",
790
814
"tower-http",
···
938
962
checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0"
939
963
dependencies = [
940
964
"unicode-ident",
965
965
+
]
966
966
+
967
967
+
[[package]]
968
968
+
name = "proc-macro2-diagnostics"
969
969
+
version = "0.10.1"
970
970
+
source = "registry+https://github.com/rust-lang/crates.io-index"
971
971
+
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
972
972
+
dependencies = [
973
973
+
"proc-macro2",
974
974
+
"quote",
975
975
+
"syn",
976
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
1604
+
1605
1605
+
[[package]]
1606
1606
+
name = "version_check"
1607
1607
+
version = "0.9.5"
1608
1608
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1609
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
11
-
reqwest = "0.12.12"
11
11
+
maud = { version = "0.26.0", git="https://github.com/lambda-fairy/maud.git", features = ["axum"] }
12
12
+
reqwest = { version = "0.12.12", features = ["json"] }
13
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
26
-
.route("/scrobbles.json", get(get_scrobble))
26
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
49
-
tracing::error!("failed to get data from last.fm: {err}");
49
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
6
+
use maud::{html, Markup};
6
7
use reqwest::Client;
8
8
+
use serde::Deserialize;
7
9
use tokio::sync::RwLock;
8
10
9
11
#[derive(Debug, Clone)]
10
10
-
struct Scrobble {
11
11
-
data: String,
12
12
+
struct CachedScrobble {
13
13
+
data: Markup,
12
14
fetch_time: Instant,
13
15
}
14
16
17
17
+
#[derive(Debug, Clone, Deserialize)]
18
18
+
struct ScrobbleArtist {
19
19
+
#[serde(rename = "#text")]
20
20
+
text: String,
21
21
+
}
22
22
+
23
23
+
#[derive(Debug, Clone, Deserialize)]
24
24
+
struct ScrobbleImage {
25
25
+
#[serde(rename = "#text")]
26
26
+
text: String,
27
27
+
}
28
28
+
29
29
+
#[derive(Debug, Clone, Deserialize)]
30
30
+
struct ScrobbleAttributes {
31
31
+
#[serde(rename = "nowplaying")]
32
32
+
now_playing: bool,
33
33
+
}
34
34
+
35
35
+
#[derive(Debug, Clone, Deserialize)]
36
36
+
struct ScrobbleTrack {
37
37
+
artist: ScrobbleArtist,
38
38
+
image: Vec<ScrobbleImage>,
39
39
+
name: String,
40
40
+
#[serde(rename = "@attr")]
41
41
+
attributes: Option<ScrobbleAttributes>,
42
42
+
}
43
43
+
44
44
+
#[derive(Debug, Clone, Deserialize)]
45
45
+
struct ScrobbleRecentTracks {
46
46
+
track: Vec<ScrobbleTrack>,
47
47
+
}
48
48
+
49
49
+
#[derive(Debug, Clone, Deserialize)]
50
50
+
struct Scrobble {
51
51
+
#[serde(rename = "recenttracks")]
52
52
+
recent_tracks: ScrobbleRecentTracks,
53
53
+
}
54
54
+
15
55
#[derive(Debug, Clone)]
16
56
pub struct ScrobbleMonitor {
17
57
client: Client,
18
58
api_key: String,
19
19
-
last_scrobble: Arc<RwLock<Option<Scrobble>>>,
59
59
+
last_scrobble: Arc<RwLock<Option<CachedScrobble>>>,
20
60
}
21
61
22
62
impl ScrobbleMonitor {
···
28
68
}
29
69
}
30
70
31
31
-
pub async fn get_scrobble(&mut self) -> anyhow::Result<String> {
71
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
51
-
let latest = self.fetch_scrobble().await?;
52
52
-
*last_scrobble = Some(Scrobble {
53
53
-
data: latest.clone(),
91
91
+
let scrobble = self.fetch_scrobble().await?;
92
92
+
let scrobble_partial = self.scrobble_partial(&scrobble);
93
93
+
*last_scrobble = Some(CachedScrobble {
94
94
+
data: scrobble_partial.clone(),
54
95
fetch_time: Instant::now(),
55
96
});
56
56
-
Ok(latest)
97
97
+
Ok(scrobble_partial)
57
98
}
58
99
}
59
100
}
60
101
61
61
-
async fn fetch_scrobble(&self) -> anyhow::Result<String> {
102
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
74
-
Ok(response.text().await?)
115
115
+
Ok(response.json().await?)
116
116
+
}
117
117
+
118
118
+
fn scrobble_partial(&self, scrobble: &Scrobble) -> Markup {
119
119
+
let latest_track = &scrobble.recent_tracks.track[0];
120
120
+
let srcset = format!(
121
121
+
"{}, {} 2x, {} 3x",
122
122
+
latest_track.image[0].text, latest_track.image[1].text, latest_track.image[2].text
123
123
+
);
124
124
+
let text_intro = if latest_track
125
125
+
.attributes
126
126
+
.as_ref()
127
127
+
.map_or(false, |attr| attr.now_playing)
128
128
+
{
129
129
+
"Now playing: "
130
130
+
} else {
131
131
+
"Last played: "
132
132
+
};
133
133
+
let now_playing = format!("{} - {}", latest_track.name, latest_track.artist.text);
134
134
+
135
135
+
html! {
136
136
+
.bar-container {
137
137
+
img .bar-cover
138
138
+
src=(latest_track.image[0].text)
139
139
+
alt="Cover art"
140
140
+
srcset=(srcset);
141
141
+
142
142
+
p .bar-text-intro {
143
143
+
(text_intro)
144
144
+
}
145
145
+
146
146
+
p .bar-text-music {
147
147
+
(now_playing)
148
148
+
}
149
149
+
}
150
150
+
}
75
151
}
76
152
}