Commit Diff


commit - 72f44dcb85e8ae0623e927bb0a65c06e636e8005
commit + 503e9aac05c8fbfe91124901d16006f2a5bc93ec
blob - 3af35445fc14e2b2d4582f4ed6d09f7ee0780f77
blob + a0aeb011827c34b4bf980a6768a5f2cb24d87268
--- docs/architecture.dot
+++ docs/architecture.dot
@@ -1,102 +1,209 @@
-digraph corpus {
+digraph corpus_architecture {
     graph [
         rankdir=LR
-        bgcolor="transparent"
-        nodesep=0.7
-        ranksep=1.0
-        pad=0.2
-        fontname="Atkinson Hyperlegible Mono"
-        fontcolor="#e8edf5"
+        bgcolor="#ffffff"
+        pad=0.30
+        nodesep=0.32
+        ranksep=1.15
+        splines=spline
+        compound=true
+        newrank=true
+        outputorder=edgesfirst
+        fontname="Intel Mono"
+        fontcolor="#424242"
     ]
 
     node [
-        fontname="Atkinson Hyperlegible Mono"
-        fontsize=10
         shape=box
         style="rounded,filled"
-        color="#8b97a8"
-        fontcolor="#ffffff"
-        margin="0.18,0.12"
+        color="#424242"
+        fillcolor="#ffffff"
+        fontname="Intel Mono"
+        fontsize=11
+        fontcolor="#424242"
+        margin="0.18,0.10"
+        penwidth=1.0
+        width=1.45
     ]
+
     edge [
-        fontname="Atkinson Hyperlegible Mono"
+        color="#424242"
+        fontcolor="#424242"
+        fontname="Intel Mono"
         fontsize=8
-        color="#aeb8c7"
-        fontcolor="#d5dce7"
-        arrowhead=vee
-        arrowsize=0.7
-        penwidth=1.25
+        arrowsize=0.65
+        penwidth=1.0
     ]
 
     subgraph cluster_clients {
-        label="People and clients"
+        label="CLIENTS"
+        labelloc=t
+        labeljust=l
         style="rounded,filled"
-        fillcolor="#201a35"
-        color="#9879dc"
-        fontcolor="#ffffff"
-        Clients [
-            label="Browser\nElm SPA\n\nScrobbler\nListenBrainz-compatible\n\nAdministrator · Prometheus (optional)"
-            fillcolor="#302550"
-        ]
+        color="#424242"
+        fillcolor="#ffffff"
+        fontcolor="#424242"
+        fontsize=11
+        margin=16
+
+        Browser
+        Scrobbler
+        Operator
+        { rank=same; Browser; Scrobbler; Operator }
+        Browser -> Scrobbler -> Operator [style=invis, weight=30]
     }
 
-    subgraph cluster_server {
-        label="Corpus server  ·  PureScript on Node.js"
+    subgraph cluster_corpus {
+        label="CORPUS  ·  PURESCRIPT / NODE.JS"
+        labelloc=t
+        labeljust=l
         style="rounded,filled"
-        fillcolor="#11291d"
-        color="#59aa76"
-        fontcolor="#ffffff"
-        Corpus [
-            label="HTTP server and API\nSPA · proxy · stats · cover · similar\n\nPer-user background work\nListenBrainz and Last.fm sync\nmetadata enrichment · cover cache · database backup\n\nRegistration/admin workflow · optional metrics"
-            fillcolor="#1b422d"
-        ]
+        color="#424242"
+        fillcolor="#ffffff"
+        fontcolor="#424242"
+        fontsize=11
+        margin=18
+
+        subgraph cluster_interfaces {
+            label="INTERFACES"
+            labelloc=t
+            labeljust=l
+            style="rounded,filled"
+            color="#424242"
+            fillcolor="#ffffff"
+            fontcolor="#424242"
+            fontsize=9
+            margin=12
+
+            Frontend [label="Frontend\nElm"]
+            API [label="API\nHTTP"]
+            CLI
+            { rank=same; Frontend; API; CLI }
+            Frontend -> API -> CLI [style=invis, weight=30]
+        }
+
+        subgraph cluster_services {
+            label="SERVICES"
+            labelloc=t
+            labeljust=l
+            style="rounded,filled"
+            color="#424242"
+            fillcolor="#ffffff"
+            fontcolor="#424242"
+            fontsize=9
+            margin=12
+
+            Synchronizer
+            Query
+            Enricher
+            Artwork
+            Recommender
+            Registrar
+            Archiver
+            { rank=same; Synchronizer; Query; Enricher; Artwork; Recommender; Registrar; Archiver }
+            Synchronizer -> Query -> Enricher -> Artwork -> Recommender -> Registrar -> Archiver [style=invis, weight=30]
+        }
     }
 
-    subgraph cluster_storage {
-        label="Persistent storage"
+    subgraph cluster_persistence {
+        label="PERSISTENCE"
+        labelloc=t
+        labeljust=l
         style="rounded,filled"
-        fillcolor="#302a10"
-        color="#d2aa35"
-        fontcolor="#ffffff"
-        Storage [
-            label="DuckDB files: one per user\nscrobbles · release metadata · API tokens\n\nregistrations.db: shared registration state\n\nS3-compatible bucket\ncover cache · database backups"
-            shape=cylinder
-            fillcolor="#473d15"
-        ]
+        color="#424242"
+        fillcolor="#ffffff"
+        fontcolor="#424242"
+        fontsize=11
+        margin=16
+
+        DuckDB [label="DuckDB\nOLAP"]
+        S3 [label="S3\nObjects"]
+        { rank=same; DuckDB; S3 }
+        DuckDB -> S3 [style=invis, weight=30]
     }
 
-    subgraph cluster_external {
-        label="External services"
+    subgraph cluster_ingestion {
+        label="INGESTION"
+        labelloc=t
+        labeljust=l
         style="rounded,filled"
-        fillcolor="#11243e"
-        color="#6396e7"
-        fontcolor="#ffffff"
-        External [
-            label="ListenBrainz · Last.fm\nMusicBrainz · Discogs\nCover Art Archive\ncosine.club\nSMTP server (optional)"
-            fillcolor="#19365d"
-        ]
+        color="#424242"
+        fillcolor="#ffffff"
+        fontcolor="#424242"
+        fontsize=11
+        margin=16
+
+        ListenBrainz
+        LastFM [label="Last.fm"]
+        { rank=same; ListenBrainz; LastFM }
+        ListenBrainz -> LastFM [style=invis, weight=30]
     }
 
-    // Model labels as nodes so connection lines cannot cross their text.
-    node [
-        shape=box
+    subgraph cluster_catalogs {
+        label="CATALOGS"
+        labelloc=t
+        labeljust=l
         style="rounded,filled"
-        color="#4b5563"
-        fillcolor="#111827"
-        fontcolor="#ffffff"
-        fontsize=8
-        margin="0.1,0.06"
-    ]
-    ClientFlow [label="SPA, listen submission, admin, /metrics"]
-    StorageFlow [label="per-user data, registrations, cached covers, backups"]
-    ExternalFlow [
-        label="sync · enrichment · cover lookup\nCAA → Discogs → Last.fm · similar tracks · email"
-    ]
+        color="#424242"
+        fillcolor="#ffffff"
+        fontcolor="#424242"
+        fontsize=11
+        margin=16
 
-    Clients -> ClientFlow [arrowhead=none]
-    ClientFlow -> Corpus
-    Corpus -> StorageFlow [arrowhead=none]
-    StorageFlow -> Storage
-    Corpus -> ExternalFlow [dir=back]
-    ExternalFlow -> External
+        MusicBrainz
+        Discogs
+        CoverArchive [label="CoverArchive\nCAA"]
+        { rank=same; MusicBrainz; Discogs; CoverArchive }
+        MusicBrainz -> Discogs -> CoverArchive [style=invis, weight=30]
+    }
+
+    subgraph cluster_integrations {
+        label="INTEGRATIONS"
+        labelloc=t
+        labeljust=l
+        style="rounded,filled"
+        color="#424242"
+        fillcolor="#ffffff"
+        fontcolor="#424242"
+        fontsize=11
+        margin=16
+
+        Cosine [label="cosine.club"]
+        SMTP
+        Prometheus
+        { rank=same; Cosine; SMTP; Prometheus }
+        Cosine -> SMTP -> Prometheus [style=invis, weight=30]
+    }
+
+    Browser -> Frontend [label="HTTPS"]
+    Browser -> API [label="JSON"]
+    Scrobbler -> API [label="POST /1/submit-listens"]
+    Operator -> API [label="admin"]
+    Operator -> CLI [label="shell"]
+    Frontend -> API [label="JSON", constraint=false]
+
+    API -> Query [label="queries"]
+    API -> Artwork [label="covers"]
+    API -> Recommender [label="similar"]
+    API -> Registrar [label="accounts"]
+    CLI -> Registrar [label="users"]
+
+    Synchronizer -> ListenBrainz [dir=both]
+    Synchronizer -> LastFM [dir=both]
+    Synchronizer -> DuckDB
+    Query -> DuckDB [dir=both]
+    Enricher -> MusicBrainz [dir=both]
+    Enricher -> Discogs [dir=both]
+    Enricher -> DuckDB
+    Artwork -> CoverArchive
+    Artwork -> Discogs
+    Artwork -> S3 [dir=both]
+    Recommender -> Cosine [dir=both]
+    Registrar -> DuckDB
+    Registrar -> SMTP
+    Archiver -> DuckDB
+    Archiver -> S3
+    Prometheus -> API [label="scrape"]
+
+    { rank=same; DuckDB; S3; ListenBrainz; LastFM; MusicBrainz; Discogs; CoverArchive; Cosine; SMTP; Prometheus }
 }
blob - f996ff32a633b5ef5bc5d2501990387bc7c11aa7
blob + 590686597b8f7465d7a3aeff28e5010a0af86080
--- docs/architecture.svg
+++ docs/architecture.svg
@@ -3,125 +3,391 @@
  "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
 <!-- Generated by graphviz version 15.1.1 (0)
  -->
-<!-- Title: corpus Pages: 1 -->
-<svg width="1738pt" height="342pt"
- viewBox="0.00 0.00 1738.00 342.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(14.4 327.4)">
-<title>corpus</title>
+<!-- Title: corpus_architecture Pages: 1 -->
+<svg width="893pt" height="926pt"
+ viewBox="0.00 0.00 893.00 926.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(21.6 904.6)">
+<title>corpus_architecture</title>
+<polygon fill="#ffffff" stroke="none" points="-21.6,21.6 -21.6,-904.6 870.97,-904.6 870.97,21.6 -21.6,21.6"/>
 <g id="clust1" class="cluster">
 <title>cluster_clients</title>
-<path fill="#201a35" stroke="#9879dc" d="M20,-43C20,-43 259.92,-43 259.92,-43 265.92,-43 271.92,-49 271.92,-55 271.92,-55 271.92,-168 271.92,-168 271.92,-174 265.92,-180 259.92,-180 259.92,-180 20,-180 20,-180 14,-180 8,-174 8,-168 8,-168 8,-55 8,-55 8,-49 14,-43 20,-43"/>
-<text xml:space="preserve" text-anchor="middle" x="139.96" y="-162.7" font-family="Atkinson Hyperlegible Mono" font-size="14.00" fill="#ffffff">People and clients</text>
+<path fill="#ffffff" stroke="#424242" d="M20,-187C20,-187 132.4,-187 132.4,-187 138.4,-187 144.4,-193 144.4,-199 144.4,-199 144.4,-429 144.4,-429 144.4,-435 138.4,-441 132.4,-441 132.4,-441 20,-441 20,-441 14,-441 8,-435 8,-429 8,-429 8,-199 8,-199 8,-193 14,-187 20,-187"/>
+<text xml:space="preserve" text-anchor="middle" x="38.5" y="-426.55" font-family="Intel Mono" font-size="11.00" fill="#424242">CLIENTS</text>
 </g>
-<g id="clust2" class="cluster">
-<title>cluster_server</title>
-<path fill="#11291d" stroke="#59aa76" d="M631.07,-38C631.07,-38 974.07,-38 974.07,-38 980.07,-38 986.07,-44 986.07,-50 986.07,-50 986.07,-173 986.07,-173 986.07,-179 980.07,-185 974.07,-185 974.07,-185 631.07,-185 631.07,-185 625.07,-185 619.07,-179 619.07,-173 619.07,-173 619.07,-50 619.07,-50 619.07,-44 625.07,-38 631.07,-38"/>
-<text xml:space="preserve" text-anchor="middle" x="802.57" y="-167.7" font-family="Atkinson Hyperlegible Mono" font-size="14.00" fill="#ffffff">Corpus server &#160;· &#160;PureScript on Node.js</text>
-</g>
 <g id="clust3" class="cluster">
-<title>cluster_storage</title>
-<path fill="#302a10" stroke="#d2aa35" d="M1413.47,-132C1413.47,-132 1689.39,-132 1689.39,-132 1695.39,-132 1701.39,-138 1701.39,-144 1701.39,-144 1701.39,-293 1701.39,-293 1701.39,-299 1695.39,-305 1689.39,-305 1689.39,-305 1413.47,-305 1413.47,-305 1407.47,-305 1401.47,-299 1401.47,-293 1401.47,-293 1401.47,-144 1401.47,-144 1401.47,-138 1407.47,-132 1413.47,-132"/>
-<text xml:space="preserve" text-anchor="middle" x="1551.43" y="-287.7" font-family="Atkinson Hyperlegible Mono" font-size="14.00" fill="#ffffff">Persistent storage</text>
+<title>cluster_corpus</title>
+<path fill="#ffffff" stroke="#424242" d="M286.65,-165C286.65,-165 653.97,-165 653.97,-165 659.97,-165 665.97,-171 665.97,-177 665.97,-177 665.97,-690 665.97,-690 665.97,-696 659.97,-702 653.97,-702 653.97,-702 286.65,-702 286.65,-702 280.65,-702 274.65,-696 274.65,-690 274.65,-690 274.65,-177 274.65,-177 274.65,-171 280.65,-165 286.65,-165"/>
+<text xml:space="preserve" text-anchor="middle" x="377.15" y="-687.55" font-family="Intel Mono" font-size="11.00" fill="#424242">CORPUS &#160;· &#160;PURESCRIPT / NODE.JS</text>
 </g>
 <g id="clust4" class="cluster">
-<title>cluster_external</title>
-<path fill="#11243e" stroke="#6396e7" d="M1476.47,-8C1476.47,-8 1626.39,-8 1626.39,-8 1632.39,-8 1638.39,-14 1638.39,-20 1638.39,-20 1638.39,-109 1638.39,-109 1638.39,-115 1632.39,-121 1626.39,-121 1626.39,-121 1476.47,-121 1476.47,-121 1470.47,-121 1464.47,-115 1464.47,-109 1464.47,-109 1464.47,-20 1464.47,-20 1464.47,-14 1470.47,-8 1476.47,-8"/>
-<text xml:space="preserve" text-anchor="middle" x="1551.43" y="-103.7" font-family="Atkinson Hyperlegible Mono" font-size="14.00" fill="#ffffff">External services</text>
+<title>cluster_interfaces</title>
+<path fill="#ffffff" stroke="#424242" d="M304.65,-183C304.65,-183 409.05,-183 409.05,-183 415.05,-183 421.05,-189 421.05,-195 421.05,-195 421.05,-424 421.05,-424 421.05,-430 415.05,-436 409.05,-436 409.05,-436 304.65,-436 304.65,-436 298.65,-436 292.65,-430 292.65,-424 292.65,-424 292.65,-195 292.65,-195 292.65,-189 298.65,-183 304.65,-183"/>
+<text xml:space="preserve" text-anchor="middle" x="328.4" y="-423.45" font-family="Intel Mono" font-size="9.00" fill="#424242">INTERFACES</text>
 </g>
-<!-- Clients -->
+<g id="clust6" class="cluster">
+<title>cluster_services</title>
+<path fill="#ffffff" stroke="#424242" d="M530.55,-183C530.55,-183 635.97,-183 635.97,-183 641.97,-183 647.97,-189 647.97,-195 647.97,-195 647.97,-650 647.97,-650 647.97,-656 641.97,-662 635.97,-662 635.97,-662 530.55,-662 530.55,-662 524.55,-662 518.55,-656 518.55,-650 518.55,-650 518.55,-195 518.55,-195 518.55,-189 524.55,-183 530.55,-183"/>
+<text xml:space="preserve" text-anchor="middle" x="549.05" y="-649.45" font-family="Intel Mono" font-size="9.00" fill="#424242">SERVICES</text>
+</g>
+<g id="clust8" class="cluster">
+<title>cluster_persistence</title>
+<path fill="#ffffff" stroke="#424242" d="M716.97,-513C716.97,-513 829.37,-513 829.37,-513 835.37,-513 841.37,-519 841.37,-525 841.37,-525 841.37,-683 841.37,-683 841.37,-689 835.37,-695 829.37,-695 829.37,-695 716.97,-695 716.97,-695 710.97,-695 704.97,-689 704.97,-683 704.97,-683 704.97,-525 704.97,-525 704.97,-519 710.97,-513 716.97,-513"/>
+<text xml:space="preserve" text-anchor="middle" x="749.72" y="-680.55" font-family="Intel Mono" font-size="11.00" fill="#424242">PERSISTENCE</text>
+</g>
+<g id="clust10" class="cluster">
+<title>cluster_ingestion</title>
+<path fill="#ffffff" stroke="#424242" d="M716.97,-703C716.97,-703 829.37,-703 829.37,-703 835.37,-703 841.37,-709 841.37,-715 841.37,-715 841.37,-863 841.37,-863 841.37,-869 835.37,-875 829.37,-875 829.37,-875 716.97,-875 716.97,-875 710.97,-875 704.97,-869 704.97,-863 704.97,-863 704.97,-715 704.97,-715 704.97,-709 710.97,-703 716.97,-703"/>
+<text xml:space="preserve" text-anchor="middle" x="742.97" y="-860.55" font-family="Intel Mono" font-size="11.00" fill="#424242">INGESTION</text>
+</g>
+<g id="clust12" class="cluster">
+<title>cluster_catalogs</title>
+<path fill="#ffffff" stroke="#424242" d="M716.97,-247C716.97,-247 829.37,-247 829.37,-247 835.37,-247 841.37,-253 841.37,-259 841.37,-259 841.37,-493 841.37,-493 841.37,-499 835.37,-505 829.37,-505 829.37,-505 716.97,-505 716.97,-505 710.97,-505 704.97,-499 704.97,-493 704.97,-493 704.97,-259 704.97,-259 704.97,-253 710.97,-247 716.97,-247"/>
+<text xml:space="preserve" text-anchor="middle" x="741.85" y="-490.55" font-family="Intel Mono" font-size="11.00" fill="#424242">CATALOGS</text>
+</g>
+<g id="clust14" class="cluster">
+<title>cluster_integrations</title>
+<path fill="#ffffff" stroke="#424242" d="M716.97,-8C716.97,-8 829.37,-8 829.37,-8 835.37,-8 841.37,-14 841.37,-20 841.37,-20 841.37,-227 841.37,-227 841.37,-233 835.37,-239 829.37,-239 829.37,-239 716.97,-239 716.97,-239 710.97,-239 704.97,-233 704.97,-227 704.97,-227 704.97,-20 704.97,-20 704.97,-14 710.97,-8 716.97,-8"/>
+<text xml:space="preserve" text-anchor="middle" x="753.47" y="-224.55" font-family="Intel Mono" font-size="11.00" fill="#424242">INTEGRATIONS</text>
+</g>
+<!-- Browser&#45;&gt;Scrobbler -->
+<!-- Browser&#45;&gt;Frontend -->
+<g id="edge17" class="edge">
+<title>Browser&#45;&gt;Frontend</title>
+<path fill="none" stroke="#424242" d="M128.63,-220.08C176.1,-219.23 246.57,-217.96 296.43,-217.07"/>
+<polygon fill="#424242" stroke="#424242" points="296.27,-219.34 302.73,-216.95 296.19,-214.8 296.27,-219.34"/>
+<text xml:space="preserve" text-anchor="middle" x="216.53" y="-221.39" font-family="Intel Mono" font-size="8.00" fill="#424242">HTTPS</text>
+</g>
+<!-- Browser&#45;&gt;API -->
+<g id="edge18" class="edge">
+<title>Browser&#45;&gt;API</title>
+<path fill="none" stroke="#424242" d="M128.63,-236.14C176.2,-250.13 246.86,-270.93 296.73,-285.6"/>
+<polygon fill="#424242" stroke="#424242" points="295.92,-287.73 302.79,-287.39 297.2,-283.37 295.92,-287.73"/>
+<text xml:space="preserve" text-anchor="middle" x="216.53" y="-276.29" font-family="Intel Mono" font-size="8.00" fill="#424242">JSON</text>
+</g>
+<!-- Scrobbler&#45;&gt;Operator -->
+<!-- Scrobbler&#45;&gt;API -->
+<g id="edge19" class="edge">
+<title>Scrobbler&#45;&gt;API</title>
+<path fill="none" stroke="#424242" d="M128.63,-303C176.1,-303 246.57,-303 296.43,-303"/>
+<polygon fill="#424242" stroke="#424242" points="296.23,-305.28 302.73,-303 296.23,-300.73 296.23,-305.28"/>
+<text xml:space="preserve" text-anchor="middle" x="216.53" y="-305.15" font-family="Intel Mono" font-size="8.00" fill="#424242">POST /1/submit&#45;listens</text>
+</g>
+<!-- Operator&#45;&gt;API -->
+<g id="edge20" class="edge">
+<title>Operator&#45;&gt;API</title>
+<path fill="none" stroke="#424242" d="M128.63,-369.86C176.2,-355.87 246.86,-335.07 296.73,-320.4"/>
+<polygon fill="#424242" stroke="#424242" points="297.2,-322.63 302.79,-318.61 295.92,-318.27 297.2,-322.63"/>
+<text xml:space="preserve" text-anchor="middle" x="216.53" y="-358.29" font-family="Intel Mono" font-size="8.00" fill="#424242">admin</text>
+</g>
+<!-- Operator&#45;&gt;CLI -->
+<g id="edge21" class="edge">
+<title>Operator&#45;&gt;CLI</title>
+<path fill="none" stroke="#424242" d="M128.63,-385.37C176.1,-385.71 246.57,-386.22 296.43,-386.57"/>
+<polygon fill="#424242" stroke="#424242" points="296.22,-388.85 302.73,-386.62 296.25,-384.3 296.22,-388.85"/>
+<text xml:space="preserve" text-anchor="middle" x="216.53" y="-388.45" font-family="Intel Mono" font-size="8.00" fill="#424242">shell</text>
+</g>
+<!-- Frontend&#45;&gt;API -->
+<!-- Frontend&#45;&gt;API -->
+<g id="edge22" class="edge">
+<title>Frontend&#45;&gt;API</title>
+<path fill="none" stroke="#424242" d="M356.85,-237.06C356.85,-248.2 356.85,-262.04 356.85,-274.16"/>
+<polygon fill="#424242" stroke="#424242" points="354.58,-273.82 356.85,-280.32 359.13,-273.82 354.58,-273.82"/>
+<text xml:space="preserve" text-anchor="middle" x="348.98" y="-256.77" font-family="Intel Mono" font-size="8.00" fill="#424242">JSON</text>
+</g>
+<!-- API&#45;&gt;CLI -->
+<!-- API&#45;&gt;Query -->
+<g id="edge23" class="edge">
+<title>API&#45;&gt;Query</title>
+<path fill="none" stroke="#424242" d="M383.39,-324.01C395.15,-333.99 409.11,-346.28 421.05,-358 467.19,-403.31 466.37,-427.81 518.55,-466 521.78,-468.36 525.23,-470.6 528.79,-472.7"/>
+<polygon fill="#424242" stroke="#424242" points="527.64,-474.66 534.43,-475.83 529.85,-470.68 527.64,-474.66"/>
+<text xml:space="preserve" text-anchor="middle" x="469.8" y="-440.53" font-family="Intel Mono" font-size="8.00" fill="#424242">queries</text>
+</g>
+<!-- API&#45;&gt;Artwork -->
+<g id="edge24" class="edge">
+<title>API&#45;&gt;Artwork</title>
+<path fill="none" stroke="#424242" d="M409.33,-310.97C434.9,-316.8 465.22,-326.69 488.55,-343.25 506.89,-356.27 503.05,-367.71 518.55,-384 528.26,-394.2 539.94,-404.26 550.64,-412.78"/>
+<polygon fill="#424242" stroke="#424242" points="549.09,-414.45 555.61,-416.67 551.89,-410.87 549.09,-414.45"/>
+<text xml:space="preserve" text-anchor="middle" x="469.8" y="-345.4" font-family="Intel Mono" font-size="8.00" fill="#424242">covers</text>
+</g>
+<!-- API&#45;&gt;Recommender -->
+<g id="edge25" class="edge">
+<title>API&#45;&gt;Recommender</title>
+<path fill="none" stroke="#424242" d="M409.33,-282.35C445.24,-267.95 493.03,-248.79 529.26,-234.26"/>
+<polygon fill="#424242" stroke="#424242" points="529.76,-236.51 534.94,-231.98 528.06,-232.28 529.76,-236.51"/>
+<text xml:space="preserve" text-anchor="middle" x="469.8" y="-265.54" font-family="Intel Mono" font-size="8.00" fill="#424242">similar</text>
+</g>
+<!-- API&#45;&gt;Registrar -->
+<g id="edge26" class="edge">
+<title>API&#45;&gt;Registrar</title>
+<path fill="none" stroke="#424242" d="M409.33,-301.16C443.14,-299.96 487.47,-298.38 522.78,-297.12"/>
+<polygon fill="#424242" stroke="#424242" points="522.7,-299.4 529.11,-296.89 522.54,-294.85 522.7,-299.4"/>
+<text xml:space="preserve" text-anchor="middle" x="469.8" y="-301.63" font-family="Intel Mono" font-size="8.00" fill="#424242">accounts</text>
+</g>
+<!-- CLI&#45;&gt;Registrar -->
+<g id="edge27" class="edge">
+<title>CLI&#45;&gt;Registrar</title>
+<path fill="none" stroke="#424242" d="M409.53,-382.9C434.7,-379.12 464.63,-371.88 488.55,-358 505.7,-348.05 502.61,-336.78 518.55,-325 522.36,-322.19 526.47,-319.54 530.71,-317.07"/>
+<polygon fill="#424242" stroke="#424242" points="531.54,-319.21 536.12,-314.07 529.33,-315.23 531.54,-319.21"/>
+<text xml:space="preserve" text-anchor="middle" x="469.8" y="-375.73" font-family="Intel Mono" font-size="8.00" fill="#424242">users</text>
+</g>
+<!-- Synchronizer&#45;&gt;Query -->
+<!-- Synchronizer&#45;&gt;DuckDB -->
+<g id="edge30" class="edge">
+<title>Synchronizer&#45;&gt;DuckDB</title>
+<path fill="none" stroke="#424242" d="M635.61,-595.78C659.54,-587.76 688.15,-578.17 712.98,-569.84"/>
+<polygon fill="#424242" stroke="#424242" points="713.68,-572.01 719.12,-567.78 712.24,-567.69 713.68,-572.01"/>
+</g>
+<!-- Synchronizer&#45;&gt;ListenBrainz -->
+<g id="edge28" class="edge">
+<title>Synchronizer&#45;&gt;ListenBrainz</title>
+<path fill="none" stroke="#424242" d="M600.71,-637.65C622.51,-669.39 663.13,-725.42 704.97,-767 715.39,-777.35 727.87,-787.52 739.23,-796.08"/>
+<polygon fill="#424242" stroke="#424242" points="602.74,-636.59 597.2,-632.51 598.98,-639.16 602.74,-636.59"/>
+<polygon fill="#424242" stroke="#424242" points="737.61,-797.71 744.19,-799.75 740.32,-794.06 737.61,-797.71"/>
+</g>
+<!-- Synchronizer&#45;&gt;LastFM -->
+<g id="edge29" class="edge">
+<title>Synchronizer&#45;&gt;LastFM</title>
+<path fill="none" stroke="#424242" d="M613.88,-635.89C637.9,-654.07 672.9,-679.72 704.97,-700 712.88,-705.01 721.52,-710.05 729.89,-714.74"/>
+<polygon fill="#424242" stroke="#424242" points="615.47,-634.25 608.92,-632.12 612.72,-637.87 615.47,-634.25"/>
+<polygon fill="#424242" stroke="#424242" points="728.71,-716.69 735.5,-717.85 730.92,-712.71 728.71,-716.69"/>
+</g>
+<!-- Query&#45;&gt;Enricher -->
+<!-- Query&#45;&gt;DuckDB -->
+<g id="edge31" class="edge">
+<title>Query&#45;&gt;DuckDB</title>
+<path fill="none" stroke="#424242" d="M643.17,-512.24C665.46,-518.77 690.82,-526.19 713.12,-532.72"/>
+<polygon fill="#424242" stroke="#424242" points="643.94,-510.1 637.06,-510.45 642.66,-514.46 643.94,-510.1"/>
+<polygon fill="#424242" stroke="#424242" points="712.23,-534.83 719.11,-534.47 713.51,-530.46 712.23,-534.83"/>
+</g>
+<!-- Enricher&#45;&gt;Artwork -->
+<!-- Enricher&#45;&gt;DuckDB -->
+<g id="edge34" class="edge">
+<title>Enricher&#45;&gt;DuckDB</title>
+<path fill="none" stroke="#424242" d="M623.12,-372.41C638.17,-381.13 654.51,-392.79 665.97,-407 696.7,-445.1 673.51,-472.5 704.97,-510 709.46,-515.35 714.85,-520.16 720.61,-524.44"/>
+<polygon fill="#424242" stroke="#424242" points="719.09,-526.16 725.73,-528 721.69,-522.42 719.09,-526.16"/>
+</g>
+<!-- Enricher&#45;&gt;MusicBrainz -->
+<g id="edge32" class="edge">
+<title>Enricher&#45;&gt;MusicBrainz</title>
+<path fill="none" stroke="#424242" d="M642.94,-333.59C650.71,-330.76 658.55,-327.85 665.97,-325 684.23,-317.98 704.07,-309.89 721.61,-302.57"/>
+<polygon fill="#424242" stroke="#424242" points="642.61,-331.29 637.27,-335.63 644.15,-335.57 642.61,-331.29"/>
+<polygon fill="#424242" stroke="#424242" points="722.46,-304.69 727.57,-300.08 720.7,-300.49 722.46,-304.69"/>
+</g>
+<!-- Enricher&#45;&gt;Discogs -->
+<g id="edge33" class="edge">
+<title>Enricher&#45;&gt;Discogs</title>
+<path fill="none" stroke="#424242" d="M643.5,-356.84C665.52,-357.89 690.51,-359.09 712.57,-360.15"/>
+<polygon fill="#424242" stroke="#424242" points="643.72,-354.57 637.12,-356.53 643.5,-359.12 643.72,-354.57"/>
+<polygon fill="#424242" stroke="#424242" points="712.45,-362.42 719.05,-360.46 712.66,-357.87 712.45,-362.42"/>
+</g>
+<!-- Artwork&#45;&gt;Recommender -->
+<!-- Artwork&#45;&gt;S3 -->
+<g id="edge37" class="edge">
+<title>Artwork&#45;&gt;S3</title>
+<path fill="none" stroke="#424242" d="M643.1,-450.21C651.55,-454.24 659.55,-459.4 665.97,-466 703.9,-504.99 674.05,-537.25 704.97,-582 712.56,-592.99 722.95,-602.96 733.29,-611.3"/>
+<polygon fill="#424242" stroke="#424242" points="644.17,-448.2 637.3,-447.69 642.36,-452.37 644.17,-448.2"/>
+<polygon fill="#424242" stroke="#424242" points="731.63,-612.89 738.15,-615.09 734.42,-609.3 731.63,-612.89"/>
+</g>
+<!-- Artwork&#45;&gt;Discogs -->
+<g id="edge36" class="edge">
+<title>Artwork&#45;&gt;Discogs</title>
+<path fill="none" stroke="#424242" d="M631.68,-417.58C657.83,-407.42 690.41,-394.76 717.61,-384.19"/>
+<polygon fill="#424242" stroke="#424242" points="718.12,-386.44 723.36,-381.96 716.47,-382.2 718.12,-386.44"/>
+</g>
+<!-- Artwork&#45;&gt;CoverArchive -->
+<g id="edge35" class="edge">
+<title>Artwork&#45;&gt;CoverArchive</title>
+<path fill="none" stroke="#424242" d="M635.61,-439.01C659.43,-440.4 687.9,-442.07 712.66,-443.52"/>
+<polygon fill="#424242" stroke="#424242" points="712.43,-445.78 719.05,-443.89 712.69,-441.24 712.43,-445.78"/>
+</g>
+<!-- Recommender&#45;&gt;Registrar -->
+<!-- Recommender&#45;&gt;Cosine -->
+<g id="edge38" class="edge">
+<title>Recommender&#45;&gt;Cosine</title>
+<path fill="none" stroke="#424242" d="M622.08,-190.46C654.84,-170.93 701.99,-142.83 734.66,-123.35"/>
+<polygon fill="#424242" stroke="#424242" points="621.01,-188.45 616.59,-193.73 623.34,-192.36 621.01,-188.45"/>
+<polygon fill="#424242" stroke="#424242" points="735.69,-125.39 740.11,-120.11 733.36,-121.48 735.69,-125.39"/>
+</g>
+<!-- Registrar&#45;&gt;Archiver -->
+<!-- Registrar&#45;&gt;DuckDB -->
+<g id="edge39" class="edge">
+<title>Registrar&#45;&gt;DuckDB</title>
+<path fill="none" stroke="#424242" d="M635.8,-305.08C647.11,-309.5 658.06,-315.86 665.97,-325 720.96,-388.54 656.09,-441.65 704.97,-510 708.77,-515.32 713.52,-520.05 718.73,-524.23"/>
+<polygon fill="#424242" stroke="#424242" points="717.23,-525.95 723.81,-527.98 719.93,-522.29 717.23,-525.95"/>
+</g>
+<!-- Registrar&#45;&gt;SMTP -->
+<g id="edge40" class="edge">
+<title>Registrar&#45;&gt;SMTP</title>
+<path fill="none" stroke="#424242" d="M615.29,-276.51C648.03,-256.99 699.63,-226.24 734.64,-205.37"/>
+<polygon fill="#424242" stroke="#424242" points="735.69,-207.39 740.11,-202.11 733.36,-203.48 735.69,-207.39"/>
+</g>
+<!-- Archiver&#45;&gt;DuckDB -->
+<g id="edge41" class="edge">
+<title>Archiver&#45;&gt;DuckDB</title>
+<path fill="none" stroke="#424242" d="M635.61,-552.91C659.43,-552.4 687.9,-551.79 712.66,-551.27"/>
+<polygon fill="#424242" stroke="#424242" points="712.6,-553.54 719.05,-551.13 712.5,-548.99 712.6,-553.54"/>
+</g>
+<!-- Archiver&#45;&gt;S3 -->
+<g id="edge42" class="edge">
+<title>Archiver&#45;&gt;S3</title>
+<path fill="none" stroke="#424242" d="M635.6,-571.86C645.75,-575.67 656.26,-579.81 665.97,-584 685.76,-592.54 707.11,-602.95 725.44,-612.28"/>
+<polygon fill="#424242" stroke="#424242" points="724.4,-614.3 731.23,-615.24 726.48,-610.25 724.4,-614.3"/>
+</g>
+<!-- DuckDB&#45;&gt;S3 -->
+<!-- ListenBrainz&#45;&gt;LastFM -->
+<!-- MusicBrainz&#45;&gt;Discogs -->
+<!-- Discogs&#45;&gt;CoverArchive -->
+<!-- Cosine&#45;&gt;SMTP -->
+<!-- SMTP&#45;&gt;Prometheus -->
+<!-- Prometheus&#45;&gt;API -->
+<g id="edge43" class="edge">
+<title>Prometheus&#45;&gt;API</title>
+<path fill="none" stroke="#424242" d="M730.06,-60.36C721.71,-64.12 713.03,-68.12 704.97,-72 620.94,-112.41 596.18,-116.59 518.55,-168.25 504.94,-177.31 431.28,-240.02 388.56,-276.62"/>
+<polygon fill="#424242" stroke="#424242" points="387.1,-274.88 383.65,-280.83 390.06,-278.33 387.1,-274.88"/>
+<text xml:space="preserve" text-anchor="middle" x="583.26" y="-170.4" font-family="Intel Mono" font-size="8.00" fill="#424242">scrape</text>
+</g>
+<!-- Browser -->
 <g id="node1" class="node">
-<title>Clients</title>
-<path fill="#302550" stroke="#8b97a8" d="M251.92,-148.76C251.92,-148.76 28,-148.76 28,-148.76 22,-148.76 16,-142.76 16,-136.76 16,-136.76 16,-63.23 16,-63.23 16,-57.23 22,-51.23 28,-51.23 28,-51.23 251.92,-51.23 251.92,-51.23 257.92,-51.23 263.92,-57.23 263.92,-63.23 263.92,-63.23 263.92,-136.76 263.92,-136.76 263.92,-142.76 257.92,-148.76 251.92,-148.76"/>
-<text xml:space="preserve" text-anchor="middle" x="139.96" y="-130.62" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">Browser</text>
-<text xml:space="preserve" text-anchor="middle" x="139.96" y="-119.38" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">Elm SPA</text>
-<text xml:space="preserve" text-anchor="middle" x="139.96" y="-96.12" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">Scrobbler</text>
-<text xml:space="preserve" text-anchor="middle" x="139.96" y="-84.88" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">ListenBrainz&#45;compatible</text>
-<text xml:space="preserve" text-anchor="middle" x="139.96" y="-61.62" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">Administrator · Prometheus (optional)</text>
+<title>Browser</title>
+<path fill="#ffffff" stroke="#424242" d="M116.4,-239C116.4,-239 36,-239 36,-239 30,-239 24,-233 24,-227 24,-227 24,-215 24,-215 24,-209 30,-203 36,-203 36,-203 116.4,-203 116.4,-203 122.4,-203 128.4,-209 128.4,-215 128.4,-215 128.4,-227 128.4,-227 128.4,-233 122.4,-239 116.4,-239"/>
+<text xml:space="preserve" text-anchor="middle" x="76.2" y="-217.3" font-family="Intel Mono" font-size="11.00" fill="#424242">Browser</text>
 </g>
-<!-- ClientFlow -->
-<g id="node5" class="node">
-<title>ClientFlow</title>
-<path fill="#111827" stroke="#4b5563" d="M543.07,-118C543.07,-118 347.92,-118 347.92,-118 341.92,-118 335.92,-112 335.92,-106 335.92,-106 335.92,-94 335.92,-94 335.92,-88 341.92,-82 347.92,-82 347.92,-82 543.07,-82 543.07,-82 549.07,-82 555.07,-88 555.07,-94 555.07,-94 555.07,-106 555.07,-106 555.07,-112 549.07,-118 543.07,-118"/>
-<text xml:space="preserve" text-anchor="middle" x="445.5" y="-96.9" font-family="Atkinson Hyperlegible Mono" font-size="8.00" fill="#ffffff">SPA, listen submission, admin, /metrics</text>
-</g>
-<!-- Clients&#45;&gt;ClientFlow -->
-<g id="edge1" class="edge">
-<title>Clients&#45;&gt;ClientFlow</title>
-<path fill="none" stroke="#aeb8c7" stroke-width="1.25" d="M264.42,-100C288.05,-100 312.64,-100 335.77,-100"/>
-</g>
-<!-- Corpus -->
+<!-- Scrobbler -->
 <g id="node2" class="node">
-<title>Corpus</title>
-<path fill="#1b422d" stroke="#8b97a8" d="M956.03,-154.39C956.03,-154.39 648.11,-154.39 648.11,-154.39 642.11,-154.39 636.11,-148.39 636.11,-142.39 636.11,-142.39 636.11,-57.61 636.11,-57.61 636.11,-51.61 642.11,-45.61 648.11,-45.61 648.11,-45.61 956.03,-45.61 956.03,-45.61 962.03,-45.61 968.03,-51.61 968.03,-57.61 968.03,-57.61 968.03,-142.39 968.03,-142.39 968.03,-148.39 962.03,-154.39 956.03,-154.39"/>
-<text xml:space="preserve" text-anchor="middle" x="802.07" y="-136.25" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">HTTP server and API</text>
-<text xml:space="preserve" text-anchor="middle" x="802.07" y="-125" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">SPA · proxy · stats · cover · similar</text>
-<text xml:space="preserve" text-anchor="middle" x="802.07" y="-101.75" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">Per&#45;user background work</text>
-<text xml:space="preserve" text-anchor="middle" x="802.07" y="-90.5" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">ListenBrainz and Last.fm sync</text>
-<text xml:space="preserve" text-anchor="middle" x="802.07" y="-79.25" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">metadata enrichment · cover cache · database backup</text>
-<text xml:space="preserve" text-anchor="middle" x="802.07" y="-56" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">Registration/admin workflow · optional metrics</text>
+<title>Scrobbler</title>
+<path fill="#ffffff" stroke="#424242" d="M116.4,-321C116.4,-321 36,-321 36,-321 30,-321 24,-315 24,-309 24,-309 24,-297 24,-297 24,-291 30,-285 36,-285 36,-285 116.4,-285 116.4,-285 122.4,-285 128.4,-291 128.4,-297 128.4,-297 128.4,-309 128.4,-309 128.4,-315 122.4,-321 116.4,-321"/>
+<text xml:space="preserve" text-anchor="middle" x="76.2" y="-299.3" font-family="Intel Mono" font-size="11.00" fill="#424242">Scrobbler</text>
 </g>
-<!-- StorageFlow -->
-<g id="node6" class="node">
-<title>StorageFlow</title>
-<path fill="#111827" stroke="#4b5563" d="M1325.47,-202C1325.47,-202 1062.07,-202 1062.07,-202 1056.07,-202 1050.07,-196 1050.07,-190 1050.07,-190 1050.07,-178 1050.07,-178 1050.07,-172 1056.07,-166 1062.07,-166 1062.07,-166 1325.47,-166 1325.47,-166 1331.47,-166 1337.47,-172 1337.47,-178 1337.47,-178 1337.47,-190 1337.47,-190 1337.47,-196 1331.47,-202 1325.47,-202"/>
-<text xml:space="preserve" text-anchor="middle" x="1193.77" y="-180.9" font-family="Atkinson Hyperlegible Mono" font-size="8.00" fill="#ffffff">per&#45;user data, registrations, cached covers, backups</text>
-</g>
-<!-- Corpus&#45;&gt;StorageFlow -->
-<g id="edge3" class="edge">
-<title>Corpus&#45;&gt;StorageFlow</title>
-<path fill="none" stroke="#aeb8c7" stroke-width="1.25" d="M968.34,-135.62C1016.15,-145.93 1066.4,-156.76 1107.21,-165.56"/>
-</g>
-<!-- ExternalFlow -->
-<g id="node7" class="node">
-<title>ExternalFlow</title>
-<path fill="#111827" stroke="#4b5563" d="M1316.47,-94C1316.47,-94 1071.07,-94 1071.07,-94 1065.07,-94 1059.07,-88 1059.07,-82 1059.07,-82 1059.07,-70 1059.07,-70 1059.07,-64 1065.07,-58 1071.07,-58 1071.07,-58 1316.47,-58 1316.47,-58 1322.47,-58 1328.47,-64 1328.47,-70 1328.47,-70 1328.47,-82 1328.47,-82 1328.47,-88 1322.47,-94 1316.47,-94"/>
-<text xml:space="preserve" text-anchor="middle" x="1193.77" y="-77.78" font-family="Atkinson Hyperlegible Mono" font-size="8.00" fill="#ffffff">sync · enrichment · cover lookup</text>
-<text xml:space="preserve" text-anchor="middle" x="1193.77" y="-68.78" font-family="Atkinson Hyperlegible Mono" font-size="8.00" fill="#ffffff">CAA → Discogs → Last.fm · similar tracks · email</text>
-</g>
-<!-- Corpus&#45;&gt;ExternalFlow -->
-<g id="edge5" class="edge">
-<title>Corpus&#45;&gt;ExternalFlow</title>
-<path fill="none" stroke="#aeb8c7" stroke-width="1.25" d="M977.21,-89.28C1004.59,-87.59 1032.52,-85.87 1058.81,-84.25"/>
-<polygon fill="#aeb8c7" stroke="#aeb8c7" stroke-width="1.25" points="977.06,-86.83 970.23,-89.71 977.36,-91.72 977.06,-86.83"/>
-</g>
-<!-- Storage -->
+<!-- Operator -->
 <g id="node3" class="node">
-<title>Storage</title>
-<path fill="#473d15" stroke="#8b97a8" d="M1693.39,-261.86C1693.39,-268.59 1629.76,-274.05 1551.43,-274.05 1473.1,-274.05 1409.47,-268.59 1409.47,-261.86 1409.47,-261.86 1409.47,-152.14 1409.47,-152.14 1409.47,-145.41 1473.1,-139.95 1551.43,-139.95 1629.76,-139.95 1693.39,-145.41 1693.39,-152.14 1693.39,-152.14 1693.39,-261.86 1693.39,-261.86"/>
-<path fill="none" stroke="#8b97a8" d="M1693.39,-261.86C1693.39,-255.13 1629.76,-249.67 1551.43,-249.67 1473.1,-249.67 1409.47,-255.13 1409.47,-261.86"/>
-<text xml:space="preserve" text-anchor="middle" x="1551.43" y="-237.62" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">DuckDB files: one per user</text>
-<text xml:space="preserve" text-anchor="middle" x="1551.43" y="-226.38" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">scrobbles · release metadata · API tokens</text>
-<text xml:space="preserve" text-anchor="middle" x="1551.43" y="-203.12" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">registrations.db: shared registration state</text>
-<text xml:space="preserve" text-anchor="middle" x="1551.43" y="-179.88" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">S3&#45;compatible bucket</text>
-<text xml:space="preserve" text-anchor="middle" x="1551.43" y="-168.62" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">cover cache · database backups</text>
+<title>Operator</title>
+<path fill="#ffffff" stroke="#424242" d="M116.4,-403C116.4,-403 36,-403 36,-403 30,-403 24,-397 24,-391 24,-391 24,-379 24,-379 24,-373 30,-367 36,-367 36,-367 116.4,-367 116.4,-367 122.4,-367 128.4,-373 128.4,-379 128.4,-379 128.4,-391 128.4,-391 128.4,-397 122.4,-403 116.4,-403"/>
+<text xml:space="preserve" text-anchor="middle" x="76.2" y="-381.3" font-family="Intel Mono" font-size="11.00" fill="#424242">Operator</text>
 </g>
-<!-- External -->
+<!-- Frontend -->
 <g id="node4" class="node">
-<title>External</title>
-<path fill="#19365d" stroke="#8b97a8" d="M1618.39,-89.77C1618.39,-89.77 1484.47,-89.77 1484.47,-89.77 1478.47,-89.77 1472.47,-83.77 1472.47,-77.77 1472.47,-77.77 1472.47,-28.23 1472.47,-28.23 1472.47,-22.23 1478.47,-16.23 1484.47,-16.23 1484.47,-16.23 1618.39,-16.23 1618.39,-16.23 1624.39,-16.23 1630.39,-22.23 1630.39,-28.23 1630.39,-28.23 1630.39,-77.77 1630.39,-77.77 1630.39,-83.77 1624.39,-89.77 1618.39,-89.77"/>
-<text xml:space="preserve" text-anchor="middle" x="1551.43" y="-71.62" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">ListenBrainz · Last.fm</text>
-<text xml:space="preserve" text-anchor="middle" x="1551.43" y="-60.38" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">MusicBrainz · Discogs</text>
-<text xml:space="preserve" text-anchor="middle" x="1551.43" y="-49.12" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">Cover Art Archive</text>
-<text xml:space="preserve" text-anchor="middle" x="1551.43" y="-37.88" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">cosine.club</text>
-<text xml:space="preserve" text-anchor="middle" x="1551.43" y="-26.62" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">SMTP server (optional)</text>
+<title>Frontend</title>
+<path fill="#ffffff" stroke="#424242" d="M397.05,-236.7C397.05,-236.7 316.65,-236.7 316.65,-236.7 310.65,-236.7 304.65,-230.7 304.65,-224.7 304.65,-224.7 304.65,-207.3 304.65,-207.3 304.65,-201.3 310.65,-195.3 316.65,-195.3 316.65,-195.3 397.05,-195.3 397.05,-195.3 403.05,-195.3 409.05,-201.3 409.05,-207.3 409.05,-207.3 409.05,-224.7 409.05,-224.7 409.05,-230.7 403.05,-236.7 397.05,-236.7"/>
+<text xml:space="preserve" text-anchor="middle" x="356.85" y="-219.05" font-family="Intel Mono" font-size="11.00" fill="#424242">Frontend</text>
+<text xml:space="preserve" text-anchor="middle" x="356.85" y="-205.55" font-family="Intel Mono" font-size="11.00" fill="#424242">Elm</text>
 </g>
-<!-- ClientFlow&#45;&gt;Corpus -->
-<g id="edge2" class="edge">
-<title>ClientFlow&#45;&gt;Corpus</title>
-<path fill="none" stroke="#aeb8c7" stroke-width="1.25" d="M555.29,-100C578.15,-100 602.81,-100 627.37,-100"/>
-<polygon fill="#aeb8c7" stroke="#aeb8c7" stroke-width="1.25" points="634.18,-100 627.18,-103.15 632.2,-100.13 627.18,-100.13 627.18,-100 627.18,-99.88 632.2,-99.88 627.18,-96.85 634.18,-100"/>
+<!-- API -->
+<g id="node5" class="node">
+<title>API</title>
+<path fill="#ffffff" stroke="#424242" d="M397.05,-323.7C397.05,-323.7 316.65,-323.7 316.65,-323.7 310.65,-323.7 304.65,-317.7 304.65,-311.7 304.65,-311.7 304.65,-294.3 304.65,-294.3 304.65,-288.3 310.65,-282.3 316.65,-282.3 316.65,-282.3 397.05,-282.3 397.05,-282.3 403.05,-282.3 409.05,-288.3 409.05,-294.3 409.05,-294.3 409.05,-311.7 409.05,-311.7 409.05,-317.7 403.05,-323.7 397.05,-323.7"/>
+<text xml:space="preserve" text-anchor="middle" x="356.85" y="-306.05" font-family="Intel Mono" font-size="11.00" fill="#424242">API</text>
+<text xml:space="preserve" text-anchor="middle" x="356.85" y="-292.55" font-family="Intel Mono" font-size="11.00" fill="#424242">HTTP</text>
 </g>
-<!-- StorageFlow&#45;&gt;Storage -->
-<g id="edge4" class="edge">
-<title>StorageFlow&#45;&gt;Storage</title>
-<path fill="none" stroke="#aeb8c7" stroke-width="1.25" d="M1337.89,-193.26C1358.6,-194.59 1379.95,-195.98 1400.78,-197.32"/>
-<polygon fill="#aeb8c7" stroke="#aeb8c7" stroke-width="1.25" points="1407.53,-197.76 1400.34,-200.45 1405.55,-197.76 1400.53,-197.43 1400.54,-197.31 1400.55,-197.18 1405.56,-197.51 1400.74,-194.16 1407.53,-197.76"/>
+<!-- CLI -->
+<g id="node6" class="node">
+<title>CLI</title>
+<path fill="#ffffff" stroke="#424242" d="M397.05,-405C397.05,-405 316.65,-405 316.65,-405 310.65,-405 304.65,-399 304.65,-393 304.65,-393 304.65,-381 304.65,-381 304.65,-375 310.65,-369 316.65,-369 316.65,-369 397.05,-369 397.05,-369 403.05,-369 409.05,-375 409.05,-381 409.05,-381 409.05,-393 409.05,-393 409.05,-399 403.05,-405 397.05,-405"/>
+<text xml:space="preserve" text-anchor="middle" x="356.85" y="-383.3" font-family="Intel Mono" font-size="11.00" fill="#424242">CLI</text>
 </g>
-<!-- ExternalFlow&#45;&gt;External -->
-<g id="edge6" class="edge">
-<title>ExternalFlow&#45;&gt;External</title>
-<path fill="none" stroke="#aeb8c7" stroke-width="1.25" d="M1328.72,-67.34C1373.95,-64.41 1423.21,-61.23 1463.79,-58.6"/>
-<polygon fill="#aeb8c7" stroke="#aeb8c7" stroke-width="1.25" points="1470.53,-58.17 1463.75,-61.76 1468.57,-58.42 1463.55,-58.74 1463.55,-58.62 1463.54,-58.49 1468.55,-58.17 1463.34,-55.48 1470.53,-58.17"/>
+<!-- Synchronizer -->
+<g id="node7" class="node">
+<title>Synchronizer</title>
+<path fill="#ffffff" stroke="#424242" d="M623.46,-631C623.46,-631 543.06,-631 543.06,-631 537.06,-631 531.06,-625 531.06,-619 531.06,-619 531.06,-607 531.06,-607 531.06,-601 537.06,-595 543.06,-595 543.06,-595 623.46,-595 623.46,-595 629.46,-595 635.46,-601 635.46,-607 635.46,-607 635.46,-619 635.46,-619 635.46,-625 629.46,-631 623.46,-631"/>
+<text xml:space="preserve" text-anchor="middle" x="583.26" y="-609.3" font-family="Intel Mono" font-size="11.00" fill="#424242">Synchronizer</text>
 </g>
+<!-- Query -->
+<g id="node8" class="node">
+<title>Query</title>
+<path fill="#ffffff" stroke="#424242" d="M623.46,-513C623.46,-513 543.06,-513 543.06,-513 537.06,-513 531.06,-507 531.06,-501 531.06,-501 531.06,-489 531.06,-489 531.06,-483 537.06,-477 543.06,-477 543.06,-477 623.46,-477 623.46,-477 629.46,-477 635.46,-483 635.46,-489 635.46,-489 635.46,-501 635.46,-501 635.46,-507 629.46,-513 623.46,-513"/>
+<text xml:space="preserve" text-anchor="middle" x="583.26" y="-491.3" font-family="Intel Mono" font-size="11.00" fill="#424242">Query</text>
 </g>
+<!-- Enricher -->
+<g id="node9" class="node">
+<title>Enricher</title>
+<path fill="#ffffff" stroke="#424242" d="M623.46,-372C623.46,-372 543.06,-372 543.06,-372 537.06,-372 531.06,-366 531.06,-360 531.06,-360 531.06,-348 531.06,-348 531.06,-342 537.06,-336 543.06,-336 543.06,-336 623.46,-336 623.46,-336 629.46,-336 635.46,-342 635.46,-348 635.46,-348 635.46,-360 635.46,-360 635.46,-366 629.46,-372 623.46,-372"/>
+<text xml:space="preserve" text-anchor="middle" x="583.26" y="-350.3" font-family="Intel Mono" font-size="11.00" fill="#424242">Enricher</text>
+</g>
+<!-- Artwork -->
+<g id="node10" class="node">
+<title>Artwork</title>
+<path fill="#ffffff" stroke="#424242" d="M623.46,-454C623.46,-454 543.06,-454 543.06,-454 537.06,-454 531.06,-448 531.06,-442 531.06,-442 531.06,-430 531.06,-430 531.06,-424 537.06,-418 543.06,-418 543.06,-418 623.46,-418 623.46,-418 629.46,-418 635.46,-424 635.46,-430 635.46,-430 635.46,-442 635.46,-442 635.46,-448 629.46,-454 623.46,-454"/>
+<text xml:space="preserve" text-anchor="middle" x="583.26" y="-432.3" font-family="Intel Mono" font-size="11.00" fill="#424242">Artwork</text>
+</g>
+<!-- Recommender -->
+<g id="node11" class="node">
+<title>Recommender</title>
+<path fill="#ffffff" stroke="#424242" d="M623.97,-231C623.97,-231 542.55,-231 542.55,-231 536.55,-231 530.55,-225 530.55,-219 530.55,-219 530.55,-207 530.55,-207 530.55,-201 536.55,-195 542.55,-195 542.55,-195 623.97,-195 623.97,-195 629.97,-195 635.97,-201 635.97,-207 635.97,-207 635.97,-219 635.97,-219 635.97,-225 629.97,-231 623.97,-231"/>
+<text xml:space="preserve" text-anchor="middle" x="583.26" y="-209.3" font-family="Intel Mono" font-size="11.00" fill="#424242">Recommender</text>
+</g>
+<!-- Registrar -->
+<g id="node12" class="node">
+<title>Registrar</title>
+<path fill="#ffffff" stroke="#424242" d="M623.46,-313C623.46,-313 543.06,-313 543.06,-313 537.06,-313 531.06,-307 531.06,-301 531.06,-301 531.06,-289 531.06,-289 531.06,-283 537.06,-277 543.06,-277 543.06,-277 623.46,-277 623.46,-277 629.46,-277 635.46,-283 635.46,-289 635.46,-289 635.46,-301 635.46,-301 635.46,-307 629.46,-313 623.46,-313"/>
+<text xml:space="preserve" text-anchor="middle" x="583.26" y="-291.3" font-family="Intel Mono" font-size="11.00" fill="#424242">Registrar</text>
+</g>
+<!-- Archiver -->
+<g id="node13" class="node">
+<title>Archiver</title>
+<path fill="#ffffff" stroke="#424242" d="M623.46,-572C623.46,-572 543.06,-572 543.06,-572 537.06,-572 531.06,-566 531.06,-560 531.06,-560 531.06,-548 531.06,-548 531.06,-542 537.06,-536 543.06,-536 543.06,-536 623.46,-536 623.46,-536 629.46,-536 635.46,-542 635.46,-548 635.46,-548 635.46,-560 635.46,-560 635.46,-566 629.46,-572 623.46,-572"/>
+<text xml:space="preserve" text-anchor="middle" x="583.26" y="-550.3" font-family="Intel Mono" font-size="11.00" fill="#424242">Archiver</text>
+</g>
+<!-- DuckDB -->
+<g id="node14" class="node">
+<title>DuckDB</title>
+<path fill="#ffffff" stroke="#424242" d="M813.37,-570.7C813.37,-570.7 732.97,-570.7 732.97,-570.7 726.97,-570.7 720.97,-564.7 720.97,-558.7 720.97,-558.7 720.97,-541.3 720.97,-541.3 720.97,-535.3 726.97,-529.3 732.97,-529.3 732.97,-529.3 813.37,-529.3 813.37,-529.3 819.37,-529.3 825.37,-535.3 825.37,-541.3 825.37,-541.3 825.37,-558.7 825.37,-558.7 825.37,-564.7 819.37,-570.7 813.37,-570.7"/>
+<text xml:space="preserve" text-anchor="middle" x="773.17" y="-553.05" font-family="Intel Mono" font-size="11.00" fill="#424242">DuckDB</text>
+<text xml:space="preserve" text-anchor="middle" x="773.17" y="-539.55" font-family="Intel Mono" font-size="11.00" fill="#424242">OLAP</text>
+</g>
+<!-- S3 -->
+<g id="node15" class="node">
+<title>S3</title>
+<path fill="#ffffff" stroke="#424242" d="M813.37,-657.7C813.37,-657.7 732.97,-657.7 732.97,-657.7 726.97,-657.7 720.97,-651.7 720.97,-645.7 720.97,-645.7 720.97,-628.3 720.97,-628.3 720.97,-622.3 726.97,-616.3 732.97,-616.3 732.97,-616.3 813.37,-616.3 813.37,-616.3 819.37,-616.3 825.37,-622.3 825.37,-628.3 825.37,-628.3 825.37,-645.7 825.37,-645.7 825.37,-651.7 819.37,-657.7 813.37,-657.7"/>
+<text xml:space="preserve" text-anchor="middle" x="773.17" y="-640.05" font-family="Intel Mono" font-size="11.00" fill="#424242">S3</text>
+<text xml:space="preserve" text-anchor="middle" x="773.17" y="-626.55" font-family="Intel Mono" font-size="11.00" fill="#424242">Objects</text>
+</g>
+<!-- ListenBrainz -->
+<g id="node16" class="node">
+<title>ListenBrainz</title>
+<path fill="#ffffff" stroke="#424242" d="M813.37,-837C813.37,-837 732.97,-837 732.97,-837 726.97,-837 720.97,-831 720.97,-825 720.97,-825 720.97,-813 720.97,-813 720.97,-807 726.97,-801 732.97,-801 732.97,-801 813.37,-801 813.37,-801 819.37,-801 825.37,-807 825.37,-813 825.37,-813 825.37,-825 825.37,-825 825.37,-831 819.37,-837 813.37,-837"/>
+<text xml:space="preserve" text-anchor="middle" x="773.17" y="-815.3" font-family="Intel Mono" font-size="11.00" fill="#424242">ListenBrainz</text>
+</g>
+<!-- LastFM -->
+<g id="node17" class="node">
+<title>LastFM</title>
+<path fill="#ffffff" stroke="#424242" d="M813.37,-755C813.37,-755 732.97,-755 732.97,-755 726.97,-755 720.97,-749 720.97,-743 720.97,-743 720.97,-731 720.97,-731 720.97,-725 726.97,-719 732.97,-719 732.97,-719 813.37,-719 813.37,-719 819.37,-719 825.37,-725 825.37,-731 825.37,-731 825.37,-743 825.37,-743 825.37,-749 819.37,-755 813.37,-755"/>
+<text xml:space="preserve" text-anchor="middle" x="773.17" y="-733.3" font-family="Intel Mono" font-size="11.00" fill="#424242">Last.fm</text>
+</g>
+<!-- MusicBrainz -->
+<g id="node18" class="node">
+<title>MusicBrainz</title>
+<path fill="#ffffff" stroke="#424242" d="M813.37,-299C813.37,-299 732.97,-299 732.97,-299 726.97,-299 720.97,-293 720.97,-287 720.97,-287 720.97,-275 720.97,-275 720.97,-269 726.97,-263 732.97,-263 732.97,-263 813.37,-263 813.37,-263 819.37,-263 825.37,-269 825.37,-275 825.37,-275 825.37,-287 825.37,-287 825.37,-293 819.37,-299 813.37,-299"/>
+<text xml:space="preserve" text-anchor="middle" x="773.17" y="-277.3" font-family="Intel Mono" font-size="11.00" fill="#424242">MusicBrainz</text>
+</g>
+<!-- Discogs -->
+<g id="node19" class="node">
+<title>Discogs</title>
+<path fill="#ffffff" stroke="#424242" d="M813.37,-381C813.37,-381 732.97,-381 732.97,-381 726.97,-381 720.97,-375 720.97,-369 720.97,-369 720.97,-357 720.97,-357 720.97,-351 726.97,-345 732.97,-345 732.97,-345 813.37,-345 813.37,-345 819.37,-345 825.37,-351 825.37,-357 825.37,-357 825.37,-369 825.37,-369 825.37,-375 819.37,-381 813.37,-381"/>
+<text xml:space="preserve" text-anchor="middle" x="773.17" y="-359.3" font-family="Intel Mono" font-size="11.00" fill="#424242">Discogs</text>
+</g>
+<!-- CoverArchive -->
+<g id="node20" class="node">
+<title>CoverArchive</title>
+<path fill="#ffffff" stroke="#424242" d="M813.37,-467.7C813.37,-467.7 732.97,-467.7 732.97,-467.7 726.97,-467.7 720.97,-461.7 720.97,-455.7 720.97,-455.7 720.97,-438.3 720.97,-438.3 720.97,-432.3 726.97,-426.3 732.97,-426.3 732.97,-426.3 813.37,-426.3 813.37,-426.3 819.37,-426.3 825.37,-432.3 825.37,-438.3 825.37,-438.3 825.37,-455.7 825.37,-455.7 825.37,-461.7 819.37,-467.7 813.37,-467.7"/>
+<text xml:space="preserve" text-anchor="middle" x="773.17" y="-450.05" font-family="Intel Mono" font-size="11.00" fill="#424242">CoverArchive</text>
+<text xml:space="preserve" text-anchor="middle" x="773.17" y="-436.55" font-family="Intel Mono" font-size="11.00" fill="#424242">CAA</text>
+</g>
+<!-- Cosine -->
+<g id="node21" class="node">
+<title>Cosine</title>
+<path fill="#ffffff" stroke="#424242" d="M813.37,-119C813.37,-119 732.97,-119 732.97,-119 726.97,-119 720.97,-113 720.97,-107 720.97,-107 720.97,-95 720.97,-95 720.97,-89 726.97,-83 732.97,-83 732.97,-83 813.37,-83 813.37,-83 819.37,-83 825.37,-89 825.37,-95 825.37,-95 825.37,-107 825.37,-107 825.37,-113 819.37,-119 813.37,-119"/>
+<text xml:space="preserve" text-anchor="middle" x="773.17" y="-97.3" font-family="Intel Mono" font-size="11.00" fill="#424242">cosine.club</text>
+</g>
+<!-- SMTP -->
+<g id="node22" class="node">
+<title>SMTP</title>
+<path fill="#ffffff" stroke="#424242" d="M813.37,-201C813.37,-201 732.97,-201 732.97,-201 726.97,-201 720.97,-195 720.97,-189 720.97,-189 720.97,-177 720.97,-177 720.97,-171 726.97,-165 732.97,-165 732.97,-165 813.37,-165 813.37,-165 819.37,-165 825.37,-171 825.37,-177 825.37,-177 825.37,-189 825.37,-189 825.37,-195 819.37,-201 813.37,-201"/>
+<text xml:space="preserve" text-anchor="middle" x="773.17" y="-179.3" font-family="Intel Mono" font-size="11.00" fill="#424242">SMTP</text>
+</g>
+<!-- Prometheus -->
+<g id="node23" class="node">
+<title>Prometheus</title>
+<path fill="#ffffff" stroke="#424242" d="M813.37,-60C813.37,-60 732.97,-60 732.97,-60 726.97,-60 720.97,-54 720.97,-48 720.97,-48 720.97,-36 720.97,-36 720.97,-30 726.97,-24 732.97,-24 732.97,-24 813.37,-24 813.37,-24 819.37,-24 825.37,-30 825.37,-36 825.37,-36 825.37,-48 825.37,-48 825.37,-54 819.37,-60 813.37,-60"/>
+<text xml:space="preserve" text-anchor="middle" x="773.17" y="-38.3" font-family="Intel Mono" font-size="11.00" fill="#424242">Prometheus</text>
+</g>
+</g>
 </svg>
blob - 0766e642a6335caab6d517122823269323c231e6
blob + 51d8656f41f20ff8bb0725a789b574553d5ab675
--- flake.lock
+++ flake.lock
@@ -51,11 +51,11 @@
     },
     "nixpkgs_2": {
       "locked": {
-        "lastModified": 1787814960,
-        "narHash": "sha256-PYZq1qzCJXC2zGI0mH07vrZBsw6DRBAOX0jN1pPtqOQ=",
+        "lastModified": 1787964612,
+        "narHash": "sha256-0N9nghg3nwzX6b6qc77EzjR9cu/Z+UR66FlfsCqiURs=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "c27cdad491a991b11ed731760aa2ef8db0cb0410",
+        "rev": "e8be7818e19ada32105a8af937a6a473b38167ca",
         "type": "github"
       },
       "original": {