My personal site cherry.computer
htmx tailwind axum askama

Compare changes

Choose any two refs to compare.

+1101 -233
+2
.gitignore
··· 1 node_modules 2 /frontend/fonts 3 /frontend/build 4 /frontend/src/css/tailwind-out.css
··· 1 node_modules 2 + .env 3 + keys/ 4 /frontend/fonts 5 /frontend/build 6 /frontend/src/css/tailwind-out.css
-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"]
···
-41
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 - [experimental] 13 - allowed_public_ports = [] 14 - auto_rollback = true 15 - 16 - [[services]] 17 - http_checks = [] 18 - internal_port = 8080 19 - processes = ["app"] 20 - protocol = "tcp" 21 - script_checks = [] 22 - 23 - [services.concurrency] 24 - hard_limit = 25 25 - soft_limit = 20 26 - type = "connections" 27 - 28 - [[services.ports]] 29 - force_https = true 30 - handlers = ["http"] 31 - port = 80 32 - 33 - [[services.ports]] 34 - handlers = ["tls", "http"] 35 - port = 443 36 - 37 - [[services.tcp_checks]] 38 - grace_period = "1s" 39 - interval = "15s" 40 - restart_limit = 0 41 - timeout = "2s"
···
+4 -2
frontend/esbuild.js
··· 50 }; 51 const url = new URL(`http://localhost${req.url}`); 52 const route = 53 - url.pathname === "/" || url.pathname === "/scrobbles" 54 - ? { hostname: "127.0.0.1", port: 8080 } 55 : { hostname: hosts[0], port }; 56 const routedOptions = { ...options, ...route }; 57
··· 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: 53465 } 57 : { hostname: hosts[0], port }; 58 const routedOptions = { ...options, ...route }; 59
+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": {
+4 -32
frontend/src/css/tailwind.css
··· 2 @source "../../../server/templates"; 3 @source "."; 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 - } 25 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 - }
··· 2 @source "../../../server/templates"; 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));
+7 -5
justfile
··· 1 [parallel] 2 serve: serve-js serve-rs 3 4 - [working-directory: 'frontend'] 5 serve-js: 6 - npm start 7 8 - [working-directory: 'server'] 9 - serve-rs $RUST_LOG=env('RUST_LOG', 'debug,selectors=warn,html5ever=warn'): 10 - watchexec --restart --ignore "target/**" cargo run
··· 1 + set dotenv-load := true 2 + 3 [parallel] 4 serve: serve-js serve-rs 5 6 + [working-directory('frontend')] 7 serve-js: 8 + watchexec --restart --watch esbuild.js npm start 9 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 "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",
+230 -2
server/Cargo.lock
··· 327 ] 328 329 [[package]] 330 name = "derive_more" 331 version = "2.0.1" 332 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 523 checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 524 dependencies = [ 525 "cfg-if", 526 "libc", 527 "wasi 0.11.1+wasi-snapshot-preview1", 528 ] 529 530 [[package]] ··· 569 version = "0.15.5" 570 source = "registry+https://github.com/rust-lang/crates.io-index" 571 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 572 573 [[package]] 574 name = "html5ever" ··· 885 ] 886 887 [[package]] 888 name = "lazy_static" 889 version = "1.5.0" 890 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1007 "anyhow", 1008 "askama", 1009 "axum", 1010 "reqwest", 1011 "scraper", 1012 "tokio", 1013 "tower", 1014 "tower-http", ··· 1050 ] 1051 1052 [[package]] 1053 name = "object" 1054 version = "0.36.7" 1055 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1138 ] 1139 1140 [[package]] 1141 name = "percent-encoding" 1142 version = "2.3.2" 1143 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1170 checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" 1171 dependencies = [ 1172 "phf_shared", 1173 - "rand", 1174 ] 1175 1176 [[package]] ··· 1223 ] 1224 1225 [[package]] 1226 name = "precomputed-hash" 1227 version = "0.1.1" 1228 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1258 source = "registry+https://github.com/rust-lang/crates.io-index" 1259 checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1260 dependencies = [ 1261 - "rand_core", 1262 ] 1263 1264 [[package]] ··· 1266 version = "0.6.4" 1267 source = "registry+https://github.com/rust-lang/crates.io-index" 1268 checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 1269 1270 [[package]] 1271 name = "redox_syscall" ··· 1560 ] 1561 1562 [[package]] 1563 name = "siphasher" 1564 version = "1.0.1" 1565 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1619 ] 1620 1621 [[package]] 1622 name = "subtle" 1623 version = "2.6.1" 1624 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1701 ] 1702 1703 [[package]] 1704 name = "thread_local" 1705 version = "1.1.9" 1706 source = "registry+https://github.com/rust-lang/crates.io-index" 1707 checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 1708 dependencies = [ 1709 "cfg-if", 1710 ] 1711 1712 [[package]] ··· 2266 "quote", 2267 "syn", 2268 "synstructure", 2269 ] 2270 2271 [[package]]
··· 327 ] 328 329 [[package]] 330 + name = "deranged" 331 + version = "0.4.0" 332 + source = "registry+https://github.com/rust-lang/crates.io-index" 333 + checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" 334 + dependencies = [ 335 + "powerfmt", 336 + ] 337 + 338 + [[package]] 339 name = "derive_more" 340 version = "2.0.1" 341 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 532 checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 533 dependencies = [ 534 "cfg-if", 535 + "js-sys", 536 "libc", 537 "wasi 0.11.1+wasi-snapshot-preview1", 538 + "wasm-bindgen", 539 ] 540 541 [[package]] ··· 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" ··· 902 ] 903 904 [[package]] 905 + name = "jsonwebtoken" 906 + version = "9.3.1" 907 + source = "registry+https://github.com/rust-lang/crates.io-index" 908 + checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" 909 + dependencies = [ 910 + "base64", 911 + "js-sys", 912 + "pem", 913 + "ring", 914 + "serde", 915 + "serde_json", 916 + "simple_asn1", 917 + ] 918 + 919 + [[package]] 920 name = "lazy_static" 921 version = "1.5.0" 922 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", ··· 1086 ] 1087 1088 [[package]] 1089 + name = "num-bigint" 1090 + version = "0.4.6" 1091 + source = "registry+https://github.com/rust-lang/crates.io-index" 1092 + checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" 1093 + dependencies = [ 1094 + "num-integer", 1095 + "num-traits", 1096 + ] 1097 + 1098 + [[package]] 1099 + name = "num-conv" 1100 + version = "0.1.0" 1101 + source = "registry+https://github.com/rust-lang/crates.io-index" 1102 + checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 1103 + 1104 + [[package]] 1105 + name = "num-integer" 1106 + version = "0.1.46" 1107 + source = "registry+https://github.com/rust-lang/crates.io-index" 1108 + checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 1109 + dependencies = [ 1110 + "num-traits", 1111 + ] 1112 + 1113 + [[package]] 1114 + name = "num-traits" 1115 + version = "0.2.19" 1116 + source = "registry+https://github.com/rust-lang/crates.io-index" 1117 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1118 + dependencies = [ 1119 + "autocfg", 1120 + ] 1121 + 1122 + [[package]] 1123 name = "object" 1124 version = "0.36.7" 1125 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1208 ] 1209 1210 [[package]] 1211 + name = "pem" 1212 + version = "3.0.5" 1213 + source = "registry+https://github.com/rust-lang/crates.io-index" 1214 + checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" 1215 + dependencies = [ 1216 + "base64", 1217 + "serde", 1218 + ] 1219 + 1220 + [[package]] 1221 name = "percent-encoding" 1222 version = "2.3.2" 1223 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1250 checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" 1251 dependencies = [ 1252 "phf_shared", 1253 + "rand 0.8.5", 1254 ] 1255 1256 [[package]] ··· 1303 ] 1304 1305 [[package]] 1306 + name = "powerfmt" 1307 + version = "0.2.0" 1308 + source = "registry+https://github.com/rust-lang/crates.io-index" 1309 + checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 1310 + 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]] 1321 name = "precomputed-hash" 1322 version = "0.1.1" 1323 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1353 source = "registry+https://github.com/rust-lang/crates.io-index" 1354 checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1355 dependencies = [ 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", 1377 ] 1378 1379 [[package]] ··· 1381 version = "0.6.4" 1382 source = "registry+https://github.com/rust-lang/crates.io-index" 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 + ] 1393 1394 [[package]] 1395 name = "redox_syscall" ··· 1684 ] 1685 1686 [[package]] 1687 + name = "simple_asn1" 1688 + version = "0.6.3" 1689 + source = "registry+https://github.com/rust-lang/crates.io-index" 1690 + checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" 1691 + dependencies = [ 1692 + "num-bigint", 1693 + "num-traits", 1694 + "thiserror", 1695 + "time", 1696 + ] 1697 + 1698 + [[package]] 1699 name = "siphasher" 1700 version = "1.0.1" 1701 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 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" 1780 version = "2.6.1" 1781 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1858 ] 1859 1860 [[package]] 1861 + name = "thiserror" 1862 + version = "2.0.16" 1863 + source = "registry+https://github.com/rust-lang/crates.io-index" 1864 + checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" 1865 + dependencies = [ 1866 + "thiserror-impl", 1867 + ] 1868 + 1869 + [[package]] 1870 + name = "thiserror-impl" 1871 + version = "2.0.16" 1872 + source = "registry+https://github.com/rust-lang/crates.io-index" 1873 + checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" 1874 + dependencies = [ 1875 + "proc-macro2", 1876 + "quote", 1877 + "syn", 1878 + ] 1879 + 1880 + [[package]] 1881 name = "thread_local" 1882 version = "1.1.9" 1883 source = "registry+https://github.com/rust-lang/crates.io-index" 1884 checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 1885 dependencies = [ 1886 "cfg-if", 1887 + ] 1888 + 1889 + [[package]] 1890 + name = "time" 1891 + version = "0.3.41" 1892 + source = "registry+https://github.com/rust-lang/crates.io-index" 1893 + checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" 1894 + dependencies = [ 1895 + "deranged", 1896 + "itoa", 1897 + "num-conv", 1898 + "powerfmt", 1899 + "serde", 1900 + "time-core", 1901 + "time-macros", 1902 + ] 1903 + 1904 + [[package]] 1905 + name = "time-core" 1906 + version = "0.1.4" 1907 + source = "registry+https://github.com/rust-lang/crates.io-index" 1908 + checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" 1909 + 1910 + [[package]] 1911 + name = "time-macros" 1912 + version = "0.2.22" 1913 + source = "registry+https://github.com/rust-lang/crates.io-index" 1914 + checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" 1915 + dependencies = [ 1916 + "num-conv", 1917 + "time-core", 1918 ] 1919 1920 [[package]] ··· 2474 "quote", 2475 "syn", 2476 "synstructure", 2477 + ] 2478 + 2479 + [[package]] 2480 + name = "zerocopy" 2481 + version = "0.8.26" 2482 + source = "registry+https://github.com/rust-lang/crates.io-index" 2483 + checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" 2484 + dependencies = [ 2485 + "zerocopy-derive", 2486 + ] 2487 + 2488 + [[package]] 2489 + name = "zerocopy-derive" 2490 + version = "0.8.26" 2491 + source = "registry+https://github.com/rust-lang/crates.io-index" 2492 + checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" 2493 + dependencies = [ 2494 + "proc-macro2", 2495 + "quote", 2496 + "syn", 2497 ] 2498 2499 [[package]]
+7 -2
server/Cargo.toml
··· 3 version = "0.1.0" 4 edition = "2024" 5 6 - # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 8 [dependencies] 9 anyhow = "1.0.57" 10 askama = "0.14.0" 11 axum = "0.8.1" 12 - reqwest = "0.12.23" 13 scraper = "0.24.0" 14 tokio = { version = "1.18.2", features = ["full"] } 15 tower = "0.5.2" 16 tower-http = { version = "0.6.2", features = ["compression-full", "fs", "trace", "set-header"] }
··· 3 version = "0.1.0" 4 edition = "2024" 5 6 + [lints.clippy] 7 + pedantic = "warn" 8 9 [dependencies] 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"] }
-20
server/src/index.rs
··· 1 - use crate::scrapers::backloggd::Backloggd; 2 - 3 - use askama::Template; 4 - 5 - #[derive(Template, Debug, Clone)] 6 - #[template(path = "index.html")] 7 - pub struct RootTemplate { 8 - game: Option<Backloggd>, 9 - } 10 - 11 - impl RootTemplate { 12 - pub async fn new() -> RootTemplate { 13 - RootTemplate { 14 - game: Backloggd::fetch() 15 - .await 16 - .map_err(|error| tracing::warn!(%error, "failed to scrape Backloggd")) 17 - .ok(), 18 - } 19 - } 20 - }
···
+73 -19
server/src/main.rs
··· 1 - mod index; 2 mod scrapers; 3 4 - use std::net::SocketAddr; 5 6 - use crate::index::RootTemplate; 7 8 use askama::Template; 9 use axum::{ 10 Router, 11 http::{HeaderName, HeaderValue, StatusCode}, 12 response::{Html, IntoResponse}, 13 - routing::{get, get_service}, 14 }; 15 use tower::ServiceBuilder; 16 use tower_http::{ 17 - compression::CompressionLayer, services::ServeDir, set_header::SetResponseHeaderLayer, 18 - trace::TraceLayer, 19 }; 20 21 #[tokio::main] 22 async fn main() -> anyhow::Result<()> { 23 tracing_subscriber::fmt::init(); 24 25 let app = Router::new() 26 .route("/", get(render_index_handler)) 27 - .fallback(get_service(ServeDir::new("."))) 28 - .layer( 29 - ServiceBuilder::new() 30 - .layer(TraceLayer::new_for_http()) 31 - .layer(CompressionLayer::new()) 32 - .layer(SetResponseHeaderLayer::overriding( 33 - HeaderName::from_static("strict-transport-security"), 34 - HeaderValue::from_static("max-age=2592000; includeSubDomains"), 35 - )), 36 - ); 37 38 - let addr = SocketAddr::from(([0, 0, 0, 0], 8080)); 39 tracing::debug!("starting server on {addr}"); 40 let listener = tokio::net::TcpListener::bind(addr).await?; 41 axum::serve(listener, app).await?; ··· 43 Ok(()) 44 } 45 46 - async fn render_index_handler() -> impl IntoResponse { 47 - let template = RootTemplate::new().await; 48 template.render().map(Html).map_err(|err| { 49 tracing::error!("failed to render index: {err:?}"); 50 StatusCode::INTERNAL_SERVER_ERROR 51 }) 52 }
··· 1 mod scrapers; 2 + mod templates; 3 4 + use std::{env, net::SocketAddr, sync::Arc}; 5 6 + use crate::scrapers::{MediaType, apple_music::AppleMusicClient}; 7 + #[cfg(debug_assertions)] 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 + }; 13 14 use askama::Template; 15 use axum::{ 16 Router, 17 + extract::{Path, Query, State}, 18 http::{HeaderName, HeaderValue, StatusCode}, 19 response::{Html, IntoResponse}, 20 + routing::get, 21 }; 22 use tower::ServiceBuilder; 23 use tower_http::{ 24 + compression::CompressionLayer, set_header::SetResponseHeaderLayer, trace::TraceLayer, 25 }; 26 27 + #[derive(Clone)] 28 + struct AppState { 29 + apple_music_client: Arc<AppleMusicClient>, 30 + shas: Shas, 31 + } 32 + 33 #[tokio::main] 34 async fn main() -> anyhow::Result<()> { 35 tracing_subscriber::fmt::init(); 36 37 + let apple_music_client = Arc::new(AppleMusicClient::new()?); 38 + let shas = Shas { 39 + website: env::var("MYIVO_GIT_SHA").ok(), 40 + }; 41 + let state = AppState { 42 + apple_music_client, 43 + shas, 44 + }; 45 + 46 let app = Router::new() 47 .route("/", get(render_index_handler)) 48 + .route("/media/{media_type}", get(render_media_partial_handler)); 49 + #[cfg(debug_assertions)] 50 + let app = app.route("/dev/am-auth-flow", get(render_apple_music_auth_flow)); 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 + ); 60 61 + let addr = SocketAddr::from(([0, 0, 0, 0], 53465)); 62 tracing::debug!("starting server on {addr}"); 63 let listener = tokio::net::TcpListener::bind(addr).await?; 64 axum::serve(listener, app).await?; ··· 66 Ok(()) 67 } 68 69 + async fn render_index_handler( 70 + Query(options): Query<IndexOptions>, 71 + State(state): State<AppState>, 72 + ) -> impl IntoResponse { 73 + let template = RootTemplate::new(state.apple_music_client, state.shas, &options); 74 template.render().map(Html).map_err(|err| { 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:?}"); 90 + StatusCode::INTERNAL_SERVER_ERROR 91 + }) 92 + } 93 + 94 + #[cfg(debug_assertions)] 95 + async fn render_apple_music_auth_flow( 96 + #[allow(unused_variables)] State(state): State<AppState>, 97 + ) -> impl IntoResponse { 98 + let template = AuthFlowTemplate::new(&state.apple_music_client); 99 + template 100 + .and_then(|template| Ok(template.render()?)) 101 + .map(Html) 102 + .map_err(|err| { 103 + tracing::error!("failed to render Apple Music auth flow: {err:?}"); 104 + StatusCode::INTERNAL_SERVER_ERROR 105 + }) 106 + }
+146
server/src/scrapers/apple_music.rs
···
··· 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 + use tracing::instrument; 9 + 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); 16 + 17 + #[derive(Serialize, Debug, Clone)] 18 + struct Claims { 19 + iss: String, 20 + iat: u64, 21 + exp: u64, 22 + } 23 + 24 + impl Claims { 25 + fn new(issuer_id: String) -> Self { 26 + let iat = jsonwebtoken::get_current_timestamp(); 27 + Claims { 28 + iss: issuer_id, 29 + iat, 30 + exp: iat + 3600, 31 + } 32 + } 33 + } 34 + 35 + #[derive(Deserialize, Debug, Clone)] 36 + struct AppleMusicResponse { 37 + data: [AppleMusicTrack; 1], 38 + } 39 + 40 + #[derive(Deserialize, Debug, Clone)] 41 + struct AppleMusicTrack { 42 + attributes: AppleMusicTrackAttributes, 43 + } 44 + 45 + #[derive(Deserialize, Debug, Clone)] 46 + #[serde(rename_all = "camelCase")] 47 + struct AppleMusicTrackAttributes { 48 + name: String, 49 + album_name: String, 50 + artist_name: String, 51 + artwork: AppleMusicTrackArtwork, 52 + url: String, 53 + } 54 + 55 + #[derive(Deserialize, Debug, Clone)] 56 + struct AppleMusicTrackArtwork { 57 + url: String, 58 + } 59 + 60 + pub struct AppleMusicClient { 61 + cache: MediaCache, 62 + http_client: Client, 63 + key_id: String, 64 + team_id: String, 65 + key: EncodingKey, 66 + user_token: String, 67 + } 68 + 69 + impl AppleMusicClient { 70 + pub fn new() -> anyhow::Result<Self> { 71 + let cache = Arc::new(RwLock::new(None)); 72 + let key_id = 73 + env::var("APPLE_DEVELOPER_TOKEN_KEY_ID").context("missing apple developer key ID")?; 74 + let team_id = 75 + env::var("APPLE_DEVELOPER_TOKEN_TEAM_ID").context("missing apple developer team ID")?; 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")?; 79 + let key = EncodingKey::from_ec_pem(&auth_key) 80 + .context("failed to parse apple developer auth key")?; 81 + let user_token = env::var("APPLE_USER_TOKEN").context("missing apple user token")?; 82 + 83 + Ok(Self { 84 + cache, 85 + http_client: Client::new(), 86 + key_id, 87 + team_id, 88 + key, 89 + user_token, 90 + }) 91 + } 92 + 93 + pub async fn fetch(&self) -> anyhow::Result<Media> { 94 + let jwt = self.build_developer_token()?; 95 + 96 + let response: AppleMusicResponse = self 97 + .http_client 98 + .get("https://api.music.apple.com/v1/me/recent/played/tracks") 99 + .bearer_auth(jwt) 100 + .header("Music-User-Token", &self.user_token) 101 + .query(&[("types", "songs"), ("limit", "1")]) 102 + .send() 103 + .await 104 + .context("failed to call Apple Music API")? 105 + .json() 106 + .await 107 + .context("failed to parse Apple Music response")?; 108 + let track = &response.data[0]; 109 + 110 + let artwork_url = &track.attributes.artwork.url; 111 + let dimensions = "240"; 112 + let artwork_url = artwork_url 113 + .replace("{w}", dimensions) 114 + .replace("{h}", dimensions); 115 + 116 + let artist = &track.attributes.artist_name; 117 + let album = &track.attributes.album_name; 118 + let context = format!("{artist} โ€” {album}"); 119 + 120 + Ok(Media { 121 + name: track.attributes.name.clone(), 122 + image: artwork_url, 123 + context, 124 + url: track.attributes.url.clone(), 125 + }) 126 + } 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 + 138 + pub fn build_developer_token(&self) -> anyhow::Result<String> { 139 + let mut header = Header::new(Algorithm::ES256); 140 + header.kid = Some(self.key_id.clone()); 141 + let claims = Claims::new(self.team_id.clone()); 142 + 143 + jsonwebtoken::encode(&header, &claims, &self.key) 144 + .context("failed to encode apple developer JWT") 145 + } 146 + }
+82 -42
server/src/scrapers/backloggd.rs
··· 1 - use std::sync::LazyLock; 2 3 use anyhow::Context; 4 use scraper::{Html, Selector}; 5 6 - #[derive(Debug, Clone)] 7 - pub struct Backloggd { 8 - pub name: String, 9 - pub image: String, 10 - } 11 12 - impl Backloggd { 13 - pub async fn fetch() -> anyhow::Result<Self> { 14 - static FIRST_ENTRY_SEL: LazyLock<Selector> = 15 - LazyLock::new(|| Selector::parse(".journal_entry:first-child").unwrap()); 16 - static NAME_SEL: LazyLock<Selector> = 17 - LazyLock::new(|| Selector::parse(".game-name a").unwrap()); 18 - static IMAGE_SEL: LazyLock<Selector> = 19 - LazyLock::new(|| Selector::parse(".card-img").unwrap()); 20 21 - let html = reqwest::get("https://backloggd.com/u/cherryfunk/journal") 22 - .await 23 - .context("failed to fetch Backloggd page")? 24 - .text() 25 - .await 26 - .context("failed to get HTML text")?; 27 - let document = Html::parse_document(&html); 28 29 - let first_entry = document 30 - .select(&FIRST_ENTRY_SEL) 31 - .next() 32 - .context("couldn't find any journal entries")?; 33 - let name = first_entry 34 - .select(&NAME_SEL) 35 - .next() 36 - .context("couldn't find name element")? 37 - .text() 38 - .next() 39 - .context("name element didn't have any text")? 40 - .to_owned(); 41 - let image = first_entry 42 - .select(&IMAGE_SEL) 43 - .next() 44 - .context("couldn't find image element")? 45 - .attr("src") 46 - .context("image element didn't have src attribute")? 47 - .to_owned(); 48 49 - Ok(Self { name, image }) 50 - } 51 }
··· 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 + use tracing::instrument; 11 12 + use super::{ 13 + Media, 14 + cached::{MediaCache, cache_or_fetch, try_cache_or_fetch}, 15 + }; 16 17 + pub async fn fetch() -> anyhow::Result<Media> { 18 + static FIRST_ENTRY_SEL: LazyLock<Selector> = 19 + LazyLock::new(|| Selector::parse(".journal_entry:first-child").unwrap()); 20 + static NAME_SEL: LazyLock<Selector> = 21 + LazyLock::new(|| Selector::parse(".game-name a").unwrap()); 22 + static IMAGE_SEL: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".card-img").unwrap()); 23 + static PLATFORM_SEL: LazyLock<Selector> = 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()); 27 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()) 31 + .await 32 + .context("failed to fetch Backloggd page")? 33 + .text() 34 + .await 35 + .context("failed to get HTML text")?; 36 + let document = Html::parse_document(&html); 37 38 + let first_entry = document 39 + .select(&FIRST_ENTRY_SEL) 40 + .next() 41 + .context("couldn't find any journal entries")?; 42 + let name = first_entry 43 + .select(&NAME_SEL) 44 + .next() 45 + .context("couldn't find name element")? 46 + .text() 47 + .next() 48 + .context("name element didn't have any text")? 49 + .to_owned(); 50 + let image = first_entry 51 + .select(&IMAGE_SEL) 52 + .next() 53 + .context("couldn't find image element")? 54 + .attr("src") 55 + .context("image element didn't have src attribute")? 56 + .to_owned(); 57 + let platform = first_entry 58 + .select(&PLATFORM_SEL) 59 + .next() 60 + .context("couldn't find platform element")? 61 + .text() 62 + .next() 63 + .context("platform element didn't have any text")? 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")?; 73 74 + Ok(Media { 75 + name, 76 + image, 77 + context: platform, 78 + url: url.into(), 79 + }) 80 + } 81 + 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")] 89 + pub async fn cached_fetch() -> Option<Media> { 90 + cache_or_fetch(&CACHE, TTL, fetch).await 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 + }
+163
server/src/scrapers/letterboxd.rs
···
··· 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 + use tracing::instrument; 12 + 13 + use super::{ 14 + Media, 15 + cached::{MediaCache, cache_or_fetch, try_cache_or_fetch}, 16 + }; 17 + 18 + #[derive(Deserialize, Debug, Clone)] 19 + pub struct ImageUrlMetadata { 20 + url: String, 21 + } 22 + 23 + struct Extracted { 24 + name: String, 25 + image_url: Url, 26 + rating: Option<u8>, 27 + url: String, 28 + } 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 + 34 + pub async fn fetch() -> anyhow::Result<Media> { 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")?; 41 + let html = client 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") 45 + .send() 46 + .await 47 + .context("failed to fetch Letterboxd page")? 48 + .text() 49 + .await 50 + .context("failed to get HTML text")?; 51 + let Extracted { 52 + name, 53 + image_url, 54 + rating, 55 + url, 56 + } = parse_html(&html)?; 57 + 58 + let image_url_data: ImageUrlMetadata = client 59 + .get(image_url.clone()) 60 + .send() 61 + .await 62 + .with_context(|| format!("failed to fetch image metadata from URL {image_url}"))? 63 + .json() 64 + .await 65 + .context("failed to parse image metadata")?; 66 + let formatted_rating = match rating { 67 + Some(rating) => format!( 68 + "{} {}", 69 + f32::from(rating) / 2.0, 70 + if rating == 2 { "star" } else { "stars" } 71 + ), 72 + None => "no rating".to_owned(), 73 + }; 74 + let url = page_url.join(&url).context("film URL was invalid")?; 75 + 76 + Ok(Media { 77 + name, 78 + image: image_url_data.url, 79 + context: formatted_rating, 80 + url: url.into(), 81 + }) 82 + } 83 + 84 + fn parse_html(html: &str) -> anyhow::Result<Extracted> { 85 + static FIRST_ENTRY_SEL: LazyLock<Selector> = 86 + LazyLock::new(|| Selector::parse(".diary-entry-row:first-child").unwrap()); 87 + static NAME_SEL: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".name").unwrap()); 88 + static POSTER_COMPONENT_SEL: LazyLock<Selector> = 89 + LazyLock::new(|| Selector::parse(".react-component:has(> .poster)").unwrap()); 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()); 93 + 94 + let document = Html::parse_document(html); 95 + 96 + let first_entry = document 97 + .select(&FIRST_ENTRY_SEL) 98 + .next() 99 + .context("couldn't find any journal entries")?; 100 + let name = first_entry 101 + .select(&NAME_SEL) 102 + .next() 103 + .context("couldn't find name element")? 104 + .text() 105 + .next() 106 + .context("name element didn't have any text")? 107 + .to_owned(); 108 + let poster_component = first_entry 109 + .select(&POSTER_COMPONENT_SEL) 110 + .next() 111 + .context("couldn't find post component")?; 112 + let rating = first_entry 113 + .select(&RATING_SEL) 114 + .next() 115 + .context("couldn't find rating component")? 116 + .value() 117 + .classes() 118 + .find_map(|class| class.strip_prefix("rated-")) 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(); 127 + 128 + let image_url = build_image_url(poster_component)?; 129 + 130 + Ok(Extracted { 131 + name, 132 + image_url, 133 + rating, 134 + url, 135 + }) 136 + } 137 + 138 + fn build_image_url(poster_component: ElementRef) -> anyhow::Result<Url> { 139 + let film_path = poster_component 140 + .attr("data-item-link") 141 + .context("poster component didn't have an image URL path")?; 142 + let cache_key = poster_component.attr("data-cache-busting-key"); 143 + let image_size = 230; 144 + let image_url = format!("https://letterboxd.com{film_path}/poster/std/{image_size}/",); 145 + let mut image_url = 146 + Url::parse(&image_url).with_context(|| format!("failed to parse URL {image_url}"))?; 147 + if let Some(cache_key) = cache_key { 148 + image_url.query_pairs_mut().append_pair("k", cache_key); 149 + } 150 + 151 + Ok(image_url) 152 + } 153 + 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")] 161 + pub async fn cached_fetch() -> Option<Media> { 162 + cache_or_fetch(&CACHE, TTL, fetch).await 163 + }
+24
server/src/scrapers.rs
··· 1 pub mod backloggd;
··· 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, 13 + pub image: String, 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, 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;
+23
server/templates/am-auth-flow.html
···
··· 1 + <!doctype html> 2 + <html> 3 + <head> 4 + <title>cherry.computer's Apple Music Sign-In</title> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <meta name="apple-music-developer-token" content={{ token }} /> 7 + <meta name="apple-music-app-name" content="cherry.computer sign-in" /> 8 + <meta name="apple-music-app-build" content="1.0" /> 9 + <script src="https://js-cdn.music.apple.com/musickit/v3/musickit.js" async></script> 10 + <script> 11 + document.addEventListener('musickitloaded', async function () { 12 + const music = MusicKit.getInstance(); 13 + const userToken = await music.authorize(); 14 + 15 + const codeEl = document.createElement("code"); 16 + codeEl.textContent = userToken; 17 + document.body.appendChild(codeEl); 18 + }); 19 + </script> 20 + </head> 21 + <body> 22 + </body> 23 + </html>
+69 -15
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"> 12 - <div class="flex justify-center bg-gray-950"> 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 18 </h1> 19 </div> 20 - <div class="flex justify-center"> 21 - {% if let Some(game) = game -%} 22 - <img 23 - class="p-3" 24 - src="{{ game.image }}" 25 - alt="Cover art for {{ game.name }}" 26 - /> 27 - {%- endif %} 28 </div> 29 </div> 30 </body>
··· 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"> 12 + <div class="bg-gray-950 text-center"> 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 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 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> 33 + </div> 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> 82 </div> 83 </div> 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>