commit 3a6752fda8ba988767efb16bb4da4b8130d2304a
parent 4dd83890e47eebd7b85f69d1a70acff71a4d24d2
Author: mtmn <miro@haravara.org>
Date: Fri, 8 May 2026 20:32:52 +0200
feat: render diagram using graphviz
Diffstat:
11 files changed, 444 insertions(+), 139 deletions(-)
diff --git a/docs/architecture.dot b/docs/architecture.dot
@@ -0,0 +1,91 @@
+digraph corpus {
+ rankdir=LR
+ compound=true
+ splines=ortho
+ concentrate=true
+ nodesep=0.7
+ ranksep=1.6
+ bgcolor="#111111"
+
+ node [fontname="Atkinson Hyperlegible Mono", fontsize=11, shape=box, style="rounded,filled", fontcolor=white]
+ edge [fontname="Atkinson Hyperlegible Mono", fontsize=9, color="#aaaaaa", fontcolor="#aaaaaa", arrowhead=vee, arrowsize=0.8, penwidth=1.4]
+
+ subgraph cluster_frontend {
+ label="Frontend"
+ style="rounded,filled"
+ fillcolor="#1e1533"
+ color="#9b7de8"
+ fontcolor=white
+ fontname="Atkinson Hyperlegible Mono"
+ UI [label="Elm SPA\n?user=slug", fillcolor="#2d2050"]
+ Client [label="Scrobble Client\n(LB-compatible)", fillcolor="#2d2050"]
+ }
+
+ subgraph cluster_server {
+ label="Corpus Server"
+ style="rounded,filled"
+ fillcolor="#112611"
+ color="#5aaa5a"
+ fontcolor=white
+ fontname="Atkinson Hyperlegible Mono"
+ API [label="Web API", fillcolor="#1c3e1c"]
+ Proxy [label="Cover Proxy", fillcolor="#1c3e1c"]
+ LBSync [label="ListenBrainz Sync", fillcolor="#1c3e1c"]
+ LFSync [label="Last.fm Sync", fillcolor="#1c3e1c"]
+ Enrich [label="Enrichment Task", fillcolor="#1c3e1c"]
+ }
+
+ subgraph cluster_storage {
+ label="Storage"
+ style="rounded,filled"
+ fillcolor="#272000"
+ color="#d4a80c"
+ fontcolor=white
+ fontname="Atkinson Hyperlegible Mono"
+ DB [label="DuckDB", shape=cylinder, fillcolor="#3d3100"]
+ S3 [label="S3 Bucket", shape=box3d, fillcolor="#3d3100"]
+ }
+
+ subgraph cluster_external {
+ label="External APIs"
+ style="rounded,filled"
+ fillcolor="#0e1e38"
+ color="#5a8de0"
+ fontcolor=white
+ fontname="Atkinson Hyperlegible Mono"
+ LB [label="ListenBrainz", fillcolor="#162d52"]
+ LF [label="Last.fm", fillcolor="#162d52"]
+ MB [label="MusicBrainz", fillcolor="#162d52"]
+ DC [label="Discogs", fillcolor="#162d52"]
+ CAA [label="Cover Art Archive", fillcolor="#162d52"]
+ }
+
+ // Scrobble Sync
+ LB -> LBSync
+ LF -> LFSync
+ LBSync -> DB
+ LFSync -> DB
+
+ // Enrichment
+ DB -> Enrich
+ Enrich -> MB
+ Enrich -> LF
+ Enrich -> DC
+ Enrich -> DB [constraint=false]
+
+ // Scrobble Submission
+ Client -> API [label="POST /1/submit-listens\n(token auth)"]
+ API -> DB [constraint=false]
+
+ // Web UI
+ UI -> API
+ API -> DB
+
+ // Cover Art
+ UI -> Proxy
+ Proxy -> S3
+ Proxy -> CAA
+ Proxy -> LF
+ Proxy -> DC
+ Proxy -> S3 [constraint=false]
+}
diff --git a/docs/architecture.md b/docs/architecture.md
@@ -193,58 +193,6 @@ Corpus relies on FFI to interact with the Node.js ecosystem where native PureScr
## System Flow
-```mermaid
-graph TD
- subgraph External APIs
- LB[ListenBrainz API]
- LF[Last.fm API]
- MB[MusicBrainz API]
- DC[Discogs API]
- CAA[Cover Art Archive]
- end
-
- subgraph Corpus Server
- LBSync[ListenBrainz Sync\nper user]
- LFSync[Last.fm Sync\nper user]
- Enrich[Enrichment Task\nper user]
- Proxy[Cover Proxy]
- API[Web API]
- end
-
- subgraph Storage
- DB[(DuckDB\nper user)]
- S3[[S3 Bucket]]
- end
-
- subgraph Frontend
- UI[Elm SPA\n?user=slug]
- end
-
- %% Scrobble Sync Flow
- LB -- "Fetch scrobbles" --> LBSync
- LBSync -- "Store scrobbles" --> DB
- LF -- "Fetch scrobbles" --> LFSync
- LFSync -- "Store scrobbles" --> DB
-
- %% Enrichment Flow
- DB -- "Get MBIDs" --> Enrich
- Enrich -- "Metadata" --> MB
- Enrich -- "1. Fallback Genre" --> LF
- Enrich -- "2. Fallback Genre" --> DC
- Enrich -- "Store Metadata" --> DB
-
- %% Web UI Flow
- UI -- "Request Data" --> API
- API -- "Query" --> DB
- DB -- "Results" --> API
- API -- "JSON" --> UI
-
- %% Cover Art Flow
- UI -- "Request Cover" --> Proxy
- Proxy -- "1. Check Cache" --> S3
- Proxy -- "2. Fallback CAA" --> CAA
- Proxy -- "3. Fallback Last.fm" --> LF
- Proxy -- "4. Fallback Discogs" --> DC
- Proxy -- "Cache Result" --> S3
- Proxy -- "Serve Image" --> UI
-```
+See [`architecture.dot`](architecture.dot) (render with `just docs`).
+
+
diff --git a/docs/architecture.svg b/docs/architecture.svg
@@ -0,0 +1,221 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
+ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<!-- Generated by graphviz version 12.2.1 (0)
+ -->
+<!-- Title: corpus Pages: 1 -->
+<svg width="784pt" height="615pt"
+ viewBox="0.00 0.00 783.75 615.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 611)">
+<title>corpus</title>
+<polygon fill="#111111" stroke="none" points="-4,4 -4,-611 779.75,-611 779.75,4 -4,4"/>
+<g id="clust1" class="cluster">
+<title>cluster_frontend</title>
+<path fill="#1e1533" stroke="#9b7de8" d="M20,-395C20,-395 129.25,-395 129.25,-395 135.25,-395 141.25,-401 141.25,-407 141.25,-407 141.25,-544 141.25,-544 141.25,-550 135.25,-556 129.25,-556 129.25,-556 20,-556 20,-556 14,-556 8,-550 8,-544 8,-544 8,-407 8,-407 8,-401 14,-395 20,-395"/>
+<text text-anchor="middle" x="74.62" y="-538.7" font-family="Atkinson Hyperlegible Mono" font-size="14.00" fill="white">Frontend</text>
+</g>
+<g id="clust2" class="cluster">
+<title>cluster_server</title>
+<path fill="#112611" stroke="#5aaa5a" d="M385.25,-180C385.25,-180 508,-180 508,-180 514,-180 520,-186 520,-192 520,-192 520,-587 520,-587 520,-593 514,-599 508,-599 508,-599 385.25,-599 385.25,-599 379.25,-599 373.25,-593 373.25,-587 373.25,-587 373.25,-192 373.25,-192 373.25,-186 379.25,-180 385.25,-180"/>
+<text text-anchor="middle" x="446.62" y="-581.7" font-family="Atkinson Hyperlegible Mono" font-size="14.00" fill="white">Corpus Server</text>
+</g>
+<g id="clust3" class="cluster">
+<title>cluster_storage</title>
+<path fill="#272000" stroke="#d4a80c" d="M660,-438C660,-438 728.75,-438 728.75,-438 734.75,-438 740.75,-444 740.75,-450 740.75,-450 740.75,-587 740.75,-587 740.75,-593 734.75,-599 728.75,-599 728.75,-599 660,-599 660,-599 654,-599 648,-593 648,-587 648,-587 648,-450 648,-450 648,-444 654,-438 660,-438"/>
+<text text-anchor="middle" x="694.38" y="-581.7" font-family="Atkinson Hyperlegible Mono" font-size="14.00" fill="white">Storage</text>
+</g>
+<g id="clust4" class="cluster">
+<title>cluster_external</title>
+<path fill="#0e1e38" stroke="#5a8de0" d="M633,-8C633,-8 755.75,-8 755.75,-8 761.75,-8 767.75,-14 767.75,-20 767.75,-20 767.75,-415 767.75,-415 767.75,-421 761.75,-427 755.75,-427 755.75,-427 633,-427 633,-427 627,-427 621,-421 621,-415 621,-415 621,-20 621,-20 621,-14 627,-8 633,-8"/>
+<text text-anchor="middle" x="694.38" y="-409.7" font-family="Atkinson Hyperlegible Mono" font-size="14.00" fill="white">External APIs</text>
+</g>
+<!-- UI -->
+<g id="node1" class="node">
+<title>UI</title>
+<path fill="#2d2050" stroke="black" d="M104.38,-439C104.38,-439 44.88,-439 44.88,-439 38.88,-439 32.88,-433 32.88,-427 32.88,-427 32.88,-415 32.88,-415 32.88,-409 38.88,-403 44.88,-403 44.88,-403 104.38,-403 104.38,-403 110.38,-403 116.38,-409 116.38,-415 116.38,-415 116.38,-427 116.38,-427 116.38,-433 110.38,-439 104.38,-439"/>
+<text text-anchor="middle" x="74.62" y="-422.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">Elm SPA</text>
+<text text-anchor="middle" x="74.62" y="-410.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">?user=slug</text>
+</g>
+<!-- API -->
+<g id="node3" class="node">
+<title>API</title>
+<path fill="#1c3e1c" stroke="black" d="M466.25,-482C466.25,-482 427,-482 427,-482 421,-482 415,-476 415,-470 415,-470 415,-458 415,-458 415,-452 421,-446 427,-446 427,-446 466.25,-446 466.25,-446 472.25,-446 478.25,-452 478.25,-458 478.25,-458 478.25,-470 478.25,-470 478.25,-476 472.25,-482 466.25,-482"/>
+<text text-anchor="middle" x="446.62" y="-459.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">Web API</text>
+</g>
+<!-- UI->API -->
+<g id="edge12" class="edge">
+<title>UI->API</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M116.59,-421C213.91,-421 446.75,-421 446.75,-421 446.75,-421 446.75,-435.81 446.75,-435.81"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="446.75,-443.81 443.15,-435.81 446.55,-441.51 446.55,-435.81 446.75,-435.81 446.95,-435.81 446.95,-441.51 450.35,-435.81 446.75,-443.81"/>
+</g>
+<!-- Proxy -->
+<g id="node4" class="node">
+<title>Proxy</title>
+<path fill="#1c3e1c" stroke="black" d="M479.75,-310C479.75,-310 413.5,-310 413.5,-310 407.5,-310 401.5,-304 401.5,-298 401.5,-298 401.5,-286 401.5,-286 401.5,-280 407.5,-274 413.5,-274 413.5,-274 479.75,-274 479.75,-274 485.75,-274 491.75,-280 491.75,-286 491.75,-286 491.75,-298 491.75,-298 491.75,-304 485.75,-310 479.75,-310"/>
+<text text-anchor="middle" x="446.62" y="-287.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">Cover Proxy</text>
+</g>
+<!-- UI->Proxy -->
+<g id="edge14" class="edge">
+<title>UI->Proxy</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M74.75,-402.52C74.75,-366.85 74.75,-292 74.75,-292 74.75,-292 391.32,-292 391.32,-292"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="399.32,-292 391.32,-295.6 397.03,-292.2 391.32,-292.2 391.32,-292 391.32,-291.8 397.03,-291.8 391.32,-288.4 399.32,-292"/>
+</g>
+<!-- Client -->
+<g id="node2" class="node">
+<title>Client</title>
+<path fill="#2d2050" stroke="black" d="M121.25,-525C121.25,-525 28,-525 28,-525 22,-525 16,-519 16,-513 16,-513 16,-501 16,-501 16,-495 22,-489 28,-489 28,-489 121.25,-489 121.25,-489 127.25,-489 133.25,-495 133.25,-501 133.25,-501 133.25,-513 133.25,-513 133.25,-519 127.25,-525 121.25,-525"/>
+<text text-anchor="middle" x="74.62" y="-508.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">Scrobble Client</text>
+<text text-anchor="middle" x="74.62" y="-496.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">(LB-compatible)</text>
+</g>
+<!-- Client->API -->
+<g id="edge10" class="edge">
+<title>Client->API</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M124.75,-488.51C124.75,-476.86 124.75,-464 124.75,-464 124.75,-464 404.88,-464 404.88,-464"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="412.88,-464 404.88,-467.6 410.59,-464.2 404.88,-464.2 404.88,-464 404.88,-463.8 410.59,-463.8 404.88,-460.4 412.88,-464"/>
+<text text-anchor="middle" x="257.25" y="-517.45" font-family="Atkinson Hyperlegible Mono" font-size="9.00" fill="#aaaaaa">POST /1/submit-listens</text>
+<text text-anchor="middle" x="257.25" y="-506.95" font-family="Atkinson Hyperlegible Mono" font-size="9.00" fill="#aaaaaa">(token auth)</text>
+</g>
+<!-- DB -->
+<g id="node8" class="node">
+<title>DB</title>
+<path fill="#3d3100" stroke="black" d="M722.62,-564.73C722.62,-566.53 709.96,-568 694.38,-568 678.79,-568 666.12,-566.53 666.12,-564.73 666.12,-564.73 666.12,-535.27 666.12,-535.27 666.12,-533.47 678.79,-532 694.38,-532 709.96,-532 722.62,-533.47 722.62,-535.27 722.62,-535.27 722.62,-564.73 722.62,-564.73"/>
+<path fill="none" stroke="black" d="M722.62,-564.73C722.62,-562.92 709.96,-561.45 694.38,-561.45 678.79,-561.45 666.12,-562.92 666.12,-564.73"/>
+<text text-anchor="middle" x="694.38" y="-545.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">DuckDB</text>
+</g>
+<!-- API->DB -->
+<g id="edge11" class="edge">
+<title>API->DB</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M478.75,-470C520.94,-470 589.75,-470 589.75,-470 589.75,-470 589.75,-550 589.75,-550 589.75,-550 656.08,-550 656.08,-550"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="664.08,-550 656.08,-553.6 661.79,-550.2 656.08,-550.2 656.08,-550 656.08,-549.8 661.79,-549.8 656.08,-546.4 664.08,-550"/>
+</g>
+<!-- S3 -->
+<g id="node9" class="node">
+<title>S3</title>
+<polygon fill="#3d3100" stroke="black" points="732.75,-482 660,-482 656,-478 656,-446 728.75,-446 732.75,-450 732.75,-482"/>
+<polyline fill="none" stroke="black" points="728.75,-478 656,-478"/>
+<polyline fill="none" stroke="black" points="728.75,-478 728.75,-446"/>
+<polyline fill="none" stroke="black" points="728.75,-478 732.75,-482"/>
+<text text-anchor="middle" x="694.38" y="-459.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">S3 Bucket</text>
+</g>
+<!-- Proxy->S3 -->
+<g id="edge15" class="edge">
+<title>Proxy->S3</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M492.06,-302.8C540.33,-302.8 609.75,-302.8 609.75,-302.8 609.75,-302.8 609.75,-458 609.75,-458 609.75,-458 645.82,-458 645.82,-458"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="653.82,-458 645.82,-461.6 651.53,-458.2 645.82,-458.2 645.82,-458 645.82,-457.8 651.53,-457.8 645.82,-454.4 653.82,-458"/>
+</g>
+<!-- LF -->
+<g id="node11" class="node">
+<title>LF</title>
+<path fill="#162d52" stroke="black" d="M714,-224C714,-224 674.75,-224 674.75,-224 668.75,-224 662.75,-218 662.75,-212 662.75,-212 662.75,-200 662.75,-200 662.75,-194 668.75,-188 674.75,-188 674.75,-188 714,-188 714,-188 720,-188 726,-194 726,-200 726,-200 726,-212 726,-212 726,-218 720,-224 714,-224"/>
+<text text-anchor="middle" x="694.38" y="-201.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">Last.fm</text>
+</g>
+<!-- Proxy->LF -->
+<g id="edge17" class="edge">
+<title>Proxy->LF</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M491.99,-288.4C520.05,-288.4 550.75,-288.4 550.75,-288.4 550.75,-288.4 550.75,-209.6 550.75,-209.6 550.75,-209.6 652.79,-209.6 652.79,-209.6"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="660.79,-209.6 652.79,-213.2 658.5,-209.8 652.79,-209.8 652.79,-209.6 652.79,-209.4 658.5,-209.4 652.79,-206 660.79,-209.6"/>
+</g>
+<!-- DC -->
+<g id="node13" class="node">
+<title>DC</title>
+<path fill="#162d52" stroke="black" d="M714,-138C714,-138 674.75,-138 674.75,-138 668.75,-138 662.75,-132 662.75,-126 662.75,-126 662.75,-114 662.75,-114 662.75,-108 668.75,-102 674.75,-102 674.75,-102 714,-102 714,-102 720,-102 726,-108 726,-114 726,-114 726,-126 726,-126 726,-132 720,-138 714,-138"/>
+<text text-anchor="middle" x="694.38" y="-115.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">Discogs</text>
+</g>
+<!-- Proxy->DC -->
+<g id="edge18" class="edge">
+<title>Proxy->DC</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M492.18,-281.2C512.42,-281.2 531.75,-281.2 531.75,-281.2 531.75,-281.2 531.75,-126 531.75,-126 531.75,-126 652.93,-126 652.93,-126"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="660.93,-126 652.93,-129.6 658.63,-126.2 652.93,-126.2 652.93,-126 652.93,-125.8 658.63,-125.8 652.93,-122.4 660.93,-126"/>
+</g>
+<!-- CAA -->
+<g id="node14" class="node">
+<title>CAA</title>
+<path fill="#162d52" stroke="black" d="M747.75,-310C747.75,-310 641,-310 641,-310 635,-310 629,-304 629,-298 629,-298 629,-286 629,-286 629,-280 635,-274 641,-274 641,-274 747.75,-274 747.75,-274 753.75,-274 759.75,-280 759.75,-286 759.75,-286 759.75,-298 759.75,-298 759.75,-304 753.75,-310 747.75,-310"/>
+<text text-anchor="middle" x="694.38" y="-287.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">Cover Art Archive</text>
+</g>
+<!-- Proxy->CAA -->
+<g id="edge16" class="edge">
+<title>Proxy->CAA</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M492.07,-295.6C492.07,-295.6 618.98,-295.6 618.98,-295.6"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="626.98,-295.6 618.98,-299.2 624.68,-295.8 618.98,-295.8 618.98,-295.6 618.98,-295.4 624.68,-295.4 618.98,-292 626.98,-295.6"/>
+</g>
+<!-- LBSync -->
+<g id="node5" class="node">
+<title>LBSync</title>
+<path fill="#1c3e1c" stroke="black" d="M500,-568C500,-568 393.25,-568 393.25,-568 387.25,-568 381.25,-562 381.25,-556 381.25,-556 381.25,-544 381.25,-544 381.25,-538 387.25,-532 393.25,-532 393.25,-532 500,-532 500,-532 506,-532 512,-538 512,-544 512,-544 512,-556 512,-556 512,-562 506,-568 500,-568"/>
+<text text-anchor="middle" x="446.62" y="-545.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">ListenBrainz Sync</text>
+</g>
+<!-- LBSync->DB -->
+<g id="edge3" class="edge">
+<title>LBSync->DB</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M512.37,-559C512.37,-559 655.97,-559 655.97,-559"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="663.97,-559 655.97,-562.6 661.67,-559.2 655.97,-559.2 655.97,-559 655.97,-558.8 661.67,-558.8 655.97,-555.4 663.97,-559"/>
+</g>
+<!-- LFSync -->
+<g id="node6" class="node">
+<title>LFSync</title>
+<path fill="#1c3e1c" stroke="black" d="M483.12,-396C483.12,-396 410.12,-396 410.12,-396 404.12,-396 398.12,-390 398.12,-384 398.12,-384 398.12,-372 398.12,-372 398.12,-366 404.12,-360 410.12,-360 410.12,-360 483.12,-360 483.12,-360 489.12,-360 495.12,-366 495.12,-372 495.12,-372 495.12,-384 495.12,-384 495.12,-390 489.12,-396 483.12,-396"/>
+<text text-anchor="middle" x="446.62" y="-373.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">Last.fm Sync</text>
+</g>
+<!-- LFSync->DB -->
+<g id="edge4" class="edge">
+<title>LFSync->DB</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M486.75,-396.43C486.75,-427.44 486.75,-486.67 486.75,-486.67 486.75,-486.67 684.75,-486.67 684.75,-486.67 684.75,-486.67 684.75,-521.98 684.75,-521.98"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="684.75,-529.98 681.15,-521.98 684.55,-527.68 684.55,-521.98 684.75,-521.98 684.95,-521.98 684.95,-527.68 688.35,-521.98 684.75,-529.98"/>
+</g>
+<!-- Enrich -->
+<g id="node7" class="node">
+<title>Enrich</title>
+<path fill="#1c3e1c" stroke="black" d="M493.25,-224C493.25,-224 400,-224 400,-224 394,-224 388,-218 388,-212 388,-212 388,-200 388,-200 388,-194 394,-188 400,-188 400,-188 493.25,-188 493.25,-188 499.25,-188 505.25,-194 505.25,-200 505.25,-200 505.25,-212 505.25,-212 505.25,-218 499.25,-224 493.25,-224"/>
+<text text-anchor="middle" x="446.62" y="-201.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">Enrichment Task</text>
+</g>
+<!-- Enrich->DB -->
+<g id="edge9" class="edge">
+<title>Enrich->DB</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M499.75,-224.26C499.75,-286.18 499.75,-484.33 499.75,-484.33 499.75,-484.33 703.75,-484.33 703.75,-484.33 703.75,-484.33 703.75,-522 703.75,-522"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="703.75,-530 700.15,-522 703.55,-527.71 703.55,-522 703.75,-522 703.95,-522 703.95,-527.71 707.35,-522 703.75,-530"/>
+</g>
+<!-- Enrich->LF -->
+<g id="edge7" class="edge">
+<title>Enrich->LF</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M505.65,-202.4C505.65,-202.4 652.6,-202.4 652.6,-202.4"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="660.6,-202.4 652.6,-206 658.31,-202.6 652.6,-202.6 652.6,-202.4 652.6,-202.2 658.31,-202.2 652.6,-198.8 660.6,-202.4"/>
+</g>
+<!-- MB -->
+<g id="node12" class="node">
+<title>MB</title>
+<path fill="#162d52" stroke="black" d="M727.5,-52C727.5,-52 661.25,-52 661.25,-52 655.25,-52 649.25,-46 649.25,-40 649.25,-40 649.25,-28 649.25,-28 649.25,-22 655.25,-16 661.25,-16 661.25,-16 727.5,-16 727.5,-16 733.5,-16 739.5,-22 739.5,-28 739.5,-28 739.5,-40 739.5,-40 739.5,-46 733.5,-52 727.5,-52"/>
+<text text-anchor="middle" x="694.38" y="-29.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">MusicBrainz</text>
+</g>
+<!-- Enrich->MB -->
+<g id="edge6" class="edge">
+<title>Enrich->MB</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M505.71,-195.2C567.55,-195.2 655.75,-195.2 655.75,-195.2 655.75,-195.2 655.75,-62.2 655.75,-62.2"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="655.75,-54.2 659.35,-62.2 655.95,-56.49 655.95,-62.2 655.75,-62.2 655.55,-62.2 655.55,-56.49 652.15,-62.2 655.75,-54.2"/>
+</g>
+<!-- Enrich->DC -->
+<g id="edge8" class="edge">
+<title>Enrich->DC</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M446.75,-187.82C446.75,-161.01 446.75,-114 446.75,-114 446.75,-114 652.55,-114 652.55,-114"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="660.55,-114 652.55,-117.6 658.26,-114.2 652.55,-114.2 652.55,-114 652.55,-113.8 658.26,-113.8 652.55,-110.4 660.55,-114"/>
+</g>
+<!-- LB -->
+<g id="node10" class="node">
+<title>LB</title>
+<path fill="#162d52" stroke="black" d="M730.88,-396C730.88,-396 657.88,-396 657.88,-396 651.88,-396 645.88,-390 645.88,-384 645.88,-384 645.88,-372 645.88,-372 645.88,-366 651.88,-360 657.88,-360 657.88,-360 730.88,-360 730.88,-360 736.88,-360 742.88,-366 742.88,-372 742.88,-372 742.88,-384 742.88,-384 742.88,-390 736.88,-396 730.88,-396"/>
+<text text-anchor="middle" x="694.38" y="-373.55" font-family="Atkinson Hyperlegible Mono" font-size="11.00" fill="white">ListenBrainz</text>
+</g>
+<!-- LB->LBSync -->
+<g id="edge1" class="edge">
+<title>LB->LBSync</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M650.75,-396.38C650.75,-439.01 650.75,-541 650.75,-541 650.75,-541 522.04,-541 522.04,-541"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="514.04,-541 522.04,-537.4 516.34,-540.8 522.04,-540.8 522.04,-541 522.04,-541.2 516.34,-541.2 522.04,-544.6 514.04,-541"/>
+</g>
+<!-- LF->LFSync -->
+<g id="edge2" class="edge">
+<title>LF->LFSync</title>
+<path fill="none" stroke="#aaaaaa" stroke-width="1.4" d="M662.54,-216.8C625.91,-216.8 570.75,-216.8 570.75,-216.8 570.75,-216.8 570.75,-378 570.75,-378 570.75,-378 505.03,-378 505.03,-378"/>
+<polygon fill="#aaaaaa" stroke="#aaaaaa" stroke-width="1.4" points="497.03,-378 505.03,-374.4 499.33,-377.8 505.03,-377.8 505.03,-378 505.03,-378.2 499.33,-378.2 505.03,-381.6 497.03,-378"/>
+</g>
+</g>
+</svg>
diff --git a/hack/graphviz b/hack/graphviz
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+command -v dot &>/dev/null || {
+ echo "error: graphviz (dot) not found"
+ exit 1
+}
+
+for dot_file in "$@"; do
+ svg_file="${dot_file%.dot}.svg"
+ dot -Tsvg "$dot_file" -o "$svg_file" \
+ 2> >(grep -v "do not currently handle edge labels" >&2)
+ git add "$svg_file"
+done
diff --git a/justfile b/justfile
@@ -61,6 +61,10 @@ reset-token *args:
list-users:
node server.js list-users
+# Render docs diagrams (requires graphviz)
+docs:
+ hack/graphviz docs/architecture.dot
+
# Run quality and security checks
check:
npx whine
diff --git a/package.json b/package.json
@@ -1,33 +1,33 @@
{
- "name": "corpus",
- "version": "2.13.1",
- "description": "ListenBrainz and Last.fm frontend",
- "main": "index.js",
- "type": "module",
- "scripts": {
- "build": "npx spago build && npx esbuild output/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:duckdb --external:@aws-sdk/client-s3 --external:prom-client --external:sharp --external:uuid && elm make src/Client.elm --output=client.js",
- "release": "npx spago build && npx purs-backend-es build && npx esbuild output-es/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:duckdb --external:@aws-sdk/client-s3 --external:prom-client --external:sharp --external:uuid && elm make src/Client.elm --optimize --output=client.js && npx uglifyjs client.js --compress \"pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe\" | npx uglifyjs --mangle --output client.js",
- "test": "npx spago test",
- "tidy": "purs-tidy format-in-place src/**/*.purs"
- },
- "devDependencies": {
- "elm": "^0.19.1",
- "esbuild": "^0.28.0",
- "purescript": "^0.15.16",
- "purescript-language-server": "^0.18.5",
- "purescript-psa": "^0.9.0",
- "purs-backend-es": "^1.4.3",
- "purs-tidy": "^0.11.1",
- "spago": "^1.0.4",
- "uglify-js": "^3.19.3",
- "whine": "^0.0.33"
- },
- "dependencies": {
- "@aws-sdk/client-s3": "^3.1029.0",
- "dotenv": "^17.4.1",
- "duckdb": "^1.4.4",
- "prom-client": "^15.1.3",
- "sharp": "^0.34.5",
- "uuid": "^14.0.0"
- }
+ "name": "corpus",
+ "version": "v2.13.1",
+ "description": "ListenBrainz and Last.fm frontend",
+ "main": "index.js",
+ "type": "module",
+ "scripts": {
+ "build": "npx spago build && npx esbuild output/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:duckdb --external:@aws-sdk/client-s3 --external:prom-client --external:sharp --external:uuid && elm make src/Client.elm --output=client.js",
+ "release": "npx spago build && npx purs-backend-es build && npx esbuild output-es/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:duckdb --external:@aws-sdk/client-s3 --external:prom-client --external:sharp --external:uuid && elm make src/Client.elm --optimize --output=client.js && npx uglifyjs client.js --compress \"pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe\" | npx uglifyjs --mangle --output client.js",
+ "test": "npx spago test",
+ "tidy": "purs-tidy format-in-place src/**/*.purs"
+ },
+ "devDependencies": {
+ "elm": "^0.19.1",
+ "esbuild": "^0.28.0",
+ "purescript": "^0.15.16",
+ "purescript-language-server": "^0.18.5",
+ "purescript-psa": "^0.9.0",
+ "purs-backend-es": "^1.4.3",
+ "purs-tidy": "^0.11.1",
+ "spago": "^1.0.4",
+ "uglify-js": "^3.19.3",
+ "whine": "^0.0.33"
+ },
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.1029.0",
+ "dotenv": "^17.4.1",
+ "duckdb": "^1.4.4",
+ "prom-client": "^15.1.3",
+ "sharp": "^0.34.5",
+ "uuid": "^14.0.0"
+ }
}
diff --git a/prek.toml b/prek.toml
@@ -27,3 +27,14 @@ rev = "v1.3.1"
hooks = [
{ id = "deadnix" }
]
+[[repos]]
+repo = "local"
+hooks = [
+ {
+ id = "graphviz",
+ name = "graphviz",
+ language = "system",
+ entry = "hack/graphviz",
+ files = '\.dot$'
+ },
+]
diff --git a/src/Db.js b/src/Db.js
@@ -36,7 +36,10 @@ export const checkpointImpl = (conn, cb) => () => {
// Pure transformation: produce new row objects rather than mutating in place.
const convertBigInts = (row) =>
Object.fromEntries(
- Object.entries(row).map(([k, v]) => [k, typeof v === "bigint" ? Number(v) : v]),
+ Object.entries(row).map(([k, v]) => [
+ k,
+ typeof v === "bigint" ? Number(v) : v,
+ ]),
);
export const allImpl = (conn, sql, params, cb) => () => {
diff --git a/src/Image.js b/src/Image.js
@@ -1,11 +1,13 @@
-import sharp from 'sharp';
+import sharp from "sharp";
export const convertToAvifImpl = (buffer) => () =>
- new Promise((resolve, reject) =>
- sharp(buffer)
- .avif()
- .toBuffer((err, data) => {
- if (err) return reject(err);
- resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength));
- })
- );
+ new Promise((resolve, reject) =>
+ sharp(buffer)
+ .avif()
+ .toBuffer((err, data) => {
+ if (err) return reject(err);
+ resolve(
+ data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
+ );
+ }),
+ );
diff --git a/src/Metrics.js b/src/Metrics.js
@@ -99,15 +99,23 @@ const makeActiveMetrics = () => {
httpRequestsTotal.inc({ method, path, status: String(status) });
httpRequestDurationSeconds.observe({ method, path }, durationMs / 1000);
},
- incSyncRuns: (user, source, result) => syncRunsTotal.inc({ user, source, result }),
- incSyncScrobbles: (user, source, count) => syncScrobblesAddedTotal.inc({ user, source }, count),
- setSyncLastSuccess: (user, source) => syncLastSuccessSeconds.setToCurrentTime({ user, source }),
- incEnrichmentFetch: (user, source, result) => enrichmentFetchesTotal.inc({ user, source, result }),
- setEnrichmentQueueSize: (user, type, size) => enrichmentQueueSize.set({ user, type }, size),
- incCoverRequest: (user, source, result) => coverRequestsTotal.inc({ user, source, result }),
- incCosineRequest: (user, result) => cosineRequestsTotal.inc({ user, result }),
+ incSyncRuns: (user, source, result) =>
+ syncRunsTotal.inc({ user, source, result }),
+ incSyncScrobbles: (user, source, count) =>
+ syncScrobblesAddedTotal.inc({ user, source }, count),
+ setSyncLastSuccess: (user, source) =>
+ syncLastSuccessSeconds.setToCurrentTime({ user, source }),
+ incEnrichmentFetch: (user, source, result) =>
+ enrichmentFetchesTotal.inc({ user, source, result }),
+ setEnrichmentQueueSize: (user, type, size) =>
+ enrichmentQueueSize.set({ user, type }, size),
+ incCoverRequest: (user, source, result) =>
+ coverRequestsTotal.inc({ user, source, result }),
+ incCosineRequest: (user, result) =>
+ cosineRequestsTotal.inc({ user, result }),
incDbBackupRun: (user, result) => dbBackupRunsTotal.inc({ user, result }),
- setDbBackupLastSuccess: (user) => dbBackupLastSuccessSeconds.setToCurrentTime({ user }),
+ setDbBackupLastSuccess: (user) =>
+ dbBackupLastSuccessSeconds.setToCurrentTime({ user }),
};
};
@@ -129,7 +137,9 @@ const makeNoOpMetrics = () => ({
});
const activeMetrics =
- process.env.METRICS_ENABLED === "true" ? makeActiveMetrics() : makeNoOpMetrics();
+ process.env.METRICS_ENABLED === "true"
+ ? makeActiveMetrics()
+ : makeNoOpMetrics();
// ---------------------------------------------------------------------------
// Exports
@@ -146,16 +156,17 @@ export const getContentType = () => activeMetrics.contentType;
// Attaches a 'finish' listener to record metrics and log the request.
// logFn: String -> Effect Unit — structured logger called on completion.
-export const wrapRequestImpl = (method, path, logFn, _req, res, handler) => () => {
- const startMs = Date.now();
- res.once("finish", () => {
- const durationMs = Date.now() - startMs;
- const status = res.statusCode || 0;
- activeMetrics.recordHttpRequest(method, path, status, durationMs);
- logFn(`${method} ${path} ${status} ${durationMs}ms`)();
- });
- handler();
-};
+export const wrapRequestImpl =
+ (method, path, logFn, _req, res, handler) => () => {
+ const startMs = Date.now();
+ res.once("finish", () => {
+ const durationMs = Date.now() - startMs;
+ const status = res.statusCode || 0;
+ activeMetrics.recordHttpRequest(method, path, status, durationMs);
+ logFn(`${method} ${path} ${status} ${durationMs}ms`)();
+ });
+ handler();
+ };
export const incSyncRunsImpl = (user, source, result) => () => {
activeMetrics.incSyncRuns(user, source, result);
diff --git a/users.json b/users.json
@@ -1,26 +1,26 @@
{
- "users": [
- {
- "config": {
- "backupEnabled": true,
- "backupIntervalHours": 24,
- "coverCacheEnabled": true,
- "databaseFile": "corpus-mtmn.db",
- "listenbrainzUser": "mtmn"
- },
- "name": "mtmn",
- "slug": ""
- },
- {
- "config": {
- "backupEnabled": true,
- "backupIntervalHours": 24,
- "coverCacheEnabled": true,
- "databaseFile": "corpus-lastfm-mtmnn.db",
- "lastfmUser": "mtmnn"
- },
- "name": "mtmn (last.fm)",
- "slug": "mtmnn"
- }
- ]
+ "users": [
+ {
+ "config": {
+ "backupEnabled": true,
+ "backupIntervalHours": 24,
+ "coverCacheEnabled": true,
+ "databaseFile": "corpus-mtmn.db",
+ "listenbrainzUser": "mtmn"
+ },
+ "name": "mtmn",
+ "slug": ""
+ },
+ {
+ "config": {
+ "backupEnabled": true,
+ "backupIntervalHours": 24,
+ "coverCacheEnabled": true,
+ "databaseFile": "corpus-lastfm-mtmnn.db",
+ "lastfmUser": "mtmnn"
+ },
+ "name": "mtmn (last.fm)",
+ "slug": "mtmnn"
+ }
+ ]
}