corpus

Log | Files | Refs | README | LICENSE

commit 3a6752fda8ba988767efb16bb4da4b8130d2304a
parent 4dd83890e47eebd7b85f69d1a70acff71a4d24d2
Author: mtmn <miro@haravara.org>
Date:   Fri,  8 May 2026 20:32:52 +0200

feat: render diagram using graphviz

Diffstat:
Adocs/architecture.dot | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/architecture.md | 58+++-------------------------------------------------------
Adocs/architecture.svg | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahack/graphviz | 14++++++++++++++
Mjustfile | 4++++
Mpackage.json | 62+++++++++++++++++++++++++++++++-------------------------------
Mprek.toml | 11+++++++++++
Msrc/Db.js | 5++++-
Msrc/Image.js | 20+++++++++++---------
Msrc/Metrics.js | 49++++++++++++++++++++++++++++++-------------------
Musers.json | 48++++++++++++++++++++++++------------------------
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`). + +![System Flow](architecture.svg) 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&#45;&gt;API --> +<g id="edge12" class="edge"> +<title>UI&#45;&gt;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&#45;&gt;Proxy --> +<g id="edge14" class="edge"> +<title>UI&#45;&gt;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&#45;compatible)</text> +</g> +<!-- Client&#45;&gt;API --> +<g id="edge10" class="edge"> +<title>Client&#45;&gt;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&#45;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&#45;&gt;DB --> +<g id="edge11" class="edge"> +<title>API&#45;&gt;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&#45;&gt;S3 --> +<g id="edge15" class="edge"> +<title>Proxy&#45;&gt;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&#45;&gt;LF --> +<g id="edge17" class="edge"> +<title>Proxy&#45;&gt;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&#45;&gt;DC --> +<g id="edge18" class="edge"> +<title>Proxy&#45;&gt;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&#45;&gt;CAA --> +<g id="edge16" class="edge"> +<title>Proxy&#45;&gt;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&#45;&gt;DB --> +<g id="edge3" class="edge"> +<title>LBSync&#45;&gt;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&#45;&gt;DB --> +<g id="edge4" class="edge"> +<title>LFSync&#45;&gt;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&#45;&gt;DB --> +<g id="edge9" class="edge"> +<title>Enrich&#45;&gt;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&#45;&gt;LF --> +<g id="edge7" class="edge"> +<title>Enrich&#45;&gt;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&#45;&gt;MB --> +<g id="edge6" class="edge"> +<title>Enrich&#45;&gt;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&#45;&gt;DC --> +<g id="edge8" class="edge"> +<title>Enrich&#45;&gt;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&#45;&gt;LBSync --> +<g id="edge1" class="edge"> +<title>LB&#45;&gt;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&#45;&gt;LFSync --> +<g id="edge2" class="edge"> +<title>LF&#45;&gt;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" + } + ] }