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
+584
-462
23 changed files
expand all
collapse all
unified
split
Dockerfile
fly.toml
frontend
esbuild.js
package.json
src
css
tailwind.css
justfile
package-lock.json
server
Cargo.lock
Cargo.toml
src
am_auth_flow.rs
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
index.html
media.html
-39
Dockerfile
···
1
1
-
FROM node:24 AS build-js
2
2
-
3
3
-
WORKDIR /usr/src/myivo
4
4
-
5
5
-
COPY frontend/package*.json .
6
6
-
RUN npm install
7
7
-
8
8
-
COPY frontend .
9
9
-
10
10
-
# tailwind classes are in the backend's HTML template files
11
11
-
COPY server/templates templates
12
12
-
RUN sed -i "s|../../../server/templates|../../templates|" src/css/tailwind.css
13
13
-
14
14
-
RUN npm run build:production
15
15
-
16
16
-
FROM rust:1.89 AS builder-rs
17
17
-
18
18
-
WORKDIR /usr/src/myivo-server
19
19
-
COPY server .
20
20
-
21
21
-
# point to minimised, production versions of build artefacts
22
22
-
RUN sed -i "s|build/app|build/app.min|g" templates/index.html
23
23
-
RUN cargo install --profile release --locked --path .
24
24
-
25
25
-
# run on different image
26
26
-
FROM debian:trixie-slim
27
27
-
28
28
-
RUN apt-get update \
29
29
-
&& apt-get install -y openssl ca-certificates \
30
30
-
&& rm -rf /var/lib/apt/lists/*
31
31
-
32
32
-
WORKDIR /root
33
33
-
34
34
-
COPY --from=build-js /usr/src/myivo/build ./build
35
35
-
COPY --from=builder-rs /usr/local/cargo/bin/myivo-server /usr/local/bin/
36
36
-
37
37
-
EXPOSE 8080
38
38
-
39
39
-
CMD ["myivo-server"]
-45
fly.toml
···
1
1
-
# fly.toml file generated for myivo on 2022-05-23T09:56:13+01:00
2
2
-
3
3
-
app = "myivo"
4
4
-
5
5
-
kill_signal = "SIGINT"
6
6
-
kill_timeout = 5
7
7
-
processes = []
8
8
-
9
9
-
[env]
10
10
-
RUST_LOG = "debug"
11
11
-
12
12
-
[[files]]
13
13
-
guest_path = "/root/keys/AuthKey.p8"
14
14
-
secret_name = "APPLE_DEVELOPER_TOKEN_AUTH_KEY"
15
15
-
16
16
-
[experimental]
17
17
-
allowed_public_ports = []
18
18
-
auto_rollback = true
19
19
-
20
20
-
[[services]]
21
21
-
http_checks = []
22
22
-
internal_port = 8080
23
23
-
processes = ["app"]
24
24
-
protocol = "tcp"
25
25
-
script_checks = []
26
26
-
27
27
-
[services.concurrency]
28
28
-
hard_limit = 25
29
29
-
soft_limit = 20
30
30
-
type = "connections"
31
31
-
32
32
-
[[services.ports]]
33
33
-
force_https = true
34
34
-
handlers = ["http"]
35
35
-
port = 80
36
36
-
37
37
-
[[services.ports]]
38
38
-
handlers = ["tls", "http"]
39
39
-
port = 443
40
40
-
41
41
-
[[services.tcp_checks]]
42
42
-
grace_period = "1s"
43
43
-
interval = "15s"
44
44
-
restart_limit = 0
45
45
-
timeout = "2s"
+4
-2
frontend/esbuild.js
···
50
50
};
51
51
const url = new URL(`http://localhost${req.url}`);
52
52
const route =
53
53
-
url.pathname === "/" || url.pathname === "/dev/am-auth-flow"
54
54
-
? { hostname: "127.0.0.1", port: 8080 }
53
53
+
url.pathname === "/" ||
54
54
+
url.pathname === "/dev/am-auth-flow" ||
55
55
+
url.pathname.startsWith("/media/")
56
56
+
? { hostname: "127.0.0.1", port: 53465 }
55
57
: { hostname: hosts[0], port };
56
58
const routedOptions = { ...options, ...route };
57
59
+1
-1
frontend/package.json
···
32
32
"url": "https://github.com/ivomurrell/myivo.git"
33
33
},
34
34
"dependencies": {
35
35
-
"htmx.org": "^2.0.6",
35
35
+
"htmx.org": "^2.0.8",
36
36
"tailwindcss": "^4.1.12"
37
37
},
38
38
"volta": {
+4
-32
frontend/src/css/tailwind.css
···
2
2
@source "../../../server/templates";
3
3
@source ".";
4
4
5
5
-
@theme {
6
6
-
--animate-marquee: marquee-start 1s linear 2, marquee-end 1s 2s ease-out;
7
7
-
--animate-glow: glow 0.5s 3.12s backwards ease-in;
8
8
-
9
9
-
@keyframes marquee-start {
10
10
-
0% {
11
11
-
transform: translateX(-75vw);
12
12
-
}
13
13
-
100% {
14
14
-
transform: translateX(75vw);
15
15
-
}
16
16
-
}
17
17
-
18
18
-
@keyframes marquee-end {
19
19
-
0% {
20
20
-
transform: translateX(-75vw);
21
21
-
}
22
22
-
100% {
23
23
-
}
24
24
-
}
5
5
+
@source inline("hoverable:grid-cols-{1..3}");
6
6
+
@source inline("peer/{game,film,song}");
7
7
+
@source inline("peer-hover/{game,film,song}:block");
25
8
26
26
-
@keyframes glow {
27
27
-
0% {
28
28
-
color: var(--color-gray-200);
29
29
-
}
30
30
-
50% {
31
31
-
color: var(--color-pink-900);
32
32
-
}
33
33
-
100% {
34
34
-
color: var(--color-pink-700);
35
35
-
}
36
36
-
}
37
37
-
}
9
9
+
@custom-variant hoverable (@media (hover: hover));
+5
-5
justfile
···
3
3
[parallel]
4
4
serve: serve-js serve-rs
5
5
6
6
-
[working-directory: 'frontend']
6
6
+
[working-directory('frontend')]
7
7
serve-js:
8
8
-
watchexec --restart --watch esbuild.js npm start
8
8
+
watchexec --restart --watch esbuild.js npm start
9
9
10
10
-
[working-directory: 'server']
11
11
-
serve-rs $RUST_LOG=env('RUST_LOG', 'debug,selectors=warn,html5ever=warn'):
12
12
-
watchexec --restart --ignore "target/**" cargo run
10
10
+
[working-directory('server')]
11
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
12
+
watchexec --restart --ignore "target/**" cargo run {{ args }}
+15
-13
package-lock.json
···
45
45
"version": "1.0.0",
46
46
"license": "MIT",
47
47
"dependencies": {
48
48
-
"htmx.org": "^2.0.6",
48
48
+
"htmx.org": "^2.0.8",
49
49
"tailwindcss": "^4.1.12"
50
50
},
51
51
"devDependencies": {
···
62
62
"typescript": "^5.9.2",
63
63
"typescript-eslint": "^8.41.0"
64
64
}
65
65
+
},
66
66
+
"frontend/node_modules/htmx.org": {
67
67
+
"version": "2.0.8",
68
68
+
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz",
69
69
+
"integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==",
70
70
+
"license": "0BSD"
65
71
},
66
72
"node_modules/@esbuild/aix-ppc64": {
67
73
"version": "0.25.9",
···
2445
2451
"engines": {
2446
2452
"node": ">=8"
2447
2453
}
2448
2448
-
},
2449
2449
-
"node_modules/htmx.org": {
2450
2450
-
"version": "2.0.6",
2451
2451
-
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz",
2452
2452
-
"integrity": "sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA==",
2453
2453
-
"license": "0BSD"
2454
2454
},
2455
2455
"node_modules/ignore": {
2456
2456
"version": "5.3.2",
···
4461
4461
"eslint": "^9.34.0",
4462
4462
"eslint-config-prettier": "^10.1.8",
4463
4463
"globals": "^16.3.0",
4464
4464
-
"htmx.org": "^2.0.6",
4464
4464
+
"htmx.org": "^2.0.8",
4465
4465
"minimist": "^1.2.8",
4466
4466
"tailwindcss": "^4.1.12",
4467
4467
"typescript": "^5.9.2",
4468
4468
"typescript-eslint": "^8.41.0"
4469
4469
+
},
4470
4470
+
"dependencies": {
4471
4471
+
"htmx.org": {
4472
4472
+
"version": "2.0.8",
4473
4473
+
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz",
4474
4474
+
"integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q=="
4475
4475
+
}
4469
4476
}
4470
4477
},
4471
4478
"balanced-match": {
···
4906
4913
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
4907
4914
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
4908
4915
"dev": true
4909
4909
-
},
4910
4910
-
"htmx.org": {
4911
4911
-
"version": "2.0.6",
4912
4912
-
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz",
4913
4913
-
"integrity": "sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA=="
4914
4916
},
4915
4917
"ignore": {
4916
4918
"version": "5.3.2",
+66
-161
server/Cargo.lock
···
18
18
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
19
19
20
20
[[package]]
21
21
-
name = "ahash"
22
22
-
version = "0.8.12"
23
23
-
source = "registry+https://github.com/rust-lang/crates.io-index"
24
24
-
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
25
25
-
dependencies = [
26
26
-
"cfg-if",
27
27
-
"once_cell",
28
28
-
"version_check",
29
29
-
"zerocopy",
30
30
-
]
31
31
-
32
32
-
[[package]]
33
21
name = "alloc-no-stdlib"
34
22
version = "2.0.4"
35
23
source = "registry+https://github.com/rust-lang/crates.io-index"
···
43
31
dependencies = [
44
32
"alloc-no-stdlib",
45
33
]
46
46
-
47
47
-
[[package]]
48
48
-
name = "allocator-api2"
49
49
-
version = "0.2.21"
50
50
-
source = "registry+https://github.com/rust-lang/crates.io-index"
51
51
-
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
52
34
53
35
[[package]]
54
36
name = "anyhow"
···
114
96
"tokio",
115
97
"zstd",
116
98
"zstd-safe",
117
117
-
]
118
118
-
119
119
-
[[package]]
120
120
-
name = "async-trait"
121
121
-
version = "0.1.89"
122
122
-
source = "registry+https://github.com/rust-lang/crates.io-index"
123
123
-
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
124
124
-
dependencies = [
125
125
-
"proc-macro2",
126
126
-
"quote",
127
127
-
"syn",
128
99
]
129
100
130
101
[[package]]
···
269
240
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
270
241
271
242
[[package]]
272
272
-
name = "cached"
273
273
-
version = "0.56.0"
274
274
-
source = "registry+https://github.com/rust-lang/crates.io-index"
275
275
-
checksum = "801927ee168e17809ab8901d9f01f700cd7d8d6a6527997fee44e4b0327a253c"
276
276
-
dependencies = [
277
277
-
"ahash",
278
278
-
"async-trait",
279
279
-
"cached_proc_macro",
280
280
-
"cached_proc_macro_types",
281
281
-
"futures",
282
282
-
"hashbrown",
283
283
-
"once_cell",
284
284
-
"thiserror",
285
285
-
"tokio",
286
286
-
"web-time",
287
287
-
]
288
288
-
289
289
-
[[package]]
290
290
-
name = "cached_proc_macro"
291
291
-
version = "0.25.0"
292
292
-
source = "registry+https://github.com/rust-lang/crates.io-index"
293
293
-
checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201"
294
294
-
dependencies = [
295
295
-
"darling",
296
296
-
"proc-macro2",
297
297
-
"quote",
298
298
-
"syn",
299
299
-
]
300
300
-
301
301
-
[[package]]
302
302
-
name = "cached_proc_macro_types"
303
303
-
version = "0.1.1"
304
304
-
source = "registry+https://github.com/rust-lang/crates.io-index"
305
305
-
checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
306
306
-
307
307
-
[[package]]
308
243
name = "cc"
309
244
version = "1.2.34"
310
245
source = "registry+https://github.com/rust-lang/crates.io-index"
···
392
327
]
393
328
394
329
[[package]]
395
395
-
name = "darling"
396
396
-
version = "0.20.11"
397
397
-
source = "registry+https://github.com/rust-lang/crates.io-index"
398
398
-
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
399
399
-
dependencies = [
400
400
-
"darling_core",
401
401
-
"darling_macro",
402
402
-
]
403
403
-
404
404
-
[[package]]
405
405
-
name = "darling_core"
406
406
-
version = "0.20.11"
407
407
-
source = "registry+https://github.com/rust-lang/crates.io-index"
408
408
-
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
409
409
-
dependencies = [
410
410
-
"fnv",
411
411
-
"ident_case",
412
412
-
"proc-macro2",
413
413
-
"quote",
414
414
-
"strsim",
415
415
-
"syn",
416
416
-
]
417
417
-
418
418
-
[[package]]
419
419
-
name = "darling_macro"
420
420
-
version = "0.20.11"
421
421
-
source = "registry+https://github.com/rust-lang/crates.io-index"
422
422
-
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
423
423
-
dependencies = [
424
424
-
"darling_core",
425
425
-
"quote",
426
426
-
"syn",
427
427
-
]
428
428
-
429
429
-
[[package]]
430
330
name = "deranged"
431
331
version = "0.4.0"
432
332
source = "registry+https://github.com/rust-lang/crates.io-index"
···
535
435
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
536
436
537
437
[[package]]
538
538
-
name = "foldhash"
539
539
-
version = "0.1.5"
540
540
-
source = "registry+https://github.com/rust-lang/crates.io-index"
541
541
-
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
542
542
-
543
543
-
[[package]]
544
438
name = "foreign-types"
545
439
version = "0.3.2"
546
440
source = "registry+https://github.com/rust-lang/crates.io-index"
···
575
469
]
576
470
577
471
[[package]]
578
578
-
name = "futures"
579
579
-
version = "0.3.31"
580
580
-
source = "registry+https://github.com/rust-lang/crates.io-index"
581
581
-
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
582
582
-
dependencies = [
583
583
-
"futures-channel",
584
584
-
"futures-core",
585
585
-
"futures-io",
586
586
-
"futures-sink",
587
587
-
"futures-task",
588
588
-
"futures-util",
589
589
-
]
590
590
-
591
591
-
[[package]]
592
472
name = "futures-channel"
593
473
version = "0.3.31"
594
474
source = "registry+https://github.com/rust-lang/crates.io-index"
595
475
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
596
476
dependencies = [
597
477
"futures-core",
598
598
-
"futures-sink",
599
478
]
600
479
601
480
[[package]]
···
605
484
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
606
485
607
486
[[package]]
608
608
-
name = "futures-io"
609
609
-
version = "0.3.31"
610
610
-
source = "registry+https://github.com/rust-lang/crates.io-index"
611
611
-
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
612
612
-
613
613
-
[[package]]
614
487
name = "futures-sink"
615
488
version = "0.3.31"
616
489
source = "registry+https://github.com/rust-lang/crates.io-index"
···
629
502
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
630
503
dependencies = [
631
504
"futures-core",
632
632
-
"futures-sink",
633
505
"futures-task",
634
506
"pin-project-lite",
635
507
"pin-utils",
···
708
580
version = "0.15.5"
709
581
source = "registry+https://github.com/rust-lang/crates.io-index"
710
582
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
711
711
-
dependencies = [
712
712
-
"allocator-api2",
713
713
-
"equivalent",
714
714
-
"foldhash",
715
715
-
]
583
583
+
584
584
+
[[package]]
585
585
+
name = "heck"
586
586
+
version = "0.5.0"
587
587
+
source = "registry+https://github.com/rust-lang/crates.io-index"
588
588
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
716
589
717
590
[[package]]
718
591
name = "html5ever"
···
945
818
]
946
819
947
820
[[package]]
948
948
-
name = "ident_case"
949
949
-
version = "1.0.1"
950
950
-
source = "registry+https://github.com/rust-lang/crates.io-index"
951
951
-
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
952
952
-
953
953
-
[[package]]
954
821
name = "idna"
955
822
version = "1.1.0"
956
823
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1172
1039
"anyhow",
1173
1040
"askama",
1174
1041
"axum",
1175
1175
-
"cached",
1176
1042
"jsonwebtoken",
1043
1043
+
"rand 0.9.2",
1177
1044
"reqwest",
1178
1045
"scraper",
1179
1046
"serde",
1047
1047
+
"strum",
1180
1048
"tokio",
1181
1049
"tower",
1182
1050
"tower-http",
···
1382
1250
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
1383
1251
dependencies = [
1384
1252
"phf_shared",
1385
1385
-
"rand",
1253
1253
+
"rand 0.8.5",
1386
1254
]
1387
1255
1388
1256
[[package]]
···
1441
1309
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
1442
1310
1443
1311
[[package]]
1312
1312
+
name = "ppv-lite86"
1313
1313
+
version = "0.2.21"
1314
1314
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1315
1315
+
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
1316
1316
+
dependencies = [
1317
1317
+
"zerocopy",
1318
1318
+
]
1319
1319
+
1320
1320
+
[[package]]
1444
1321
name = "precomputed-hash"
1445
1322
version = "0.1.1"
1446
1323
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1476
1353
source = "registry+https://github.com/rust-lang/crates.io-index"
1477
1354
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
1478
1355
dependencies = [
1479
1479
-
"rand_core",
1356
1356
+
"rand_core 0.6.4",
1357
1357
+
]
1358
1358
+
1359
1359
+
[[package]]
1360
1360
+
name = "rand"
1361
1361
+
version = "0.9.2"
1362
1362
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1363
1363
+
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
1364
1364
+
dependencies = [
1365
1365
+
"rand_chacha",
1366
1366
+
"rand_core 0.9.3",
1367
1367
+
]
1368
1368
+
1369
1369
+
[[package]]
1370
1370
+
name = "rand_chacha"
1371
1371
+
version = "0.9.0"
1372
1372
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1373
1373
+
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1374
1374
+
dependencies = [
1375
1375
+
"ppv-lite86",
1376
1376
+
"rand_core 0.9.3",
1480
1377
]
1481
1378
1482
1379
[[package]]
···
1484
1381
version = "0.6.4"
1485
1382
source = "registry+https://github.com/rust-lang/crates.io-index"
1486
1383
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1384
1384
+
1385
1385
+
[[package]]
1386
1386
+
name = "rand_core"
1387
1387
+
version = "0.9.3"
1388
1388
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1389
1389
+
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
1390
1390
+
dependencies = [
1391
1391
+
"getrandom 0.3.3",
1392
1392
+
]
1487
1393
1488
1394
[[package]]
1489
1395
name = "redox_syscall"
···
1849
1755
]
1850
1756
1851
1757
[[package]]
1852
1852
-
name = "strsim"
1853
1853
-
version = "0.11.1"
1758
1758
+
name = "strum"
1759
1759
+
version = "0.27.2"
1854
1760
source = "registry+https://github.com/rust-lang/crates.io-index"
1855
1855
-
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1761
1761
+
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
1762
1762
+
dependencies = [
1763
1763
+
"strum_macros",
1764
1764
+
]
1765
1765
+
1766
1766
+
[[package]]
1767
1767
+
name = "strum_macros"
1768
1768
+
version = "0.27.2"
1769
1769
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1770
1770
+
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
1771
1771
+
dependencies = [
1772
1772
+
"heck",
1773
1773
+
"proc-macro2",
1774
1774
+
"quote",
1775
1775
+
"syn",
1776
1776
+
]
1856
1777
1857
1778
[[package]]
1858
1779
name = "subtle"
···
2252
2173
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
2253
2174
2254
2175
[[package]]
2255
2255
-
name = "version_check"
2256
2256
-
version = "0.9.5"
2257
2257
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2258
2258
-
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2259
2259
-
2260
2260
-
[[package]]
2261
2176
name = "want"
2262
2177
version = "0.3.1"
2263
2178
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2357
2272
version = "0.3.77"
2358
2273
source = "registry+https://github.com/rust-lang/crates.io-index"
2359
2274
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
2360
2360
-
dependencies = [
2361
2361
-
"js-sys",
2362
2362
-
"wasm-bindgen",
2363
2363
-
]
2364
2364
-
2365
2365
-
[[package]]
2366
2366
-
name = "web-time"
2367
2367
-
version = "1.1.0"
2368
2368
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2369
2369
-
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
2370
2275
dependencies = [
2371
2276
"js-sys",
2372
2277
"wasm-bindgen",
+4
-2
server/Cargo.toml
···
3
3
version = "0.1.0"
4
4
edition = "2024"
5
5
6
6
-
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
6
6
+
[lints.clippy]
7
7
+
pedantic = "warn"
7
8
8
9
[dependencies]
9
10
anyhow = "1.0.57"
10
11
askama = "0.14.0"
11
12
axum = "0.8.1"
12
12
-
cached = { version = "0.56.0", features = ["async"] }
13
13
jsonwebtoken = "9.3.1"
14
14
+
rand = "0.9.2"
14
15
reqwest = { version = "0.12.23", features = ["json"] }
15
16
scraper = "0.24.0"
16
17
serde = { version = "1.0.219", features = ["derive"] }
18
18
+
strum = { version = "0.27.2", features = ["derive"] }
17
19
tokio = { version = "1.18.2", features = ["full"] }
18
20
tower = "0.5.2"
19
21
tower-http = { version = "0.6.2", features = ["compression-full", "fs", "trace", "set-header"] }
-16
server/src/am_auth_flow.rs
···
1
1
-
use askama::Template;
2
2
-
3
3
-
use crate::scrapers::apple_music::AppleMusicClient;
4
4
-
5
5
-
#[derive(Template, Debug, Clone)]
6
6
-
#[template(path = "am-auth-flow.html")]
7
7
-
pub struct AuthFlowTemplate {
8
8
-
token: String,
9
9
-
}
10
10
-
11
11
-
impl AuthFlowTemplate {
12
12
-
pub fn new(apple_music_client: &AppleMusicClient) -> anyhow::Result<Self> {
13
13
-
let token = apple_music_client.build_developer_token()?;
14
14
-
Ok(Self { token })
15
15
-
}
16
16
-
}
-69
server/src/index.rs
···
1
1
-
use crate::scrapers::{
2
2
-
Media,
3
3
-
apple_music::{self, AppleMusicClient},
4
4
-
backloggd, letterboxd,
5
5
-
};
6
6
-
7
7
-
use askama::Template;
8
8
-
use serde::Deserialize;
9
9
-
10
10
-
#[derive(Deserialize)]
11
11
-
pub struct IndexOptions {
12
12
-
#[cfg(debug_assertions)]
13
13
-
#[serde(default)]
14
14
-
mock: bool,
15
15
-
}
16
16
-
17
17
-
#[derive(Template, Debug, Clone)]
18
18
-
#[template(path = "index.html")]
19
19
-
pub struct RootTemplate {
20
20
-
media: Vec<Media>,
21
21
-
}
22
22
-
23
23
-
impl RootTemplate {
24
24
-
pub async fn new(
25
25
-
apple_music_client: &AppleMusicClient,
26
26
-
#[allow(unused_variables)] options: IndexOptions,
27
27
-
) -> RootTemplate {
28
28
-
#[cfg(debug_assertions)]
29
29
-
let media = if options.mock {
30
30
-
mocked_media()
31
31
-
} else {
32
32
-
Self::fetch_media(apple_music_client).await
33
33
-
};
34
34
-
#[cfg(not(debug_assertions))]
35
35
-
let media = Self::fetch_media(apple_music_client).await;
36
36
-
37
37
-
RootTemplate { media }
38
38
-
}
39
39
-
40
40
-
async fn fetch_media(apple_music_client: &AppleMusicClient) -> Vec<Media> {
41
41
-
let (game, movie, song) = tokio::join!(
42
42
-
backloggd::cached_fetch(),
43
43
-
letterboxd::cached_fetch(),
44
44
-
apple_music::cached_fetch(apple_music_client)
45
45
-
);
46
46
-
[game, movie, song].into_iter().flatten().collect()
47
47
-
}
48
48
-
}
49
49
-
50
50
-
#[cfg(debug_assertions)]
51
51
-
fn mocked_media() -> Vec<Media> {
52
52
-
vec![
53
53
-
Media {
54
54
-
name: "Cyberpunk 2077: Ultimate Edition".to_owned(),
55
55
-
image: "https://images.igdb.com/igdb/image/upload/t_cover_big/co7iy1.jpg".to_owned(),
56
56
-
context: "Nintendo Switch 2".to_owned(),
57
57
-
},
58
58
-
Media {
59
59
-
name: "The Thursday Murder Club".to_owned(),
60
60
-
image: "https://a.ltrbxd.com/resized/film-poster/6/6/6/2/8/6/666286-the-thursday-murder-club-0-230-0-345-crop.jpg?v=4bfeae38a7".to_owned(),
61
61
-
context: "1 star".to_owned(),
62
62
-
},
63
63
-
Media {
64
64
-
name: "We Might Feel Unsound".to_owned(),
65
65
-
image: "https://is1-ssl.mzstatic.com/image/thumb/Music124/v4/f4/b2/8e/f4b28ee4-01c6-232c-56a7-b97fd5b0e0ae/00602527857671.rgb.jpg/240x240bb.jpg".to_owned(),
66
66
-
context: "James Blake โ James Blake".to_owned(),
67
67
-
},
68
68
-
]
69
69
-
}
+47
-27
server/src/main.rs
···
1
1
-
#[cfg(debug_assertions)]
2
2
-
mod am_auth_flow;
3
3
-
mod index;
4
1
mod scrapers;
2
2
+
mod templates;
5
3
6
6
-
use std::{net::SocketAddr, sync::Arc};
4
4
+
use std::{env, net::SocketAddr, sync::Arc};
7
5
6
6
+
use crate::scrapers::{MediaType, apple_music::AppleMusicClient};
8
7
#[cfg(debug_assertions)]
9
9
-
use crate::am_auth_flow::AuthFlowTemplate;
10
10
-
use crate::index::{IndexOptions, RootTemplate};
11
11
-
use crate::scrapers::apple_music::AppleMusicClient;
8
8
+
use crate::templates::am_auth_flow::AuthFlowTemplate;
9
9
+
use crate::templates::{
10
10
+
index::{IndexOptions, RootTemplate, Shas},
11
11
+
media::{MediaTemplate, fetch_media_of_type},
12
12
+
};
12
13
13
14
use askama::Template;
14
15
use axum::{
15
16
Router,
16
16
-
extract::{Query, State},
17
17
+
extract::{Path, Query, State},
17
18
http::{HeaderName, HeaderValue, StatusCode},
18
19
response::{Html, IntoResponse},
19
19
-
routing::{get, get_service},
20
20
+
routing::get,
20
21
};
21
22
use tower::ServiceBuilder;
22
23
use tower_http::{
23
23
-
compression::CompressionLayer, services::ServeDir, set_header::SetResponseHeaderLayer,
24
24
-
trace::TraceLayer,
24
24
+
compression::CompressionLayer, set_header::SetResponseHeaderLayer, trace::TraceLayer,
25
25
};
26
26
27
27
#[derive(Clone)]
28
28
struct AppState {
29
29
apple_music_client: Arc<AppleMusicClient>,
30
30
+
shas: Shas,
30
31
}
31
32
32
33
#[tokio::main]
···
34
35
tracing_subscriber::fmt::init();
35
36
36
37
let apple_music_client = Arc::new(AppleMusicClient::new()?);
37
37
-
let state = AppState { apple_music_client };
38
38
+
let shas = Shas {
39
39
+
website: env::var("MYIVO_GIT_SHA").ok(),
40
40
+
};
41
41
+
let state = AppState {
42
42
+
apple_music_client,
43
43
+
shas,
44
44
+
};
38
45
39
39
-
let app = Router::new().route("/", get(render_index_handler));
46
46
+
let app = Router::new()
47
47
+
.route("/", get(render_index_handler))
48
48
+
.route("/media/{media_type}", get(render_media_partial_handler));
40
49
#[cfg(debug_assertions)]
41
50
let app = app.route("/dev/am-auth-flow", get(render_apple_music_auth_flow));
42
42
-
let app = app
43
43
-
.fallback(get_service(ServeDir::new(".")))
44
44
-
.with_state(state)
45
45
-
.layer(
46
46
-
ServiceBuilder::new()
47
47
-
.layer(TraceLayer::new_for_http())
48
48
-
.layer(CompressionLayer::new())
49
49
-
.layer(SetResponseHeaderLayer::overriding(
50
50
-
HeaderName::from_static("strict-transport-security"),
51
51
-
HeaderValue::from_static("max-age=2592000; includeSubDomains"),
52
52
-
)),
53
53
-
);
51
51
+
let app = app.with_state(state).layer(
52
52
+
ServiceBuilder::new()
53
53
+
.layer(TraceLayer::new_for_http())
54
54
+
.layer(CompressionLayer::new())
55
55
+
.layer(SetResponseHeaderLayer::overriding(
56
56
+
HeaderName::from_static("strict-transport-security"),
57
57
+
HeaderValue::from_static("max-age=2592000; includeSubDomains"),
58
58
+
)),
59
59
+
);
54
60
55
55
-
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
61
61
+
let addr = SocketAddr::from(([0, 0, 0, 0], 53465));
56
62
tracing::debug!("starting server on {addr}");
57
63
let listener = tokio::net::TcpListener::bind(addr).await?;
58
64
axum::serve(listener, app).await?;
···
64
70
Query(options): Query<IndexOptions>,
65
71
State(state): State<AppState>,
66
72
) -> impl IntoResponse {
67
67
-
let template = RootTemplate::new(&state.apple_music_client, options).await;
73
73
+
let template = RootTemplate::new(state.apple_music_client, state.shas, &options);
68
74
template.render().map(Html).map_err(|err| {
69
75
tracing::error!("failed to render index: {err:?}");
76
76
+
StatusCode::INTERNAL_SERVER_ERROR
77
77
+
})
78
78
+
}
79
79
+
80
80
+
async fn render_media_partial_handler(
81
81
+
Path(media_type): Path<MediaType>,
82
82
+
State(state): State<AppState>,
83
83
+
) -> impl IntoResponse {
84
84
+
let media = fetch_media_of_type(media_type, state.apple_music_client)
85
85
+
.await
86
86
+
.unwrap();
87
87
+
let template = MediaTemplate { media_type, media };
88
88
+
template.render().map(Html).map_err(|err| {
89
89
+
tracing::error!("failed to render {media_type} media: {err:?}");
70
90
StatusCode::INTERNAL_SERVER_ERROR
71
91
})
72
92
}
+27
-12
server/src/scrapers/apple_music.rs
···
1
1
-
use std::{env, fs, time::Duration};
1
1
+
use std::{env, fs, sync::Arc, time::Duration};
2
2
3
3
use anyhow::Context;
4
4
-
use cached::proc_macro::once;
5
4
use jsonwebtoken::{Algorithm, EncodingKey, Header};
6
5
use reqwest::Client;
7
6
use serde::{Deserialize, Serialize};
7
7
+
use tokio::sync::RwLock;
8
8
+
use tracing::instrument;
8
9
9
9
-
use super::Media;
10
10
+
use super::{
11
11
+
Media,
12
12
+
cached::{MediaCache, cache_or_fetch, try_cache_or_fetch},
13
13
+
};
14
14
+
15
15
+
static TTL: Duration = Duration::from_secs(30);
10
16
11
17
#[derive(Serialize, Debug, Clone)]
12
18
struct Claims {
···
43
49
album_name: String,
44
50
artist_name: String,
45
51
artwork: AppleMusicTrackArtwork,
52
52
+
url: String,
46
53
}
47
54
48
55
#[derive(Deserialize, Debug, Clone)]
···
51
58
}
52
59
53
60
pub struct AppleMusicClient {
61
61
+
cache: MediaCache,
54
62
http_client: Client,
55
63
key_id: String,
56
64
team_id: String,
···
60
68
61
69
impl AppleMusicClient {
62
70
pub fn new() -> anyhow::Result<Self> {
71
71
+
let cache = Arc::new(RwLock::new(None));
63
72
let key_id =
64
73
env::var("APPLE_DEVELOPER_TOKEN_KEY_ID").context("missing apple developer key ID")?;
65
74
let team_id =
66
75
env::var("APPLE_DEVELOPER_TOKEN_TEAM_ID").context("missing apple developer team ID")?;
67
67
-
let auth_key = fs::read("keys/AuthKey.p8").context("missing apple developer auth key")?;
76
76
+
let auth_key_path = env::var("APPLE_DEVELOPER_AUTH_KEY_PATH")
77
77
+
.context("missing apple developer apple developer auth key path")?;
78
78
+
let auth_key = fs::read(auth_key_path).context("missing apple developer auth key")?;
68
79
let key = EncodingKey::from_ec_pem(&auth_key)
69
80
.context("failed to parse apple developer auth key")?;
70
81
let user_token = env::var("APPLE_USER_TOKEN").context("missing apple user token")?;
71
82
72
83
Ok(Self {
84
84
+
cache,
73
85
http_client: Client::new(),
74
86
key_id,
75
87
team_id,
···
109
121
name: track.attributes.name.clone(),
110
122
image: artwork_url,
111
123
context,
124
124
+
url: track.attributes.url.clone(),
112
125
})
113
126
}
114
127
128
128
+
#[instrument(name = "apple_music_try_cached_fetch", skip(self))]
129
129
+
pub fn try_cached_fetch(self: Arc<Self>) -> Option<Media> {
130
130
+
try_cache_or_fetch(&self.cache.clone(), TTL, async move || self.fetch().await)
131
131
+
}
132
132
+
133
133
+
#[instrument(name = "apple_music_cached_fetch", skip(self))]
134
134
+
pub async fn cached_fetch(self: Arc<Self>) -> Option<Media> {
135
135
+
cache_or_fetch(&self.cache.clone(), TTL, async move || self.fetch().await).await
136
136
+
}
137
137
+
115
138
pub fn build_developer_token(&self) -> anyhow::Result<String> {
116
139
let mut header = Header::new(Algorithm::ES256);
117
140
header.kid = Some(self.key_id.clone());
···
121
144
.context("failed to encode apple developer JWT")
122
145
}
123
146
}
124
124
-
125
125
-
#[once(time = 30, option = false)]
126
126
-
pub async fn cached_fetch(this: &AppleMusicClient) -> Option<Media> {
127
127
-
this.fetch()
128
128
-
.await
129
129
-
.map_err(|error| tracing::warn!(?error, "failed to call Apple Music"))
130
130
-
.ok()
131
131
-
}
+33
-9
server/src/scrapers/backloggd.rs
···
1
1
-
use std::{sync::LazyLock, time::Duration};
1
1
+
use std::{
2
2
+
sync::{Arc, LazyLock},
3
3
+
time::Duration,
4
4
+
};
2
5
3
6
use anyhow::Context;
4
4
-
use cached::proc_macro::once;
7
7
+
use reqwest::Url;
5
8
use scraper::{Html, Selector};
9
9
+
use tokio::sync::RwLock;
10
10
+
use tracing::instrument;
6
11
7
7
-
use super::Media;
12
12
+
use super::{
13
13
+
Media,
14
14
+
cached::{MediaCache, cache_or_fetch, try_cache_or_fetch},
15
15
+
};
8
16
9
17
pub async fn fetch() -> anyhow::Result<Media> {
10
18
static FIRST_ENTRY_SEL: LazyLock<Selector> =
···
14
22
static IMAGE_SEL: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".card-img").unwrap());
15
23
static PLATFORM_SEL: LazyLock<Selector> =
16
24
LazyLock::new(|| Selector::parse(".journal-platform").unwrap());
25
25
+
static URL_SEL: LazyLock<Selector> =
26
26
+
LazyLock::new(|| Selector::parse("a:has(.fa-arrow-right)").unwrap());
17
27
18
18
-
let html = reqwest::get("https://backloggd.com/u/cherryfunk/journal")
28
28
+
let page_url = Url::parse("https://backloggd.com/u/cherryfunk/journal")
29
29
+
.context("wrote invalid Backloggd URL")?;
30
30
+
let html = reqwest::get(page_url.clone())
19
31
.await
20
32
.context("failed to fetch Backloggd page")?
21
33
.text()
···
50
62
.next()
51
63
.context("platform element didn't have any text")?
52
64
.to_owned();
65
65
+
let url = first_entry
66
66
+
.select(&URL_SEL)
67
67
+
.next()
68
68
+
.context("couldn't find log URL element")?
69
69
+
.attr("href")
70
70
+
.context("log anchor didn't have a URL")?
71
71
+
.to_owned();
72
72
+
let url = page_url.join(&url).context("log URL was invalid")?;
53
73
54
74
Ok(Media {
55
75
name,
56
76
image,
57
77
context: platform,
78
78
+
url: url.into(),
58
79
})
59
80
}
60
81
61
61
-
#[once(time = 300, option = false)]
82
82
+
static CACHE: LazyLock<MediaCache> = LazyLock::new(|| Arc::new(RwLock::new(None)));
83
83
+
static TTL: Duration = Duration::from_secs(300);
84
84
+
#[instrument(name = "backlogged_try_cached_fetch")]
85
85
+
pub fn try_cached_fetch() -> Option<Media> {
86
86
+
try_cache_or_fetch(&CACHE, TTL, fetch)
87
87
+
}
88
88
+
#[instrument(name = "backlogged_cached_fetch")]
62
89
pub async fn cached_fetch() -> Option<Media> {
63
63
-
fetch()
64
64
-
.await
65
65
-
.map_err(|error| tracing::warn!(?error, "failed to scrape Backloggd"))
66
66
-
.ok()
90
90
+
cache_or_fetch(&CACHE, TTL, fetch).await
67
91
}
+90
server/src/scrapers/cached.rs
···
1
1
+
use std::{
2
2
+
sync::Arc,
3
3
+
time::{Duration, Instant},
4
4
+
};
5
5
+
use tokio::sync::RwLock;
6
6
+
7
7
+
use super::Media;
8
8
+
9
9
+
#[derive(Debug)]
10
10
+
pub struct CachedMediaResult {
11
11
+
media_result: Option<Media>,
12
12
+
cache_time: Instant,
13
13
+
ttl: Duration,
14
14
+
}
15
15
+
16
16
+
impl CachedMediaResult {
17
17
+
fn has_expired(&self) -> bool {
18
18
+
let ttl = if self.media_result.is_some() {
19
19
+
self.ttl
20
20
+
} else {
21
21
+
Duration::from_secs(30)
22
22
+
};
23
23
+
self.cache_time.elapsed() >= ttl
24
24
+
}
25
25
+
}
26
26
+
27
27
+
pub type MediaCache = Arc<RwLock<Option<CachedMediaResult>>>;
28
28
+
29
29
+
pub fn try_cache_or_fetch<F, Fut>(cache: &MediaCache, ttl: Duration, fetcher: F) -> Option<Media>
30
30
+
where
31
31
+
F: FnOnce() -> Fut + Send + 'static,
32
32
+
Fut: Future<Output = anyhow::Result<Media>> + Send,
33
33
+
{
34
34
+
{
35
35
+
let cached = cache.try_read().ok()?;
36
36
+
if let Some(cached) = &*cached
37
37
+
&& !cached.has_expired()
38
38
+
{
39
39
+
return cached.media_result.clone();
40
40
+
}
41
41
+
}
42
42
+
43
43
+
let cache = cache.clone();
44
44
+
tokio::spawn(async move {
45
45
+
synced_fetch(&cache, ttl, fetcher).await;
46
46
+
});
47
47
+
48
48
+
None
49
49
+
}
50
50
+
51
51
+
pub async fn cache_or_fetch<F, Fut>(cache: &MediaCache, ttl: Duration, fetcher: F) -> Option<Media>
52
52
+
where
53
53
+
F: FnOnce() -> Fut,
54
54
+
Fut: Future<Output = anyhow::Result<Media>>,
55
55
+
{
56
56
+
{
57
57
+
let cached = cache.read().await;
58
58
+
if let Some(cached) = &*cached
59
59
+
&& !cached.has_expired()
60
60
+
{
61
61
+
return cached.media_result.clone();
62
62
+
}
63
63
+
}
64
64
+
65
65
+
synced_fetch(cache, ttl, fetcher).await
66
66
+
}
67
67
+
68
68
+
async fn synced_fetch<F, Fut>(cache: &MediaCache, ttl: Duration, fetcher: F) -> Option<Media>
69
69
+
where
70
70
+
F: FnOnce() -> Fut,
71
71
+
Fut: Future<Output = anyhow::Result<Media>>,
72
72
+
{
73
73
+
let mut cached = cache.write().await;
74
74
+
if let Some(cached) = &*cached
75
75
+
&& !cached.has_expired()
76
76
+
{
77
77
+
return cached.media_result.clone();
78
78
+
}
79
79
+
80
80
+
let result = fetcher()
81
81
+
.await
82
82
+
.map_err(|error| tracing::warn!(?error, "failed to scrape backend"))
83
83
+
.ok();
84
84
+
*cached = Some(CachedMediaResult {
85
85
+
media_result: result.clone(),
86
86
+
cache_time: Instant::now(),
87
87
+
ttl,
88
88
+
});
89
89
+
result
90
90
+
}
+45
-10
server/src/scrapers/letterboxd.rs
···
1
1
-
use std::{sync::LazyLock, time::Duration};
1
1
+
use std::{
2
2
+
sync::{Arc, LazyLock},
3
3
+
time::Duration,
4
4
+
};
2
5
3
6
use anyhow::Context;
4
4
-
use cached::proc_macro::once;
5
7
use reqwest::{Client, Url};
6
8
use scraper::{ElementRef, Html, Selector};
7
9
use serde::Deserialize;
10
10
+
use tokio::sync::RwLock;
11
11
+
use tracing::instrument;
8
12
9
9
-
use super::Media;
13
13
+
use super::{
14
14
+
Media,
15
15
+
cached::{MediaCache, cache_or_fetch, try_cache_or_fetch},
16
16
+
};
10
17
11
18
#[derive(Deserialize, Debug, Clone)]
12
19
pub struct ImageUrlMetadata {
···
17
24
name: String,
18
25
image_url: Url,
19
26
rating: Option<u8>,
27
27
+
url: String,
20
28
}
21
29
30
30
+
// CloudFlare's bot detection seems to be more generous towards user agents that don't include
31
31
+
// known HTTP clients, like reqwest or curl.
32
32
+
const USER_AGENT: &str = "myivo/1.0.0";
33
33
+
22
34
pub async fn fetch() -> anyhow::Result<Media> {
23
23
-
let client = Client::new();
35
35
+
let client = Client::builder()
36
36
+
.user_agent(USER_AGENT)
37
37
+
.build()
38
38
+
.context("failed to build client")?;
39
39
+
let page_url = Url::parse("https://letterboxd.com/ivom/films/diary/")
40
40
+
.context("wrote invalid Letterboxd URL")?;
24
41
let html = client
25
25
-
.get("https://letterboxd.com/ivom/films/diary/")
42
42
+
.get(page_url.clone())
43
43
+
// including this header seems to contribute to getting past CloudFlare's bot detection.
44
44
+
.header("priority", "u=0, i")
26
45
.send()
27
46
.await
28
47
.context("failed to fetch Letterboxd page")?
···
33
52
name,
34
53
image_url,
35
54
rating,
55
55
+
url,
36
56
} = parse_html(&html)?;
37
57
38
58
let image_url_data: ImageUrlMetadata = client
···
51
71
),
52
72
None => "no rating".to_owned(),
53
73
};
74
74
+
let url = page_url.join(&url).context("film URL was invalid")?;
54
75
55
76
Ok(Media {
56
77
name,
57
78
image: image_url_data.url,
58
79
context: formatted_rating,
80
80
+
url: url.into(),
59
81
})
60
82
}
61
83
···
66
88
static POSTER_COMPONENT_SEL: LazyLock<Selector> =
67
89
LazyLock::new(|| Selector::parse(".react-component:has(> .poster)").unwrap());
68
90
static RATING_SEL: LazyLock<Selector> = LazyLock::new(|| Selector::parse(".rating").unwrap());
91
91
+
static URL_SEL: LazyLock<Selector> =
92
92
+
LazyLock::new(|| Selector::parse(".inline-production-masthead .name a").unwrap());
69
93
70
94
let document = Html::parse_document(html);
71
95
···
93
117
.classes()
94
118
.find_map(|class| class.strip_prefix("rated-"))
95
119
.and_then(|rating| rating.parse().ok());
120
120
+
let url = first_entry
121
121
+
.select(&URL_SEL)
122
122
+
.next()
123
123
+
.context("couldn't find film URL element")?
124
124
+
.attr("href")
125
125
+
.context("film URL element didn't have a URL")?
126
126
+
.to_owned();
96
127
97
128
let image_url = build_image_url(poster_component)?;
98
129
···
100
131
name,
101
132
image_url,
102
133
rating,
134
134
+
url,
103
135
})
104
136
}
105
137
···
119
151
Ok(image_url)
120
152
}
121
153
122
122
-
#[once(time = 1800, option = false)]
154
154
+
static CACHE: LazyLock<MediaCache> = LazyLock::new(|| Arc::new(RwLock::new(None)));
155
155
+
static TTL: Duration = Duration::from_secs(1800);
156
156
+
#[instrument(name = "letterboxd_try_cached_fetch")]
157
157
+
pub fn try_cached_fetch() -> Option<Media> {
158
158
+
try_cache_or_fetch(&CACHE, TTL, fetch)
159
159
+
}
160
160
+
#[instrument(name = "letterboxd_cached_fetch")]
123
161
pub async fn cached_fetch() -> Option<Media> {
124
124
-
fetch()
125
125
-
.await
126
126
-
.map_err(|error| tracing::warn!(?error, "failed to scrape Letterboxd"))
127
127
-
.ok()
162
162
+
cache_or_fetch(&CACHE, TTL, fetch).await
128
163
}
+15
server/src/scrapers.rs
···
1
1
+
pub mod cached;
2
2
+
1
3
pub mod apple_music;
2
4
pub mod backloggd;
3
5
pub mod letterboxd;
4
6
7
7
+
use serde::Deserialize;
8
8
+
use strum::{Display, EnumString};
9
9
+
5
10
#[derive(Debug, Clone)]
6
11
pub struct Media {
7
12
pub name: String,
8
13
pub image: String,
9
14
pub context: String,
15
15
+
pub url: String,
16
16
+
}
17
17
+
18
18
+
#[derive(Debug, Clone, Copy, Display, EnumString, Deserialize)]
19
19
+
#[strum(serialize_all = "snake_case")]
20
20
+
#[serde(rename_all = "snake_case")]
21
21
+
pub enum MediaType {
22
22
+
Game,
23
23
+
Film,
24
24
+
Song,
10
25
}
+16
server/src/templates/am_auth_flow.rs
···
1
1
+
use askama::Template;
2
2
+
3
3
+
use crate::scrapers::apple_music::AppleMusicClient;
4
4
+
5
5
+
#[derive(Template, Debug, Clone)]
6
6
+
#[template(path = "am-auth-flow.html")]
7
7
+
pub struct AuthFlowTemplate {
8
8
+
token: String,
9
9
+
}
10
10
+
11
11
+
impl AuthFlowTemplate {
12
12
+
pub fn new(apple_music_client: &AppleMusicClient) -> anyhow::Result<Self> {
13
13
+
let token = apple_music_client.build_developer_token()?;
14
14
+
Ok(Self { token })
15
15
+
}
16
16
+
}
+92
server/src/templates/index.rs
···
1
1
+
use std::sync::Arc;
2
2
+
3
3
+
use crate::scrapers::{Media, MediaType, apple_music::AppleMusicClient, backloggd, letterboxd};
4
4
+
5
5
+
use askama::Template;
6
6
+
use rand::seq::IndexedRandom;
7
7
+
use serde::Deserialize;
8
8
+
9
9
+
#[derive(Deserialize)]
10
10
+
pub struct IndexOptions {
11
11
+
#[cfg(debug_assertions)]
12
12
+
#[serde(default)]
13
13
+
mock: bool,
14
14
+
}
15
15
+
16
16
+
type MediaList = [(MediaType, Option<Media>); 3];
17
17
+
18
18
+
#[derive(Debug, Clone)]
19
19
+
pub struct Shas {
20
20
+
pub website: Option<String>,
21
21
+
}
22
22
+
23
23
+
#[derive(Template, Debug, Clone)]
24
24
+
#[template(path = "index.html")]
25
25
+
pub struct RootTemplate {
26
26
+
media: MediaList,
27
27
+
consumption_verb: &'static str,
28
28
+
shas: Shas,
29
29
+
}
30
30
+
31
31
+
impl RootTemplate {
32
32
+
pub fn new(
33
33
+
apple_music_client: Arc<AppleMusicClient>,
34
34
+
shas: Shas,
35
35
+
#[allow(unused_variables)] options: &IndexOptions,
36
36
+
) -> RootTemplate {
37
37
+
#[cfg(debug_assertions)]
38
38
+
let media = if options.mock {
39
39
+
mocked_media()
40
40
+
} else {
41
41
+
Self::fetch_media(apple_music_client)
42
42
+
};
43
43
+
#[cfg(not(debug_assertions))]
44
44
+
let media = Self::fetch_media(apple_music_client);
45
45
+
46
46
+
let consumption_verb = Self::random_consumption_verb();
47
47
+
48
48
+
RootTemplate {
49
49
+
media,
50
50
+
consumption_verb,
51
51
+
shas,
52
52
+
}
53
53
+
}
54
54
+
55
55
+
fn fetch_media(apple_music_client: Arc<AppleMusicClient>) -> MediaList {
56
56
+
[
57
57
+
(MediaType::Game, backloggd::try_cached_fetch()),
58
58
+
(MediaType::Film, letterboxd::try_cached_fetch()),
59
59
+
(MediaType::Song, apple_music_client.try_cached_fetch()),
60
60
+
]
61
61
+
}
62
62
+
63
63
+
fn random_consumption_verb() -> &'static str {
64
64
+
static CONSUMPTION_VERBS: &[&str] = &["swallowed", "inhaled", "digested", "ingested"];
65
65
+
66
66
+
CONSUMPTION_VERBS.choose(&mut rand::rng()).unwrap()
67
67
+
}
68
68
+
}
69
69
+
70
70
+
#[cfg(debug_assertions)]
71
71
+
fn mocked_media() -> MediaList {
72
72
+
[
73
73
+
(MediaType::Game, Some(Media {
74
74
+
name: "Cyberpunk 2077: Ultimate Edition".to_owned(),
75
75
+
image: "https://images.igdb.com/igdb/image/upload/t_cover_big/co7iy1.jpg".to_owned(),
76
76
+
context: "Nintendo Switch 2".to_owned(),
77
77
+
url: "https://backloggd.com/u/cherryfunk/logs/cyberpunk-2077-ultimate-edition/".to_owned()
78
78
+
})),
79
79
+
(MediaType::Film, Some(Media {
80
80
+
name: "The Thursday Murder Club".to_owned(),
81
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
82
+
context: "1 star".to_owned(),
83
83
+
url: "https://letterboxd.com/ivom/film/the-thursday-murder-club/".to_owned()
84
84
+
})),
85
85
+
(MediaType::Song, Some(Media {
86
86
+
name: "We Might Feel Unsound".to_owned(),
87
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
88
+
context: "James Blake โ James Blake".to_owned(),
89
89
+
url: "https://music.apple.com/gb/album/we-might-feel-unsound/1443124478?i=1443125024".to_owned()
90
90
+
})),
91
91
+
]
92
92
+
}
+23
server/src/templates/media.rs
···
1
1
+
use std::sync::Arc;
2
2
+
3
3
+
use crate::scrapers::{Media, MediaType, apple_music::AppleMusicClient, backloggd, letterboxd};
4
4
+
5
5
+
use askama::Template;
6
6
+
7
7
+
#[derive(Template, Debug, Clone)]
8
8
+
#[template(path = "media.html")]
9
9
+
pub struct MediaTemplate {
10
10
+
pub media_type: MediaType,
11
11
+
pub media: Media,
12
12
+
}
13
13
+
14
14
+
pub async fn fetch_media_of_type(
15
15
+
media_type: MediaType,
16
16
+
apple_music_client: Arc<AppleMusicClient>,
17
17
+
) -> Option<Media> {
18
18
+
match media_type {
19
19
+
MediaType::Game => backloggd::cached_fetch().await,
20
20
+
MediaType::Film => letterboxd::cached_fetch().await,
21
21
+
MediaType::Song => apple_music_client.cached_fetch().await,
22
22
+
}
23
23
+
}
+4
server/src/templates.rs
···
1
1
+
#[cfg(debug_assertions)]
2
2
+
pub mod am_auth_flow;
3
3
+
pub mod index;
4
4
+
pub mod media;
+67
-19
server/templates/index.html
···
5
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
<link rel="stylesheet" href="build/app.css" />
7
7
<link rel="me" href="https://hachyderm.io/@cherry" />
8
8
-
<script defer src="build/app.js" type="module"></script>
8
8
+
<script src="build/app.js" type="module"></script>
9
9
</head>
10
10
<body class="bg-gray-900">
11
11
<div class="flex flex-col">
12
12
<div class="bg-gray-950 text-center">
13
13
-
<h1
14
14
-
class="font-serif text-4xl text-gray-200 motion-safe:animate-marquee sm:text-6xl md:text-8xl"
15
15
-
>
16
16
-
<span class="text-pink-700 motion-safe:animate-glow">cherry</span
17
17
-
>.computer
13
13
+
<h1 class="font-serif text-4xl text-gray-200 sm:text-6xl md:text-8xl">
14
14
+
<span class="text-pink-700">cherry</span>.computer
18
15
</h1>
19
16
</div>
20
20
-
<div class="flex w-full max-w-xl flex-col self-center">
21
21
-
{% for media in media -%}
22
22
-
<div class="flex odd:flex-row even:flex-row-reverse">
23
23
-
<img
24
24
-
class="m-3 h-50 rounded-xs object-contain"
25
25
-
src="{{ media.image }}"
26
26
-
alt="Cover art for {{ media.name }}"
27
27
-
/>
28
28
-
<div class="flex flex-col self-center">
29
29
-
<p class="text-2xl text-white">{{ media.name }}</p>
30
30
-
<p class="text-xl text-gray-700 italic">{{ media.context }}</p>
31
31
-
</div>
17
17
+
<div
18
18
+
class="flex w-full max-w-2xl flex-col items-center gap-4 self-center p-4"
19
19
+
>
20
20
+
<div class="max-w-xl text-2xl text-pink-100 uppercase">
21
21
+
<p class="my-2">
22
22
+
Welcome to the intersection of product and politics.
23
23
+
</p>
24
24
+
<p class="my-2">
25
25
+
Where we synthesise the contradictions of consumption and communism.
26
26
+
</p>
27
27
+
<p class="my-2">Where we reason about both art and anarchism.</p>
28
28
+
<p class="mt-2">
29
29
+
Where I jokingly gesture towards my neurotic connection between
30
30
+
engaging with media products and life fulfillment, but fail to
31
31
+
interrogate the apparent complex further.
32
32
+
</p>
32
33
</div>
33
33
-
{%- endfor %}
34
34
+
<h2 class="self-start text-2xl text-pink-50">
35
35
+
Here is what I've {{ consumption_verb }} most recently:
36
36
+
</h2>
37
37
+
<div
38
38
+
class="grid gap-x-4 w-full grid-cols-2 hoverable:grid-cols-{{ media.len() }}"
39
39
+
>
40
40
+
{% for media in media -%} {% match media %} {% when (media_type,
41
41
+
Some(media)) %} {% include "media.html" %} {% when (media_type, None)
42
42
+
%}
43
43
+
<div
44
44
+
class="aspect-square w-full max-w-50 animate-pulse justify-self-center rounded-xs bg-gray-800 hoverable:max-w-none"
45
45
+
hx-get="/media/{{ media_type }}"
46
46
+
hx-swap="outerHTML"
47
47
+
hx-trigger="load"
48
48
+
></div>
49
49
+
<div
50
50
+
id="media-description-{{ media_type }}"
51
51
+
class="hoverable:hidden"
52
52
+
></div>
53
53
+
{% endmatch %} {%- endfor %}
54
54
+
</div>
55
55
+
<p class="font-serif text-3xl text-pink-50">Free Palestine ๐ต๐ธ</p>
56
56
+
</div>
57
57
+
<div class="bg-gray-800">
58
58
+
<div
59
59
+
class="flex w-full justify-around px-5 py-2 leading-5 text-pink-50 italic"
60
60
+
>
61
61
+
<p>
62
62
+
running on
63
63
+
<a
64
64
+
href="https://tangled.org/cherry.computer/nixos"
65
65
+
target="_blank"
66
66
+
class="text-pink-300 hover:text-pink-500"
67
67
+
>NixOS</a
68
68
+
>
69
69
+
</p>
70
70
+
{% if let Some(website_rev) = shas.website %}
71
71
+
<p>
72
72
+
site revision
73
73
+
<a
74
74
+
href="https://tangled.org/cherry.computer/website/tree/{{website_rev}}"
75
75
+
target="_blank"
76
76
+
class="text-pink-300 hover:text-pink-500"
77
77
+
>{{ website_rev | fmt("{:.8}") }}</a
78
78
+
>
79
79
+
</p>
80
80
+
{% endif %}
81
81
+
</div>
34
82
</div>
35
83
</div>
36
84
</body>
+26
server/templates/media.html
···
1
1
+
<a
2
2
+
href="{{ media.url }}"
3
3
+
target="_blank"
4
4
+
class="peer/{{ media_type }} relative aspect-square max-h-50 justify-self-center hoverable:max-h-none"
5
5
+
>
6
6
+
<img
7
7
+
class="absolute inset-0 aspect-square w-full rounded-xs object-fill"
8
8
+
aria-hidden="true"
9
9
+
src="{{ media.image }}"
10
10
+
/>
11
11
+
<img
12
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
13
+
src="{{ media.image }}"
14
14
+
alt="Cover art for {{ media.name }}"
15
15
+
/>
16
16
+
</a>
17
17
+
<a
18
18
+
href="{{ media.url }}"
19
19
+
target="_blank"
20
20
+
id="media-description-{{ media_type }}"
21
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
22
+
hx-swap-oob="true"
23
23
+
>
24
24
+
<p class="text-2xl text-white">{{ media.name }}</p>
25
25
+
<p class="text-xl text-gray-700 italic">{{ media.context }}</p>
26
26
+
</a>