My personal site cherry.computer
htmx tailwind axum askama

feat: call Apple Music API for latest song

This cost me £80 to set up!!

cherry.computer dae4de89 65ff1b93

verified
+248 -4
+1
.gitignore
··· 1 node_modules 2 /frontend/fonts 3 /frontend/build 4 /frontend/src/css/tailwind-out.css
··· 1 node_modules 2 + .env 3 /frontend/fonts 4 /frontend/build 5 /frontend/src/css/tailwind-out.css
+2
justfile
··· 1 [parallel] 2 serve: serve-js serve-rs 3
··· 1 + set dotenv-load := true 2 + 3 [parallel] 4 serve: serve-js serve-rs 5
+120
server/Cargo.lock
··· 427 ] 428 429 [[package]] 430 name = "derive_more" 431 version = "2.0.1" 432 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 651 checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 652 dependencies = [ 653 "cfg-if", 654 "libc", 655 "wasi 0.11.1+wasi-snapshot-preview1", 656 ] 657 658 [[package]] ··· 1024 ] 1025 1026 [[package]] 1027 name = "lazy_static" 1028 version = "1.5.0" 1029 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1147 "askama", 1148 "axum", 1149 "cached", 1150 "reqwest", 1151 "scraper", 1152 "serde", ··· 1191 ] 1192 1193 [[package]] 1194 name = "object" 1195 version = "0.36.7" 1196 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1279 ] 1280 1281 [[package]] 1282 name = "percent-encoding" 1283 version = "2.3.2" 1284 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1364 ] 1365 1366 [[package]] 1367 name = "precomputed-hash" 1368 version = "0.1.1" 1369 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1701 ] 1702 1703 [[package]] 1704 name = "siphasher" 1705 version = "1.0.1" 1706 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1874 checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 1875 dependencies = [ 1876 "cfg-if", 1877 ] 1878 1879 [[package]]
··· 427 ] 428 429 [[package]] 430 + name = "deranged" 431 + version = "0.4.0" 432 + source = "registry+https://github.com/rust-lang/crates.io-index" 433 + checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" 434 + dependencies = [ 435 + "powerfmt", 436 + ] 437 + 438 + [[package]] 439 name = "derive_more" 440 version = "2.0.1" 441 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 660 checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 661 dependencies = [ 662 "cfg-if", 663 + "js-sys", 664 "libc", 665 "wasi 0.11.1+wasi-snapshot-preview1", 666 + "wasm-bindgen", 667 ] 668 669 [[package]] ··· 1035 ] 1036 1037 [[package]] 1038 + name = "jsonwebtoken" 1039 + version = "9.3.1" 1040 + source = "registry+https://github.com/rust-lang/crates.io-index" 1041 + checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" 1042 + dependencies = [ 1043 + "base64", 1044 + "js-sys", 1045 + "pem", 1046 + "ring", 1047 + "serde", 1048 + "serde_json", 1049 + "simple_asn1", 1050 + ] 1051 + 1052 + [[package]] 1053 name = "lazy_static" 1054 version = "1.5.0" 1055 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1173 "askama", 1174 "axum", 1175 "cached", 1176 + "jsonwebtoken", 1177 "reqwest", 1178 "scraper", 1179 "serde", ··· 1218 ] 1219 1220 [[package]] 1221 + name = "num-bigint" 1222 + version = "0.4.6" 1223 + source = "registry+https://github.com/rust-lang/crates.io-index" 1224 + checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" 1225 + dependencies = [ 1226 + "num-integer", 1227 + "num-traits", 1228 + ] 1229 + 1230 + [[package]] 1231 + name = "num-conv" 1232 + version = "0.1.0" 1233 + source = "registry+https://github.com/rust-lang/crates.io-index" 1234 + checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 1235 + 1236 + [[package]] 1237 + name = "num-integer" 1238 + version = "0.1.46" 1239 + source = "registry+https://github.com/rust-lang/crates.io-index" 1240 + checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 1241 + dependencies = [ 1242 + "num-traits", 1243 + ] 1244 + 1245 + [[package]] 1246 + name = "num-traits" 1247 + version = "0.2.19" 1248 + source = "registry+https://github.com/rust-lang/crates.io-index" 1249 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1250 + dependencies = [ 1251 + "autocfg", 1252 + ] 1253 + 1254 + [[package]] 1255 name = "object" 1256 version = "0.36.7" 1257 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1340 ] 1341 1342 [[package]] 1343 + name = "pem" 1344 + version = "3.0.5" 1345 + source = "registry+https://github.com/rust-lang/crates.io-index" 1346 + checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" 1347 + dependencies = [ 1348 + "base64", 1349 + "serde", 1350 + ] 1351 + 1352 + [[package]] 1353 name = "percent-encoding" 1354 version = "2.3.2" 1355 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1435 ] 1436 1437 [[package]] 1438 + name = "powerfmt" 1439 + version = "0.2.0" 1440 + source = "registry+https://github.com/rust-lang/crates.io-index" 1441 + checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 1442 + 1443 + [[package]] 1444 name = "precomputed-hash" 1445 version = "0.1.1" 1446 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1778 ] 1779 1780 [[package]] 1781 + name = "simple_asn1" 1782 + version = "0.6.3" 1783 + source = "registry+https://github.com/rust-lang/crates.io-index" 1784 + checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" 1785 + dependencies = [ 1786 + "num-bigint", 1787 + "num-traits", 1788 + "thiserror", 1789 + "time", 1790 + ] 1791 + 1792 + [[package]] 1793 name = "siphasher" 1794 version = "1.0.1" 1795 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1963 checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 1964 dependencies = [ 1965 "cfg-if", 1966 + ] 1967 + 1968 + [[package]] 1969 + name = "time" 1970 + version = "0.3.41" 1971 + source = "registry+https://github.com/rust-lang/crates.io-index" 1972 + checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" 1973 + dependencies = [ 1974 + "deranged", 1975 + "itoa", 1976 + "num-conv", 1977 + "powerfmt", 1978 + "serde", 1979 + "time-core", 1980 + "time-macros", 1981 + ] 1982 + 1983 + [[package]] 1984 + name = "time-core" 1985 + version = "0.1.4" 1986 + source = "registry+https://github.com/rust-lang/crates.io-index" 1987 + checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" 1988 + 1989 + [[package]] 1990 + name = "time-macros" 1991 + version = "0.2.22" 1992 + source = "registry+https://github.com/rust-lang/crates.io-index" 1993 + checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" 1994 + dependencies = [ 1995 + "num-conv", 1996 + "time-core", 1997 ] 1998 1999 [[package]]
+1
server/Cargo.toml
··· 10 askama = "0.14.0" 11 axum = "0.8.1" 12 cached = { version = "0.56.0", features = ["async"] } 13 reqwest = { version = "0.12.23", features = ["json"] } 14 scraper = "0.24.0" 15 serde = { version = "1.0.219", features = ["derive"] }
··· 10 askama = "0.14.0" 11 axum = "0.8.1" 12 cached = { version = "0.56.0", features = ["async"] } 13 + jsonwebtoken = "9.3.1" 14 reqwest = { version = "0.12.23", features = ["json"] } 15 scraper = "0.24.0" 16 serde = { version = "1.0.219", features = ["derive"] }
+12 -4
server/src/index.rs
··· 1 - use crate::scrapers::backloggd::{self, Backloggd}; 2 - use crate::scrapers::letterboxd::{self, Letterboxd}; 3 4 use askama::Template; 5 ··· 8 pub struct RootTemplate { 9 game: Option<Backloggd>, 10 movie: Option<Letterboxd>, 11 } 12 13 impl RootTemplate { 14 pub async fn new() -> RootTemplate { 15 - let (game, movie) = tokio::join!(backloggd::cached_fetch(), letterboxd::cached_fetch(),); 16 - RootTemplate { game, movie } 17 } 18 }
··· 1 + use crate::scrapers::{ 2 + apple_music::{self, AppleMusic}, 3 + backloggd::{self, Backloggd}, 4 + letterboxd::{self, Letterboxd}, 5 + }; 6 7 use askama::Template; 8 ··· 11 pub struct RootTemplate { 12 game: Option<Backloggd>, 13 movie: Option<Letterboxd>, 14 + song: Option<AppleMusic>, 15 } 16 17 impl RootTemplate { 18 pub async fn new() -> RootTemplate { 19 + let (game, movie, song) = tokio::join!( 20 + backloggd::cached_fetch(), 21 + letterboxd::cached_fetch(), 22 + apple_music::cached_fetch() 23 + ); 24 + RootTemplate { game, movie, song } 25 } 26 }
+1
server/src/scrapers.rs
··· 1 pub mod backloggd; 2 pub mod letterboxd;
··· 1 + pub mod apple_music; 2 pub mod backloggd; 3 pub mod letterboxd;
+104
server/src/scrapers/apple_music.rs
···
··· 1 + use std::{env, time::Duration}; 2 + 3 + use anyhow::Context; 4 + use cached::proc_macro::once; 5 + use jsonwebtoken::{Algorithm, EncodingKey, Header}; 6 + use reqwest::Client; 7 + use serde::{Deserialize, Serialize}; 8 + 9 + #[derive(Debug, Clone)] 10 + pub struct AppleMusic { 11 + pub name: String, 12 + pub art: String, 13 + } 14 + 15 + #[derive(Serialize, Debug, Clone)] 16 + struct Claims { 17 + iss: String, 18 + iat: u64, 19 + exp: u64, 20 + } 21 + 22 + impl Claims { 23 + fn new(issuer_id: String) -> Self { 24 + let iat = jsonwebtoken::get_current_timestamp(); 25 + Claims { 26 + iss: issuer_id, 27 + iat, 28 + exp: iat + 3600, 29 + } 30 + } 31 + } 32 + 33 + #[derive(Deserialize, Debug, Clone)] 34 + struct AppleMusicResponse { 35 + data: [AppleMusicTrack; 1], 36 + } 37 + 38 + #[derive(Deserialize, Debug, Clone)] 39 + struct AppleMusicTrack { 40 + attributes: AppleMusicTrackAttributes, 41 + } 42 + 43 + #[derive(Deserialize, Debug, Clone)] 44 + struct AppleMusicTrackAttributes { 45 + name: String, 46 + artwork: AppleMusicTrackArtwork, 47 + } 48 + 49 + #[derive(Deserialize, Debug, Clone)] 50 + struct AppleMusicTrackArtwork { 51 + url: String, 52 + } 53 + 54 + impl AppleMusic { 55 + pub async fn fetch() -> anyhow::Result<Self> { 56 + let mut header = Header::new(Algorithm::ES256); 57 + header.kid = Some( 58 + env::var("APPLE_DEVELOPER_TOKEN_KEY_ID").context("missing apple developer key ID")?, 59 + ); 60 + let team_id = 61 + env::var("APPLE_DEVELOPER_TOKEN_TEAM_ID").context("missing apple developer team ID")?; 62 + let claims = Claims::new(team_id); 63 + let auth_key = env::var("APPLE_DEVELOPER_TOKEN_AUTH_KEY") 64 + .context("missing apple developer auth key")?; 65 + let key = EncodingKey::from_ec_pem(auth_key.as_bytes()) 66 + .context("failed to parse appple developer auth key")?; 67 + let jwt = jsonwebtoken::encode(&header, &claims, &key) 68 + .context("failed to encode apple developer JWT")?; 69 + let user_token = env::var("APPLE_USER_TOKEN").context("missing apple user token")?; 70 + 71 + let client = Client::new(); 72 + let response: AppleMusicResponse = client 73 + .get("https://api.music.apple.com/v1/me/recent/played/tracks") 74 + .bearer_auth(jwt) 75 + .header("Music-User-Token", user_token) 76 + .query(&[("types", "songs"), ("limit", "1")]) 77 + .send() 78 + .await 79 + .context("failed to call Apple Music API")? 80 + .json() 81 + .await 82 + .context("failed to parse Apple Music response")?; 83 + let track = &response.data[0]; 84 + 85 + let artwork_url = &track.attributes.artwork.url; 86 + let dimensions = "240"; 87 + let artwork_url = artwork_url 88 + .replace("{w}", dimensions) 89 + .replace("{h}", dimensions); 90 + 91 + Ok(Self { 92 + name: track.attributes.name.clone(), 93 + art: artwork_url, 94 + }) 95 + } 96 + } 97 + 98 + #[once(time = 30, option = false)] 99 + pub async fn cached_fetch() -> Option<AppleMusic> { 100 + AppleMusic::fetch() 101 + .await 102 + .map_err(|error| tracing::warn!(?error, "failed to call Apple Music")) 103 + .ok() 104 + }
+7
server/templates/index.html
··· 30 src="{{ movie.poster }}" 31 alt="Poster for {{ movie.name }}" 32 /> 33 {%- endif %} 34 </div> 35 </div> 36 </body>
··· 30 src="{{ movie.poster }}" 31 alt="Poster for {{ movie.name }}" 32 /> 33 + {%- endif %} {% if let Some(song) = song -%} 34 + <img 35 + class="p-3" 36 + src="{{ song.art }}" 37 + alt="Album art for {{ song.name }}" 38 + /> 39 {%- endif %} 40 + 41 </div> 42 </div> 43 </body>