tangled
alpha
login
or
join now
cherry.computer
/
website
My personal site
cherry.computer
htmx
tailwind
axum
askama
0
fork
atom
overview
issues
pulls
pipelines
Compare changes
Choose any two refs to compare.
base:
main
no tags found
compare:
main
no tags found
go
+1101
-233
24 changed files
expand all
collapse all
unified
split
.gitignore
Dockerfile
fly.toml
frontend
esbuild.js
package.json
src
css
tailwind.css
justfile
package-lock.json
server
Cargo.lock
Cargo.toml
src
index.rs
main.rs
scrapers
apple_music.rs
backloggd.rs
cached.rs
letterboxd.rs
scrapers.rs
templates
am_auth_flow.rs
index.rs
media.rs
templates.rs
templates
am-auth-flow.html
index.html
media.html
+2
.gitignore
···
1
node_modules
0
0
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"]
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
-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"
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
+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 }
0
0
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");
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
8
9
+
@custom-variant hoverable (@media (hover: hover));
0
0
0
0
0
0
0
0
0
0
0
+7
-5
justfile
···
0
0
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
}
0
0
0
0
0
0
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"
0
0
0
0
0
0
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
}
0
0
0
0
0
0
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
0
0
0
0
0
4916
},
4917
"ignore": {
4918
"version": "5.3.2",
+230
-2
server/Cargo.lock
···
327
]
328
329
[[package]]
0
0
0
0
0
0
0
0
0
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",
0
526
"libc",
527
"wasi 0.11.1+wasi-snapshot-preview1",
0
528
]
529
530
[[package]]
···
569
version = "0.15.5"
570
source = "registry+https://github.com/rust-lang/crates.io-index"
571
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
0
0
0
0
0
0
572
573
[[package]]
574
name = "html5ever"
···
885
]
886
887
[[package]]
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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",
0
0
1010
"reqwest",
1011
"scraper",
0
0
1012
"tokio",
1013
"tower",
1014
"tower-http",
···
1050
]
1051
1052
[[package]]
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1053
name = "object"
1054
version = "0.36.7"
1055
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1138
]
1139
1140
[[package]]
0
0
0
0
0
0
0
0
0
0
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]]
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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",
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1262
]
1263
1264
[[package]]
···
1266
version = "0.6.4"
1267
source = "registry+https://github.com/rust-lang/crates.io-index"
1268
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
0
0
0
0
0
0
0
0
0
1269
1270
[[package]]
1271
name = "redox_syscall"
···
1560
]
1561
1562
[[package]]
0
0
0
0
0
0
0
0
0
0
0
0
1563
name = "siphasher"
1564
version = "1.0.1"
1565
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1619
]
1620
1621
[[package]]
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1622
name = "subtle"
1623
version = "2.6.1"
1624
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1701
]
1702
1703
[[package]]
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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",
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1710
]
1711
1712
[[package]]
···
2266
"quote",
2267
"syn",
2268
"synstructure",
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
0
7
8
[dependencies]
9
anyhow = "1.0.57"
10
askama = "0.14.0"
11
axum = "0.8.1"
12
-
reqwest = "0.12.23"
0
0
13
scraper = "0.24.0"
0
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
-
}
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
+73
-19
server/src/main.rs
···
1
-
mod index;
2
mod scrapers;
0
3
4
-
use std::net::SocketAddr;
5
6
-
use crate::index::RootTemplate;
0
0
0
0
0
0
7
8
use askama::Template;
9
use axum::{
10
Router,
0
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
0
0
0
0
0
0
21
#[tokio::main]
22
async fn main() -> anyhow::Result<()> {
23
tracing_subscriber::fmt::init();
24
0
0
0
0
0
0
0
0
0
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
-
);
0
0
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;
0
0
0
48
template.render().map(Html).map_err(|err| {
49
tracing::error!("failed to render index: {err:?}");
50
StatusCode::INTERNAL_SERVER_ERROR
51
})
52
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
0
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,
0
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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;
0
0
0
2
3
use anyhow::Context;
0
4
use scraper::{Html, Selector};
0
0
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());
0
0
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);
0
0
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();
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
48
49
-
Ok(Self { name, image })
50
-
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
+
};
0
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
1
pub mod backloggd;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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 %}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
0
0
0
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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>