corpus

Log | Files | Refs | README | LICENSE

commit 346b4d5fc7425664c82be8e496dfafb4d9437cdc
parent a7f4e30a632ad9a176b57fbfe42e603fc900dd32
Author: mtmn <miro@haravara.org>
Date:   Sun, 19 Apr 2026 20:07:25 +0200

feat: add about, labels and fix search

Diffstat:
Msrc/Client.elm | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Msrc/Config.purs | 4+++-
Msrc/Db.purs | 41+++++++++++++++++++++++++----------------
Msrc/Main.purs | 19+++++++++++--------
Msrc/Templates.purs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/Types.purs | 7+++++--
Musers.dhall | 2++
Musers.json | 1+
8 files changed, 159 insertions(+), 52 deletions(-)

diff --git a/src/Client.elm b/src/Client.elm @@ -19,10 +19,16 @@ import Url port pushUrl : String -> Cmd msg +type alias UserInfo = + { slug : String + , name : String + } + + type alias Flags = { search : String , userSlug : String - , allUsers : List String + , allUsers : List UserInfo } @@ -64,6 +70,7 @@ type alias Listen = , releaseName : Maybe String , listenedAt : Maybe Int , genre : Maybe String + , label : Maybe String , releaseMbid : Maybe String , caaReleaseMbid : Maybe String } @@ -122,7 +129,7 @@ type alias Model = , customError : Maybe String , userSlug : String , similarStates : Dict String SimilarState - , allUsers : List String + , allUsers : List UserInfo , searchInput : String , activeSearch : Maybe String } @@ -639,6 +646,7 @@ listenDecoder = , releaseName = meta.releaseName , listenedAt = listenedAt , genre = meta.genre + , label = meta.label , releaseMbid = meta.releaseMbid , caaReleaseMbid = meta.caaReleaseMbid } @@ -654,18 +662,20 @@ type alias TrackMeta = , genre : Maybe String , releaseMbid : Maybe String , caaReleaseMbid : Maybe String + , label : Maybe String } trackMetaDecoder : Decoder TrackMeta trackMetaDecoder = - D.map6 TrackMeta + D.map7 TrackMeta (D.maybe (D.field "track_name" D.string)) (D.maybe (D.field "artist_name" D.string)) (D.maybe (D.field "release_name" D.string)) (D.maybe (D.field "genre" D.string)) (D.maybe (D.at [ "mbid_mapping", "release_mbid" ] D.string)) (D.maybe (D.at [ "mbid_mapping", "caa_release_mbid" ] D.string)) + (D.maybe (D.field "label" D.string)) statsDecoder : Decoder Stats @@ -929,14 +939,23 @@ renderListen userSlug currentTime failedCovers hoveredCover similarStates idx li , div [ class "track-artist" ] [ text artist ] , div [ class "track-time" ] [ span [] - [ a + (a [ href ("https://www.discogs.com/search/?q=" ++ Url.percentEncode (artist ++ " " ++ release) ++ "&type=release") , target "_blank" , class "album-link" ] [ text release ] - , text (" • " ++ timeAgo currentTime listen.listenedAt) - ] + :: (case listen.label of + Just l -> + [ text " • " + , button [ class "label-link", onClick (FilterBy "label" l) ] [ text l ] + ] + + Nothing -> + [] + ) + ++ [ text (" • " ++ timeAgo currentTime listen.listenedAt) ] + ) ] ] , div [ class "cover-wrapper" ] @@ -1265,7 +1284,6 @@ renderPeriodSelector current showInput customVal mError = ] - onEnter : msg -> Html.Attribute msg onEnter msg = on "keydown" @@ -1348,13 +1366,13 @@ timeAgo mNow mTimestamp = "unknown time" -renderAboutView : String -> List String -> Html Msg +renderAboutView : String -> List UserInfo -> Html Msg renderAboutView currentSlug allUsers = let otherUsers = - List.filter (\s -> s /= currentSlug) allUsers + List.filter (\u -> u.slug /= currentSlug) allUsers - userLink slug = + userLink { slug, name } = let url = if slug == "" then @@ -1362,25 +1380,41 @@ renderAboutView currentSlug allUsers = else "/u/" ++ slug - - label = - if slug == "" then - "root" - - else - slug in - li [] [ a [ href url ] [ text label ] ] + li [] [ a [ href url ] [ text name ] ] + + extLink url label = + a [ href url, target "_blank", class "about-link" ] [ text label ] in div [] - [ p [ class "about-description" ] - [ text "corpus is a self-hosted music scrobble viewer. it aggregates listening history from ListenBrainz and Last.fm, enriches tracks with metadata from MusicBrainz, and provides browsable listen history and statistics." ] + [ p [ class "about-lead" ] + [ text "corpus is a self-hosted listen history proxy that syncs scrobbles from " + , extLink "https://listenbrainz.org" "ListenBrainz" + , text " and " + , extLink "https://www.last.fm" "Last.fm." + ] + , div [ class "stats-section" ] + [ h2 [] [ text "features" ] + , ul [ class "about-list" ] + [ li [] [ text "searchable listen history with pagination" ] + , li [] [ text "stats by artist, track, label, year, and genre" ] + , li [] [ text "filter listens by label, artist, genre, or year" ] + , li [] [ text "cover art from Cover Art Archive, Last.fm, and Discogs" ] + , li [] [ text "similar track discovery via cosine.club" ] + , li [] [ text "metadata enrichment via MusicBrainz, Last.fm, and Discogs" ] + ] + ] + , div [ class "stats-section" ] + [ h2 [] [ text "source" ] + , p [ class "about-meta" ] + [ extLink "https://codeberg.org/mtmn/corpus" "codeberg.org/mtmn/corpus" ] + ] , if List.isEmpty otherUsers then text "" else div [ class "stats-section" ] - [ h2 [] [ text "users" ] + [ h2 [] [ text "friends" ] , ul [ class "about-users" ] (List.map userLink otherUsers) ] diff --git a/src/Config.purs b/src/Config.purs @@ -37,6 +37,7 @@ type UserConfig = type UserEntry = { slug :: String + , name :: Maybe String , config :: UserConfig } @@ -157,9 +158,10 @@ decodeUserEntry :: Json -> Either String UserEntry decodeUserEntry json = do obj <- mapLeft show $ decodeJson json slug <- mapLeft show $ obj .: "slug" + name <- mapLeft show $ obj .:? "name" configJson <- mapLeft show (obj .: "config" :: Either _ Json) config <- decodeUserConfig configJson - pure { slug, config } + pure { slug, name, config } -- Decodes only the non-sensitive, per-user fields from users.json. -- Shared credentials are injected from environment variables in loadConfig. diff --git a/src/Db.purs b/src/Db.purs @@ -177,20 +177,31 @@ upsertScrobble conn (Listen { listenedAt, trackMetadata: TrackMetadata track }) ] run conn "INSERT INTO scrobbles SELECT * FROM (SELECT ? as listened_at, ? as track_name, ? as artist_name, ? as release_name, ? as release_mbid, ? as caa_release_mbid) t WHERE NOT EXISTS (SELECT 1 FROM scrobbles WHERE listened_at = t.listened_at)" params +scrobbleCols :: String +scrobbleCols = "SELECT s.listened_at, s.track_name, s.artist_name, s.release_name, s.release_mbid, s.caa_release_mbid, rm.genre, rm.label" + +scrobbleFromLeft :: String +scrobbleFromLeft = " FROM scrobbles s LEFT JOIN release_metadata rm ON s.release_mbid = rm.release_mbid" + +scrobbleFromInner :: String +scrobbleFromInner = " FROM scrobbles s JOIN release_metadata rm ON s.release_mbid = rm.release_mbid" + +scrobbleOrderPage :: String +scrobbleOrderPage = " ORDER BY s.listened_at DESC LIMIT ? OFFSET ?" + getScrobbles :: Connection -> Int -> Int -> Maybe { field :: FilterField, value :: String } -> Maybe String -> Aff (Array Listen) getScrobbles conn limit offset _ (Just q) = do let pattern = "%" <> q <> "%" 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" + ( scrobbleCols <> scrobbleFromLeft <> " WHERE (s.track_name ILIKE ? OR s.artist_name ILIKE ? OR s.release_name ILIKE ? OR rm.label ILIKE ?)" - <> " ORDER BY s.listened_at DESC LIMIT ? OFFSET ?" + <> scrobbleOrderPage ) [ unsafeCoerce pattern, unsafeCoerce pattern, unsafeCoerce pattern, unsafeCoerce pattern, unsafeCoerce limit, unsafeCoerce offset ] pure $ mapMaybe rowToListen rows getScrobbles conn limit offset Nothing Nothing = do 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 ?" + (scrobbleCols <> scrobbleFromLeft <> scrobbleOrderPage) [ unsafeCoerce limit, unsafeCoerce offset ] pure $ mapMaybe rowToListen rows getScrobbles conn limit offset (Just { field, value }) Nothing = do @@ -199,21 +210,17 @@ getScrobbles conn limit offset (Just { field, value }) Nothing = do filterQuery :: FilterField -> String filterQuery FilterArtist = - "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" - <> " WHERE s.artist_name = ? ORDER BY s.listened_at DESC LIMIT ? OFFSET ?" + scrobbleCols <> scrobbleFromLeft + <> " WHERE s.artist_name = ?" <> scrobbleOrderPage filterQuery FilterLabel = - "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 rm.label = ? ORDER BY s.listened_at DESC LIMIT ? OFFSET ?" + scrobbleCols <> scrobbleFromInner + <> " WHERE rm.label = ?" <> scrobbleOrderPage filterQuery FilterYear = - "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 rm.release_year::VARCHAR = ? ORDER BY s.listened_at DESC LIMIT ? OFFSET ?" + scrobbleCols <> scrobbleFromInner + <> " WHERE rm.release_year::VARCHAR = ?" <> scrobbleOrderPage filterQuery FilterGenre = - "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 rm.genre = ? ORDER BY s.listened_at DESC LIMIT ? OFFSET ?" + scrobbleCols <> scrobbleFromInner + <> " WHERE rm.genre = ?" <> scrobbleOrderPage initReleaseMetadata :: Connection -> Aff Unit initReleaseMetadata conn = do @@ -333,6 +340,7 @@ rowToListen json = do releaseMbid <- Object.lookup "release_mbid" obj >>= toString caaReleaseMbid <- Object.lookup "caa_release_mbid" obj >>= toString let genre = Object.lookup "genre" obj >>= toString + let label = Object.lookup "label" obj >>= toString pure $ Listen { listenedAt: Just listenedAt @@ -341,6 +349,7 @@ rowToListen json = do , artistName: Just artistName , releaseName: Just releaseName , genre + , label , 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 @@ -67,6 +67,7 @@ type UserContext = , writeLock :: AVar Unit , config :: UserConfig , slug :: String + , displayName :: String } listenBrainzUrl :: String -> String @@ -157,6 +158,7 @@ lastfmTrackToListen json = do , artistName: Just artistName , releaseName: releaseName , genre: Nothing + , label: Nothing , mbidMapping: Just $ MbidMapping { releaseMbid: releaseMbid , caaReleaseMbid: releaseMbid @@ -332,7 +334,7 @@ handleRequest :: Boolean -> Array UserContext -> Request -> Response -> Effect U handleRequest metricsEnabled contexts req res = do let method = IM.method req let rawUrl = IM.url req - let allSlugs = map _.slug contexts + let allUsers = map (\ctx -> { slug: ctx.slug, name: ctx.displayName }) contexts case URL.fromRelative rawUrl "http://localhost" of Nothing -> serveNotFound res @@ -345,7 +347,7 @@ handleRequest metricsEnabled contexts req res = do "/favicon.png" -> serveAsset "image/png" "assets/favicon.png" res "/" -> - serveIndex allSlugs "" res + serveIndex allUsers "" res "/metrics" -> if metricsEnabled then serveMetrics res else do @@ -364,7 +366,7 @@ handleRequest metricsEnabled contexts req res = do _ -> case stripPrefix (Pattern "/u/") path of Just slug -> - serveIndex allSlugs slug res + serveIndex allUsers slug res Nothing -> do Log.warn $ "Path not found: " <> path serveNotFound res @@ -380,12 +382,12 @@ handleRequest metricsEnabled contexts req res = do Just ctx -> f ctx -serveIndex :: Array String -> String -> Response -> Effect Unit -serveIndex allSlugs slug res = do +serveIndex :: Array { slug :: String, name :: String } -> String -> Response -> Effect Unit +serveIndex allUsers slug res = do setHeader "Content-Type" "text/html" (toOutgoingMessage res) setStatusCode 200 res let w = toWriteable (toOutgoingMessage res) - void $ writeString w UTF8 (indexHtml slug allSlugs) + void $ writeString w UTF8 (indexHtml slug allUsers) end w serveMetrics :: Response -> Effect Unit @@ -946,7 +948,7 @@ enrichMetadata conn cfg slug = forever do -- Initialise one user: connect DB, start sync loops, return a UserContext. startUser :: UserEntry -> Aff UserContext -startUser { slug, config } = do +startUser { slug, name, config } = do Log.info $ "Starting user: " <> if slug == "" then "(root)" else slug conn <- connect config.databaseFile initDb conn @@ -984,7 +986,8 @@ startUser { slug, config } = do (toNumber config.backupIntervalHours * 3600000.0) slug - pure { conn, writeLock, config, slug } + let displayName = fromMaybe (if slug == "" then "root" else slug) name + pure { conn, writeLock, config, slug, displayName } foreign import dotenvConfig :: Effect Unit diff --git a/src/Templates.purs b/src/Templates.purs @@ -3,10 +3,11 @@ module Templates where import Prelude import Data.String.Common (joinWith) -indexHtml :: String -> Array String -> String +indexHtml :: String -> Array { slug :: String, name :: String } -> String indexHtml userSlug allUsers = let - usersJson = "[" <> joinWith "," (map (\s -> "\"" <> s <> "\"") allUsers) <> "]" + encodeUser { slug, name } = "{\"slug\":\"" <> slug <> "\",\"name\":\"" <> name <> "\"}" + usersJson = "[" <> joinWith "," (map encodeUser allUsers) <> "]" in """<!DOCTYPE html> <html lang="en"> @@ -101,6 +102,21 @@ indexHtml userSlug allUsers = color: #ffffff; } + .label-link { + background: none; + border: none; + padding: 0; + color: #9fbfe7; + text-decoration: underline; + font-family: inherit; + font-size: inherit; + cursor: pointer; + } + + .label-link:hover { + color: #ffffff; + } + .track-cover { width: 60px; height: 60px; @@ -612,11 +628,48 @@ indexHtml userSlug allUsers = border-color: #6a5fa0; } - .about-description { + .about-lead { color: #a0c0d0; font-size: 13px; line-height: 1.8; - margin: 0 0 24px 0; + margin: 0 0 30px 0; + } + + .about-link { + color: #9fbfe7; + text-decoration: underline; + } + + .about-link:hover { + color: #ffffff; + } + + .about-list { + list-style: none; + padding: 0; + margin: 0; + } + + .about-list li { + background: none; + border: none; + border-radius: 0; + padding: 3px 0; + margin-bottom: 0; + display: block; + box-shadow: none; + font-size: 13px; + color: #a0c0d0; + } + + .about-list li::before { + content: "→ "; + color: #50447f; + } + + .about-meta { + font-size: 13px; + margin: 0; } .about-users { diff --git a/src/Types.purs b/src/Types.purs @@ -77,6 +77,7 @@ newtype TrackMetadata = TrackMetadata , releaseName :: Maybe String , mbidMapping :: Maybe MbidMapping , genre :: Maybe String + , label :: Maybe String } derive instance eqTrackMetadata :: Eq TrackMetadata @@ -92,15 +93,17 @@ instance DecodeJson TrackMetadata where releaseName <- obj .:? "release_name" mbidMapping <- obj .:? "mbid_mapping" genre <- obj .:? "genre" - pure $ TrackMetadata { trackName, artistName, releaseName, mbidMapping, genre } + label <- obj .:? "label" + pure $ TrackMetadata { trackName, artistName, releaseName, mbidMapping, genre, label } instance EncodeJson TrackMetadata where - encodeJson (TrackMetadata { trackName, artistName, releaseName, mbidMapping, genre }) = + encodeJson (TrackMetadata { trackName, artistName, releaseName, mbidMapping, genre, label }) = "track_name" := encodeJson trackName ~> "artist_name" := encodeJson artistName ~> "release_name" := encodeJson releaseName ~> "mbid_mapping" := encodeJson mbidMapping ~> "genre" := encodeJson genre + ~> "label" := encodeJson label ~> jsonEmptyObject newtype MbidMapping = MbidMapping diff --git a/users.dhall b/users.dhall @@ -21,6 +21,7 @@ let defaults in { users = [ { slug = "" + , name = Some "mtmn" , config = defaults with listenbrainzUser = Some "mtmn" @@ -28,6 +29,7 @@ in { users = with backupEnabled = True } , { slug = "mtmnn" + , name = None Text , config = defaults with lastfmUser = Some "mtmnn" diff --git a/users.json b/users.json @@ -9,6 +9,7 @@ "initialSync": false, "listenbrainzUser": "mtmn" }, + "name": "mtmn", "slug": "" }, {