corpus

Log | Files | Refs | README | LICENSE

commit f9ae9aee4c4815d658aabc36c8e2a0cda18c35cb
parent cada497f6a566dc3bce7c714f58ec74cb52bc6a2
Author: mtmn <miro@haravara.org>
Date:   Mon, 13 Apr 2026 12:59:50 +0200

feat: add lastfm support

Diffstat:
M.env.example | 1+
Msrc/Main.purs | 124++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 121 insertions(+), 4 deletions(-)

diff --git a/.env.example b/.env.example @@ -1,4 +1,5 @@ LISTENBRAINZ_USER=mtmn +LASTFM_USER= DISCOGS_TOKEN= LASTFM_API_KEY= S3_BUCKET=scorpus-covers diff --git a/src/Main.purs b/src/Main.purs @@ -34,12 +34,12 @@ import Data.Maybe (Maybe(..), fromMaybe) import JSURI (encodeURIComponent) import Foreign.Object as Object import Data.Argonaut (decodeJson, encodeJson, parseJson) -import Data.Argonaut.Core (toObject, toArray, toString, stringify) -import Data.Array ((!!), length, uncons) +import Data.Argonaut.Core (Json, toObject, toArray, toString, stringify) +import Data.Array ((!!), length, uncons, mapMaybe) import Data.Tuple (Tuple(..)) import Data.Foldable (for_) import Db (Connection, connect, initDb, upsertScrobble, getScrobbles, checkExists, run, initReleaseMetadata, getUnenrichedMbids, getEmptyGenreMbids, getArtistReleaseByMbid, upsertReleaseMetadata, touchGenreCheckedAt, getStats, ping, backupDb) -import Types (Listen(..), ListenBrainzResponse(..), Payload(..), TrackMetadata(..)) +import Types (Listen(..), ListenBrainzResponse(..), MbidMapping(..), Payload(..), TrackMetadata(..)) import Control.Monad.Rec.Class (forever) import Data.Time.Duration (Milliseconds(..)) import Data.Int (fromString, toNumber) @@ -99,6 +99,71 @@ fetchListenBrainzDataBefore username maxTs = makeAff \callback -> do pure nonCanceler +fetchLastfmPage :: String -> String -> Int -> Aff { tracks :: Array Json, totalPages :: Int } +fetchLastfmPage apiKey lfmUser page = do + let + url = "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" + <> (fromMaybe lfmUser $ encodeURIComponent lfmUser) + <> "&api_key=" + <> apiKey + <> "&format=json&limit=200&page=" + <> show page + result <- try $ fetch url { method: GET } + case result of + Left err -> do + Log.error $ "Last.fm fetch error: " <> Exception.message err + pure { tracks: [], totalPages: 0 } + Right fr | fr.status == 200 -> do + jsonResult <- try $ fromJson fr.json + case jsonResult of + Left err -> do + Log.error $ "Last.fm JSON parse error: " <> Exception.message err + pure { tracks: [], totalPages: 0 } + Right json -> do + let + parsed = do + obj <- toObject json + rt <- Object.lookup "recenttracks" obj >>= toObject + tracks <- Object.lookup "track" rt >>= toArray + attr <- Object.lookup "@attr" rt >>= toObject + totalPagesStr <- Object.lookup "totalPages" attr >>= toString + totalPages <- fromString totalPagesStr + pure { tracks, totalPages } + pure $ fromMaybe { tracks: [], totalPages: 0 } parsed + Right fr -> do + Log.warn $ "Last.fm API returned status " <> show fr.status + pure { tracks: [], totalPages: 0 } + +lastfmTrackToListen :: Json -> Maybe Listen +lastfmTrackToListen json = do + obj <- toObject json + trackName <- Object.lookup "name" obj >>= toString + artistObj <- Object.lookup "artist" obj >>= toObject + artistName <- Object.lookup "#text" artistObj >>= toString + albumObj <- Object.lookup "album" obj >>= toObject + let releaseName = Object.lookup "#text" albumObj >>= toString + let + releaseMbid = do + s <- Object.lookup "mbid" albumObj >>= toString + if s == "" then Nothing else Just s + -- nowplaying tracks have no date field — naturally filtered out here + dateObj <- Object.lookup "date" obj >>= toObject + utsStr <- Object.lookup "uts" dateObj >>= toString + ts <- fromString utsStr + pure $ Listen + { listenedAt: Just ts + , trackMetadata: TrackMetadata + { trackName: Just trackName + , artistName: Just artistName + , releaseName: releaseName + , genre: Nothing + , mbidMapping: Just $ MbidMapping + { releaseMbid: releaseMbid + , caaReleaseMbid: releaseMbid + } + } + } + syncData :: Connection -> String -> Ref Boolean -> Aff Unit syncData _ username _ | username == "" = pure unit syncData conn username isSyncing = do @@ -164,6 +229,48 @@ syncData conn username isSyncing = do Log.warn "Skipping scrobble without timestamp" syncRecursive acc minTs tail +syncLastfmData :: Connection -> String -> String -> Ref Boolean -> Aff Unit +syncLastfmData conn apiKey lfmUser isSyncing = do + Log.info $ "Starting Last.fm sync for user: " <> lfmUser + forever do + liftEffect $ Ref.write true isSyncing + void $ performLastfmSync + liftEffect $ Ref.write false isSyncing + delay (Milliseconds 60000.0) + where + performLastfmSync = do + { tracks, totalPages } <- fetchLastfmPage apiKey lfmUser 1 + run conn "BEGIN TRANSACTION" [] + Tuple added hitExisting <- processLastfmTracks tracks + run conn "COMMIT" [] + if hitExisting || totalPages <= 1 then + when (added > 0) $ Log.info $ "Last.fm sync complete. Added " <> show added <> " new scrobbles." + else do + total <- paginateLastfmUntilDone 2 totalPages added + Log.info $ "Last.fm sync complete. Added " <> show total <> " new scrobbles." + + paginateLastfmUntilDone page totalPages acc + | page > totalPages = pure acc + | otherwise = do + { tracks } <- fetchLastfmPage apiKey lfmUser page + run conn "BEGIN TRANSACTION" [] + Tuple added hitExisting <- processLastfmTracks tracks + run conn "COMMIT" [] + if hitExisting || length tracks == 0 then pure (acc + added) + else paginateLastfmUntilDone (page + 1) totalPages (acc + added) + + processLastfmTracks tracks = syncLastfmRecursive 0 (mapMaybe lastfmTrackToListen tracks) + + syncLastfmRecursive acc listens = case uncons listens of + Nothing -> pure $ Tuple acc false + Just { head: l@(Listen { listenedAt: Just ts }), tail } -> do + exists <- checkExists conn ts + if exists then pure $ Tuple acc true + else do + upsertScrobble conn l + syncLastfmRecursive (acc + 1) tail + Just { head: _, tail } -> syncLastfmRecursive acc tail + indexHtml :: String indexHtml = """<!DOCTYPE html> @@ -1042,7 +1149,16 @@ startServer port dbFile username backupEnabled backupIntervalMs coverCacheEnable initDb conn initReleaseMetadata conn isSyncing <- liftEffect $ Ref.new false - void $ forkAff $ syncData conn username isSyncing + when (username /= "") $ void $ forkAff $ syncData conn username isSyncing + env <- liftEffect getEnv + let + lfmUser = fromMaybe "" $ Object.lookup "LASTFM_USER" env + lfmApiKey = fromMaybe "" $ Object.lookup "LASTFM_API_KEY" env + when (lfmUser /= "") $ + if lfmApiKey == "" then + Log.warn "LASTFM_USER is set but LASTFM_API_KEY is missing — Last.fm syncing disabled" + else + void $ forkAff $ syncLastfmData conn lfmApiKey lfmUser isSyncing void $ forkAff $ enrichMetadata conn isSyncing when backupEnabled $ void $ forkAff $ backupDb conn dbFile backupIntervalMs