Post your last.fm now playing to your Bluesky followers

*: cache blobs, reorg code

+147 -109
+1
rustfmt.toml
···
··· 1 + reorder_imports = true
+73 -109
src/lib.rs
··· 1 - use atrium_api::app; 2 use atrium_api::app::bsky::embed::external; 3 use atrium_api::app::bsky::feed; 4 use atrium_api::app::bsky::feed::post::RecordEmbedRefs; 5 use atrium_api::types; 6 use atrium_api::types::string::Datetime; 7 use bsky_sdk::BskyAgent; 8 - use serde_derive::Deserialize; 9 - use serde_derive::Serialize; 10 use worker::*; 11 - 12 - #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 13 - #[serde(rename_all = "camelCase")] 14 - pub struct Root { 15 - pub recenttracks: Recenttracks, 16 - } 17 18 - #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 19 - #[serde(rename_all = "camelCase")] 20 - pub struct Recenttracks { 21 - pub track: Vec<Track>, 22 - } 23 - 24 - #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 25 - #[serde(rename_all = "camelCase")] 26 - pub struct Track { 27 - pub artist: Artist, 28 - pub album: Album, 29 - pub name: String, 30 - pub image: Vec<Image>, 31 - #[serde(rename = "@attr")] 32 - pub attr: Option<Attr>, 33 - pub url: String, 34 - } 35 36 - #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 37 - #[serde(rename_all = "camelCase")] 38 - pub struct Artist { 39 - pub mbid: String, 40 - #[serde(rename = "#text")] 41 - pub text: String, 42 - } 43 - 44 - #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 45 - #[serde(rename_all = "camelCase")] 46 - pub struct Album { 47 - pub mbid: String, 48 - #[serde(rename = "#text")] 49 - pub text: String, 50 - } 51 - 52 - #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 53 - #[serde(rename_all = "camelCase")] 54 - pub struct Attr { 55 - pub nowplaying: String, 56 - } 57 - 58 - #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 59 - #[serde(rename_all = "camelCase")] 60 - pub struct Image { 61 - pub size: String, 62 - #[serde(rename = "#text")] 63 - pub text: String, 64 } 65 66 fn now_playing_url(username: String, api_key: String) -> String { ··· 69 ) 70 } 71 72 - struct NowPlaying { 73 - artist: String, 74 - title: String, 75 - image: String, 76 - url: String, 77 - } 78 - 79 - impl std::fmt::Display for NowPlaying { 80 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 81 - write!(f, "{}, {}", self.title, self.artist) 82 - } 83 - } 84 - 85 - async fn get_now_playing(u: String) -> Option<NowPlaying> { 86 - let res: Root = reqwest::get(u).await.unwrap().json().await.unwrap(); 87 88 for e in res.recenttracks.track { 89 match e.attr { 90 Some(a) => { 91 - if a.nowplaying == "false" || a.nowplaying == "" { 92 continue; 93 } 94 95 let img = e.image.iter().find(|i| i.size == "extralarge"); 96 - return Some(NowPlaying { 97 artist: e.artist.text, 98 title: e.name, 99 url: e.url, ··· 107 None 108 } 109 110 - async fn embeds_if_any( 111 agent: &BskyAgent, 112 - np: &NowPlaying, 113 - ) -> Option<types::Union<RecordEmbedRefs>> { 114 - let img_data = reqwest::get(np.image.clone()) 115 - .await 116 - .unwrap() 117 - .bytes() 118 - .await 119 - .unwrap(); 120 - let img_ref = agent 121 - .api 122 - .com 123 - .atproto 124 - .repo 125 - .upload_blob(img_data.into()) 126 - .await 127 - .unwrap(); 128 129 - let data = app::bsky::embed::external::ExternalData { 130 description: np.artist.clone(), 131 - thumb: Some(img_ref.blob.clone()), 132 title: np.title.clone(), 133 uri: np.url.clone(), 134 }; ··· 137 external: data.into(), 138 }; 139 140 - Some(types::Union::Refs( 141 - RecordEmbedRefs::AppBskyEmbedExternalMain(Box::new(main.into())), 142 - )) 143 } 144 145 - async fn post_now_playing(np: NowPlaying, user: String, pass: String, pds: String) { 146 let agent = BskyAgent::builder() 147 .config(bsky_sdk::agent::config::Config { 148 endpoint: pds, ··· 157 agent 158 .create_record(feed::post::RecordData { 159 created_at: Datetime::now(), 160 - embed: embeds_if_any(&agent, &np).await, 161 entities: None, 162 facets: None, 163 labels: None, ··· 195 } 196 } 197 198 - const KEY_PREFIX: &'static str = "skeetfm_"; 199 - 200 #[event(scheduled)] 201 - async fn scheduled(_evt: ScheduledEvent, _env: Env, _ctx: ScheduleContext) { 202 console_error_panic_hook::set_once(); 203 204 - let vars = Vars::load(&_env); 205 206 let np_url = now_playing_url(vars.lastfm_username, vars.lastfm_api_key); 207 ··· 210 None => return, 211 }; 212 213 - let kv_key = format!("{}", np); 214 - let kv = _env.kv("skeetfm").unwrap(); 215 let res = kv 216 .list() 217 .limit(1) 218 - .prefix(format!("{KEY_PREFIX}{}", kv_key.clone())) 219 .execute() 220 .await 221 .unwrap(); ··· 225 226 let res = kv 227 .list() 228 - .prefix(KEY_PREFIX.to_string()) 229 .execute() 230 .await 231 .unwrap(); ··· 234 kv.delete(&key.name).await.ok(); 235 } 236 237 - post_now_playing(np, vars.bsky_username, vars.bsky_password, vars.pds_address).await; 238 239 - kv.put(&format!("{KEY_PREFIX}{}", kv_key.clone()), kv_key.clone()) 240 - .unwrap() 241 - .execute() 242 - .await 243 - .unwrap(); 244 }
··· 1 use atrium_api::app::bsky::embed::external; 2 use atrium_api::app::bsky::feed; 3 use atrium_api::app::bsky::feed::post::RecordEmbedRefs; 4 use atrium_api::types; 5 use atrium_api::types::string::Datetime; 6 use bsky_sdk::BskyAgent; 7 + use std::sync::LazyLock; 8 use worker::*; 9 + mod model; 10 11 + const KV_MAIN: &str = "skeetfm"; 12 + static KV_PREFIX: LazyLock<String> = LazyLock::new(|| KV_MAIN.to_string() + "_"); 13 + static KV_BLOB_CACHE: LazyLock<String> = LazyLock::new(|| KV_PREFIX.clone() + "blobcache"); 14 15 + fn kv_key(s: String) -> String { 16 + format!("{}{}", *KV_PREFIX, s) 17 } 18 19 fn now_playing_url(username: String, api_key: String) -> String { ··· 22 ) 23 } 24 25 + async fn get_now_playing(u: String) -> Option<model::NowPlaying> { 26 + let res: model::Root = reqwest::get(u).await.unwrap().json().await.unwrap(); 27 28 for e in res.recenttracks.track { 29 match e.attr { 30 Some(a) => { 31 + if a.nowplaying == "false" || a.nowplaying.is_empty() { 32 continue; 33 } 34 35 let img = e.image.iter().find(|i| i.size == "extralarge"); 36 + return Some(model::NowPlaying { 37 artist: e.artist.text, 38 title: e.name, 39 url: e.url, ··· 47 None 48 } 49 50 + async fn cached_album_art( 51 + env: &Env, 52 agent: &BskyAgent, 53 + np: &model::NowPlaying, 54 + ) -> types::Union<RecordEmbedRefs> { 55 + let kv = env.kv(&KV_BLOB_CACHE).unwrap(); 56 57 + let cached_art: types::BlobRef = match kv.get(&np.to_string()).json().await.unwrap() { 58 + Some(b) => b, 59 + None => { 60 + let img_data = reqwest::get(np.image.clone()) 61 + .await 62 + .unwrap() 63 + .bytes() 64 + .await 65 + .unwrap(); 66 + let blob = agent 67 + .api 68 + .com 69 + .atproto 70 + .repo 71 + .upload_blob(img_data.into()) 72 + .await 73 + .unwrap() 74 + .blob 75 + .clone(); 76 + kv.put(&np.to_string(), serde_json::to_string(&blob).unwrap()) 77 + .unwrap() 78 + .execute() 79 + .await 80 + .unwrap(); 81 + 82 + blob 83 + } 84 + }; 85 + 86 + let data = external::ExternalData { 87 description: np.artist.clone(), 88 + thumb: Some(cached_art.clone()), 89 title: np.title.clone(), 90 uri: np.url.clone(), 91 }; ··· 94 external: data.into(), 95 }; 96 97 + types::Union::Refs(RecordEmbedRefs::AppBskyEmbedExternalMain(Box::new( 98 + main.into(), 99 + ))) 100 } 101 102 + async fn post_now_playing( 103 + env: &Env, 104 + np: model::NowPlaying, 105 + user: String, 106 + pass: String, 107 + pds: String, 108 + ) { 109 let agent = BskyAgent::builder() 110 .config(bsky_sdk::agent::config::Config { 111 endpoint: pds, ··· 120 agent 121 .create_record(feed::post::RecordData { 122 created_at: Datetime::now(), 123 + embed: Some(cached_album_art(env, &agent, &np).await), 124 entities: None, 125 facets: None, 126 labels: None, ··· 158 } 159 } 160 161 #[event(scheduled)] 162 + async fn scheduled(_: ScheduledEvent, env: Env, _: ScheduleContext) { 163 console_error_panic_hook::set_once(); 164 165 + let vars = Vars::load(&env); 166 167 let np_url = now_playing_url(vars.lastfm_username, vars.lastfm_api_key); 168 ··· 171 None => return, 172 }; 173 174 + let key = kv_key(np.to_string()); 175 + let kv = env.kv(KV_MAIN).unwrap(); 176 let res = kv 177 .list() 178 .limit(1) 179 + .prefix(key.clone()) 180 .execute() 181 .await 182 .unwrap(); ··· 186 187 let res = kv 188 .list() 189 + .prefix(KV_PREFIX.to_string()) 190 .execute() 191 .await 192 .unwrap(); ··· 195 kv.delete(&key.name).await.ok(); 196 } 197 198 + post_now_playing( 199 + &env, 200 + np, 201 + vars.bsky_username, 202 + vars.bsky_password, 203 + vars.pds_address, 204 + ) 205 + .await; 206 207 + kv.put(&key, "").unwrap().execute().await.unwrap(); 208 }
+68
src/model.rs
···
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 4 + #[serde(rename_all = "camelCase")] 5 + pub struct Root { 6 + pub recenttracks: Recenttracks, 7 + } 8 + 9 + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 10 + #[serde(rename_all = "camelCase")] 11 + pub struct Recenttracks { 12 + pub track: Vec<Track>, 13 + } 14 + 15 + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 16 + #[serde(rename_all = "camelCase")] 17 + pub struct Track { 18 + pub artist: Artist, 19 + pub album: Album, 20 + pub name: String, 21 + pub image: Vec<Image>, 22 + #[serde(rename = "@attr")] 23 + pub attr: Option<Attr>, 24 + pub url: String, 25 + } 26 + 27 + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 28 + #[serde(rename_all = "camelCase")] 29 + pub struct Artist { 30 + pub mbid: String, 31 + #[serde(rename = "#text")] 32 + pub text: String, 33 + } 34 + 35 + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 36 + #[serde(rename_all = "camelCase")] 37 + pub struct Album { 38 + pub mbid: String, 39 + #[serde(rename = "#text")] 40 + pub text: String, 41 + } 42 + 43 + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 44 + #[serde(rename_all = "camelCase")] 45 + pub struct Attr { 46 + pub nowplaying: String, 47 + } 48 + 49 + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 50 + #[serde(rename_all = "camelCase")] 51 + pub struct Image { 52 + pub size: String, 53 + #[serde(rename = "#text")] 54 + pub text: String, 55 + } 56 + 57 + pub struct NowPlaying { 58 + pub artist: String, 59 + pub title: String, 60 + pub image: String, 61 + pub url: String, 62 + } 63 + 64 + impl std::fmt::Display for NowPlaying { 65 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 66 + write!(f, "{}, {}", self.title, self.artist) 67 + } 68 + }
+5
wrangler.toml
··· 15 binding = "skeetfm" 16 id = "1daf3d0198ef400e9ad03d8495955b03" 17 preview_id = "1daf3d0198ef400e9ad03d8495955b03"
··· 15 binding = "skeetfm" 16 id = "1daf3d0198ef400e9ad03d8495955b03" 17 preview_id = "1daf3d0198ef400e9ad03d8495955b03" 18 + 19 + [[kv_namespaces]] 20 + binding = "skeetfm_blobcache" 21 + id = "a60098e075e54b50814b2ae06f211c36" 22 + preview_id = "a60098e075e54b50814b2ae06f211c36"