commit - a2fb44947a9c1af9ec01fbf359d8d545945db122
commit + 72f44dcb85e8ae0623e927bb0a65c06e636e8005
blob - 05b6b6efa1daadfec696a4def816fbd1c3c13982
blob + eb3ef38038ea0612429a72a99f25afb6fa711c5c
--- README.md
+++ README.md
## Documentation
-- [Architecture](docs/architecture.md) — components, routing, data flows, configuration, and operations.
-- [DuckDB](docs/duckdb.md) — schema and analytical queries.
+- [Architecture](docs/architecture.md): components, routes, data flows, configuration and operations
+- [DuckDB](docs/duckdb.md): schema and analytical queries
## Quick start
## Scrobbling API
-Corpus accepts ListenBrainz-compatible submissions at `POST /1/submit-listens`. Send `Authorization: Token <token>` and a standard ListenBrainz payload. Clients may first validate a token with `GET /1/validate-token` using the same header.
+Send ListenBrainz-compatible submissions to `POST /1/submit-listens`. Include `Authorization: Token <token>` and a standard ListenBrainz payload. Validate a token with `GET /1/validate-token` and the same header.
-Tokens are shown once when a user is created, reset, or approved through self-registration. Store them securely.
+Corpus shows tokens once when you create, reset or approve a user. Store them securely.
## Configuration
| `PORT`, `HOST` | HTTP listener (defaults: `8000`, `127.0.0.1`) |
| `METRICS_ENABLED` | Enable Prometheus metrics at `/metrics` |
-Set `REGISTRATION_ENABLED=true` to allow public registration at `/register`; `ADMIN_TOKEN` enables approval at `/admin`. See the [architecture guide](docs/architecture.md#configuration-reference) for every setting and the full registration workflow.
+Set `REGISTRATION_ENABLED=true` to allow public registration at `/register`. Set `ADMIN_TOKEN` to enable approval at `/admin`. See the [configuration reference](docs/architecture.md#configuration-reference) for all settings and the registration workflow.
blob - cd34b0c1e426b5409f77e7157d277be655b93dfb
blob + 3af35445fc14e2b2d4582f4ed6d09f7ee0780f77
--- docs/architecture.dot
+++ docs/architecture.dot
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"
+ 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"
]
]
}
- Clients -> Corpus [label="SPA, listen submission, admin, /metrics"]
- Corpus -> Storage [label="per-user data, registrations, cached covers, backups"]
- Corpus -> External [
- dir=both
+ // Model labels as nodes so connection lines cannot cross their text.
+ node [
+ shape=box
+ 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"
]
+
+ Clients -> ClientFlow [arrowhead=none]
+ ClientFlow -> Corpus
+ Corpus -> StorageFlow [arrowhead=none]
+ StorageFlow -> Storage
+ Corpus -> ExternalFlow [dir=back]
+ ExternalFlow -> External
}
blob - 46d03c0e4bc484e0e32a08b23b5bd4bb28602c79
blob + ce440ea57f8ec5de8feb5faf5dc482c692771ca0
--- docs/architecture.md
+++ docs/architecture.md
-# Corpus Architecture
+# Corpus architecture
-Corpus is a self-hosted music listening history dashboard and analytics service. It supports multiple users, synchronizing scrobbles from ListenBrainz and Last.fm and providing a performant web interface for data exploration and statistics.
+Corpus is a self-hosted listening history dashboard. It synchronises ListenBrainz and Last.fm scrobbles for multiple users.
-## System Components
+## System components
-### Web Server
-The server is built with PureScript running on Node.js. It handles several core responsibilities:
-- **HTTP API**: Serves the frontend, scrobble data (with filtering/pagination), statistics, and similar tracks (via [cosine.club](https://cosine.club)). It also provides a **ListenBrainz-compatible scrobble submission endpoint**.
-- **ListenBrainz Sync**: A background process that polls the ListenBrainz API every 60 seconds to fetch new scrobbles.
-- **Last.fm Sync**: A background process that polls the Last.fm API every 60 seconds to fetch new scrobbles. Both syncs write to the same `scrobbles` table; duplicate timestamps are silently ignored.
-- **Metadata Enrichment**: A background process that identifies scrobbles with missing metadata (genres, labels, release years) and fetches information from MusicBrainz, Last.fm, and Discogs.
-- **Cover Art Proxy**: A specialized endpoint that redirects to cover art and caches it in the background, using the fallback strategy Cover Art Archive → Discogs → Last.fm.
-- **Registration and administration**: An optional public registration flow with authenticated approval, user provisioning, and SMTP notifications.
-- **Observability**: Optional Prometheus metrics exposed at `/metrics`.
+### Web server
+The PureScript server runs on Node.js. It:
+- serves the frontend, scrobble data, statistics and similar tracks from [cosine.club](https://cosine.club)
+- accepts ListenBrainz-compatible scrobble submissions
+- polls ListenBrainz and Last.fm every 60 seconds
+- deduplicates matching timestamps using the `listened_at` primary key
+- enriches missing genres, labels and release years from MusicBrainz, Last.fm and Discogs
+- redirects and caches cover art, trying Cover Art Archive, Discogs and then Last.fm
+- provides optional registration, administration, email and Prometheus metrics
+
+External API requests time out after 10 seconds. Image requests time out after 20 seconds.
+
### Frontend
-A Single Page Application (SPA) built with [Elm](https://elm-lang.org).
-- **Real-time Updates**: Periodically refreshes the scrobble list.
-- **Filtering & Search**: Supports deep filtering by genre, label, or release year.
-- **Search Functionality**: Global search across tracks, artists, albums, and labels with real-time results.
-- **About Page**: Provides system information, feature list, and links to related resources.
-- **Clickable Metadata**: Track name, artist, album, and label in listen entries are all clickable for quick filtering.
-- **Responsive UI**: Designed for both desktop and mobile viewing with a "retro-modern" aesthetic.
+The Elm single-page application:
+- refreshes the scrobble list periodically
+- filters by genre, label or release year
+- searches tracks, artists, albums and labels
+- links listen metadata to matching filters
+- works on desktop and mobile
+
### Database
-Corpus uses **DuckDB** for its primary data storage. Each user has their own database file.
-- **Schema**:
- - `scrobbles`: Stores the core listening history (timestamp, track, artist, album, MBIDs). The `listened_at` Unix timestamp is the primary key — scrobbles from ListenBrainz and Last.fm deduplicate naturally.
- - `release_metadata`: Stores enriched metadata indexed by MusicBrainz Release ID (MBID), including genre, label, and release year.
- - `api_tokens`: Stores hashed user API tokens for scrobble submission. Tokens are hashed with SHA-256 before storage.
-- **Performance**: DuckDB's columnar storage allows for extremely fast analytical queries across large listening histories.
+Corpus stores each user's data in a separate DuckDB file:
+- `scrobbles` stores listening history with `listened_at` as its primary key
+- `release_metadata` stores genre, label and release year by MusicBrainz release ID
+- `api_tokens` stores SHA-256 hashes of submission tokens
+
+DuckDB's columnar storage supports analytical queries over large listening histories.
+
### Storage
-Uses an S3-compatible bucket for cover art and optional database backups.
-- **Cover cache**: Cached images are converted to AVIF and stored in S3. Cache hits redirect to a presigned S3 URL; cache misses redirect to the upstream image and populate the cache in the background.
-- **Backups**: When enabled per user, the server checkpoints that user's DuckDB database and uploads timestamped snapshots to S3.
-- **Registration data**: Pending, approved, denied, and revoked self-registration requests are stored separately in the shared `registrations.db` database.
+Corpus uses an S3-compatible bucket when you enable cover caching or backups:
-## Multi-User Support
+- the cover cache stores AVIF images and redirects hits to presigned S3 URLs
+- backups checkpoint a user's DuckDB file and upload timestamped snapshots
+- the shared `registrations.db` file stores pending, approved, denied and revoked registrations
-Corpus runs as a single server process serving multiple users. User configuration is defined in `users.json`, managed via the built-in CLI commands.
+## Multi-user support
-### Routing
-- `/` and `/u/<slug>` — serve the Elm SPA for the root user and named users respectively
-- `/proxy?user=<slug>`, `/stats?user=<slug>`, `/cover?user=<slug>`, `/similar?user=<slug>` — shared API endpoints, user selected via query parameter
-- `/healthz?user=<slug>` — liveness check; pings the user's DuckDB connection
-- `/1/validate-token` — ListenBrainz-compatible token validation endpoint
-- `/1/submit-listens` — ListenBrainz-compatible scrobble submission endpoint (requires `Authorization: Token <token>` header)
-- `/register` and `/admin` — registration and administration UI; registration is enabled with `REGISTRATION_ENABLED=true`, while admin API routes additionally require `ADMIN_TOKEN`
-- `/metrics` — Prometheus metrics (no user parameter; covers all users; only available when `METRICS_ENABLED=true`)
+Corpus serves multiple users from one process. Use the command-line interface to manage static users in `users.json`.
-### User Management
+### Routes
-Users are managed via built-in CLI commands. The server must not be running when modifying `users.json`.
+Corpus provides these routes:
+- `/` and `/u/<slug>`: serve the Elm application for the root user and named users
+- `/proxy?user=<slug>`, `/stats?user=<slug>`, `/cover?user=<slug>`, `/similar?user=<slug>`: select a user through the query parameter
+- `/healthz?user=<slug>`: checks the user's DuckDB connection
+- `/1/validate-token`: validates a ListenBrainz-compatible token
+- `/1/submit-listens`: accepts ListenBrainz-compatible submissions with an `Authorization: Token <token>` header
+- `/register` and `/admin`: serve registration and administration pages
+- `/metrics`: returns metrics for all users when `METRICS_ENABLED=true`
+
+Set `REGISTRATION_ENABLED=true` to enable registration. Set `ADMIN_TOKEN` to enable the administration APIs.
+
+### User management
+
+Stop Corpus before using these commands to change `users.json`:
+
```sh
# Add a new user (creates the DB, prints the API token once)
node server.js add-user --slug filip --name "Filip" --db filip.db
node server.js reset-token --slug filip
```
-The API token is only printed once on creation or reset — store it securely. It is used for the `/1/submit-listens` endpoint via `Authorization: Token <token>`.
+Corpus prints the API token once when you create or reset a user. Store it securely. Send it to `/1/submit-listens` as `Authorization: Token <token>`.
### Configuration
-User configuration is split into two layers:
+User configuration has 2 layers:
-1. **`users.json`** (non-sensitive): defines user slugs, source usernames, database filenames, and feature flags. Managed via CLI (`add-user`, `reset-token`, `list-users`). The server reads this file at startup from the path in `CORPUS_USERS_FILE` (defaults to `users.json`).
+1. `users.json` defines user slugs, source usernames, database filenames and feature flags. The CLI manages this file. Corpus reads it at startup from `CORPUS_USERS_FILE`, which defaults to `users.json`.
+2. Environment variables provide shared API keys and S3 credentials at runtime.
-2. **Environment variables** (runtime, sensitive): shared API keys and S3 credentials are read from the environment at startup and applied to all users.
+Each user has a separate DuckDB connection, sync loop and write lock. The lock runs ListenBrainz and Last.fm sync transactions in sequence. HTTP reads use DuckDB snapshots without taking the lock.
-Each user gets their own `UserContext` with an independent DuckDB connection, sync loop, and write lock (`AVar Unit`). The write lock serializes all sync transactions — if a user has both ListenBrainz and Last.fm configured, their transactions are queued rather than run concurrently. HTTP reads do not acquire the lock; DuckDB's MVCC provides consistent snapshots.
+Corpus provisions approved registrations immediately and reloads them from `registrations.db` at startup. It does not change `users.json`. A `users.json` entry takes precedence over a registration with the same slug.
-Approved self-registered users are provisioned immediately and loaded at startup from `registrations.db`; `users.json` is not changed. A user defined in `users.json` takes precedence over an approved registration with the same slug.
+### Remove registered users
-### Removing Registered Users
+An administrator can remove a self-registered user without restarting Corpus. Corpus stops their background work, closes and deletes their DuckDB file, then revokes the registration. Administrators cannot use this process for users defined in `users.json`.
-An administrator can remove a self-registered user without restarting the server. Corpus stops that user's recurring sync, enrichment, and backup fibers; removes the live user context; closes and deletes the user's DuckDB file; and marks the registration as revoked. Users defined in `users.json` are managed manually and cannot be removed through this flow.
+## Configuration reference
-## Configuration Reference
+### Environment variables
-### Environment Variables
-
| Variable | Default | Purpose |
|---|---|---|
-| `CORPUS_USERS_FILE` | `users.json` | Path to the compiled users config |
+| `CORPUS_USERS_FILE` | `users.json` | Path to the user configuration file |
| `DATABASE_PATH` | _(cwd)_ | Root directory for all user database files |
-| `LASTFM_API_KEY` | — | Last.fm API key (required if any user has `lastfmUser`) |
-| `DISCOGS_TOKEN` | — | Discogs token for cover/genre fallback |
-| `S3_BUCKET` | — | S3 bucket for cover art cache |
+| `LASTFM_API_KEY` | not set | Last.fm API key (required if any user has `lastfmUser`) |
+| `DISCOGS_TOKEN` | not set | Discogs token for cover and genre fallback |
+| `S3_BUCKET` | not set | S3 bucket for cover art cache |
| `S3_REGION` | `us-east-1` | S3 region |
-| `AWS_ACCESS_KEY_ID` | — | S3 credentials |
-| `AWS_SECRET_ACCESS_KEY` | — | S3 credentials |
-| `AWS_ENDPOINT_URL` | — | S3 endpoint (for S3-compatible storage) |
-| `AWS_S3_ADDRESSING_STYLE` | — | `virtual` or `path` |
-| `COSINE_API_KEY` | — | [cosine.club](https://cosine.club) API key for similar tracks |
+| `AWS_ACCESS_KEY_ID` | not set | S3 credentials |
+| `AWS_SECRET_ACCESS_KEY` | not set | S3 credentials |
+| `AWS_ENDPOINT_URL` | not set | S3-compatible endpoint |
+| `AWS_S3_ADDRESSING_STYLE` | not set | `virtual` or `path` |
+| `COSINE_API_KEY` | not set | [cosine.club](https://cosine.club) API key for similar tracks |
| `PORT` | `8000` | HTTP listen port |
| `HOST` | `127.0.0.1` | HTTP listen host |
-| `METRICS_ENABLED` | `false` | Set to `true` to enable the Prometheus `/metrics` endpoint |
+| `METRICS_ENABLED` | `false` | Set to `true` to enable `/metrics` |
| `CORS_ORIGIN` | `*` | Value of `Access-Control-Allow-Origin` on `/proxy` responses |
| `REGISTRATION_ENABLED` | `false` | Set to `true` to enable public registration at `/register` |
-| `ADMIN_TOKEN` | — | Bearer token required by `/admin/*` API routes; when unset, they return 404 |
-| `ADMIN_EMAIL` | — | Address notified of new registration requests |
+| `ADMIN_TOKEN` | not set | Bearer token for `/admin/*`; these routes return 404 when unset |
+| `ADMIN_EMAIL` | not set | Address notified of new registration requests |
| `CORPUS_REGISTRATIONS_DB` | `registrations.db` | Shared DuckDB file for registration state |
-| `SMTP_HOST` | — | SMTP server host; email is skipped when unset |
+| `SMTP_HOST` | not set | SMTP server host; Corpus skips email when unset |
| `SMTP_PORT` | `587` | SMTP port (STARTTLS) |
-| `SMTP_USER` | — | SMTP username |
-| `SMTP_PASS` | — | SMTP password |
-| `SMTP_FROM` | — | Sender address |
+| `SMTP_USER` | not set | SMTP username |
+| `SMTP_PASS` | not set | SMTP password |
+| `SMTP_FROM` | not set | Sender address |
-### users.json Fields
+Set `LASTFM_API_KEY` when any user has `lastfmUser`. Cover caching and backups require `S3_BUCKET`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_ENDPOINT_URL`.
+### `users.json` fields
+
+`slug` and `name` belong to each item in `users`. Put the other fields in its `config` object. Only `name`, `listenbrainzUser` and `lastfmUser` are optional.
+
| Field | Type | Purpose |
|---|---|---|
-| `slug` | `Text` | URL slug (`""` for root user, `"filip"` for `/u/filip`) |
-| `name` | `Optional Text` | Display name for the user (defaults to slug if not provided) |
-| `listenbrainzUser` | `Optional Text` | ListenBrainz username |
-| `lastfmUser` | `Optional Text` | Last.fm username |
-| `databaseFile` | `Text` | DuckDB filename (relative to `DATABASE_PATH`) |
-| `coverCacheEnabled` | `Bool` | Enable S3 cover art caching |
-| `backupEnabled` | `Bool` | Enable periodic S3 database backups |
-| `backupIntervalHours` | `Natural` | Backup frequency |
+| `slug` | string | URL slug (`""` for the root user, `"filip"` for `/u/filip`) |
+| `name` | optional string | Display name, which defaults to the slug |
+| `listenbrainzUser` | optional string | ListenBrainz username |
+| `lastfmUser` | optional string | Last.fm username |
+| `databaseFile` | string | DuckDB filename, relative to `DATABASE_PATH` |
+| `coverCacheEnabled` | boolean | Enables S3 cover caching |
+| `backupEnabled` | boolean | Enables periodic S3 database backups |
+| `backupIntervalHours` | integer | Hours between backups |
-## Data Flow
+## Data flow
-### Scrobble Synchronization
+### Scrobble synchronisation
-Both sync processes follow the same pattern: fetch the most recent page, insert any new scrobbles, and paginate backwards through history until an already-known timestamp is encountered.
+Both sync processes fetch the newest page, insert new scrobbles and move backwards until they find a known timestamp.
-**ListenBrainz** (timestamp-based pagination):
+ListenBrainz uses timestamp-based pagination:
1. Fetch latest 100 scrobbles from the ListenBrainz API.
-2. Insert new scrobbles; stop if an existing timestamp is found.
+2. Insert new scrobbles. Stop if an existing timestamp is found.
3. Paginate backwards using `max_ts` until fully caught up.
-**Last.fm** (page-based pagination):
+Last.fm uses page-based pagination:
1. Fetch page 1 (most recent 200 scrobbles) from the Last.fm API.
-2. Insert new scrobbles; stop if an existing timestamp is found.
+2. Insert new scrobbles. Stop if an existing timestamp is found.
3. Paginate through subsequent pages using `totalPages` from the API response until fully caught up.
-Both processes run every 60 seconds per user. On subsequent syncs they stop at the first known timestamp, making incremental updates efficient.
+Both processes run every 60 seconds for each user. Later syncs stop at the first known timestamp.
-### Metadata Enrichment
-1. Background task identifies MBIDs in `scrobbles` that are not in `release_metadata`.
-2. Queries MusicBrainz API for release details.
-3. If MusicBrainz lacks genre information, falls back to Last.fm and Discogs APIs.
-4. Updates `release_metadata` with found information.
+### Metadata enrichment
-### Cover Art Retrieval
-When a cover is requested:
-1. Check S3 cache.
-2. If not found:
- - Try **Cover Art Archive (CAA)** using the Release MBID.
- - Fallback to **Discogs** using Artist/Album name.
- - Final fallback to **Last.fm** using Artist/Album name.
-3. The client is redirected to the selected source immediately. If caching is enabled, Corpus fetches, converts, and uploads the image to S3 in the background.
+1. Corpus finds release MBIDs that do not have metadata.
+2. It requests release details from MusicBrainz.
+3. If MusicBrainz has no genre, it tries Last.fm and Discogs.
+4. It updates `release_metadata` with any details found.
+### Cover art retrieval
+When a client requests a cover:
+
+1. Corpus checks the S3 cache.
+2. On a miss, Corpus tries Cover Art Archive, Discogs and then Last.fm.
+3. Corpus redirects the client immediately. If caching is enabled, it converts and uploads the image in the background.
+
## Observability
-### Prometheus Metrics
+### Prometheus metrics
-Prometheus metrics are **disabled by default**. Set `METRICS_ENABLED=true` to enable them. When enabled, all HTTP requests are instrumented via `Metrics.wrapRequest` and background work is tracked with dedicated counters and gauges. When disabled, the `/metrics` endpoint returns 404 and all metric-increment calls are no-ops with no runtime overhead.
+Prometheus metrics are disabled by default. Set `METRICS_ENABLED=true` to record HTTP requests and background work. When disabled, `/metrics` returns 404 and metric calls do nothing.
| Metric | Type | Labels | Description |
|---|---|---|---|
| `corpus_db_backup_runs_total` | Counter | `user`, `result` | Database backup runs |
| `corpus_db_backup_last_success_seconds` | Gauge | `user` | Timestamp of last successful backup |
-Node.js default metrics (GC, event loop, memory) are also collected via `prom-client`'s `collectDefaultMetrics`.
+`prom-client` also collects Node.js garbage collection, event loop and memory metrics.
-## Tech Stack
+## Tech stack
-- **Language**: [PureScript](https://purescript.org) (server), [Elm](https://elm-lang.org) (frontend)
-- **Runtime**: [Node.js](https://nodejs.org)
-- **Database**: [DuckDB](https://duckdb.org) (one file per user)
-- **Config**: JSON (`users.json`, managed via CLI)
-- **Bundling**: [spago](https://github.com/purescript/spago) + [esbuild](https://esbuild.github.io/) (server), [elm make](https://guide.elm-lang.org/install/elm.html) (frontend)
-- **Environment**: [Nix](https://nixos.org) for reproducible development shells and container builds
+- server language: [PureScript](https://purescript.org)
+- frontend language: [Elm](https://elm-lang.org)
+- runtime: [Node.js](https://nodejs.org)
+- database: [DuckDB](https://duckdb.org), with one file for each user
+- configuration: JSON in `users.json`, managed through the command-line interface
+- bundling: [Spago](https://github.com/purescript/spago), [esbuild](https://esbuild.github.io/) and [elm make](https://guide.elm-lang.org/install/elm.html)
+- build environment: [Nix](https://nixos.org)
-## Foreign Function Interface (FFI)
+## Foreign function interface
-Corpus relies on FFI to interact with the Node.js ecosystem where native PureScript wrappers are unavailable. Key FFI integrations:
+Corpus uses JavaScript modules where no PureScript wrapper is available:
-- **Database (`Db.js`)**: Interface to the `@duckdb/node-api` library. Includes BigInt → Number conversion for JSON compatibility.
-- **Cloud Storage (`S3.js`)**: AWS SDK (`@aws-sdk/client-s3`) for cover art caching. Takes explicit config structs rather than reading `process.env`.
-- **System Utilities (`Main.js`)**: Bridges PureScript with Node.js — `dotenv` loading and request helpers.
-- **Config (`Config.js`)**: Reads and parses `users.json` from the path given by `CORPUS_USERS_FILE`.
-- **Observability (`Metrics.js`)**: Initialises `prom-client` (Prometheus). Exports metric-increment helpers called from PureScript and the `wrapRequest` function that records metrics and logs each HTTP request.
+- `Db.js` uses `@duckdb/node-api` and converts JavaScript `BigInt` values to `Number` for JSON
+- `S3.js` uses `@aws-sdk/client-s3` for cover caching
+- `Main.js` loads `.env` files and provides request helpers
+- `Config.js` reads and parses the file set by `CORPUS_USERS_FILE`
+- `Metrics.js` uses `prom-client` to record HTTP and background-work metrics
-## System Flow
+## System flow
See [`architecture.dot`](architecture.dot) (render with `just docs`).
-
+
blob - eed80c07978d2ebc4cd56334d474bf00b2025259
blob + f996ff32a633b5ef5bc5d2501990387bc7c11aa7
--- docs/architecture.svg
+++ docs/architecture.svg
<?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 15.1.0 (0)
+<!-- Generated by graphviz version 15.1.1 (0)
-->
<!-- Title: corpus Pages: 1 -->
-<svg width="1571pt" height="342pt"
- viewBox="0.00 0.00 1571.00 342.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+<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>
<g id="clust1" class="cluster">
<title>cluster_clients</title>
-<path fill="#201a35" stroke="#9879dc" d="M20,-37C20,-37 259.92,-37 259.92,-37 265.92,-37 271.92,-43 271.92,-49 271.92,-49 271.92,-162 271.92,-162 271.92,-168 265.92,-174 259.92,-174 259.92,-174 20,-174 20,-174 14,-174 8,-168 8,-162 8,-162 8,-49 8,-49 8,-43 14,-37 20,-37"/>
-<text xml:space="preserve" text-anchor="middle" x="139.96" y="-156.7" font-family="Atkinson Hyperlegible Mono" font-size="14.00" fill="#ffffff">People and clients</text>
+<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>
</g>
<g id="clust2" class="cluster">
<title>cluster_server</title>
-<path fill="#11291d" stroke="#59aa76" d="M544.67,-32C544.67,-32 887.67,-32 887.67,-32 893.67,-32 899.67,-38 899.67,-44 899.67,-44 899.67,-167 899.67,-167 899.67,-173 893.67,-179 887.67,-179 887.67,-179 544.67,-179 544.67,-179 538.67,-179 532.67,-173 532.67,-167 532.67,-167 532.67,-44 532.67,-44 532.67,-38 538.67,-32 544.67,-32"/>
-<text xml:space="preserve" text-anchor="middle" x="716.17" y="-161.7" font-family="Atkinson Hyperlegible Mono" font-size="14.00" fill="#ffffff">Corpus server  ·  PureScript on Node.js</text>
+<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  ·  PureScript on Node.js</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_storage</title>
-<path fill="#302a10" stroke="#d2aa35" d="M1240.67,-132C1240.67,-132 1522.59,-132 1522.59,-132 1528.59,-132 1534.59,-138 1534.59,-144 1534.59,-144 1534.59,-293 1534.59,-293 1534.59,-299 1528.59,-305 1522.59,-305 1522.59,-305 1240.67,-305 1240.67,-305 1234.67,-305 1228.67,-299 1228.67,-293 1228.67,-293 1228.67,-144 1228.67,-144 1228.67,-138 1234.67,-132 1240.67,-132"/>
-<text xml:space="preserve" text-anchor="middle" x="1381.63" y="-287.7" font-family="Atkinson Hyperlegible Mono" font-size="14.00" fill="#ffffff">Persistent storage</text>
+<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>
</g>
<g id="clust4" class="cluster">
<title>cluster_external</title>
-<path fill="#11243e" stroke="#6396e7" d="M1306.67,-8C1306.67,-8 1456.59,-8 1456.59,-8 1462.59,-8 1468.59,-14 1468.59,-20 1468.59,-20 1468.59,-109 1468.59,-109 1468.59,-115 1462.59,-121 1456.59,-121 1456.59,-121 1306.67,-121 1306.67,-121 1300.67,-121 1294.67,-115 1294.67,-109 1294.67,-109 1294.67,-20 1294.67,-20 1294.67,-14 1300.67,-8 1306.67,-8"/>
-<text xml:space="preserve" text-anchor="middle" x="1381.63" y="-103.7" font-family="Atkinson Hyperlegible Mono" font-size="14.00" fill="#ffffff">External services</text>
+<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>
</g>
<!-- Clients -->
<g id="node1" class="node">
<title>Clients</title>
-<path fill="#302550" stroke="#8b97a8" d="M251.92,-142.76C251.92,-142.76 28,-142.76 28,-142.76 22,-142.76 16,-136.76 16,-130.76 16,-130.76 16,-57.23 16,-57.23 16,-51.23 22,-45.23 28,-45.23 28,-45.23 251.92,-45.23 251.92,-45.23 257.92,-45.23 263.92,-51.23 263.92,-57.23 263.92,-57.23 263.92,-130.76 263.92,-130.76 263.92,-136.76 257.92,-142.76 251.92,-142.76"/>
-<text xml:space="preserve" text-anchor="middle" x="139.96" y="-124.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="-113.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="-90.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="-78.88" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">ListenBrainz-compatible</text>
-<text xml:space="preserve" text-anchor="middle" x="139.96" y="-55.62" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">Administrator · Prometheus (optional)</text>
+<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-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>
</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->ClientFlow -->
+<g id="edge1" class="edge">
+<title>Clients->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 -->
<g id="node2" class="node">
<title>Corpus</title>
-<path fill="#1b422d" stroke="#8b97a8" d="M869.63,-148.39C869.63,-148.39 561.71,-148.39 561.71,-148.39 555.71,-148.39 549.71,-142.39 549.71,-136.39 549.71,-136.39 549.71,-51.61 549.71,-51.61 549.71,-45.61 555.71,-39.61 561.71,-39.61 561.71,-39.61 869.63,-39.61 869.63,-39.61 875.63,-39.61 881.63,-45.61 881.63,-51.61 881.63,-51.61 881.63,-136.39 881.63,-136.39 881.63,-142.39 875.63,-148.39 869.63,-148.39"/>
-<text xml:space="preserve" text-anchor="middle" x="715.67" y="-130.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="715.67" y="-119" 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="715.67" y="-95.75" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">Per-user background work</text>
-<text xml:space="preserve" text-anchor="middle" x="715.67" y="-84.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="715.67" y="-73.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="715.67" y="-50" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">Registration/admin workflow · optional metrics</text>
+<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-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>
</g>
-<!-- Clients->Corpus -->
-<g id="edge1" class="edge">
-<title>Clients->Corpus</title>
-<path fill="none" stroke="#aeb8c7" stroke-width="1.25" d="M264.39,-94C345.1,-94 451.73,-94 540.96,-94"/>
-<polygon fill="#aeb8c7" stroke="#aeb8c7" stroke-width="1.25" points="547.74,-94 540.74,-97.15 545.76,-94.13 540.74,-94.13 540.74,-94 540.74,-93.88 545.76,-93.88 540.74,-90.85 547.74,-94"/>
-<text xml:space="preserve" text-anchor="middle" x="402.3" y="-95.4" font-family="Atkinson Hyperlegible Mono" font-size="8.00" fill="#d5dce7">SPA, listen submission, admin, /metrics</text>
+<!-- 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-user data, registrations, cached covers, backups</text>
</g>
+<!-- Corpus->StorageFlow -->
+<g id="edge3" class="edge">
+<title>Corpus->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->ExternalFlow -->
+<g id="edge5" class="edge">
+<title>Corpus->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 -->
<g id="node3" class="node">
<title>Storage</title>
-<path fill="#473d15" stroke="#8b97a8" d="M1526.59,-261.86C1526.59,-268.59 1461.62,-274.05 1381.63,-274.05 1301.64,-274.05 1236.67,-268.59 1236.67,-261.86 1236.67,-261.86 1236.67,-152.14 1236.67,-152.14 1236.67,-145.41 1301.64,-139.95 1381.63,-139.95 1461.62,-139.95 1526.59,-145.41 1526.59,-152.14 1526.59,-152.14 1526.59,-261.86 1526.59,-261.86"/>
-<path fill="none" stroke="#8b97a8" d="M1526.59,-261.86C1526.59,-255.13 1461.62,-249.67 1381.63,-249.67 1301.64,-249.67 1236.67,-255.13 1236.67,-261.86"/>
-<text xml:space="preserve" text-anchor="middle" x="1381.63" 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="1381.63" 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="1381.63" 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="1381.63" y="-179.88" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">S3-compatible bucket</text>
-<text xml:space="preserve" text-anchor="middle" x="1381.63" y="-168.62" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">cover cache · database backups</text>
+<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-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>
</g>
-<!-- Corpus->Storage -->
-<g id="edge2" class="edge">
-<title>Corpus->Storage</title>
-<path fill="none" stroke="#aeb8c7" stroke-width="1.25" d="M882.08,-122.15C988.09,-140.19 1124.7,-163.44 1228.1,-181.04"/>
-<polygon fill="#aeb8c7" stroke="#aeb8c7" stroke-width="1.25" points="1234.93,-182.2 1227.5,-184.13 1232.96,-181.99 1228,-181.15 1228.03,-181.03 1228.05,-180.9 1233,-181.75 1228.55,-177.92 1234.93,-182.2"/>
-<text xml:space="preserve" text-anchor="middle" x="1064.17" y="-175.01" font-family="Atkinson Hyperlegible Mono" font-size="8.00" fill="#d5dce7">per-user data, registrations, cached covers, backups</text>
-</g>
<!-- External -->
<g id="node4" class="node">
<title>External</title>
-<path fill="#19365d" stroke="#8b97a8" d="M1448.59,-89.77C1448.59,-89.77 1314.67,-89.77 1314.67,-89.77 1308.67,-89.77 1302.67,-83.77 1302.67,-77.77 1302.67,-77.77 1302.67,-28.23 1302.67,-28.23 1302.67,-22.23 1308.67,-16.23 1314.67,-16.23 1314.67,-16.23 1448.59,-16.23 1448.59,-16.23 1454.59,-16.23 1460.59,-22.23 1460.59,-28.23 1460.59,-28.23 1460.59,-77.77 1460.59,-77.77 1460.59,-83.77 1454.59,-89.77 1448.59,-89.77"/>
-<text xml:space="preserve" text-anchor="middle" x="1381.63" 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="1381.63" 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="1381.63" 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="1381.63" 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="1381.63" y="-26.62" font-family="Atkinson Hyperlegible Mono" font-size="10.00" fill="#ffffff">SMTP server (optional)</text>
+<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>
</g>
-<!-- Corpus->External -->
-<g id="edge3" class="edge">
-<title>Corpus->External</title>
-<path fill="none" stroke="#aeb8c7" stroke-width="1.25" d="M890.79,-83.25C1020.5,-75.24 1192.12,-64.64 1293.99,-58.35"/>
-<polygon fill="#aeb8c7" stroke="#aeb8c7" stroke-width="1.25" points="890.8,-80.79 883.96,-83.67 891.1,-85.68 890.8,-80.79"/>
-<polygon fill="#aeb8c7" stroke="#aeb8c7" stroke-width="1.25" points="1300.89,-57.92 1294.1,-61.5 1298.93,-58.17 1293.91,-58.48 1293.91,-58.36 1293.9,-58.23 1298.91,-57.92 1293.71,-55.21 1300.89,-57.92"/>
-<text xml:space="preserve" text-anchor="middle" x="1064.17" y="-91.89" font-family="Atkinson Hyperlegible Mono" font-size="8.00" fill="#d5dce7">sync · enrichment · cover lookup</text>
-<text xml:space="preserve" text-anchor="middle" x="1064.17" y="-82.89" font-family="Atkinson Hyperlegible Mono" font-size="8.00" fill="#d5dce7">CAA → Discogs → Last.fm · similar tracks · email</text>
+<!-- ClientFlow->Corpus -->
+<g id="edge2" class="edge">
+<title>ClientFlow->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"/>
</g>
+<!-- StorageFlow->Storage -->
+<g id="edge4" class="edge">
+<title>StorageFlow->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"/>
</g>
+<!-- ExternalFlow->External -->
+<g id="edge6" class="edge">
+<title>ExternalFlow->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"/>
+</g>
+</g>
</svg>
blob - 4ede4acaf3601eb61f4fd8869a10c146ff06be9c
blob + ea6a2217916881818e4f59dd21fa010de65907b4
--- docs/duckdb.md
+++ docs/duckdb.md
# DuckDB in Corpus
-Corpus uses [DuckDB](https://duckdb.org/) as its primary analytical database. DuckDB's columnar storage and efficient query engine allow Corpus to provide fast filtering, pagination, and statistics over large sets of listening history data.
+Corpus stores each user's listening history in [DuckDB](https://duckdb.org/). Its columnar query engine supports filtering, pagination and statistics over large histories.
-## Database Schema
+## Database schema
Each user has an independent DuckDB database file. That database consists of three main tables:
### `scrobbles`
-Stores the raw listening history synced from ListenBrainz and/or Last.fm.
+Stores listening history from ListenBrainz and Last.fm.
| Column | Type | Description |
| :--- | :--- | :--- |
Self-registration state is intentionally kept outside the per-user databases in the shared `registrations.db` file. It is managed by the server rather than by the listening-history schema.
-## Application Usage
+## Application use
-The application interacts with DuckDB via a PureScript FFI layer (`src/Db.js` and `src/Db.purs`).
-- **BigInt Handling**: Since DuckDB returns `BIGINT` as JavaScript `BigInt`, the FFI layer converts these to `Number` to ensure compatibility with standard JSON serialization.
-- **Background Enrichment**: The server identifies "unenriched" scrobbles (those with an MBID but no metadata) and performs background updates to the `release_metadata` table.
+Corpus accesses DuckDB through `src/Db.purs` and `src/Db.js`:
-## Common Analytical Queries
+- the JavaScript layer converts DuckDB `BIGINT` values to `Number` for JSON serialisation
+- the server finds scrobbles with a release MBID but no metadata, then updates `release_metadata`
-You can run these queries directly against your `corpus.db` file using the DuckDB CLI or any compatible tool.
-
-### Top 10 Artists of All Time
-```sql
-SELECT artist_name, count(*) as play_count
-FROM scrobbles
-GROUP BY artist_name
-ORDER BY play_count DESC
-LIMIT 10;
-```
-
-### Listening Activity by Hour
-```sql
-SELECT
- extract('hour' from to_timestamp(listened_at)) as hour,
- count(*) as count
-FROM scrobbles
-GROUP BY hour
-ORDER BY hour;
-```
-
-### Genre Distribution
-```sql
-SELECT rm.genre, count(*) as count
-FROM scrobbles s
-JOIN release_metadata rm ON s.release_mbid = rm.release_mbid
-WHERE rm.genre IS NOT NULL
-GROUP BY rm.genre
-ORDER BY count DESC;
-```
-
-### MBID Enrichment Coverage
-```sql
-SELECT
- count(*) as total,
- count(release_mbid) FILTER (WHERE release_mbid != '') as with_mbid,
- (count(release_mbid) FILTER (WHERE release_mbid != '')::FLOAT / count(*)) * 100 as percentage
-FROM scrobbles;
-```
+Use the [example DuckDB queries](duckdb_queries.md) with the DuckDB command-line interface or another compatible tool.
blob - 75577a4e6c36eefb9ada58662ca8d04ed44986a2
blob + 30aa19f970f98a20b9856cb8143c2eaa1568d355
--- docs/duckdb_queries.md
+++ docs/duckdb_queries.md
-- `listened_at`: BIGINT (Unix timestamp)
-- `track_name`: VARCHAR
-- `artist_name`: VARCHAR
-- `release_name`: VARCHAR
-- `release_mbid`: VARCHAR
-- `caa_release_mbid`: VARCHAR
+# Example DuckDB queries
-## General Statistics
+Run these queries against a Corpus user database with the DuckDB command-line interface or another compatible tool.
-### Total number of scrobbles
+## General statistics
+
+### Total scrobbles
```sql
SELECT count(*) FROM scrobbles;
```
-### Artist diversity (Total unique artists)
+### Total unique artists
```sql
SELECT count(DISTINCT artist_name) FROM scrobbles;
```
-## Top Lists
+## Top lists
-### Top 10 Artists
+### Top 10 artists
```sql
SELECT artist_name, count(*) as play_count
FROM scrobbles
LIMIT 10;
```
-### Top 10 Tracks
+### Top 10 tracks
```sql
SELECT artist_name, track_name, count(*) as play_count
FROM scrobbles
LIMIT 10;
```
-### Top 10 Albums
+### Top 10 albums
```sql
SELECT artist_name, release_name, count(*) as play_count
FROM scrobbles
LIMIT 10;
```
-### Top 10 Labels
+### Top 10 labels
```sql
SELECT rm.label, count(*) as play_count
FROM scrobbles s
LIMIT 10;
```
-## Time-based Analysis
+## Time-based analysis
-### Scrobbles per day (Last 30 days)
+### Scrobbles per day for the last 30 days
```sql
SELECT
to_timestamp(listened_at)::DATE as date,
ORDER BY dayofweek(to_timestamp(listened_at));
```
-## Maintenance & Integrity
+## Maintenance and integrity
-### Find duplicate scrobbles (Same artist, track, and timestamp)
+### Find duplicate scrobbles
```sql
SELECT listened_at, artist_name, track_name, count(*)
FROM scrobbles