commit eb06eb543ef08c585f3117465229198a6d23ff94
parent 9632dc517e3d488c3aa83c21068110a8e6f76cf6
Author: Miro Toman <44134862+mtmn@users.noreply.github.com>
Date: Sun, 19 Apr 2026 21:04:34 +0200
Merge pull request #15 from mtmn/feat/searchable-scrobbles
feat: scrobbles searchable by album
Diffstat:
9 files changed, 73 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
@@ -70,6 +70,8 @@ npx spago run
| Field | Default | Description |
| :--- | :--- | :--- |
+| `slug` | — | URL slug (`""` for root user, `"filip"` for `/u/filip`) |
+| `name` | — | Display name for the user (defaults to slug if not provided) |
| `listenbrainzUser` | — | ListenBrainz username to sync scrobbles from |
| `lastfmUser` | — | Last.fm username to sync scrobbles from |
| `databaseFile` | `corpus.db` | Path to the user's DuckDB database file |
diff --git a/docs/architecture.md b/docs/architecture.md
@@ -17,13 +17,16 @@ The server is built with PureScript running on Node.js. It handles several core
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.
+- **Label Integration**: Clickable labels in track listings for quick filtering.
- **Responsive UI**: Designed for both desktop and mobile viewing with a "retro-modern" aesthetic.
### 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).
+ - `release_metadata`: Stores enriched metadata indexed by MusicBrainz Release ID (MBID), including genre, label, and release year.
- **Performance**: DuckDB's columnar storage allows for extremely fast analytical queries across large listening histories.
### Storage
@@ -49,6 +52,16 @@ User configuration is split into two layers:
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.
+### Graceful Shutdown
+
+The server implements graceful shutdown handling to ensure clean termination of all background processes. When the server receives a shutdown signal, it:
+
+1. **Kills background fibers**: Terminates all running background tasks (metadata enrichment, database backups) for each user
+2. **Closes database connections**: Properly closes all DuckDB connections
+3. **Logs cleanup progress**: Provides detailed logging during the shutdown process
+
+This prevents data corruption and ensures that any in-progress operations are completed or safely aborted before the server exits.
+
## Configuration Reference
### Environment Variables
@@ -74,6 +87,7 @@ Each user gets their own `UserContext` with an independent DuckDB connection, sy
| 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`) |
diff --git a/docs/duckdb_queries.md b/docs/duckdb_queries.md
@@ -47,6 +47,17 @@ ORDER BY play_count DESC
LIMIT 10;
```
+### Top 10 Labels
+```sql
+SELECT rm.label, count(*) as play_count
+FROM scrobbles s
+JOIN release_metadata rm ON s.release_mbid = rm.release_mbid
+WHERE rm.label IS NOT NULL AND rm.label != ''
+GROUP BY rm.label
+ORDER BY play_count DESC
+LIMIT 10;
+```
+
## Time-based Analysis
### Scrobbles per day (Last 30 days)
diff --git a/justfile b/justfile
@@ -51,9 +51,10 @@ nix command:
# Run quality and security checks
check:
+ npx whine
npx spago build --strict
- elm-analyse
npx purs-tidy check "src/**/*.purs"
+ elm-analyse
statix check .
# Manage the container image (build, load, push)
diff --git a/src/Client.elm b/src/Client.elm
@@ -939,12 +939,7 @@ renderListen userSlug currentTime failedCovers hoveredCover similarStates idx li
, div [ class "track-artist" ] [ text artist ]
, div [ class "track-time" ]
[ span []
- (a
- [ href ("https://www.discogs.com/search/?q=" ++ Url.percentEncode (artist ++ " " ++ release) ++ "&type=release")
- , target "_blank"
- , class "album-link"
- ]
- [ text release ]
+ (button [ class "album-link", onClick (FilterBy "album" release) ] [ text release ]
:: (case listen.label of
Just l ->
[ text " • "
diff --git a/src/Db.purs b/src/Db.purs
@@ -35,12 +35,13 @@ import Metrics as Metrics
foreign import data Connection :: Type
-data FilterField = FilterArtist | FilterLabel | FilterYear | FilterGenre
+data FilterField = FilterArtist | FilterAlbum | FilterLabel | FilterYear | FilterGenre
derive instance Eq FilterField
instance Show FilterField where
show FilterArtist = "FilterArtist"
+ show FilterAlbum = "FilterAlbum"
show FilterLabel = "FilterLabel"
show FilterYear = "FilterYear"
show FilterGenre = "FilterGenre"
@@ -213,6 +214,10 @@ filterQuery FilterArtist =
scrobbleCols <> scrobbleFromLeft
<> " WHERE s.artist_name = ?"
<> scrobbleOrderPage
+filterQuery FilterAlbum =
+ scrobbleCols <> scrobbleFromLeft
+ <> " WHERE s.release_name = ?"
+ <> scrobbleOrderPage
filterQuery FilterLabel =
scrobbleCols <> scrobbleFromInner
<> " WHERE rm.label = ?"
diff --git a/src/Main.purs b/src/Main.purs
@@ -435,6 +435,7 @@ getQueryParam name url = URLSearchParams.get name (URL.searchParams url)
parseFilterField :: String -> Maybe FilterField
parseFilterField "artist" = Just FilterArtist
+parseFilterField "album" = Just FilterAlbum
parseFilterField "label" = Just FilterLabel
parseFilterField "year" = Just FilterYear
parseFilterField "genre" = Just FilterGenre
@@ -693,7 +694,7 @@ fetchCosineSimilar slug cfg query = do
liftEffect $ Metrics.incCosineRequest slug "not_configured"
pure "{\"data\":{\"similar_tracks\":[]},\"success\":true}"
else do
- let headers = { "User-Agent": "corpus/1.0 +https://codeberg.org/mtmn/corpus", "Authorization": "Bearer " <> apiKey }
+ let headers = { "User-Agent": "corpus/1.0 +https://github.com/mtmn/corpus", "Authorization": "Bearer " <> apiKey }
let searchUrl = "https://cosine.club/api/v1/search?q=" <> (fromMaybe "" $ encodeURIComponent query) <> "&limit=1"
Log.info $ "Cosine Club: searching for: " <> query
searchResult <- try $ fetch searchUrl { method: GET, headers: headers }
@@ -791,7 +792,7 @@ type MbData = { genre :: Maybe String, label :: Maybe String, year :: Maybe Int
fetchMusicBrainzRelease :: String -> Aff (Maybe MbData)
fetchMusicBrainzRelease mbid = do
let url = "https://musicbrainz.org/ws/2/release/" <> mbid <> "?inc=genres+labels+release-groups&fmt=json"
- result <- try $ fetch url { method: GET, headers: { "User-Agent": "corpus/1.0 +https://codeberg.org/mtmn/corpus" } }
+ result <- try $ fetch url { method: GET, headers: { "User-Agent": "corpus/1.0 +https://github.com/mtmn/corpus" } }
case result of
Left err -> do
Log.error $ "MusicBrainz fetch error for " <> mbid <> ": " <> Exception.message err
@@ -1017,12 +1018,14 @@ cleanupUser ctx = do
Just fiber -> do
Log.info "Killing enrich metadata fiber"
void $ try $ killFiber (Exception.error "Server shutting down") fiber
- Nothing -> pure unit
+ Nothing -> do
+ pure unit
case ctx.backupFiber of
Just fiber -> do
Log.info "Killing backup fiber"
void $ try $ killFiber (Exception.error "Server shutting down") fiber
- Nothing -> pure unit
+ Nothing -> do
+ pure unit
-- Close database connection
Log.info "Closing database connection"
void $ try $ ping ctx.conn
diff --git a/src/Templates.purs b/src/Templates.purs
@@ -94,8 +94,14 @@ indexHtml userSlug allUsers =
}
.album-link {
+ background: none;
+ border: none;
+ padding: 0;
color: #9fbfe7;
text-decoration: underline;
+ font-family: inherit;
+ font-size: inherit;
+ cursor: pointer;
}
.album-link:hover {
diff --git a/test/Main.purs b/test/Main.purs
@@ -32,6 +32,7 @@ main = runSpecAndExitProcess [consoleReporter] do
describe "parseFilterField" do
it "maps all valid field names" do
parseFilterField "artist" `shouldEqual` Just FilterArtist
+ parseFilterField "album" `shouldEqual` Just FilterAlbum
parseFilterField "label" `shouldEqual` Just FilterLabel
parseFilterField "year" `shouldEqual` Just FilterYear
parseFilterField "genre" `shouldEqual` Just FilterGenre
@@ -211,6 +212,28 @@ main = runSpecAndExitProcess [consoleReporter] do
listensNone <- getScrobbles conn 10 0 (Just { field: FilterArtist, value: "Gamma" }) Nothing
length listensNone `shouldEqual` 0
+ it "filters by album" do
+ conn <- connect ":memory:"
+ initDb conn
+ initReleaseMetadata conn
+ let mkListen artist release = Listen
+ { listenedAt: Just 1
+ , trackMetadata: TrackMetadata
+ { trackName: Just "Track"
+ , artistName: Just artist
+ , releaseName: Just release
+ , mbidMapping: Nothing
+ , genre: Nothing
+ , label: Nothing
+ }
+ }
+ upsertScrobble conn (mkListen "Artist" "Album A")
+ upsertScrobble conn (mkListen "Artist" "Album B")
+ listens <- getScrobbles conn 10 0 (Just { field: FilterAlbum, value: "Album A" }) Nothing
+ length listens `shouldEqual` 1
+ listensNone <- getScrobbles conn 10 0 (Just { field: FilterAlbum, value: "Album C" }) Nothing
+ length listensNone `shouldEqual` 0
+
it "filters by label" do
conn <- connect ":memory:"
initDb conn