My personal site cherry.computer
htmx tailwind axum askama

feat: lazily load media

cherry.computer 1432094d 160e7bc0

verified
+295 -242
+3 -1
frontend/esbuild.js
··· 50 50 }; 51 51 const url = new URL(`http://localhost${req.url}`); 52 52 const route = 53 - url.pathname === "/" || url.pathname === "/dev/am-auth-flow" 53 + url.pathname === "/" || 54 + url.pathname === "/dev/am-auth-flow" || 55 + url.pathname.startsWith("/media/") 54 56 ? { hostname: "127.0.0.1", port: 8080 } 55 57 : { hostname: hosts[0], port }; 56 58 const routedOptions = { ...options, ...route };
+1 -1
frontend/package.json
··· 32 32 "url": "https://github.com/ivomurrell/myivo.git" 33 33 }, 34 34 "dependencies": { 35 - "htmx.org": "^2.0.6", 35 + "htmx.org": "^2.0.8", 36 36 "tailwindcss": "^4.1.12" 37 37 }, 38 38 "volta": {
+2 -2
frontend/src/css/tailwind.css
··· 3 3 @source "."; 4 4 5 5 @source inline("hoverable:grid-cols-{1..3}"); 6 - @source inline("peer/{1..3}"); 7 - @source inline("peer-hover/{1..3}:block"); 6 + @source inline("peer/{game,film,song}"); 7 + @source inline("peer-hover/{game,film,song}:block"); 8 8 9 9 @custom-variant hoverable (@media (hover: hover));
+15 -13
package-lock.json
··· 45 45 "version": "1.0.0", 46 46 "license": "MIT", 47 47 "dependencies": { 48 - "htmx.org": "^2.0.6", 48 + "htmx.org": "^2.0.8", 49 49 "tailwindcss": "^4.1.12" 50 50 }, 51 51 "devDependencies": { ··· 62 62 "typescript": "^5.9.2", 63 63 "typescript-eslint": "^8.41.0" 64 64 } 65 + }, 66 + "frontend/node_modules/htmx.org": { 67 + "version": "2.0.8", 68 + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz", 69 + "integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==", 70 + "license": "0BSD" 65 71 }, 66 72 "node_modules/@esbuild/aix-ppc64": { 67 73 "version": "0.25.9", ··· 2445 2451 "engines": { 2446 2452 "node": ">=8" 2447 2453 } 2448 - }, 2449 - "node_modules/htmx.org": { 2450 - "version": "2.0.6", 2451 - "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz", 2452 - "integrity": "sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA==", 2453 - "license": "0BSD" 2454 2454 }, 2455 2455 "node_modules/ignore": { 2456 2456 "version": "5.3.2", ··· 4461 4461 "eslint": "^9.34.0", 4462 4462 "eslint-config-prettier": "^10.1.8", 4463 4463 "globals": "^16.3.0", 4464 - "htmx.org": "^2.0.6", 4464 + "htmx.org": "^2.0.8", 4465 4465 "minimist": "^1.2.8", 4466 4466 "tailwindcss": "^4.1.12", 4467 4467 "typescript": "^5.9.2", 4468 4468 "typescript-eslint": "^8.41.0" 4469 + }, 4470 + "dependencies": { 4471 + "htmx.org": { 4472 + "version": "2.0.8", 4473 + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz", 4474 + "integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==" 4475 + } 4469 4476 } 4470 4477 }, 4471 4478 "balanced-match": { ··· 4906 4913 "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 4907 4914 "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 4908 4915 "dev": true 4909 - }, 4910 - "htmx.org": { 4911 - "version": "2.0.6", 4912 - "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz", 4913 - "integrity": "sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA==" 4914 4916 }, 4915 4917 "ignore": { 4916 4918 "version": "5.3.2",
+25 -159
server/Cargo.lock
··· 18 18 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 19 19 20 20 [[package]] 21 - name = "ahash" 22 - version = "0.8.12" 23 - source = "registry+https://github.com/rust-lang/crates.io-index" 24 - checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" 25 - dependencies = [ 26 - "cfg-if", 27 - "once_cell", 28 - "version_check", 29 - "zerocopy", 30 - ] 31 - 32 - [[package]] 33 21 name = "alloc-no-stdlib" 34 22 version = "2.0.4" 35 23 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 43 31 dependencies = [ 44 32 "alloc-no-stdlib", 45 33 ] 46 - 47 - [[package]] 48 - name = "allocator-api2" 49 - version = "0.2.21" 50 - source = "registry+https://github.com/rust-lang/crates.io-index" 51 - checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 52 34 53 35 [[package]] 54 36 name = "anyhow" ··· 114 96 "tokio", 115 97 "zstd", 116 98 "zstd-safe", 117 - ] 118 - 119 - [[package]] 120 - name = "async-trait" 121 - version = "0.1.89" 122 - source = "registry+https://github.com/rust-lang/crates.io-index" 123 - checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 124 - dependencies = [ 125 - "proc-macro2", 126 - "quote", 127 - "syn", 128 99 ] 129 100 130 101 [[package]] ··· 269 240 checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 270 241 271 242 [[package]] 272 - name = "cached" 273 - version = "0.56.0" 274 - source = "registry+https://github.com/rust-lang/crates.io-index" 275 - checksum = "801927ee168e17809ab8901d9f01f700cd7d8d6a6527997fee44e4b0327a253c" 276 - dependencies = [ 277 - "ahash", 278 - "async-trait", 279 - "cached_proc_macro", 280 - "cached_proc_macro_types", 281 - "futures", 282 - "hashbrown", 283 - "once_cell", 284 - "thiserror", 285 - "tokio", 286 - "web-time", 287 - ] 288 - 289 - [[package]] 290 - name = "cached_proc_macro" 291 - version = "0.25.0" 292 - source = "registry+https://github.com/rust-lang/crates.io-index" 293 - checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201" 294 - dependencies = [ 295 - "darling", 296 - "proc-macro2", 297 - "quote", 298 - "syn", 299 - ] 300 - 301 - [[package]] 302 - name = "cached_proc_macro_types" 303 - version = "0.1.1" 304 - source = "registry+https://github.com/rust-lang/crates.io-index" 305 - checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" 306 - 307 - [[package]] 308 243 name = "cc" 309 244 version = "1.2.34" 310 245 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 392 327 ] 393 328 394 329 [[package]] 395 - name = "darling" 396 - version = "0.20.11" 397 - source = "registry+https://github.com/rust-lang/crates.io-index" 398 - checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 399 - dependencies = [ 400 - "darling_core", 401 - "darling_macro", 402 - ] 403 - 404 - [[package]] 405 - name = "darling_core" 406 - version = "0.20.11" 407 - source = "registry+https://github.com/rust-lang/crates.io-index" 408 - checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 409 - dependencies = [ 410 - "fnv", 411 - "ident_case", 412 - "proc-macro2", 413 - "quote", 414 - "strsim", 415 - "syn", 416 - ] 417 - 418 - [[package]] 419 - name = "darling_macro" 420 - version = "0.20.11" 421 - source = "registry+https://github.com/rust-lang/crates.io-index" 422 - checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 423 - dependencies = [ 424 - "darling_core", 425 - "quote", 426 - "syn", 427 - ] 428 - 429 - [[package]] 430 330 name = "deranged" 431 331 version = "0.4.0" 432 332 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 535 435 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 536 436 537 437 [[package]] 538 - name = "foldhash" 539 - version = "0.1.5" 540 - source = "registry+https://github.com/rust-lang/crates.io-index" 541 - checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 542 - 543 - [[package]] 544 438 name = "foreign-types" 545 439 version = "0.3.2" 546 440 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 575 469 ] 576 470 577 471 [[package]] 578 - name = "futures" 579 - version = "0.3.31" 580 - source = "registry+https://github.com/rust-lang/crates.io-index" 581 - checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 582 - dependencies = [ 583 - "futures-channel", 584 - "futures-core", 585 - "futures-io", 586 - "futures-sink", 587 - "futures-task", 588 - "futures-util", 589 - ] 590 - 591 - [[package]] 592 472 name = "futures-channel" 593 473 version = "0.3.31" 594 474 source = "registry+https://github.com/rust-lang/crates.io-index" 595 475 checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 596 476 dependencies = [ 597 477 "futures-core", 598 - "futures-sink", 599 478 ] 600 479 601 480 [[package]] ··· 605 484 checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 606 485 607 486 [[package]] 608 - name = "futures-io" 609 - version = "0.3.31" 610 - source = "registry+https://github.com/rust-lang/crates.io-index" 611 - checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 612 - 613 - [[package]] 614 487 name = "futures-sink" 615 488 version = "0.3.31" 616 489 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 629 502 checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 630 503 dependencies = [ 631 504 "futures-core", 632 - "futures-sink", 633 505 "futures-task", 634 506 "pin-project-lite", 635 507 "pin-utils", ··· 708 580 version = "0.15.5" 709 581 source = "registry+https://github.com/rust-lang/crates.io-index" 710 582 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 711 - dependencies = [ 712 - "allocator-api2", 713 - "equivalent", 714 - "foldhash", 715 - ] 583 + 584 + [[package]] 585 + name = "heck" 586 + version = "0.5.0" 587 + source = "registry+https://github.com/rust-lang/crates.io-index" 588 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 716 589 717 590 [[package]] 718 591 name = "html5ever" ··· 945 818 ] 946 819 947 820 [[package]] 948 - name = "ident_case" 949 - version = "1.0.1" 950 - source = "registry+https://github.com/rust-lang/crates.io-index" 951 - checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 952 - 953 - [[package]] 954 821 name = "idna" 955 822 version = "1.1.0" 956 823 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1172 1039 "anyhow", 1173 1040 "askama", 1174 1041 "axum", 1175 - "cached", 1176 1042 "jsonwebtoken", 1177 1043 "rand 0.9.2", 1178 1044 "reqwest", 1179 1045 "scraper", 1180 1046 "serde", 1047 + "strum", 1181 1048 "tokio", 1182 1049 "tower", 1183 1050 "tower-http", ··· 1888 1755 ] 1889 1756 1890 1757 [[package]] 1891 - name = "strsim" 1892 - version = "0.11.1" 1758 + name = "strum" 1759 + version = "0.27.2" 1893 1760 source = "registry+https://github.com/rust-lang/crates.io-index" 1894 - checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1761 + checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" 1762 + dependencies = [ 1763 + "strum_macros", 1764 + ] 1765 + 1766 + [[package]] 1767 + name = "strum_macros" 1768 + version = "0.27.2" 1769 + source = "registry+https://github.com/rust-lang/crates.io-index" 1770 + checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" 1771 + dependencies = [ 1772 + "heck", 1773 + "proc-macro2", 1774 + "quote", 1775 + "syn", 1776 + ] 1895 1777 1896 1778 [[package]] 1897 1779 name = "subtle" ··· 2291 2173 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 2292 2174 2293 2175 [[package]] 2294 - name = "version_check" 2295 - version = "0.9.5" 2296 - source = "registry+https://github.com/rust-lang/crates.io-index" 2297 - checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 2298 - 2299 - [[package]] 2300 2176 name = "want" 2301 2177 version = "0.3.1" 2302 2178 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2396 2272 version = "0.3.77" 2397 2273 source = "registry+https://github.com/rust-lang/crates.io-index" 2398 2274 checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" 2399 - dependencies = [ 2400 - "js-sys", 2401 - "wasm-bindgen", 2402 - ] 2403 - 2404 - [[package]] 2405 - name = "web-time" 2406 - version = "1.1.0" 2407 - source = "registry+https://github.com/rust-lang/crates.io-index" 2408 - checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 2409 2275 dependencies = [ 2410 2276 "js-sys", 2411 2277 "wasm-bindgen",
+1 -1
server/Cargo.toml
··· 10 10 anyhow = "1.0.57" 11 11 askama = "0.14.0" 12 12 axum = "0.8.1" 13 - cached = { version = "0.56.0", features = ["async"] } 14 13 jsonwebtoken = "9.3.1" 15 14 rand = "0.9.2" 16 15 reqwest = { version = "0.12.23", features = ["json"] } 17 16 scraper = "0.24.0" 18 17 serde = { version = "1.0.219", features = ["derive"] } 18 + strum = { version = "0.27.2", features = ["derive"] } 19 19 tokio = { version = "1.18.2", features = ["full"] } 20 20 tower = "0.5.2" 21 21 tower-http = { version = "0.6.2", features = ["compression-full", "fs", "trace", "set-header"] }
+25 -26
server/src/index.rs
··· 1 - use crate::scrapers::{ 2 - Media, 3 - apple_music::{self, AppleMusicClient}, 4 - backloggd, letterboxd, 5 - }; 1 + use std::sync::Arc; 2 + 3 + use crate::scrapers::{Media, MediaType, apple_music::AppleMusicClient, backloggd, letterboxd}; 6 4 7 5 use askama::Template; 8 6 use rand::seq::IndexedRandom; ··· 15 13 mock: bool, 16 14 } 17 15 16 + type MediaList = [(MediaType, Option<Media>); 3]; 17 + 18 18 #[derive(Template, Debug, Clone)] 19 19 #[template(path = "index.html")] 20 20 pub struct RootTemplate { 21 - media: Vec<Media>, 21 + media: MediaList, 22 22 consumption_verb: &'static str, 23 23 } 24 24 25 25 impl RootTemplate { 26 - pub async fn new( 27 - apple_music_client: &AppleMusicClient, 28 - #[allow(unused_variables)] options: IndexOptions, 26 + pub fn new( 27 + apple_music_client: Arc<AppleMusicClient>, 28 + #[allow(unused_variables)] options: &IndexOptions, 29 29 ) -> RootTemplate { 30 30 #[cfg(debug_assertions)] 31 31 let media = if options.mock { 32 32 mocked_media() 33 33 } else { 34 - Self::fetch_media(apple_music_client).await 34 + Self::fetch_media(apple_music_client) 35 35 }; 36 36 #[cfg(not(debug_assertions))] 37 - let media = Self::fetch_media(apple_music_client).await; 37 + let media = Self::fetch_media(apple_music_client); 38 38 39 39 let consumption_verb = Self::random_consumption_verb(); 40 40 ··· 44 44 } 45 45 } 46 46 47 - async fn fetch_media(apple_music_client: &AppleMusicClient) -> Vec<Media> { 48 - let (game, movie, song) = tokio::join!( 49 - backloggd::cached_fetch(), 50 - letterboxd::cached_fetch(), 51 - apple_music::cached_fetch(apple_music_client) 52 - ); 53 - [game, movie, song].into_iter().flatten().collect() 47 + fn fetch_media(apple_music_client: Arc<AppleMusicClient>) -> MediaList { 48 + [ 49 + (MediaType::Game, backloggd::try_cached_fetch()), 50 + (MediaType::Film, letterboxd::try_cached_fetch()), 51 + (MediaType::Song, apple_music_client.try_cached_fetch()), 52 + ] 54 53 } 55 54 56 55 fn random_consumption_verb() -> &'static str { ··· 61 60 } 62 61 63 62 #[cfg(debug_assertions)] 64 - fn mocked_media() -> Vec<Media> { 65 - vec![ 66 - Media { 63 + fn mocked_media() -> MediaList { 64 + [ 65 + (MediaType::Game, Some(Media { 67 66 name: "Cyberpunk 2077: Ultimate Edition".to_owned(), 68 67 image: "https://images.igdb.com/igdb/image/upload/t_cover_big/co7iy1.jpg".to_owned(), 69 68 context: "Nintendo Switch 2".to_owned(), 70 69 url: Some("https://backloggd.com/u/cherryfunk/logs/cyberpunk-2077-ultimate-edition/".to_owned()) 71 - }, 72 - Media { 70 + })), 71 + (MediaType::Film, Some(Media { 73 72 name: "The Thursday Murder Club".to_owned(), 74 73 image: "https://a.ltrbxd.com/resized/film-poster/6/6/6/2/8/6/666286-the-thursday-murder-club-0-230-0-345-crop.jpg?v=4bfeae38a7".to_owned(), 75 74 context: "1 star".to_owned(), 76 75 url: Some("https://letterboxd.com/ivom/film/the-thursday-murder-club/".to_owned()) 77 - }, 78 - Media { 76 + })), 77 + (MediaType::Song, Some(Media { 79 78 name: "We Might Feel Unsound".to_owned(), 80 79 image: "https://is1-ssl.mzstatic.com/image/thumb/Music124/v4/f4/b2/8e/f4b28ee4-01c6-232c-56a7-b97fd5b0e0ae/00602527857671.rgb.jpg/240x240bb.jpg".to_owned(), 81 80 context: "James Blake — James Blake".to_owned(), 82 81 url: None 83 - }, 82 + })), 84 83 ] 85 84 }
+22 -4
server/src/main.rs
··· 1 1 #[cfg(debug_assertions)] 2 2 mod am_auth_flow; 3 3 mod index; 4 + mod media; 4 5 mod scrapers; 5 6 6 7 use std::{net::SocketAddr, sync::Arc}; ··· 8 9 #[cfg(debug_assertions)] 9 10 use crate::am_auth_flow::AuthFlowTemplate; 10 11 use crate::index::{IndexOptions, RootTemplate}; 11 - use crate::scrapers::apple_music::AppleMusicClient; 12 + use crate::media::{MediaTemplate, fetch_media_of_type}; 13 + use crate::scrapers::{MediaType, apple_music::AppleMusicClient}; 12 14 13 15 use askama::Template; 14 16 use axum::{ 15 17 Router, 16 - extract::{Query, State}, 18 + extract::{Path, Query, State}, 17 19 http::{HeaderName, HeaderValue, StatusCode}, 18 20 response::{Html, IntoResponse}, 19 21 routing::{get, get_service}, ··· 36 38 let apple_music_client = Arc::new(AppleMusicClient::new()?); 37 39 let state = AppState { apple_music_client }; 38 40 39 - let app = Router::new().route("/", get(render_index_handler)); 41 + let app = Router::new() 42 + .route("/", get(render_index_handler)) 43 + .route("/media/{media_type}", get(render_media_partial_handler)); 40 44 #[cfg(debug_assertions)] 41 45 let app = app.route("/dev/am-auth-flow", get(render_apple_music_auth_flow)); 42 46 let app = app ··· 64 68 Query(options): Query<IndexOptions>, 65 69 State(state): State<AppState>, 66 70 ) -> impl IntoResponse { 67 - let template = RootTemplate::new(&state.apple_music_client, options).await; 71 + let template = RootTemplate::new(state.apple_music_client, &options); 68 72 template.render().map(Html).map_err(|err| { 69 73 tracing::error!("failed to render index: {err:?}"); 74 + StatusCode::INTERNAL_SERVER_ERROR 75 + }) 76 + } 77 + 78 + async fn render_media_partial_handler( 79 + Path(media_type): Path<MediaType>, 80 + State(state): State<AppState>, 81 + ) -> impl IntoResponse { 82 + let media = fetch_media_of_type(media_type, state.apple_music_client) 83 + .await 84 + .unwrap(); 85 + let template = MediaTemplate { media_type, media }; 86 + template.render().map(Html).map_err(|err| { 87 + tracing::error!("failed to render {media_type} media: {err:?}"); 70 88 StatusCode::INTERNAL_SERVER_ERROR 71 89 }) 72 90 }
+23
server/src/media.rs
··· 1 + use std::sync::Arc; 2 + 3 + use crate::scrapers::{Media, MediaType, apple_music::AppleMusicClient, backloggd, letterboxd}; 4 + 5 + use askama::Template; 6 + 7 + #[derive(Template, Debug, Clone)] 8 + #[template(path = "media.html")] 9 + pub struct MediaTemplate { 10 + pub media_type: MediaType, 11 + pub media: Media, 12 + } 13 + 14 + pub async fn fetch_media_of_type( 15 + media_type: MediaType, 16 + apple_music_client: Arc<AppleMusicClient>, 17 + ) -> Option<Media> { 18 + match media_type { 19 + MediaType::Game => backloggd::cached_fetch().await, 20 + MediaType::Film => letterboxd::cached_fetch().await, 21 + MediaType::Song => apple_music_client.cached_fetch().await, 22 + } 23 + }
+14
server/src/scrapers.rs
··· 1 + pub mod cached; 2 + 1 3 pub mod apple_music; 2 4 pub mod backloggd; 3 5 pub mod letterboxd; 4 6 7 + use serde::Deserialize; 8 + use strum::{Display, EnumString}; 9 + 5 10 #[derive(Debug, Clone)] 6 11 pub struct Media { 7 12 pub name: String, ··· 9 14 pub context: String, 10 15 pub url: Option<String>, 11 16 } 17 + 18 + #[derive(Debug, Clone, Copy, Display, EnumString, Deserialize)] 19 + #[strum(serialize_all = "snake_case")] 20 + #[serde(rename_all = "snake_case")] 21 + pub enum MediaType { 22 + Game, 23 + Film, 24 + Song, 25 + }
+19 -11
server/src/scrapers/apple_music.rs
··· 1 - use std::{env, fs, time::Duration}; 1 + use std::{env, fs, sync::Arc, time::Duration}; 2 2 3 3 use anyhow::Context; 4 - use cached::proc_macro::once; 5 4 use jsonwebtoken::{Algorithm, EncodingKey, Header}; 6 5 use reqwest::Client; 7 6 use serde::{Deserialize, Serialize}; 7 + use tokio::sync::RwLock; 8 8 9 - use super::Media; 9 + use super::{ 10 + Media, 11 + cached::{MediaCache, cache_or_fetch, try_cache_or_fetch}, 12 + }; 13 + 14 + static TTL: Duration = Duration::from_secs(30); 10 15 11 16 #[derive(Serialize, Debug, Clone)] 12 17 struct Claims { ··· 51 56 } 52 57 53 58 pub struct AppleMusicClient { 59 + cache: MediaCache, 54 60 http_client: Client, 55 61 key_id: String, 56 62 team_id: String, ··· 60 66 61 67 impl AppleMusicClient { 62 68 pub fn new() -> anyhow::Result<Self> { 69 + let cache = Arc::new(RwLock::new(None)); 63 70 let key_id = 64 71 env::var("APPLE_DEVELOPER_TOKEN_KEY_ID").context("missing apple developer key ID")?; 65 72 let team_id = ··· 70 77 let user_token = env::var("APPLE_USER_TOKEN").context("missing apple user token")?; 71 78 72 79 Ok(Self { 80 + cache, 73 81 http_client: Client::new(), 74 82 key_id, 75 83 team_id, ··· 113 121 }) 114 122 } 115 123 124 + pub fn try_cached_fetch(self: Arc<Self>) -> Option<Media> { 125 + try_cache_or_fetch(&self.cache.clone(), TTL, async move || self.fetch().await) 126 + } 127 + 128 + pub async fn cached_fetch(self: Arc<Self>) -> Option<Media> { 129 + cache_or_fetch(&self.cache.clone(), TTL, async move || self.fetch().await).await 130 + } 131 + 116 132 pub fn build_developer_token(&self) -> anyhow::Result<String> { 117 133 let mut header = Header::new(Algorithm::ES256); 118 134 header.kid = Some(self.key_id.clone()); ··· 122 138 .context("failed to encode apple developer JWT") 123 139 } 124 140 } 125 - 126 - #[once(time = 30, option = false)] 127 - pub async fn cached_fetch(this: &AppleMusicClient) -> Option<Media> { 128 - this.fetch() 129 - .await 130 - .map_err(|error| tracing::warn!(?error, "failed to call Apple Music")) 131 - .ok() 132 - }
+15 -8
server/src/scrapers/backloggd.rs
··· 1 - use std::{sync::LazyLock, time::Duration}; 1 + use std::{ 2 + sync::{Arc, LazyLock}, 3 + time::Duration, 4 + }; 2 5 3 6 use anyhow::Context; 4 - use cached::proc_macro::once; 5 7 use reqwest::Url; 6 8 use scraper::{Html, Selector}; 9 + use tokio::sync::RwLock; 7 10 8 - use super::Media; 11 + use super::{ 12 + Media, 13 + cached::{MediaCache, cache_or_fetch, try_cache_or_fetch}, 14 + }; 9 15 10 16 pub async fn fetch() -> anyhow::Result<Media> { 11 17 static FIRST_ENTRY_SEL: LazyLock<Selector> = ··· 72 78 }) 73 79 } 74 80 75 - #[once(time = 300, option = false)] 81 + static CACHE: LazyLock<MediaCache> = LazyLock::new(|| Arc::new(RwLock::new(None))); 82 + static TTL: Duration = Duration::from_secs(300); 83 + pub fn try_cached_fetch() -> Option<Media> { 84 + try_cache_or_fetch(&CACHE, TTL, fetch) 85 + } 76 86 pub async fn cached_fetch() -> Option<Media> { 77 - fetch() 78 - .await 79 - .map_err(|error| tracing::warn!(?error, "failed to scrape Backloggd")) 80 - .ok() 87 + cache_or_fetch(&CACHE, TTL, fetch).await 81 88 }
+90
server/src/scrapers/cached.rs
··· 1 + use std::{ 2 + sync::Arc, 3 + time::{Duration, Instant}, 4 + }; 5 + use tokio::sync::RwLock; 6 + 7 + use super::Media; 8 + 9 + #[derive(Debug)] 10 + pub struct CachedMediaResult { 11 + media_result: Option<Media>, 12 + cache_time: Instant, 13 + ttl: Duration, 14 + } 15 + 16 + impl CachedMediaResult { 17 + fn has_expired(&self) -> bool { 18 + let ttl = if self.media_result.is_some() { 19 + self.ttl 20 + } else { 21 + Duration::from_secs(30) 22 + }; 23 + self.cache_time.elapsed() >= ttl 24 + } 25 + } 26 + 27 + pub type MediaCache = Arc<RwLock<Option<CachedMediaResult>>>; 28 + 29 + pub fn try_cache_or_fetch<F, Fut>(cache: &MediaCache, ttl: Duration, fetcher: F) -> Option<Media> 30 + where 31 + F: FnOnce() -> Fut + Send + 'static, 32 + Fut: Future<Output = anyhow::Result<Media>> + Send, 33 + { 34 + { 35 + let cached = cache.try_read().ok()?; 36 + if let Some(cached) = &*cached 37 + && !cached.has_expired() 38 + { 39 + return cached.media_result.clone(); 40 + } 41 + } 42 + 43 + let cache = cache.clone(); 44 + tokio::spawn(async move { 45 + synced_fetch(&cache, ttl, fetcher).await; 46 + }); 47 + 48 + None 49 + } 50 + 51 + pub async fn cache_or_fetch<F, Fut>(cache: &MediaCache, ttl: Duration, fetcher: F) -> Option<Media> 52 + where 53 + F: FnOnce() -> Fut, 54 + Fut: Future<Output = anyhow::Result<Media>>, 55 + { 56 + { 57 + let cached = cache.read().await; 58 + if let Some(cached) = &*cached 59 + && !cached.has_expired() 60 + { 61 + return cached.media_result.clone(); 62 + } 63 + } 64 + 65 + synced_fetch(cache, ttl, fetcher).await 66 + } 67 + 68 + async fn synced_fetch<F, Fut>(cache: &MediaCache, ttl: Duration, fetcher: F) -> Option<Media> 69 + where 70 + F: FnOnce() -> Fut, 71 + Fut: Future<Output = anyhow::Result<Media>>, 72 + { 73 + let mut cached = cache.write().await; 74 + if let Some(cached) = &*cached 75 + && !cached.has_expired() 76 + { 77 + return cached.media_result.clone(); 78 + } 79 + 80 + let result = fetcher() 81 + .await 82 + .map_err(|error| tracing::warn!(?error, "failed to scrape backend")) 83 + .ok(); 84 + *cached = Some(CachedMediaResult { 85 + media_result: result.clone(), 86 + cache_time: Instant::now(), 87 + ttl, 88 + }); 89 + result 90 + }
+15 -8
server/src/scrapers/letterboxd.rs
··· 1 - use std::{sync::LazyLock, time::Duration}; 1 + use std::{ 2 + sync::{Arc, LazyLock}, 3 + time::Duration, 4 + }; 2 5 3 6 use anyhow::Context; 4 - use cached::proc_macro::once; 5 7 use reqwest::{Client, Url}; 6 8 use scraper::{ElementRef, Html, Selector}; 7 9 use serde::Deserialize; 10 + use tokio::sync::RwLock; 8 11 9 - use super::Media; 12 + use super::{ 13 + Media, 14 + cached::{MediaCache, cache_or_fetch, try_cache_or_fetch}, 15 + }; 10 16 11 17 #[derive(Deserialize, Debug, Clone)] 12 18 pub struct ImageUrlMetadata { ··· 135 141 Ok(image_url) 136 142 } 137 143 138 - #[once(time = 1800, option = false)] 144 + static CACHE: LazyLock<MediaCache> = LazyLock::new(|| Arc::new(RwLock::new(None))); 145 + static TTL: Duration = Duration::from_secs(1800); 146 + pub fn try_cached_fetch() -> Option<Media> { 147 + try_cache_or_fetch(&CACHE, TTL, fetch) 148 + } 139 149 pub async fn cached_fetch() -> Option<Media> { 140 - fetch() 141 - .await 142 - .map_err(|error| tracing::warn!(?error, "failed to scrape Letterboxd")) 143 - .ok() 150 + cache_or_fetch(&CACHE, TTL, fetch).await 144 151 }
+21 -6
server/templates/index.html
··· 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 6 <link rel="stylesheet" href="build/app.css" /> 7 7 <link rel="me" href="https://hachyderm.io/@cherry" /> 8 - <script defer src="build/app.js" type="module"></script> 8 + <script src="build/app.js" type="module"></script> 9 9 </head> 10 10 <body class="bg-gray-900"> 11 11 <div class="flex flex-col"> ··· 14 14 <span class="text-pink-700">cherry</span>.computer 15 15 </h1> 16 16 </div> 17 - <div class="m-4 flex max-w-2xl flex-col items-center gap-4 self-center"> 17 + <div 18 + class="flex w-full max-w-2xl flex-col items-center gap-4 self-center p-4" 19 + > 18 20 <div 19 21 class="max-w-xl text-justify font-mono text-2xl text-pink-100 uppercase" 20 22 > ··· 29 31 interrogate the apparent complex further. 30 32 </p> 31 33 </div> 32 - {% if media.len() > 0 -%} 33 34 <h2 class="self-start text-3xl text-pink-50"> 34 35 Here is what I've {{ consumption_verb }} most recently: 35 36 </h2> 36 - <div class="grid grid-cols-2 hoverable:grid-cols-{{ media.len() }}"> 37 - {% for media in media -%} {% include "media.html" %} {%- endfor %} 37 + <div 38 + class="grid gap-4 w-full grid-cols-2 hoverable:grid-cols-{{ media.len() }}" 39 + > 40 + {% for media in media -%} {% match media %} {% when (media_type, 41 + Some(media)) %} {% include "media.html" %} {% when (media_type, None) 42 + %} 43 + <div 44 + class="aspect-square w-full max-w-50 animate-pulse justify-self-center rounded-xs bg-gray-800 hoverable:max-w-none" 45 + hx-get="/media/{{ media_type }}" 46 + hx-swap="outerHTML" 47 + hx-trigger="load" 48 + ></div> 49 + <div 50 + id="media-description-{{ media_type }}" 51 + class="hoverable:hidden" 52 + ></div> 53 + {% endmatch %} {%- endfor %} 38 54 </div> 39 - {%- endif %} 40 55 <p class="font-mono text-3xl text-pink-50">Free Palestine 🇵🇸</p> 41 56 </div> 42 57 </div>
+4 -2
server/templates/media.html
··· 3 3 {% else %} 4 4 <div 5 5 {% endif %} 6 - class="peer/{{ loop.index }} relative m-2 aspect-square max-h-50 justify-self-center"> 6 + class="peer/{{ media_type }} relative aspect-square max-h-50 justify-self-center hoverable:max-h-none"> 7 7 <img 8 8 class="absolute inset-0 aspect-square rounded-xs object-fill" 9 9 aria-hidden="true" ··· 26 26 {% else %} 27 27 <div 28 28 {% endif %} 29 - class="mx-2 mt-4 flex flex-col self-center peer-hover/{{ loop.index }}:block hoverable:col-span-full hoverable:row-2 hoverable:hidden" 29 + id="media-description-{{ media_type }}" 30 + class="mx-2 mt-4 flex flex-col self-center peer-hover/{{ media_type }}:block hoverable:col-span-full hoverable:row-2 hoverable:hidden" 31 + hx-swap-oob="true" 30 32 > 31 33 <p class="text-2xl text-white">{{ media.name }}</p> 32 34 <p class="text-xl text-gray-700 italic">{{ media.context }}</p>