corpus

Log | Files | Refs | README | LICENSE

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:
MREADME.md | 2++
Mdocs/architecture.md | 16+++++++++++++++-
Mdocs/duckdb_queries.md | 11+++++++++++
Mjustfile | 3++-
Msrc/Client.elm | 7+------
Msrc/Db.purs | 7++++++-
Msrc/Main.purs | 11+++++++----
Msrc/Templates.purs | 6++++++
Mtest/Main.purs | 23+++++++++++++++++++++++
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