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 }; 51 const url = new URL(`http://localhost${req.url}`); 52 const route = 53 - url.pathname === "/" || url.pathname === "/dev/am-auth-flow" 54 ? { hostname: "127.0.0.1", port: 8080 } 55 : { hostname: hosts[0], port }; 56 const routedOptions = { ...options, ...route };
··· 50 }; 51 const url = new URL(`http://localhost${req.url}`); 52 const route = 53 + url.pathname === "/" || 54 + url.pathname === "/dev/am-auth-flow" || 55 + url.pathname.startsWith("/media/") 56 ? { hostname: "127.0.0.1", port: 8080 } 57 : { hostname: hosts[0], port }; 58 const routedOptions = { ...options, ...route };
+1 -1
frontend/package.json
··· 32 "url": "https://github.com/ivomurrell/myivo.git" 33 }, 34 "dependencies": { 35 - "htmx.org": "^2.0.6", 36 "tailwindcss": "^4.1.12" 37 }, 38 "volta": {
··· 32 "url": "https://github.com/ivomurrell/myivo.git" 33 }, 34 "dependencies": { 35 + "htmx.org": "^2.0.8", 36 "tailwindcss": "^4.1.12" 37 }, 38 "volta": {
+2 -2
frontend/src/css/tailwind.css
··· 3 @source "."; 4 5 @source inline("hoverable:grid-cols-{1..3}"); 6 - @source inline("peer/{1..3}"); 7 - @source inline("peer-hover/{1..3}:block"); 8 9 @custom-variant hoverable (@media (hover: hover));
··· 3 @source "."; 4 5 @source inline("hoverable:grid-cols-{1..3}"); 6 + @source inline("peer/{game,film,song}"); 7 + @source inline("peer-hover/{game,film,song}:block"); 8 9 @custom-variant hoverable (@media (hover: hover));
+15 -13
package-lock.json
··· 45 "version": "1.0.0", 46 "license": "MIT", 47 "dependencies": { 48 - "htmx.org": "^2.0.6", 49 "tailwindcss": "^4.1.12" 50 }, 51 "devDependencies": { ··· 62 "typescript": "^5.9.2", 63 "typescript-eslint": "^8.41.0" 64 } 65 }, 66 "node_modules/@esbuild/aix-ppc64": { 67 "version": "0.25.9", ··· 2445 "engines": { 2446 "node": ">=8" 2447 } 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 }, 2455 "node_modules/ignore": { 2456 "version": "5.3.2", ··· 4461 "eslint": "^9.34.0", 4462 "eslint-config-prettier": "^10.1.8", 4463 "globals": "^16.3.0", 4464 - "htmx.org": "^2.0.6", 4465 "minimist": "^1.2.8", 4466 "tailwindcss": "^4.1.12", 4467 "typescript": "^5.9.2", 4468 "typescript-eslint": "^8.41.0" 4469 } 4470 }, 4471 "balanced-match": { ··· 4906 "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 4907 "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 4908 "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 }, 4915 "ignore": { 4916 "version": "5.3.2",
··· 45 "version": "1.0.0", 46 "license": "MIT", 47 "dependencies": { 48 + "htmx.org": "^2.0.8", 49 "tailwindcss": "^4.1.12" 50 }, 51 "devDependencies": { ··· 62 "typescript": "^5.9.2", 63 "typescript-eslint": "^8.41.0" 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" 71 }, 72 "node_modules/@esbuild/aix-ppc64": { 73 "version": "0.25.9", ··· 2451 "engines": { 2452 "node": ">=8" 2453 } 2454 }, 2455 "node_modules/ignore": { 2456 "version": "5.3.2", ··· 4461 "eslint": "^9.34.0", 4462 "eslint-config-prettier": "^10.1.8", 4463 "globals": "^16.3.0", 4464 + "htmx.org": "^2.0.8", 4465 "minimist": "^1.2.8", 4466 "tailwindcss": "^4.1.12", 4467 "typescript": "^5.9.2", 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 + } 4476 } 4477 }, 4478 "balanced-match": { ··· 4913 "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 4914 "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 4915 "dev": true 4916 }, 4917 "ignore": { 4918 "version": "5.3.2",
+25 -159
server/Cargo.lock
··· 18 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 19 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 name = "alloc-no-stdlib" 34 version = "2.0.4" 35 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 43 dependencies = [ 44 "alloc-no-stdlib", 45 ] 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 53 [[package]] 54 name = "anyhow" ··· 114 "tokio", 115 "zstd", 116 "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 ] 129 130 [[package]] ··· 269 checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 270 271 [[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 name = "cc" 309 version = "1.2.34" 310 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 392 ] 393 394 [[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 name = "deranged" 431 version = "0.4.0" 432 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 535 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 536 537 [[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 name = "foreign-types" 545 version = "0.3.2" 546 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 575 ] 576 577 [[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 name = "futures-channel" 593 version = "0.3.31" 594 source = "registry+https://github.com/rust-lang/crates.io-index" 595 checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 596 dependencies = [ 597 "futures-core", 598 - "futures-sink", 599 ] 600 601 [[package]] ··· 605 checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 606 607 [[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 name = "futures-sink" 615 version = "0.3.31" 616 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 629 checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 630 dependencies = [ 631 "futures-core", 632 - "futures-sink", 633 "futures-task", 634 "pin-project-lite", 635 "pin-utils", ··· 708 version = "0.15.5" 709 source = "registry+https://github.com/rust-lang/crates.io-index" 710 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 711 - dependencies = [ 712 - "allocator-api2", 713 - "equivalent", 714 - "foldhash", 715 - ] 716 717 [[package]] 718 name = "html5ever" ··· 945 ] 946 947 [[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 name = "idna" 955 version = "1.1.0" 956 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1172 "anyhow", 1173 "askama", 1174 "axum", 1175 - "cached", 1176 "jsonwebtoken", 1177 "rand 0.9.2", 1178 "reqwest", 1179 "scraper", 1180 "serde", 1181 "tokio", 1182 "tower", 1183 "tower-http", ··· 1888 ] 1889 1890 [[package]] 1891 - name = "strsim" 1892 - version = "0.11.1" 1893 source = "registry+https://github.com/rust-lang/crates.io-index" 1894 - checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1895 1896 [[package]] 1897 name = "subtle" ··· 2291 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 2292 2293 [[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 name = "want" 2301 version = "0.3.1" 2302 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2396 version = "0.3.77" 2397 source = "registry+https://github.com/rust-lang/crates.io-index" 2398 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 dependencies = [ 2410 "js-sys", 2411 "wasm-bindgen",
··· 18 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 19 20 [[package]] 21 name = "alloc-no-stdlib" 22 version = "2.0.4" 23 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 31 dependencies = [ 32 "alloc-no-stdlib", 33 ] 34 35 [[package]] 36 name = "anyhow" ··· 96 "tokio", 97 "zstd", 98 "zstd-safe", 99 ] 100 101 [[package]] ··· 240 checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 241 242 [[package]] 243 name = "cc" 244 version = "1.2.34" 245 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 327 ] 328 329 [[package]] 330 name = "deranged" 331 version = "0.4.0" 332 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 435 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 436 437 [[package]] 438 name = "foreign-types" 439 version = "0.3.2" 440 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 469 ] 470 471 [[package]] 472 name = "futures-channel" 473 version = "0.3.31" 474 source = "registry+https://github.com/rust-lang/crates.io-index" 475 checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 476 dependencies = [ 477 "futures-core", 478 ] 479 480 [[package]] ··· 484 checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 485 486 [[package]] 487 name = "futures-sink" 488 version = "0.3.31" 489 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 502 checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 503 dependencies = [ 504 "futures-core", 505 "futures-task", 506 "pin-project-lite", 507 "pin-utils", ··· 580 version = "0.15.5" 581 source = "registry+https://github.com/rust-lang/crates.io-index" 582 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 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" 589 590 [[package]] 591 name = "html5ever" ··· 818 ] 819 820 [[package]] 821 name = "idna" 822 version = "1.1.0" 823 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1039 "anyhow", 1040 "askama", 1041 "axum", 1042 "jsonwebtoken", 1043 "rand 0.9.2", 1044 "reqwest", 1045 "scraper", 1046 "serde", 1047 + "strum", 1048 "tokio", 1049 "tower", 1050 "tower-http", ··· 1755 ] 1756 1757 [[package]] 1758 + name = "strum" 1759 + version = "0.27.2" 1760 source = "registry+https://github.com/rust-lang/crates.io-index" 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 + ] 1777 1778 [[package]] 1779 name = "subtle" ··· 2173 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 2174 2175 [[package]] 2176 name = "want" 2177 version = "0.3.1" 2178 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2272 version = "0.3.77" 2273 source = "registry+https://github.com/rust-lang/crates.io-index" 2274 checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" 2275 dependencies = [ 2276 "js-sys", 2277 "wasm-bindgen",
+1 -1
server/Cargo.toml
··· 10 anyhow = "1.0.57" 11 askama = "0.14.0" 12 axum = "0.8.1" 13 - cached = { version = "0.56.0", features = ["async"] } 14 jsonwebtoken = "9.3.1" 15 rand = "0.9.2" 16 reqwest = { version = "0.12.23", features = ["json"] } 17 scraper = "0.24.0" 18 serde = { version = "1.0.219", features = ["derive"] } 19 tokio = { version = "1.18.2", features = ["full"] } 20 tower = "0.5.2" 21 tower-http = { version = "0.6.2", features = ["compression-full", "fs", "trace", "set-header"] }
··· 10 anyhow = "1.0.57" 11 askama = "0.14.0" 12 axum = "0.8.1" 13 jsonwebtoken = "9.3.1" 14 rand = "0.9.2" 15 reqwest = { version = "0.12.23", features = ["json"] } 16 scraper = "0.24.0" 17 serde = { version = "1.0.219", features = ["derive"] } 18 + strum = { version = "0.27.2", features = ["derive"] } 19 tokio = { version = "1.18.2", features = ["full"] } 20 tower = "0.5.2" 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 - }; 6 7 use askama::Template; 8 use rand::seq::IndexedRandom; ··· 15 mock: bool, 16 } 17 18 #[derive(Template, Debug, Clone)] 19 #[template(path = "index.html")] 20 pub struct RootTemplate { 21 - media: Vec<Media>, 22 consumption_verb: &'static str, 23 } 24 25 impl RootTemplate { 26 - pub async fn new( 27 - apple_music_client: &AppleMusicClient, 28 - #[allow(unused_variables)] options: IndexOptions, 29 ) -> RootTemplate { 30 #[cfg(debug_assertions)] 31 let media = if options.mock { 32 mocked_media() 33 } else { 34 - Self::fetch_media(apple_music_client).await 35 }; 36 #[cfg(not(debug_assertions))] 37 - let media = Self::fetch_media(apple_music_client).await; 38 39 let consumption_verb = Self::random_consumption_verb(); 40 ··· 44 } 45 } 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() 54 } 55 56 fn random_consumption_verb() -> &'static str { ··· 61 } 62 63 #[cfg(debug_assertions)] 64 - fn mocked_media() -> Vec<Media> { 65 - vec![ 66 - Media { 67 name: "Cyberpunk 2077: Ultimate Edition".to_owned(), 68 image: "https://images.igdb.com/igdb/image/upload/t_cover_big/co7iy1.jpg".to_owned(), 69 context: "Nintendo Switch 2".to_owned(), 70 url: Some("https://backloggd.com/u/cherryfunk/logs/cyberpunk-2077-ultimate-edition/".to_owned()) 71 - }, 72 - Media { 73 name: "The Thursday Murder Club".to_owned(), 74 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 context: "1 star".to_owned(), 76 url: Some("https://letterboxd.com/ivom/film/the-thursday-murder-club/".to_owned()) 77 - }, 78 - Media { 79 name: "We Might Feel Unsound".to_owned(), 80 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 context: "James Blake — James Blake".to_owned(), 82 url: None 83 - }, 84 ] 85 }
··· 1 + use std::sync::Arc; 2 + 3 + use crate::scrapers::{Media, MediaType, apple_music::AppleMusicClient, backloggd, letterboxd}; 4 5 use askama::Template; 6 use rand::seq::IndexedRandom; ··· 13 mock: bool, 14 } 15 16 + type MediaList = [(MediaType, Option<Media>); 3]; 17 + 18 #[derive(Template, Debug, Clone)] 19 #[template(path = "index.html")] 20 pub struct RootTemplate { 21 + media: MediaList, 22 consumption_verb: &'static str, 23 } 24 25 impl RootTemplate { 26 + pub fn new( 27 + apple_music_client: Arc<AppleMusicClient>, 28 + #[allow(unused_variables)] options: &IndexOptions, 29 ) -> RootTemplate { 30 #[cfg(debug_assertions)] 31 let media = if options.mock { 32 mocked_media() 33 } else { 34 + Self::fetch_media(apple_music_client) 35 }; 36 #[cfg(not(debug_assertions))] 37 + let media = Self::fetch_media(apple_music_client); 38 39 let consumption_verb = Self::random_consumption_verb(); 40 ··· 44 } 45 } 46 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 + ] 53 } 54 55 fn random_consumption_verb() -> &'static str { ··· 60 } 61 62 #[cfg(debug_assertions)] 63 + fn mocked_media() -> MediaList { 64 + [ 65 + (MediaType::Game, Some(Media { 66 name: "Cyberpunk 2077: Ultimate Edition".to_owned(), 67 image: "https://images.igdb.com/igdb/image/upload/t_cover_big/co7iy1.jpg".to_owned(), 68 context: "Nintendo Switch 2".to_owned(), 69 url: Some("https://backloggd.com/u/cherryfunk/logs/cyberpunk-2077-ultimate-edition/".to_owned()) 70 + })), 71 + (MediaType::Film, Some(Media { 72 name: "The Thursday Murder Club".to_owned(), 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(), 74 context: "1 star".to_owned(), 75 url: Some("https://letterboxd.com/ivom/film/the-thursday-murder-club/".to_owned()) 76 + })), 77 + (MediaType::Song, Some(Media { 78 name: "We Might Feel Unsound".to_owned(), 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(), 80 context: "James Blake — James Blake".to_owned(), 81 url: None 82 + })), 83 ] 84 }
+22 -4
server/src/main.rs
··· 1 #[cfg(debug_assertions)] 2 mod am_auth_flow; 3 mod index; 4 mod scrapers; 5 6 use std::{net::SocketAddr, sync::Arc}; ··· 8 #[cfg(debug_assertions)] 9 use crate::am_auth_flow::AuthFlowTemplate; 10 use crate::index::{IndexOptions, RootTemplate}; 11 - use crate::scrapers::apple_music::AppleMusicClient; 12 13 use askama::Template; 14 use axum::{ 15 Router, 16 - extract::{Query, State}, 17 http::{HeaderName, HeaderValue, StatusCode}, 18 response::{Html, IntoResponse}, 19 routing::{get, get_service}, ··· 36 let apple_music_client = Arc::new(AppleMusicClient::new()?); 37 let state = AppState { apple_music_client }; 38 39 - let app = Router::new().route("/", get(render_index_handler)); 40 #[cfg(debug_assertions)] 41 let app = app.route("/dev/am-auth-flow", get(render_apple_music_auth_flow)); 42 let app = app ··· 64 Query(options): Query<IndexOptions>, 65 State(state): State<AppState>, 66 ) -> impl IntoResponse { 67 - let template = RootTemplate::new(&state.apple_music_client, options).await; 68 template.render().map(Html).map_err(|err| { 69 tracing::error!("failed to render index: {err:?}"); 70 StatusCode::INTERNAL_SERVER_ERROR 71 }) 72 }
··· 1 #[cfg(debug_assertions)] 2 mod am_auth_flow; 3 mod index; 4 + mod media; 5 mod scrapers; 6 7 use std::{net::SocketAddr, sync::Arc}; ··· 9 #[cfg(debug_assertions)] 10 use crate::am_auth_flow::AuthFlowTemplate; 11 use crate::index::{IndexOptions, RootTemplate}; 12 + use crate::media::{MediaTemplate, fetch_media_of_type}; 13 + use crate::scrapers::{MediaType, apple_music::AppleMusicClient}; 14 15 use askama::Template; 16 use axum::{ 17 Router, 18 + extract::{Path, Query, State}, 19 http::{HeaderName, HeaderValue, StatusCode}, 20 response::{Html, IntoResponse}, 21 routing::{get, get_service}, ··· 38 let apple_music_client = Arc::new(AppleMusicClient::new()?); 39 let state = AppState { apple_music_client }; 40 41 + let app = Router::new() 42 + .route("/", get(render_index_handler)) 43 + .route("/media/{media_type}", get(render_media_partial_handler)); 44 #[cfg(debug_assertions)] 45 let app = app.route("/dev/am-auth-flow", get(render_apple_music_auth_flow)); 46 let app = app ··· 68 Query(options): Query<IndexOptions>, 69 State(state): State<AppState>, 70 ) -> impl IntoResponse { 71 + let template = RootTemplate::new(state.apple_music_client, &options); 72 template.render().map(Html).map_err(|err| { 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:?}"); 88 StatusCode::INTERNAL_SERVER_ERROR 89 }) 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 apple_music; 2 pub mod backloggd; 3 pub mod letterboxd; 4 5 #[derive(Debug, Clone)] 6 pub struct Media { 7 pub name: String, ··· 9 pub context: String, 10 pub url: Option<String>, 11 }
··· 1 + pub mod cached; 2 + 3 pub mod apple_music; 4 pub mod backloggd; 5 pub mod letterboxd; 6 7 + use serde::Deserialize; 8 + use strum::{Display, EnumString}; 9 + 10 #[derive(Debug, Clone)] 11 pub struct Media { 12 pub name: String, ··· 14 pub context: String, 15 pub url: Option<String>, 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}; 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 - use super::Media; 10 11 #[derive(Serialize, Debug, Clone)] 12 struct Claims { ··· 51 } 52 53 pub struct AppleMusicClient { 54 http_client: Client, 55 key_id: String, 56 team_id: String, ··· 60 61 impl AppleMusicClient { 62 pub fn new() -> anyhow::Result<Self> { 63 let key_id = 64 env::var("APPLE_DEVELOPER_TOKEN_KEY_ID").context("missing apple developer key ID")?; 65 let team_id = ··· 70 let user_token = env::var("APPLE_USER_TOKEN").context("missing apple user token")?; 71 72 Ok(Self { 73 http_client: Client::new(), 74 key_id, 75 team_id, ··· 113 }) 114 } 115 116 pub fn build_developer_token(&self) -> anyhow::Result<String> { 117 let mut header = Header::new(Algorithm::ES256); 118 header.kid = Some(self.key_id.clone()); ··· 122 .context("failed to encode apple developer JWT") 123 } 124 } 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 - }
··· 1 + use std::{env, fs, sync::Arc, time::Duration}; 2 3 use anyhow::Context; 4 use jsonwebtoken::{Algorithm, EncodingKey, Header}; 5 use reqwest::Client; 6 use serde::{Deserialize, Serialize}; 7 + use tokio::sync::RwLock; 8 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); 15 16 #[derive(Serialize, Debug, Clone)] 17 struct Claims { ··· 56 } 57 58 pub struct AppleMusicClient { 59 + cache: MediaCache, 60 http_client: Client, 61 key_id: String, 62 team_id: String, ··· 66 67 impl AppleMusicClient { 68 pub fn new() -> anyhow::Result<Self> { 69 + let cache = Arc::new(RwLock::new(None)); 70 let key_id = 71 env::var("APPLE_DEVELOPER_TOKEN_KEY_ID").context("missing apple developer key ID")?; 72 let team_id = ··· 77 let user_token = env::var("APPLE_USER_TOKEN").context("missing apple user token")?; 78 79 Ok(Self { 80 + cache, 81 http_client: Client::new(), 82 key_id, 83 team_id, ··· 121 }) 122 } 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 + 132 pub fn build_developer_token(&self) -> anyhow::Result<String> { 133 let mut header = Header::new(Algorithm::ES256); 134 header.kid = Some(self.key_id.clone()); ··· 138 .context("failed to encode apple developer JWT") 139 } 140 }
+15 -8
server/src/scrapers/backloggd.rs
··· 1 - use std::{sync::LazyLock, time::Duration}; 2 3 use anyhow::Context; 4 - use cached::proc_macro::once; 5 use reqwest::Url; 6 use scraper::{Html, Selector}; 7 8 - use super::Media; 9 10 pub async fn fetch() -> anyhow::Result<Media> { 11 static FIRST_ENTRY_SEL: LazyLock<Selector> = ··· 72 }) 73 } 74 75 - #[once(time = 300, option = false)] 76 pub async fn cached_fetch() -> Option<Media> { 77 - fetch() 78 - .await 79 - .map_err(|error| tracing::warn!(?error, "failed to scrape Backloggd")) 80 - .ok() 81 }
··· 1 + use std::{ 2 + sync::{Arc, LazyLock}, 3 + time::Duration, 4 + }; 5 6 use anyhow::Context; 7 use reqwest::Url; 8 use scraper::{Html, Selector}; 9 + use tokio::sync::RwLock; 10 11 + use super::{ 12 + Media, 13 + cached::{MediaCache, cache_or_fetch, try_cache_or_fetch}, 14 + }; 15 16 pub async fn fetch() -> anyhow::Result<Media> { 17 static FIRST_ENTRY_SEL: LazyLock<Selector> = ··· 78 }) 79 } 80 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 + } 86 pub async fn cached_fetch() -> Option<Media> { 87 + cache_or_fetch(&CACHE, TTL, fetch).await 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}; 2 3 use anyhow::Context; 4 - use cached::proc_macro::once; 5 use reqwest::{Client, Url}; 6 use scraper::{ElementRef, Html, Selector}; 7 use serde::Deserialize; 8 9 - use super::Media; 10 11 #[derive(Deserialize, Debug, Clone)] 12 pub struct ImageUrlMetadata { ··· 135 Ok(image_url) 136 } 137 138 - #[once(time = 1800, option = false)] 139 pub async fn cached_fetch() -> Option<Media> { 140 - fetch() 141 - .await 142 - .map_err(|error| tracing::warn!(?error, "failed to scrape Letterboxd")) 143 - .ok() 144 }
··· 1 + use std::{ 2 + sync::{Arc, LazyLock}, 3 + time::Duration, 4 + }; 5 6 use anyhow::Context; 7 use reqwest::{Client, Url}; 8 use scraper::{ElementRef, Html, Selector}; 9 use serde::Deserialize; 10 + use tokio::sync::RwLock; 11 12 + use super::{ 13 + Media, 14 + cached::{MediaCache, cache_or_fetch, try_cache_or_fetch}, 15 + }; 16 17 #[derive(Deserialize, Debug, Clone)] 18 pub struct ImageUrlMetadata { ··· 141 Ok(image_url) 142 } 143 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 + } 149 pub async fn cached_fetch() -> Option<Media> { 150 + cache_or_fetch(&CACHE, TTL, fetch).await 151 }
+21 -6
server/templates/index.html
··· 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <link rel="stylesheet" href="build/app.css" /> 7 <link rel="me" href="https://hachyderm.io/@cherry" /> 8 - <script defer src="build/app.js" type="module"></script> 9 </head> 10 <body class="bg-gray-900"> 11 <div class="flex flex-col"> ··· 14 <span class="text-pink-700">cherry</span>.computer 15 </h1> 16 </div> 17 - <div class="m-4 flex max-w-2xl flex-col items-center gap-4 self-center"> 18 <div 19 class="max-w-xl text-justify font-mono text-2xl text-pink-100 uppercase" 20 > ··· 29 interrogate the apparent complex further. 30 </p> 31 </div> 32 - {% if media.len() > 0 -%} 33 <h2 class="self-start text-3xl text-pink-50"> 34 Here is what I've {{ consumption_verb }} most recently: 35 </h2> 36 - <div class="grid grid-cols-2 hoverable:grid-cols-{{ media.len() }}"> 37 - {% for media in media -%} {% include "media.html" %} {%- endfor %} 38 </div> 39 - {%- endif %} 40 <p class="font-mono text-3xl text-pink-50">Free Palestine 🇵🇸</p> 41 </div> 42 </div>
··· 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <link rel="stylesheet" href="build/app.css" /> 7 <link rel="me" href="https://hachyderm.io/@cherry" /> 8 + <script src="build/app.js" type="module"></script> 9 </head> 10 <body class="bg-gray-900"> 11 <div class="flex flex-col"> ··· 14 <span class="text-pink-700">cherry</span>.computer 15 </h1> 16 </div> 17 + <div 18 + class="flex w-full max-w-2xl flex-col items-center gap-4 self-center p-4" 19 + > 20 <div 21 class="max-w-xl text-justify font-mono text-2xl text-pink-100 uppercase" 22 > ··· 31 interrogate the apparent complex further. 32 </p> 33 </div> 34 <h2 class="self-start text-3xl text-pink-50"> 35 Here is what I've {{ consumption_verb }} most recently: 36 </h2> 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 %} 54 </div> 55 <p class="font-mono text-3xl text-pink-50">Free Palestine 🇵🇸</p> 56 </div> 57 </div>
+4 -2
server/templates/media.html
··· 3 {% else %} 4 <div 5 {% endif %} 6 - class="peer/{{ loop.index }} relative m-2 aspect-square max-h-50 justify-self-center"> 7 <img 8 class="absolute inset-0 aspect-square rounded-xs object-fill" 9 aria-hidden="true" ··· 26 {% else %} 27 <div 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" 30 > 31 <p class="text-2xl text-white">{{ media.name }}</p> 32 <p class="text-xl text-gray-700 italic">{{ media.context }}</p>
··· 3 {% else %} 4 <div 5 {% endif %} 6 + class="peer/{{ media_type }} relative aspect-square max-h-50 justify-self-center hoverable:max-h-none"> 7 <img 8 class="absolute inset-0 aspect-square rounded-xs object-fill" 9 aria-hidden="true" ··· 26 {% else %} 27 <div 28 {% endif %} 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" 32 > 33 <p class="text-2xl text-white">{{ media.name }}</p> 34 <p class="text-xl text-gray-700 italic">{{ media.context }}</p>