corpus

Log | Files | Refs | README | LICENSE

commit 030a32b4d7a9bd7b8fee3367d3a1d92daeb5dd98
parent 28b040e16e8438368723dd6a8aeac5457317aee9
Author: mtmn <miro@haravara.org>
Date:   Sat, 11 Apr 2026 17:09:55 +0200

docs: readme

Diffstat:
MREADME.md | 26+-------------------------
Msrc/Client.purs | 17+++++++++++------
Msrc/Db.purs | 7+++++--
Msrc/Main.purs | 21++++++++++++++++++++-
Msrc/Types.purs | 7+++++--
5 files changed, 42 insertions(+), 36 deletions(-)

diff --git a/README.md b/README.md @@ -2,29 +2,5 @@ | | | | :--- | ---: | -| Scorpus is a ListenBrainz frontend built with PureScript that provides server-side rendering and client-side interactivity using Halogen. It enables music listening tracking with DuckDB/SQLite storage and S3 integration for data management. | <img src="docs/korpus.webp" width="400" alt="Korpus"> | +| Scorpus is a [ListenBrainz](https://listenbrainz.org) frontend built with PureScript and Halogen. Under the hood it stores track metadata in DuckDB and cover images in a S3 bucket. | <img src="docs/korpus.webp" width="400" alt="Korpus"> | ---- - -## Architecture - -### Client-Side (Browser) - -- **Halogen** — Component-based UI framework for declarative, reactive interfaces -- **Affjax / Fetch** — HTTP client for API communication with the backend -- **Argonaut** — JSON encoding/decoding for structured data exchange -- **Web HTML / DOM** — Low-level browser API bindings for direct DOM manipulation - -### Server-Side (Node.js) - -- **Node HTTP** — Built-in Node.js HTTP server for request handling -- **DuckDB / SQLite3** — Embedded analytical and transactional databases for music listening data -- **AWS S3 SDK** — Object storage integration for large-scale data export/import -- **Environment Config** — Dotenv-based configuration management - -### Shared - -- **PureScript 0.15** — Strongly-typed functional language compiled to JavaScript -- **Esbuild** — Fast bundler producing both server (Node ESM) and client (browser) bundles -- **Spago** — PureScript package manager and build tool -- **Argonaut Core** — Shared JSON serialization layer across client and server diff --git a/src/Client.purs b/src/Client.purs @@ -246,12 +246,17 @@ component = ] ] ] - , if Set.member coverUrl failedCovers then HH.text "" - else HH.img - [ HP.class_ (H.ClassName "track-cover") - , HP.src coverUrl - , HP.alt release - , HE.onError \_ -> ImageError coverUrl + , HH.div [ HP.class_ (H.ClassName "cover-wrapper") ] + [ if Set.member coverUrl failedCovers then HH.text "" + else HH.img + [ HP.class_ (H.ClassName "track-cover") + , HP.src coverUrl + , HP.alt release + , HE.onError \_ -> ImageError coverUrl + ] + , case track.genre of + Just g -> HH.div [ HP.class_ (H.ClassName "genre-tag") ] [ HH.text g ] + Nothing -> HH.text "" ] ] diff --git a/src/Db.purs b/src/Db.purs @@ -82,7 +82,8 @@ upsertScrobble conn (Listen { listenedAt, trackMetadata: TrackMetadata track }) getScrobbles :: Connection -> Int -> Int -> Maybe { field :: String, value :: String } -> Aff (Array Listen) getScrobbles conn limit offset Nothing = do - rows <- queryAll conn "SELECT listened_at, track_name, artist_name, release_name, release_mbid, caa_release_mbid FROM scrobbles ORDER BY listened_at DESC LIMIT ? OFFSET ?" + rows <- queryAll conn + "SELECT s.listened_at, s.track_name, s.artist_name, s.release_name, s.release_mbid, s.caa_release_mbid, rm.genre FROM scrobbles s LEFT JOIN release_metadata rm ON s.release_mbid = rm.release_mbid ORDER BY s.listened_at DESC LIMIT ? OFFSET ?" [ unsafeCoerce limit, unsafeCoerce offset ] pure $ fromMaybe [] $ traverse rowToListen rows getScrobbles conn limit offset (Just { field, value }) = do @@ -92,7 +93,7 @@ getScrobbles conn limit offset (Just { field, value }) = do "year" -> "rm.release_year::VARCHAR" _ -> "rm.genre" rows <- queryAll conn - ( "SELECT s.listened_at, s.track_name, s.artist_name, s.release_name, s.release_mbid, s.caa_release_mbid" + ( "SELECT s.listened_at, s.track_name, s.artist_name, s.release_name, s.release_mbid, s.caa_release_mbid, rm.genre" <> " FROM scrobbles s JOIN release_metadata rm ON s.release_mbid = rm.release_mbid" <> " WHERE " <> col @@ -154,6 +155,7 @@ rowToListen json = do releaseName <- Object.lookup "release_name" obj >>= toString releaseMbid <- Object.lookup "release_mbid" obj >>= toString caaReleaseMbid <- Object.lookup "caa_release_mbid" obj >>= toString + let genre = Object.lookup "genre" obj >>= toString pure $ Listen { listenedAt: Just listenedAt @@ -161,6 +163,7 @@ rowToListen json = do { trackName: Just trackName , artistName: Just artistName , releaseName: Just releaseName + , genre , mbidMapping: Just $ MbidMapping { releaseMbid: if releaseMbid == "" then Nothing else Just releaseMbid , caaReleaseMbid: if caaReleaseMbid == "" then Nothing else Just caaReleaseMbid diff --git a/src/Main.purs b/src/Main.purs @@ -258,7 +258,6 @@ indexHtml = .track-cover { width: 60px; height: 60px; - margin-left: 15px; border-radius: 4px; object-fit: cover; background: rgba(255, 255, 255, 0.05); @@ -443,6 +442,26 @@ indexHtml = padding: 10px 0; } + .cover-wrapper { + display: flex; + flex-direction: column; + align-items: center; + margin-left: 15px; + gap: 4px; + flex-shrink: 0; + } + + .genre-tag { + font-size: 10px; + color: #9fbfe7; + text-align: center; + max-width: 60px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.8; + } + .stat-row { cursor: pointer; } diff --git a/src/Types.purs b/src/Types.purs @@ -69,6 +69,7 @@ newtype TrackMetadata = TrackMetadata , artistName :: Maybe String , releaseName :: Maybe String , mbidMapping :: Maybe MbidMapping + , genre :: Maybe String } derive instance eqTrackMetadata :: Eq TrackMetadata @@ -81,14 +82,16 @@ instance DecodeJson TrackMetadata where artistName <- obj .:? "artist_name" releaseName <- obj .:? "release_name" mbidMapping <- obj .:? "mbid_mapping" - pure $ TrackMetadata { trackName, artistName, releaseName, mbidMapping } + genre <- obj .:? "genre" + pure $ TrackMetadata { trackName, artistName, releaseName, mbidMapping, genre } instance EncodeJson TrackMetadata where - encodeJson (TrackMetadata { trackName, artistName, releaseName, mbidMapping }) = + encodeJson (TrackMetadata { trackName, artistName, releaseName, mbidMapping, genre }) = "track_name" := encodeJson trackName ~> "artist_name" := encodeJson artistName ~> "release_name" := encodeJson releaseName ~> "mbid_mapping" := encodeJson mbidMapping + ~> "genre" := encodeJson genre ~> jsonEmptyObject newtype MbidMapping = MbidMapping