My personal site cherry.computer
htmx tailwind axum askama

perf: only get Apple Music environment variables on startup

cherry.computer ad595938 8f4535e8

verified
+67 -35
+3 -3
server/src/am_auth_flow.rs
··· 1 use askama::Template; 2 3 - use crate::scrapers::apple_music; 4 5 #[derive(Template, Debug, Clone)] 6 #[template(path = "am-auth-flow.html")] ··· 9 } 10 11 impl AuthFlowTemplate { 12 - pub fn new() -> anyhow::Result<Self> { 13 - let token = apple_music::build_developer_token()?; 14 Ok(Self { token }) 15 } 16 }
··· 1 use askama::Template; 2 3 + use crate::scrapers::apple_music::AppleMusicClient; 4 5 #[derive(Template, Debug, Clone)] 6 #[template(path = "am-auth-flow.html")] ··· 9 } 10 11 impl AuthFlowTemplate { 12 + pub fn new(apple_music_client: &AppleMusicClient) -> anyhow::Result<Self> { 13 + let token = apple_music_client.build_developer_token()?; 14 Ok(Self { token }) 15 } 16 }
+3 -3
server/src/index.rs
··· 1 use crate::scrapers::{ 2 - apple_music::{self, AppleMusic}, 3 backloggd::{self, Backloggd}, 4 letterboxd::{self, Letterboxd}, 5 }; ··· 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 }
··· 1 use crate::scrapers::{ 2 + apple_music::{self, AppleMusic, AppleMusicClient}, 3 backloggd::{self, Backloggd}, 4 letterboxd::{self, Letterboxd}, 5 }; ··· 15 } 16 17 impl RootTemplate { 18 + pub async fn new(apple_music_client: &AppleMusicClient) -> RootTemplate { 19 let (game, movie, song) = tokio::join!( 20 backloggd::cached_fetch(), 21 letterboxd::cached_fetch(), 22 + apple_music::cached_fetch(apple_music_client) 23 ); 24 RootTemplate { game, movie, song } 25 }
+16 -5
server/src/main.rs
··· 3 mod index; 4 mod scrapers; 5 6 - use std::net::SocketAddr; 7 8 #[cfg(debug_assertions)] 9 use crate::am_auth_flow::AuthFlowTemplate; 10 use crate::index::RootTemplate; 11 12 use askama::Template; 13 use axum::{ 14 Router, 15 http::{HeaderName, HeaderValue, StatusCode}, 16 response::{Html, IntoResponse}, 17 routing::{get, get_service}, ··· 22 trace::TraceLayer, 23 }; 24 25 #[tokio::main] 26 async fn main() -> anyhow::Result<()> { 27 tracing_subscriber::fmt::init(); 28 29 let app = Router::new() 30 .route("/", get(render_index_handler)) 31 .route("/dev/am-auth-flow", get(render_apple_music_auth_flow)) 32 .fallback(get_service(ServeDir::new("."))) 33 .layer( 34 ServiceBuilder::new() 35 .layer(TraceLayer::new_for_http()) ··· 48 Ok(()) 49 } 50 51 - async fn render_index_handler() -> impl IntoResponse { 52 - let template = RootTemplate::new().await; 53 template.render().map(Html).map_err(|err| { 54 tracing::error!("failed to render index: {err:?}"); 55 StatusCode::INTERNAL_SERVER_ERROR 56 }) 57 } 58 59 - async fn render_apple_music_auth_flow() -> impl IntoResponse { 60 #[cfg(not(debug_assertions))] 61 return StatusCode::NOT_FOUND; 62 63 #[cfg(debug_assertions)] 64 { 65 - let template = AuthFlowTemplate::new(); 66 template 67 .and_then(|template| Ok(template.render()?)) 68 .map(Html)
··· 3 mod index; 4 mod scrapers; 5 6 + use std::{net::SocketAddr, sync::Arc}; 7 8 #[cfg(debug_assertions)] 9 use crate::am_auth_flow::AuthFlowTemplate; 10 use crate::index::RootTemplate; 11 + use crate::scrapers::apple_music::AppleMusicClient; 12 13 use askama::Template; 14 use axum::{ 15 Router, 16 + extract::State, 17 http::{HeaderName, HeaderValue, StatusCode}, 18 response::{Html, IntoResponse}, 19 routing::{get, get_service}, ··· 24 trace::TraceLayer, 25 }; 26 27 + #[derive(Clone)] 28 + struct AppState { 29 + apple_music_client: Arc<AppleMusicClient>, 30 + } 31 + 32 #[tokio::main] 33 async fn main() -> anyhow::Result<()> { 34 tracing_subscriber::fmt::init(); 35 36 + let apple_music_client = Arc::new(AppleMusicClient::new()?); 37 + let state = AppState { apple_music_client }; 38 + 39 let app = Router::new() 40 .route("/", get(render_index_handler)) 41 .route("/dev/am-auth-flow", get(render_apple_music_auth_flow)) 42 .fallback(get_service(ServeDir::new("."))) 43 + .with_state(state) 44 .layer( 45 ServiceBuilder::new() 46 .layer(TraceLayer::new_for_http()) ··· 59 Ok(()) 60 } 61 62 + async fn render_index_handler(State(state): State<AppState>) -> impl IntoResponse { 63 + let template = RootTemplate::new(&state.apple_music_client).await; 64 template.render().map(Html).map_err(|err| { 65 tracing::error!("failed to render index: {err:?}"); 66 StatusCode::INTERNAL_SERVER_ERROR 67 }) 68 } 69 70 + async fn render_apple_music_auth_flow(State(state): State<AppState>) -> impl IntoResponse { 71 #[cfg(not(debug_assertions))] 72 return StatusCode::NOT_FOUND; 73 74 #[cfg(debug_assertions)] 75 { 76 + let template = AuthFlowTemplate::new(&state.apple_music_client); 77 template 78 .and_then(|template| Ok(template.render()?)) 79 .map(Html)
+45 -24
server/src/scrapers/apple_music.rs
··· 51 url: String, 52 } 53 54 - impl AppleMusic { 55 - pub async fn fetch() -> anyhow::Result<Self> { 56 - let jwt = build_developer_token()?; 57 let user_token = env::var("APPLE_USER_TOKEN").context("missing apple user token")?; 58 59 - let client = Client::new(); 60 - let response: AppleMusicResponse = client 61 .get("https://api.music.apple.com/v1/me/recent/played/tracks") 62 .bearer_auth(jwt) 63 - .header("Music-User-Token", user_token) 64 .query(&[("types", "songs"), ("limit", "1")]) 65 .send() 66 .await ··· 76 .replace("{w}", dimensions) 77 .replace("{h}", dimensions); 78 79 - Ok(Self { 80 name: track.attributes.name.clone(), 81 art: artwork_url, 82 }) 83 } 84 } 85 86 #[once(time = 30, option = false)] 87 - pub async fn cached_fetch() -> Option<AppleMusic> { 88 - AppleMusic::fetch() 89 .await 90 .map_err(|error| tracing::warn!(?error, "failed to call Apple Music")) 91 .ok() 92 } 93 - 94 - pub fn build_developer_token() -> anyhow::Result<String> { 95 - let mut header = Header::new(Algorithm::ES256); 96 - header.kid = 97 - Some(env::var("APPLE_DEVELOPER_TOKEN_KEY_ID").context("missing apple developer key ID")?); 98 - let team_id = 99 - env::var("APPLE_DEVELOPER_TOKEN_TEAM_ID").context("missing apple developer team ID")?; 100 - let claims = Claims::new(team_id); 101 - let auth_key = 102 - env::var("APPLE_DEVELOPER_TOKEN_AUTH_KEY").context("missing apple developer auth key")?; 103 - let key = EncodingKey::from_ec_pem(auth_key.as_bytes()) 104 - .context("failed to parse appple developer auth key")?; 105 - 106 - jsonwebtoken::encode(&header, &claims, &key).context("failed to encode apple developer JWT") 107 - }
··· 51 url: String, 52 } 53 54 + pub struct AppleMusicClient { 55 + http_client: Client, 56 + key_id: String, 57 + team_id: String, 58 + key: EncodingKey, 59 + user_token: String, 60 + } 61 + 62 + impl AppleMusicClient { 63 + pub fn new() -> anyhow::Result<Self> { 64 + let key_id = 65 + env::var("APPLE_DEVELOPER_TOKEN_KEY_ID").context("missing apple developer key ID")?; 66 + let team_id = 67 + env::var("APPLE_DEVELOPER_TOKEN_TEAM_ID").context("missing apple developer team ID")?; 68 + let auth_key = env::var("APPLE_DEVELOPER_TOKEN_AUTH_KEY") 69 + .context("missing apple developer auth key")?; 70 + let key = EncodingKey::from_ec_pem(auth_key.as_bytes()) 71 + .context("failed to parse appple developer auth key")?; 72 let user_token = env::var("APPLE_USER_TOKEN").context("missing apple user token")?; 73 74 + Ok(Self { 75 + http_client: Client::new(), 76 + key_id, 77 + team_id, 78 + key, 79 + user_token, 80 + }) 81 + } 82 + 83 + pub async fn fetch(&self) -> anyhow::Result<AppleMusic> { 84 + let jwt = self.build_developer_token()?; 85 + 86 + let response: AppleMusicResponse = self 87 + .http_client 88 .get("https://api.music.apple.com/v1/me/recent/played/tracks") 89 .bearer_auth(jwt) 90 + .header("Music-User-Token", &self.user_token) 91 .query(&[("types", "songs"), ("limit", "1")]) 92 .send() 93 .await ··· 103 .replace("{w}", dimensions) 104 .replace("{h}", dimensions); 105 106 + Ok(AppleMusic { 107 name: track.attributes.name.clone(), 108 art: artwork_url, 109 }) 110 } 111 + 112 + pub fn build_developer_token(&self) -> anyhow::Result<String> { 113 + let mut header = Header::new(Algorithm::ES256); 114 + header.kid = Some(self.key_id.clone()); 115 + let claims = Claims::new(self.team_id.clone()); 116 + 117 + jsonwebtoken::encode(&header, &claims, &self.key) 118 + .context("failed to encode apple developer JWT") 119 + } 120 } 121 122 #[once(time = 30, option = false)] 123 + pub async fn cached_fetch(this: &AppleMusicClient) -> Option<AppleMusic> { 124 + this.fetch() 125 .await 126 .map_err(|error| tracing::warn!(?error, "failed to call Apple Music")) 127 .ok() 128 }