My personal site cherry.computer
htmx tailwind axum askama

Compare changes

Choose any two refs to compare.

+584 -462
-39
Dockerfile
··· 1 - FROM node:24 AS build-js 2 - 3 - WORKDIR /usr/src/myivo 4 - 5 - COPY frontend/package*.json . 6 - RUN npm install 7 - 8 - COPY frontend . 9 - 10 - # tailwind classes are in the backend's HTML template files 11 - COPY server/templates templates 12 - RUN sed -i "s|../../../server/templates|../../templates|" src/css/tailwind.css 13 - 14 - RUN npm run build:production 15 - 16 - FROM rust:1.89 AS builder-rs 17 - 18 - WORKDIR /usr/src/myivo-server 19 - COPY server . 20 - 21 - # point to minimised, production versions of build artefacts 22 - RUN sed -i "s|build/app|build/app.min|g" templates/index.html 23 - RUN cargo install --profile release --locked --path . 24 - 25 - # run on different image 26 - FROM debian:trixie-slim 27 - 28 - RUN apt-get update \ 29 - && apt-get install -y openssl ca-certificates \ 30 - && rm -rf /var/lib/apt/lists/* 31 - 32 - WORKDIR /root 33 - 34 - COPY --from=build-js /usr/src/myivo/build ./build 35 - COPY --from=builder-rs /usr/local/cargo/bin/myivo-server /usr/local/bin/ 36 - 37 - EXPOSE 8080 38 - 39 - CMD ["myivo-server"]
-45
fly.toml
··· 1 - # fly.toml file generated for myivo on 2022-05-23T09:56:13+01:00 2 - 3 - app = "myivo" 4 - 5 - kill_signal = "SIGINT" 6 - kill_timeout = 5 7 - processes = [] 8 - 9 - [env] 10 - RUST_LOG = "debug" 11 - 12 - [[files]] 13 - guest_path = "/root/keys/AuthKey.p8" 14 - secret_name = "APPLE_DEVELOPER_TOKEN_AUTH_KEY" 15 - 16 - [experimental] 17 - allowed_public_ports = [] 18 - auto_rollback = true 19 - 20 - [[services]] 21 - http_checks = [] 22 - internal_port = 8080 23 - processes = ["app"] 24 - protocol = "tcp" 25 - script_checks = [] 26 - 27 - [services.concurrency] 28 - hard_limit = 25 29 - soft_limit = 20 30 - type = "connections" 31 - 32 - [[services.ports]] 33 - force_https = true 34 - handlers = ["http"] 35 - port = 80 36 - 37 - [[services.ports]] 38 - handlers = ["tls", "http"] 39 - port = 443 40 - 41 - [[services.tcp_checks]] 42 - grace_period = "1s" 43 - interval = "15s" 44 - restart_limit = 0 45 - timeout = "2s"
+4 -2
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" 54 - ? { hostname: "127.0.0.1", port: 8080 } 53 + url.pathname === "/" || 54 + url.pathname === "/dev/am-auth-flow" || 55 + url.pathname.startsWith("/media/") 56 + ? { hostname: "127.0.0.1", port: 53465 } 55 57 : { hostname: hosts[0], port }; 56 58 const routedOptions = { ...options, ...route }; 57 59
+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": {
+4 -32
frontend/src/css/tailwind.css
··· 2 2 @source "../../../server/templates"; 3 3 @source "."; 4 4 5 - @theme { 6 - --animate-marquee: marquee-start 1s linear 2, marquee-end 1s 2s ease-out; 7 - --animate-glow: glow 0.5s 3.12s backwards ease-in; 8 - 9 - @keyframes marquee-start { 10 - 0% { 11 - transform: translateX(-75vw); 12 - } 13 - 100% { 14 - transform: translateX(75vw); 15 - } 16 - } 17 - 18 - @keyframes marquee-end { 19 - 0% { 20 - transform: translateX(-75vw); 21 - } 22 - 100% { 23 - } 24 - } 5 + @source inline("hoverable:grid-cols-{1..3}"); 6 + @source inline("peer/{game,film,song}"); 7 + @source inline("peer-hover/{game,film,song}:block"); 25 8 26 - @keyframes glow { 27 - 0% { 28 - color: var(--color-gray-200); 29 - } 30 - 50% { 31 - color: var(--color-pink-900); 32 - } 33 - 100% { 34 - color: var(--color-pink-700); 35 - } 36 - } 37 - } 9 + @custom-variant hoverable (@media (hover: hover));
+5 -5
justfile
··· 3 3 [parallel] 4 4 serve: serve-js serve-rs 5 5 6 - [working-directory: 'frontend'] 6 + [working-directory('frontend')] 7 7 serve-js: 8 - watchexec --restart --watch esbuild.js npm start 8 + watchexec --restart --watch esbuild.js npm start 9 9 10 - [working-directory: 'server'] 11 - serve-rs $RUST_LOG=env('RUST_LOG', 'debug,selectors=warn,html5ever=warn'): 12 - watchexec --restart --ignore "target/**" cargo run 10 + [working-directory('server')] 11 + serve-rs args="" $RUST_LOG=env('RUST_LOG', 'debug,selectors=warn,html5ever=warn') $MYIVO_GIT_SHA=`jj log -r@ --no-graph -T commit_id`: 12 + watchexec --restart --ignore "target/**" cargo run {{ args }}
+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",
+66 -161
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", 1043 + "rand 0.9.2", 1177 1044 "reqwest", 1178 1045 "scraper", 1179 1046 "serde", 1047 + "strum", 1180 1048 "tokio", 1181 1049 "tower", 1182 1050 "tower-http", ··· 1382 1250 checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" 1383 1251 dependencies = [ 1384 1252 "phf_shared", 1385 - "rand", 1253 + "rand 0.8.5", 1386 1254 ] 1387 1255 1388 1256 [[package]] ··· 1441 1309 checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 1442 1310 1443 1311 [[package]] 1312 + name = "ppv-lite86" 1313 + version = "0.2.21" 1314 + source = "registry+https://github.com/rust-lang/crates.io-index" 1315 + checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 1316 + dependencies = [ 1317 + "zerocopy", 1318 + ] 1319 + 1320 + [[package]] 1444 1321 name = "precomputed-hash" 1445 1322 version = "0.1.1" 1446 1323 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1476 1353 source = "registry+https://github.com/rust-lang/crates.io-index" 1477 1354 checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1478 1355 dependencies = [ 1479 - "rand_core", 1356 + "rand_core 0.6.4", 1357 + ] 1358 + 1359 + [[package]] 1360 + name = "rand" 1361 + version = "0.9.2" 1362 + source = "registry+https://github.com/rust-lang/crates.io-index" 1363 + checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 1364 + dependencies = [ 1365 + "rand_chacha", 1366 + "rand_core 0.9.3", 1367 + ] 1368 + 1369 + [[package]] 1370 + name = "rand_chacha" 1371 + version = "0.9.0" 1372 + source = "registry+https://github.com/rust-lang/crates.io-index" 1373 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 1374 + dependencies = [ 1375 + "ppv-lite86", 1376 + "rand_core 0.9.3", 1480 1377 ] 1481 1378 1482 1379 [[package]] ··· 1484 1381 version = "0.6.4" 1485 1382 source = "registry+https://github.com/rust-lang/crates.io-index" 1486 1383 checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 1384 + 1385 + [[package]] 1386 + name = "rand_core" 1387 + version = "0.9.3" 1388 + source = "registry+https://github.com/rust-lang/crates.io-index" 1389 + checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 1390 + dependencies = [ 1391 + "getrandom 0.3.3", 1392 + ] 1487 1393 1488 1394 [[package]] 1489 1395 name = "redox_syscall" ··· 1849 1755 ] 1850 1756 1851 1757 [[package]] 1852 - name = "strsim" 1853 - version = "0.11.1" 1758 + name = "strum" 1759 + version = "0.27.2" 1854 1760 source = "registry+https://github.com/rust-lang/crates.io-index" 1855 - 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 + ] 1856 1777 1857 1778 [[package]] 1858 1779 name = "subtle" ··· 2252 2173 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 2253 2174 2254 2175 [[package]] 2255 - name = "version_check" 2256 - version = "0.9.5" 2257 - source = "registry+https://github.com/rust-lang/crates.io-index" 2258 - checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 2259 - 2260 - [[package]] 2261 2176 name = "want" 2262 2177 version = "0.3.1" 2263 2178 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2357 2272 version = "0.3.77" 2358 2273 source = "registry+https://github.com/rust-lang/crates.io-index" 2359 2274 checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" 2360 - dependencies = [ 2361 - "js-sys", 2362 - "wasm-bindgen", 2363 - ] 2364 - 2365 - [[package]] 2366 - name = "web-time" 2367 - version = "1.1.0" 2368 - source = "registry+https://github.com/rust-lang/crates.io-index" 2369 - checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 2370 2275 dependencies = [ 2371 2276 "js-sys", 2372 2277 "wasm-bindgen",
+4 -2
server/Cargo.toml
··· 3 3 version = "0.1.0" 4 4 edition = "2024" 5 5 6 - # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 6 + [lints.clippy] 7 + pedantic = "warn" 7 8 8 9 [dependencies] 9 10 anyhow = "1.0.57" 10 11 askama = "0.14.0" 11 12 axum = "0.8.1" 12 - cached = { version = "0.56.0", features = ["async"] } 13 13 jsonwebtoken = "9.3.1" 14 + rand = "0.9.2" 14 15 reqwest = { version = "0.12.23", features = ["json"] } 15 16 scraper = "0.24.0" 16 17 serde = { version = "1.0.219", features = ["derive"] } 18 + strum = { version = "0.27.2", features = ["derive"] } 17 19 tokio = { version = "1.18.2", features = ["full"] } 18 20 tower = "0.5.2" 19 21 tower-http = { version = "0.6.2", features = ["compression-full", "fs", "trace", "set-header"] }
-16
server/src/am_auth_flow.rs
··· 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")] 7 - pub struct AuthFlowTemplate { 8 - token: String, 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 - }
-69
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 serde::Deserialize; 9 - 10 - #[derive(Deserialize)] 11 - pub struct IndexOptions { 12 - #[cfg(debug_assertions)] 13 - #[serde(default)] 14 - mock: bool, 15 - } 16 - 17 - #[derive(Template, Debug, Clone)] 18 - #[template(path = "index.html")] 19 - pub struct RootTemplate { 20 - media: Vec<Media>, 21 - } 22 - 23 - impl RootTemplate { 24 - pub async fn new( 25 - apple_music_client: &AppleMusicClient, 26 - #[allow(unused_variables)] options: IndexOptions, 27 - ) -> RootTemplate { 28 - #[cfg(debug_assertions)] 29 - let media = if options.mock { 30 - mocked_media() 31 - } else { 32 - Self::fetch_media(apple_music_client).await 33 - }; 34 - #[cfg(not(debug_assertions))] 35 - let media = Self::fetch_media(apple_music_client).await; 36 - 37 - RootTemplate { media } 38 - } 39 - 40 - async fn fetch_media(apple_music_client: &AppleMusicClient) -> Vec<Media> { 41 - let (game, movie, song) = tokio::join!( 42 - backloggd::cached_fetch(), 43 - letterboxd::cached_fetch(), 44 - apple_music::cached_fetch(apple_music_client) 45 - ); 46 - [game, movie, song].into_iter().flatten().collect() 47 - } 48 - } 49 - 50 - #[cfg(debug_assertions)] 51 - fn mocked_media() -> Vec<Media> { 52 - vec![ 53 - Media { 54 - name: "Cyberpunk 2077: Ultimate Edition".to_owned(), 55 - image: "https://images.igdb.com/igdb/image/upload/t_cover_big/co7iy1.jpg".to_owned(), 56 - context: "Nintendo Switch 2".to_owned(), 57 - }, 58 - Media { 59 - name: "The Thursday Murder Club".to_owned(), 60 - 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(), 61 - context: "1 star".to_owned(), 62 - }, 63 - Media { 64 - name: "We Might Feel Unsound".to_owned(), 65 - 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(), 66 - context: "James Blake โ€” James Blake".to_owned(), 67 - }, 68 - ] 69 - }
+47 -27
server/src/main.rs
··· 1 - #[cfg(debug_assertions)] 2 - mod am_auth_flow; 3 - mod index; 4 1 mod scrapers; 2 + mod templates; 5 3 6 - use std::{net::SocketAddr, sync::Arc}; 4 + use std::{env, net::SocketAddr, sync::Arc}; 7 5 6 + use crate::scrapers::{MediaType, apple_music::AppleMusicClient}; 8 7 #[cfg(debug_assertions)] 9 - use crate::am_auth_flow::AuthFlowTemplate; 10 - use crate::index::{IndexOptions, RootTemplate}; 11 - use crate::scrapers::apple_music::AppleMusicClient; 8 + use crate::templates::am_auth_flow::AuthFlowTemplate; 9 + use crate::templates::{ 10 + index::{IndexOptions, RootTemplate, Shas}, 11 + media::{MediaTemplate, fetch_media_of_type}, 12 + }; 12 13 13 14 use askama::Template; 14 15 use axum::{ 15 16 Router, 16 - extract::{Query, State}, 17 + extract::{Path, Query, State}, 17 18 http::{HeaderName, HeaderValue, StatusCode}, 18 19 response::{Html, IntoResponse}, 19 - routing::{get, get_service}, 20 + routing::get, 20 21 }; 21 22 use tower::ServiceBuilder; 22 23 use tower_http::{ 23 - compression::CompressionLayer, services::ServeDir, set_header::SetResponseHeaderLayer, 24 - trace::TraceLayer, 24 + compression::CompressionLayer, set_header::SetResponseHeaderLayer, trace::TraceLayer, 25 25 }; 26 26 27 27 #[derive(Clone)] 28 28 struct AppState { 29 29 apple_music_client: Arc<AppleMusicClient>, 30 + shas: Shas, 30 31 } 31 32 32 33 #[tokio::main] ··· 34 35 tracing_subscriber::fmt::init(); 35 36 36 37 let apple_music_client = Arc::new(AppleMusicClient::new()?); 37 - let state = AppState { apple_music_client }; 38 + let shas = Shas { 39 + website: env::var("MYIVO_GIT_SHA").ok(), 40 + }; 41 + let state = AppState { 42 + apple_music_client, 43 + shas, 44 + }; 38 45 39 - let app = Router::new().route("/", get(render_index_handler)); 46 + let app = Router::new() 47 + .route("/", get(render_index_handler)) 48 + .route("/media/{media_type}", get(render_media_partial_handler)); 40 49 #[cfg(debug_assertions)] 41 50 let app = app.route("/dev/am-auth-flow", get(render_apple_music_auth_flow)); 42 - let app = app 43 - .fallback(get_service(ServeDir::new("."))) 44 - .with_state(state) 45 - .layer( 46 - ServiceBuilder::new() 47 - .layer(TraceLayer::new_for_http()) 48 - .layer(CompressionLayer::new()) 49 - .layer(SetResponseHeaderLayer::overriding( 50 - HeaderName::from_static("strict-transport-security"), 51 - HeaderValue::from_static("max-age=2592000; includeSubDomains"), 52 - )), 53 - ); 51 + let app = app.with_state(state).layer( 52 + ServiceBuilder::new() 53 + .layer(TraceLayer::new_for_http()) 54 + .layer(CompressionLayer::new()) 55 + .layer(SetResponseHeaderLayer::overriding( 56 + HeaderName::from_static("strict-transport-security"), 57 + HeaderValue::from_static("max-age=2592000; includeSubDomains"), 58 + )), 59 + ); 54 60 55 - let addr = SocketAddr::from(([0, 0, 0, 0], 8080)); 61 + let addr = SocketAddr::from(([0, 0, 0, 0], 53465)); 56 62 tracing::debug!("starting server on {addr}"); 57 63 let listener = tokio::net::TcpListener::bind(addr).await?; 58 64 axum::serve(listener, app).await?; ··· 64 70 Query(options): Query<IndexOptions>, 65 71 State(state): State<AppState>, 66 72 ) -> impl IntoResponse { 67 - let template = RootTemplate::new(&state.apple_music_client, options).await; 73 + let template = RootTemplate::new(state.apple_music_client, state.shas, &options); 68 74 template.render().map(Html).map_err(|err| { 69 75 tracing::error!("failed to render index: {err:?}"); 76 + StatusCode::INTERNAL_SERVER_ERROR 77 + }) 78 + } 79 + 80 + async fn render_media_partial_handler( 81 + Path(media_type): Path<MediaType>, 82 + State(state): State<AppState>, 83 + ) -> impl IntoResponse { 84 + let media = fetch_media_of_type(media_type, state.apple_music_client) 85 + .await 86 + .unwrap(); 87 + let template = MediaTemplate { media_type, media }; 88 + template.render().map(Html).map_err(|err| { 89 + tracing::error!("failed to render {media_type} media: {err:?}"); 70 90 StatusCode::INTERNAL_SERVER_ERROR 71 91 }) 72 92 }
+27 -12
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 + use tracing::instrument; 8 9 9 - use super::Media; 10 + use super::{ 11 + Media, 12 + cached::{MediaCache, cache_or_fetch, try_cache_or_fetch}, 13 + }; 14 + 15 + static TTL: Duration = Duration::from_secs(30); 10 16 11 17 #[derive(Serialize, Debug, Clone)] 12 18 struct Claims { ··· 43 49 album_name: String, 44 50 artist_name: String, 45 51 artwork: AppleMusicTrackArtwork, 52 + url: String, 46 53 } 47 54 48 55 #[derive(Deserialize, Debug, Clone)] ··· 51 58 } 52 59 53 60 pub struct AppleMusicClient { 61 + cache: MediaCache, 54 62 http_client: Client, 55 63 key_id: String, 56 64 team_id: String, ··· 60 68 61 69 impl AppleMusicClient { 62 70 pub fn new() -> anyhow::Result<Self> { 71 + let cache = Arc::new(RwLock::new(None)); 63 72 let key_id = 64 73 env::var("APPLE_DEVELOPER_TOKEN_KEY_ID").context("missing apple developer key ID")?; 65 74 let team_id = 66 75 env::var("APPLE_DEVELOPER_TOKEN_TEAM_ID").context("missing apple developer team ID")?; 67 - let auth_key = fs::read("keys/AuthKey.p8").context("missing apple developer auth key")?; 76 + let auth_key_path = env::var("APPLE_DEVELOPER_AUTH_KEY_PATH") 77 + .context("missing apple developer apple developer auth key path")?; 78 + let auth_key = fs::read(auth_key_path).context("missing apple developer auth key")?; 68 79 let key = EncodingKey::from_ec_pem(&auth_key) 69 80 .context("failed to parse apple developer auth key")?; 70 81 let user_token = env::var("APPLE_USER_TOKEN").context("missing apple user token")?; 71 82 72 83 Ok(Self { 84 + cache, 73 85 http_client: Client::new(), 74 86 key_id, 75 87 team_id, ··· 109 121 name: track.attributes.name.clone(), 110 122 image: artwork_url, 111 123 context, 124 + url: track.attributes.url.clone(), 112 125 }) 113 126 } 114 127 128 + #[instrument(name = "apple_music_try_cached_fetch", skip(self))] 129 + pub fn try_cached_fetch(self: Arc<Self>) -> Option<Media> { 130 + try_cache_or_fetch(&self.cache.clone(), TTL, async move || self.fetch().await) 131 + } 132 + 133 + #[instrument(name = "apple_music_cached_fetch", skip(self))] 134 + pub async fn cached_fetch(self: Arc<Self>) -> Option<Media> { 135 + cache_or_fetch(&self.cache.clone(), TTL, async move || self.fetch().await).await 136 + } 137 + 115 138 pub fn build_developer_token(&self) -> anyhow::Result<String> { 116 139 let mut header = Header::new(Algorithm::ES256); 117 140 header.kid = Some(self.key_id.clone()); ··· 121 144 .context("failed to encode apple developer JWT") 122 145 } 123 146 } 124 - 125 - #[once(time = 30, option = false)] 126 - pub async fn cached_fetch(this: &AppleMusicClient) -> Option<Media> { 127 - this.fetch() 128 - .await 129 - .map_err(|error| tracing::warn!(?error, "failed to call Apple Music")) 130 - .ok() 131 - }
+33 -9
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; 7 + use reqwest::Url; 5 8 use scraper::{Html, Selector}; 9 + use tokio::sync::RwLock; 10 + use tracing::instrument; 6 11 7 - use super::Media; 12 + use super::{ 13 + Media, 14 + cached::{MediaCache, cache_or_fetch, try_cache_or_fetch}, 15 + }; 8 16 9 17 pub async fn fetch() -> anyhow::Result<Media> { 10 18 static FIRST_ENTRY_SEL: LazyLock<Selector> = ··· 14 22 static IMAGE_SEL: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".card-img").unwrap()); 15 23 static PLATFORM_SEL: LazyLock<Selector> = 16 24 LazyLock::new(|| Selector::parse(".journal-platform").unwrap()); 25 + static URL_SEL: LazyLock<Selector> = 26 + LazyLock::new(|| Selector::parse("a:has(.fa-arrow-right)").unwrap()); 17 27 18 - let html = reqwest::get("https://backloggd.com/u/cherryfunk/journal") 28 + let page_url = Url::parse("https://backloggd.com/u/cherryfunk/journal") 29 + .context("wrote invalid Backloggd URL")?; 30 + let html = reqwest::get(page_url.clone()) 19 31 .await 20 32 .context("failed to fetch Backloggd page")? 21 33 .text() ··· 50 62 .next() 51 63 .context("platform element didn't have any text")? 52 64 .to_owned(); 65 + let url = first_entry 66 + .select(&URL_SEL) 67 + .next() 68 + .context("couldn't find log URL element")? 69 + .attr("href") 70 + .context("log anchor didn't have a URL")? 71 + .to_owned(); 72 + let url = page_url.join(&url).context("log URL was invalid")?; 53 73 54 74 Ok(Media { 55 75 name, 56 76 image, 57 77 context: platform, 78 + url: url.into(), 58 79 }) 59 80 } 60 81 61 - #[once(time = 300, option = false)] 82 + static CACHE: LazyLock<MediaCache> = LazyLock::new(|| Arc::new(RwLock::new(None))); 83 + static TTL: Duration = Duration::from_secs(300); 84 + #[instrument(name = "backlogged_try_cached_fetch")] 85 + pub fn try_cached_fetch() -> Option<Media> { 86 + try_cache_or_fetch(&CACHE, TTL, fetch) 87 + } 88 + #[instrument(name = "backlogged_cached_fetch")] 62 89 pub async fn cached_fetch() -> Option<Media> { 63 - fetch() 64 - .await 65 - .map_err(|error| tracing::warn!(?error, "failed to scrape Backloggd")) 66 - .ok() 90 + cache_or_fetch(&CACHE, TTL, fetch).await 67 91 }
+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 + }
+45 -10
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; 11 + use tracing::instrument; 8 12 9 - use super::Media; 13 + use super::{ 14 + Media, 15 + cached::{MediaCache, cache_or_fetch, try_cache_or_fetch}, 16 + }; 10 17 11 18 #[derive(Deserialize, Debug, Clone)] 12 19 pub struct ImageUrlMetadata { ··· 17 24 name: String, 18 25 image_url: Url, 19 26 rating: Option<u8>, 27 + url: String, 20 28 } 21 29 30 + // CloudFlare's bot detection seems to be more generous towards user agents that don't include 31 + // known HTTP clients, like reqwest or curl. 32 + const USER_AGENT: &str = "myivo/1.0.0"; 33 + 22 34 pub async fn fetch() -> anyhow::Result<Media> { 23 - let client = Client::new(); 35 + let client = Client::builder() 36 + .user_agent(USER_AGENT) 37 + .build() 38 + .context("failed to build client")?; 39 + let page_url = Url::parse("https://letterboxd.com/ivom/films/diary/") 40 + .context("wrote invalid Letterboxd URL")?; 24 41 let html = client 25 - .get("https://letterboxd.com/ivom/films/diary/") 42 + .get(page_url.clone()) 43 + // including this header seems to contribute to getting past CloudFlare's bot detection. 44 + .header("priority", "u=0, i") 26 45 .send() 27 46 .await 28 47 .context("failed to fetch Letterboxd page")? ··· 33 52 name, 34 53 image_url, 35 54 rating, 55 + url, 36 56 } = parse_html(&html)?; 37 57 38 58 let image_url_data: ImageUrlMetadata = client ··· 51 71 ), 52 72 None => "no rating".to_owned(), 53 73 }; 74 + let url = page_url.join(&url).context("film URL was invalid")?; 54 75 55 76 Ok(Media { 56 77 name, 57 78 image: image_url_data.url, 58 79 context: formatted_rating, 80 + url: url.into(), 59 81 }) 60 82 } 61 83 ··· 66 88 static POSTER_COMPONENT_SEL: LazyLock<Selector> = 67 89 LazyLock::new(|| Selector::parse(".react-component:has(> .poster)").unwrap()); 68 90 static RATING_SEL: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".rating").unwrap()); 91 + static URL_SEL: LazyLock<Selector> = 92 + LazyLock::new(|| Selector::parse(".inline-production-masthead .name a").unwrap()); 69 93 70 94 let document = Html::parse_document(html); 71 95 ··· 93 117 .classes() 94 118 .find_map(|class| class.strip_prefix("rated-")) 95 119 .and_then(|rating| rating.parse().ok()); 120 + let url = first_entry 121 + .select(&URL_SEL) 122 + .next() 123 + .context("couldn't find film URL element")? 124 + .attr("href") 125 + .context("film URL element didn't have a URL")? 126 + .to_owned(); 96 127 97 128 let image_url = build_image_url(poster_component)?; 98 129 ··· 100 131 name, 101 132 image_url, 102 133 rating, 134 + url, 103 135 }) 104 136 } 105 137 ··· 119 151 Ok(image_url) 120 152 } 121 153 122 - #[once(time = 1800, option = false)] 154 + static CACHE: LazyLock<MediaCache> = LazyLock::new(|| Arc::new(RwLock::new(None))); 155 + static TTL: Duration = Duration::from_secs(1800); 156 + #[instrument(name = "letterboxd_try_cached_fetch")] 157 + pub fn try_cached_fetch() -> Option<Media> { 158 + try_cache_or_fetch(&CACHE, TTL, fetch) 159 + } 160 + #[instrument(name = "letterboxd_cached_fetch")] 123 161 pub async fn cached_fetch() -> Option<Media> { 124 - fetch() 125 - .await 126 - .map_err(|error| tracing::warn!(?error, "failed to scrape Letterboxd")) 127 - .ok() 162 + cache_or_fetch(&CACHE, TTL, fetch).await 128 163 }
+15
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, 8 13 pub image: String, 9 14 pub context: String, 15 + pub url: 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, 10 25 }
+16
server/src/templates/am_auth_flow.rs
··· 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")] 7 + pub struct AuthFlowTemplate { 8 + token: String, 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 + }
+92
server/src/templates/index.rs
··· 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; 7 + use serde::Deserialize; 8 + 9 + #[derive(Deserialize)] 10 + pub struct IndexOptions { 11 + #[cfg(debug_assertions)] 12 + #[serde(default)] 13 + mock: bool, 14 + } 15 + 16 + type MediaList = [(MediaType, Option<Media>); 3]; 17 + 18 + #[derive(Debug, Clone)] 19 + pub struct Shas { 20 + pub website: Option<String>, 21 + } 22 + 23 + #[derive(Template, Debug, Clone)] 24 + #[template(path = "index.html")] 25 + pub struct RootTemplate { 26 + media: MediaList, 27 + consumption_verb: &'static str, 28 + shas: Shas, 29 + } 30 + 31 + impl RootTemplate { 32 + pub fn new( 33 + apple_music_client: Arc<AppleMusicClient>, 34 + shas: Shas, 35 + #[allow(unused_variables)] options: &IndexOptions, 36 + ) -> RootTemplate { 37 + #[cfg(debug_assertions)] 38 + let media = if options.mock { 39 + mocked_media() 40 + } else { 41 + Self::fetch_media(apple_music_client) 42 + }; 43 + #[cfg(not(debug_assertions))] 44 + let media = Self::fetch_media(apple_music_client); 45 + 46 + let consumption_verb = Self::random_consumption_verb(); 47 + 48 + RootTemplate { 49 + media, 50 + consumption_verb, 51 + shas, 52 + } 53 + } 54 + 55 + fn fetch_media(apple_music_client: Arc<AppleMusicClient>) -> MediaList { 56 + [ 57 + (MediaType::Game, backloggd::try_cached_fetch()), 58 + (MediaType::Film, letterboxd::try_cached_fetch()), 59 + (MediaType::Song, apple_music_client.try_cached_fetch()), 60 + ] 61 + } 62 + 63 + fn random_consumption_verb() -> &'static str { 64 + static CONSUMPTION_VERBS: &[&str] = &["swallowed", "inhaled", "digested", "ingested"]; 65 + 66 + CONSUMPTION_VERBS.choose(&mut rand::rng()).unwrap() 67 + } 68 + } 69 + 70 + #[cfg(debug_assertions)] 71 + fn mocked_media() -> MediaList { 72 + [ 73 + (MediaType::Game, Some(Media { 74 + name: "Cyberpunk 2077: Ultimate Edition".to_owned(), 75 + image: "https://images.igdb.com/igdb/image/upload/t_cover_big/co7iy1.jpg".to_owned(), 76 + context: "Nintendo Switch 2".to_owned(), 77 + url: "https://backloggd.com/u/cherryfunk/logs/cyberpunk-2077-ultimate-edition/".to_owned() 78 + })), 79 + (MediaType::Film, Some(Media { 80 + name: "The Thursday Murder Club".to_owned(), 81 + 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(), 82 + context: "1 star".to_owned(), 83 + url: "https://letterboxd.com/ivom/film/the-thursday-murder-club/".to_owned() 84 + })), 85 + (MediaType::Song, Some(Media { 86 + name: "We Might Feel Unsound".to_owned(), 87 + 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(), 88 + context: "James Blake โ€” James Blake".to_owned(), 89 + url: "https://music.apple.com/gb/album/we-might-feel-unsound/1443124478?i=1443125024".to_owned() 90 + })), 91 + ] 92 + }
+23
server/src/templates/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 + }
+4
server/src/templates.rs
··· 1 + #[cfg(debug_assertions)] 2 + pub mod am_auth_flow; 3 + pub mod index; 4 + pub mod media;
+67 -19
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"> 12 12 <div class="bg-gray-950 text-center"> 13 - <h1 14 - class="font-serif text-4xl text-gray-200 motion-safe:animate-marquee sm:text-6xl md:text-8xl" 15 - > 16 - <span class="text-pink-700 motion-safe:animate-glow">cherry</span 17 - >.computer 13 + <h1 class="font-serif text-4xl text-gray-200 sm:text-6xl md:text-8xl"> 14 + <span class="text-pink-700">cherry</span>.computer 18 15 </h1> 19 16 </div> 20 - <div class="flex w-full max-w-xl flex-col self-center"> 21 - {% for media in media -%} 22 - <div class="flex odd:flex-row even:flex-row-reverse"> 23 - <img 24 - class="m-3 h-50 rounded-xs object-contain" 25 - src="{{ media.image }}" 26 - alt="Cover art for {{ media.name }}" 27 - /> 28 - <div class="flex flex-col self-center"> 29 - <p class="text-2xl text-white">{{ media.name }}</p> 30 - <p class="text-xl text-gray-700 italic">{{ media.context }}</p> 31 - </div> 17 + <div 18 + class="flex w-full max-w-2xl flex-col items-center gap-4 self-center p-4" 19 + > 20 + <div class="max-w-xl text-2xl text-pink-100 uppercase"> 21 + <p class="my-2"> 22 + Welcome to the intersection of product and politics. 23 + </p> 24 + <p class="my-2"> 25 + Where we synthesise the contradictions of consumption and communism. 26 + </p> 27 + <p class="my-2">Where we reason about both art and anarchism.</p> 28 + <p class="mt-2"> 29 + Where I jokingly gesture towards my neurotic connection between 30 + engaging with media products and life fulfillment, but fail to 31 + interrogate the apparent complex further. 32 + </p> 32 33 </div> 33 - {%- endfor %} 34 + <h2 class="self-start text-2xl text-pink-50"> 35 + Here is what I've {{ consumption_verb }} most recently: 36 + </h2> 37 + <div 38 + class="grid gap-x-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-serif text-3xl text-pink-50">Free Palestine ๐Ÿ‡ต๐Ÿ‡ธ</p> 56 + </div> 57 + <div class="bg-gray-800"> 58 + <div 59 + class="flex w-full justify-around px-5 py-2 leading-5 text-pink-50 italic" 60 + > 61 + <p> 62 + running on 63 + <a 64 + href="https://tangled.org/cherry.computer/nixos" 65 + target="_blank" 66 + class="text-pink-300 hover:text-pink-500" 67 + >NixOS</a 68 + > 69 + </p> 70 + {% if let Some(website_rev) = shas.website %} 71 + <p> 72 + site revision 73 + <a 74 + href="https://tangled.org/cherry.computer/website/tree/{{website_rev}}" 75 + target="_blank" 76 + class="text-pink-300 hover:text-pink-500" 77 + >{{ website_rev | fmt("{:.8}") }}</a 78 + > 79 + </p> 80 + {% endif %} 81 + </div> 34 82 </div> 35 83 </div> 36 84 </body>
+26
server/templates/media.html
··· 1 + <a 2 + href="{{ media.url }}" 3 + target="_blank" 4 + class="peer/{{ media_type }} relative aspect-square max-h-50 justify-self-center hoverable:max-h-none" 5 + > 6 + <img 7 + class="absolute inset-0 aspect-square w-full rounded-xs object-fill" 8 + aria-hidden="true" 9 + src="{{ media.image }}" 10 + /> 11 + <img 12 + class="relative aspect-square w-full rounded-xs object-contain backdrop-blur-sm transition-transform hover:scale-116 hover:rounded-lg hover:shadow-lg" 13 + src="{{ media.image }}" 14 + alt="Cover art for {{ media.name }}" 15 + /> 16 + </a> 17 + <a 18 + href="{{ media.url }}" 19 + target="_blank" 20 + id="media-description-{{ media_type }}" 21 + class="mx-2 pt-4 flex flex-col self-center peer-hover/{{ media_type }}:block hover:block hoverable:col-span-full hoverable:row-2 hoverable:hidden" 22 + hx-swap-oob="true" 23 + > 24 + <p class="text-2xl text-white">{{ media.name }}</p> 25 + <p class="text-xl text-gray-700 italic">{{ media.context }}</p> 26 + </a>