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
-
<!DOCTYPE html>
2
<html>
3
<head>
4
<title>Ivo's Bio</title>
···
86
/>
87
</div>
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>
94
</body>
95
</html>
···
1
+
<!doctype html>
2
<html>
3
<head>
4
<title>Ivo's Bio</title>
···
86
/>
87
</div>
88
</div>
89
+
<div
90
+
class="scrobble-bar"
91
+
hx-get="/scrobbles"
92
+
hx-trigger="load, every 30s"
93
+
/>
94
</body>
95
</html>
+12
frontend/package-lock.json
···
9
"version": "1.0.0",
10
"license": "MIT",
11
"dependencies": {
0
12
"normalize.css": "^8.0.1",
13
"three": "^0.172.0"
14
},
···
1731
"node": ">=8"
1732
}
1733
},
0
0
0
0
0
0
1734
"node_modules/ignore": {
1735
"version": "5.3.2",
1736
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
···
3406
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
3407
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
3408
"dev": true
0
0
0
0
0
3409
},
3410
"ignore": {
3411
"version": "5.3.2",
···
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"
15
},
···
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",
1738
+
"integrity": "sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ==",
1739
+
"license": "0BSD"
1740
+
},
1741
"node_modules/ignore": {
1742
"version": "5.3.2",
1743
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.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",
3419
+
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",
3420
+
"integrity": "sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ=="
3421
},
3422
"ignore": {
3423
"version": "5.3.2",
+1
frontend/package.json
···
35
"url": "https://github.com/ivomurrell/myivo.git"
36
},
37
"dependencies": {
0
38
"normalize.css": "^8.0.1",
39
"three": "^0.172.0"
40
},
···
35
"url": "https://github.com/ivomurrell/myivo.git"
36
},
37
"dependencies": {
38
+
"htmx.org": "^2.0.4",
39
"normalize.css": "^8.0.1",
40
"three": "^0.172.0"
41
},
+2
-1
frontend/src/ts/main.ts
···
1
import "../css/main.css";
2
0
0
3
import "./header";
4
import "./logo";
5
-
import "./scrobblar";
···
1
import "../css/main.css";
2
3
+
import "htmx.org";
4
+
5
import "./header";
6
import "./logo";
0
-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();
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
+42
server/Cargo.lock
···
737
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
738
739
[[package]]
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
740
name = "memchr"
741
version = "2.7.4"
742
source = "registry+https://github.com/rust-lang/crates.io-index"
···
784
dependencies = [
785
"anyhow",
786
"axum",
0
787
"reqwest",
0
788
"tokio",
789
"tower",
790
"tower-http",
···
938
checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0"
939
dependencies = [
940
"unicode-ident",
0
0
0
0
0
0
0
0
0
0
0
0
941
]
942
943
[[package]]
···
1565
version = "0.2.15"
1566
source = "registry+https://github.com/rust-lang/crates.io-index"
1567
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
0
0
0
0
0
0
1568
1569
[[package]]
1570
name = "want"
···
737
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
738
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]]
762
name = "memchr"
763
version = "2.7.4"
764
source = "registry+https://github.com/rust-lang/crates.io-index"
···
806
dependencies = [
807
"anyhow",
808
"axum",
809
+
"maud",
810
"reqwest",
811
+
"serde",
812
"tokio",
813
"tower",
814
"tower-http",
···
962
checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0"
963
dependencies = [
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",
977
]
978
979
[[package]]
···
1601
version = "0.2.15"
1602
source = "registry+https://github.com/rust-lang/crates.io-index"
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"
1610
1611
[[package]]
1612
name = "want"
+3
-1
server/Cargo.toml
···
8
[dependencies]
9
anyhow = "1.0.57"
10
axum = "0.8.1"
11
-
reqwest = "0.12.12"
0
0
12
tokio = { version = "1.18.2", features = ["full"] }
13
tower = "0.5.2"
14
tower-http = { version = "0.6.2", features = ["compression-full", "fs", "trace", "set-header"] }
···
8
[dependencies]
9
anyhow = "1.0.57"
10
axum = "0.8.1"
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"] }
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"] }
+2
-2
server/src/main.rs
···
23
let monitor = ScrobbleMonitor::new(env::var("LAST_FM_API_KEY")?);
24
25
let app = Router::new()
26
-
.route("/scrobbles.json", get(get_scrobble))
27
.fallback(get_service(ServeDir::new(".")))
28
.layer(
29
ServiceBuilder::new()
···
46
47
async fn get_scrobble(Extension(mut monitor): Extension<ScrobbleMonitor>) -> impl IntoResponse {
48
monitor.get_scrobble().await.map_err(|err| {
49
-
tracing::error!("failed to get data from last.fm: {err}");
50
StatusCode::BAD_GATEWAY
51
})
52
}
···
23
let monitor = ScrobbleMonitor::new(env::var("LAST_FM_API_KEY")?);
24
25
let app = Router::new()
26
+
.route("/scrobbles", get(get_scrobble))
27
.fallback(get_service(ServeDir::new(".")))
28
.layer(
29
ServiceBuilder::new()
···
46
47
async fn get_scrobble(Extension(mut monitor): Extension<ScrobbleMonitor>) -> impl IntoResponse {
48
monitor.get_scrobble().await.map_err(|err| {
49
+
tracing::error!("failed to get data from last.fm: {err:?}");
50
StatusCode::BAD_GATEWAY
51
})
52
}
+86
-10
server/src/scrobble_monitor.rs
···
3
time::{Duration, Instant},
4
};
5
0
6
use reqwest::Client;
0
7
use tokio::sync::RwLock;
8
9
#[derive(Debug, Clone)]
10
-
struct Scrobble {
11
-
data: String,
12
fetch_time: Instant,
13
}
14
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
15
#[derive(Debug, Clone)]
16
pub struct ScrobbleMonitor {
17
client: Client,
18
api_key: String,
19
-
last_scrobble: Arc<RwLock<Option<Scrobble>>>,
20
}
21
22
impl ScrobbleMonitor {
···
28
}
29
}
30
31
-
pub async fn get_scrobble(&mut self) -> anyhow::Result<String> {
32
let is_fresh = |fetch_time: &Instant| fetch_time.elapsed() < Duration::from_secs(30);
33
34
if let Some(scrobble) = &*self.last_scrobble.read().await {
···
48
}
49
_ => {
50
tracing::debug!("fetching new scrobble data");
51
-
let latest = self.fetch_scrobble().await?;
52
-
*last_scrobble = Some(Scrobble {
53
-
data: latest.clone(),
0
54
fetch_time: Instant::now(),
55
});
56
-
Ok(latest)
57
}
58
}
59
}
60
61
-
async fn fetch_scrobble(&self) -> anyhow::Result<String> {
62
let response = self
63
.client
64
.get("https://ws.audioscrobbler.com/2.0")
···
71
])
72
.send()
73
.await?;
74
-
Ok(response.text().await?)
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
75
}
76
}
···
3
time::{Duration, Instant},
4
};
5
6
+
use maud::{html, Markup};
7
use reqwest::Client;
8
+
use serde::Deserialize;
9
use tokio::sync::RwLock;
10
11
#[derive(Debug, Clone)]
12
+
struct CachedScrobble {
13
+
data: Markup,
14
fetch_time: Instant,
15
}
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
+
55
#[derive(Debug, Clone)]
56
pub struct ScrobbleMonitor {
57
client: Client,
58
api_key: String,
59
+
last_scrobble: Arc<RwLock<Option<CachedScrobble>>>,
60
}
61
62
impl ScrobbleMonitor {
···
68
}
69
}
70
71
+
pub async fn get_scrobble(&mut self) -> anyhow::Result<Markup> {
72
let is_fresh = |fetch_time: &Instant| fetch_time.elapsed() < Duration::from_secs(30);
73
74
if let Some(scrobble) = &*self.last_scrobble.read().await {
···
88
}
89
_ => {
90
tracing::debug!("fetching new scrobble data");
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(),
95
fetch_time: Instant::now(),
96
});
97
+
Ok(scrobble_partial)
98
}
99
}
100
}
101
102
+
async fn fetch_scrobble(&self) -> anyhow::Result<Scrobble> {
103
let response = self
104
.client
105
.get("https://ws.audioscrobbler.com/2.0")
···
112
])
113
.send()
114
.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
+
}
151
}
152
}