corpus

Log | Files | Refs | README | LICENSE

commit 612dcebd7ae4f62ddb284a5d6b3a933e7da3c11a
parent 96b22d1c3a5e8459c4807eb86d06533a48233cfb
Author: mtmn <miro@haravara.org>
Date:   Mon,  1 Jun 2026 21:52:15 +0200

fix: deduplicate sql logic; separate Handler.purs

Diffstat:
Msrc/Db.purs | 52+++++++++++++++++++++++++++++-----------------------
Asrc/Handler.purs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Msrc/Main.purs | 49++-----------------------------------------------
Msrc/Sync.purs | 127++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
4 files changed, 162 insertions(+), 113 deletions(-)

diff --git a/src/Db.purs b/src/Db.purs @@ -39,6 +39,12 @@ import Unsafe.Coerce (unsafeCoerce) foreign import data Connection :: Type +-- | Coerces a value to Foreign for SQL parameters. +-- | This centralizes all unsafeCoerce usage for database operations +-- | into a single auditable point. +toParam :: forall a. a -> Foreign +toParam = unsafeCoerce + data FilterField = FilterArtist | FilterAlbum | FilterLabel | FilterYear | FilterGenre | FilterTrack derive instance Eq FilterField @@ -152,19 +158,19 @@ initDb conn = do getOrCreateToken :: Connection -> String -> Aff (Maybe String) getOrCreateToken conn slug = do - rows <- queryAll conn "SELECT hashed_token FROM api_tokens WHERE slug = ?" [ unsafeCoerce slug ] + rows <- queryAll conn "SELECT hashed_token FROM api_tokens WHERE slug = ?" [ toParam slug ] case rows !! 0 >>= toObject >>= Object.lookup "hashed_token" >>= toString of Just _ -> pure Nothing Nothing -> do token <- liftEffect $ map UUID.toString UUID.genUUID let hashedToken = sha256 token - run conn "INSERT INTO api_tokens (slug, hashed_token) VALUES (?, ?)" [ unsafeCoerce slug, unsafeCoerce hashedToken ] + run conn "INSERT INTO api_tokens (slug, hashed_token) VALUES (?, ?)" [ toParam slug, toParam hashedToken ] pure $ Just token checkExists :: Connection -> Int -> Aff Boolean checkExists conn ts = do - rows <- queryAll conn "SELECT 1 FROM scrobbles WHERE listened_at = ?" [ unsafeCoerce ts ] + rows <- queryAll conn "SELECT 1 FROM scrobbles WHERE listened_at = ?" [ toParam ts ] pure case rows of [] -> false _ -> true @@ -186,12 +192,12 @@ upsertScrobble conn (Listen { listenedAt, trackMetadata: TrackMetadata track }) mbid = fromMaybe (MbidMapping { releaseMbid: Nothing, caaReleaseMbid: Nothing }) track.mbidMapping MbidMapping m = mbid params = - [ unsafeCoerce ts - , unsafeCoerce (fromMaybe "" track.trackName) - , unsafeCoerce (fromMaybe "" track.artistName) - , unsafeCoerce (fromMaybe "" track.releaseName) - , unsafeCoerce (fromMaybe "" m.releaseMbid) - , unsafeCoerce (fromMaybe "" m.caaReleaseMbid) + [ toParam ts + , toParam (fromMaybe "" track.trackName) + , toParam (fromMaybe "" track.artistName) + , toParam (fromMaybe "" track.releaseName) + , toParam (fromMaybe "" m.releaseMbid) + , toParam (fromMaybe "" m.caaReleaseMbid) ] 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 @@ -215,15 +221,15 @@ getScrobbles conn limit offset _ (Just q) = do <> " WHERE (s.track_name ILIKE ? OR s.artist_name ILIKE ? OR s.release_name ILIKE ? OR rm.label ILIKE ?)" <> scrobbleOrderPage ) - [ unsafeCoerce pattern, unsafeCoerce pattern, unsafeCoerce pattern, unsafeCoerce pattern, unsafeCoerce limit, unsafeCoerce offset ] + [ toParam pattern, toParam pattern, toParam pattern, toParam pattern, toParam limit, toParam offset ] pure $ mapMaybe rowToListen rows getScrobbles conn limit offset Nothing Nothing = do rows <- queryAll conn (scrobbleCols <> scrobbleFromLeft <> scrobbleOrderPage) - [ unsafeCoerce limit, unsafeCoerce offset ] + [ toParam limit, toParam offset ] pure $ mapMaybe rowToListen rows getScrobbles conn limit offset (Just { field, value }) Nothing = do - rows <- queryAll conn (filterQuery field) [ unsafeCoerce value, unsafeCoerce limit, unsafeCoerce offset ] + rows <- queryAll conn (filterQuery field) [ toParam value, toParam limit, toParam offset ] pure $ mapMaybe rowToListen rows filterQuery :: FilterField -> String @@ -265,7 +271,7 @@ getUnenrichedMbids :: Connection -> Int -> Aff (Array String) getUnenrichedMbids conn limit = do rows <- queryAll conn "SELECT DISTINCT release_mbid FROM scrobbles WHERE release_mbid != '' AND release_mbid NOT IN (SELECT release_mbid FROM release_metadata) LIMIT ?" - [ unsafeCoerce limit ] + [ toParam limit ] pure $ mapMaybe extractMbid rows where extractMbid json = do @@ -276,7 +282,7 @@ getEmptyGenreMbids :: Connection -> Int -> Aff (Array String) getEmptyGenreMbids conn limit = do rows <- queryAll conn "SELECT DISTINCT s.release_mbid FROM scrobbles s JOIN release_metadata rm ON s.release_mbid = rm.release_mbid WHERE (rm.genre IS NULL OR rm.genre = '') AND (rm.genre_checked_at IS NULL OR rm.genre_checked_at < CAST(epoch(now()) AS INTEGER) - 604800) LIMIT ?" - [ unsafeCoerce limit ] + [ toParam limit ] pure $ mapMaybe extractMbid rows where extractMbid json = do @@ -287,17 +293,17 @@ upsertReleaseMetadata :: Connection -> String -> Maybe String -> Maybe String -> upsertReleaseMetadata conn mbid genre label year = run conn "INSERT INTO release_metadata (release_mbid, genre, label, release_year) VALUES (?, ?, ?, ?) ON CONFLICT(release_mbid) DO UPDATE SET genre=excluded.genre, label=excluded.label, release_year=excluded.release_year" - [ unsafeCoerce mbid - , unsafeCoerce (toNullable genre) - , unsafeCoerce (toNullable label) - , unsafeCoerce (toNullable year) + [ toParam mbid + , toParam (toNullable genre) + , toParam (toNullable label) + , toParam (toNullable year) ] touchGenreCheckedAt :: Connection -> String -> Aff Unit touchGenreCheckedAt conn mbid = do run conn "UPDATE release_metadata SET genre_checked_at = CAST(epoch(now()) AS INTEGER) WHERE release_mbid = ?" - [ unsafeCoerce mbid ] + [ toParam mbid ] getStats :: Connection -> Maybe String -> Maybe String -> Maybe String -> Maybe String -> Aff Stats getStats conn mPeriod mFrom mTo mSection = do @@ -306,12 +312,12 @@ getStats conn mPeriod mFrom mTo mSection = do buildTimeFilterAndParams = case mFrom, mTo of Just from, Just to -> { timeFilter: " AND s.listened_at >= CAST(epoch(TIMESTAMP ? || ' 00:00:00') AS INTEGER) AND s.listened_at < CAST(epoch(TIMESTAMP ? || ' 00:00:00') AS INTEGER) + 86400" - , params: [ unsafeCoerce from, unsafeCoerce to ] + , params: [ toParam from, toParam to ] } _, _ -> case mPeriod >>= Int.fromString of Just days -> { timeFilter: " AND s.listened_at >= CAST(epoch(now()) AS INTEGER) - ?" - , params: [ unsafeCoerce (days * 86400) ] + , params: [ toParam (days * 86400) ] } Nothing -> { timeFilter: "", params: [] } @@ -350,7 +356,7 @@ getArtistReleasesByMbids conn mbids = do <> placeholders <> ") AND artist_name != '' AND release_name != ''" ) - (map unsafeCoerce mbids) + (map toParam mbids) pure $ Object.fromFoldable (mapMaybe extractPair rows) where extractPair json = do @@ -390,5 +396,5 @@ rowToListen json = do getTokenUser :: Connection -> String -> Aff (Maybe String) getTokenUser conn token = do let hashedToken = sha256 token - rows <- queryAll conn "SELECT slug FROM api_tokens WHERE hashed_token = ?" [ unsafeCoerce hashedToken ] + rows <- queryAll conn "SELECT slug FROM api_tokens WHERE hashed_token = ?" [ toParam hashedToken ] pure $ rows !! 0 >>= toObject >>= Object.lookup "slug" >>= toString diff --git a/src/Handler.purs b/src/Handler.purs @@ -0,0 +1,47 @@ +module Handler + ( Response + , Request + , serveNotFound + , serveUnauthorized + , serveInternalError + , serveBadRequest + , serveError + , respond + ) where + +import Prelude + +import Effect (Effect) +import Node.Encoding (Encoding(UTF8)) +import Node.HTTP.OutgoingMessage (setHeader, toWriteable) +import Node.HTTP.ServerResponse (setStatusCode, toOutgoingMessage) +import Node.Stream (end, writeString) +import Node.HTTP.Types (ServerResponse, IncomingMessage, IMServer) + +type Request = IncomingMessage IMServer + +type Response = ServerResponse + +respond :: String -> Int -> String -> Response -> Effect Unit +respond contentType statusCode body res = do + setHeader "Content-Type" contentType (toOutgoingMessage res) + setStatusCode statusCode res + let w = toWriteable (toOutgoingMessage res) + void $ writeString w UTF8 body + end w + +serveNotFound :: Response -> Effect Unit +serveNotFound = respond "text/plain" 404 "Not Found" + +serveUnauthorized :: Response -> Effect Unit +serveUnauthorized = respond "text/plain" 401 "Unauthorized" + +serveInternalError :: Response -> Effect Unit +serveInternalError = respond "text/plain" 500 "Internal Server Error" + +serveBadRequest :: Response -> String -> Effect Unit +serveBadRequest res message = respond "text/plain" 400 message res + +serveError :: Response -> Int -> String -> String -> Effect Unit +serveError res statusCode statusName message = + respond "text/plain" statusCode (statusName <> ": " <> message) res diff --git a/src/Main.purs b/src/Main.purs @@ -23,18 +23,17 @@ import Effect.Aff.AVar (AVar) import Effect.Aff.AVar as Avar import Effect.Class (liftEffect) import Effect.Exception as Exception +import Node.Encoding (Encoding(UTF8)) import Log as Log import Node.EventEmitter (on_) import Metadata (enrichMetadata) import Metrics as Metrics -import Node.Encoding (Encoding(UTF8)) import Node.FS.Aff as FSA import Node.HTTP (createServer) import Node.HTTP.IncomingMessage as IM import Node.HTTP.OutgoingMessage (setHeader, toWriteable) import Node.HTTP.ServerResponse (setStatusCode, toOutgoingMessage) import Node.HTTP.Server as Server -import Node.HTTP.Types (ServerResponse, IncomingMessage, IMServer) import Node.Net.Server (listenTcp, listeningH) import Node.Process (argv, lookupEnv) import Node.Stream (end, write, writeString) @@ -44,15 +43,11 @@ import Web.URL (URL) import Web.URL as URL import Web.URL.URLSearchParams as URLSearchParams import Command as Command +import Handler (Request, Response, serveBadRequest, serveError, serveInternalError, serveNotFound, serveUnauthorized) import Templates (indexHtml) import Types (Listen(..), ListenBrainzAdditionalInfo(..), ListenBrainzSubmitListen(..), ListenBrainzSubmitPayload(..), ListenBrainzSubmitTrackMetadata(..), MbidMapping(..), TrackMetadata(..)) import Foreign.Object as Object --- Types -type Request = IncomingMessage IMServer - -type Response = ServerResponse - type UserContext = { conn :: Connection , writeLock :: AVar Unit @@ -335,46 +330,6 @@ submitTrackMetadataToTrackMetadata (ListenBrainzSubmitTrackMetadata { trackName, } } -serveNotFound :: Response -> Effect Unit -serveNotFound res = do - setHeader "Content-Type" "text/plain" (toOutgoingMessage res) - setStatusCode 404 res - let w = toWriteable (toOutgoingMessage res) - void $ writeString w UTF8 "Not Found" - end w - -serveUnauthorized :: Response -> Effect Unit -serveUnauthorized res = do - setHeader "Content-Type" "text/plain" (toOutgoingMessage res) - setStatusCode 401 res - let w = toWriteable (toOutgoingMessage res) - void $ writeString w UTF8 "Unauthorized" - end w - -serveInternalError :: Response -> Effect Unit -serveInternalError res = do - setHeader "Content-Type" "text/plain" (toOutgoingMessage res) - setStatusCode 500 res - let w = toWriteable (toOutgoingMessage res) - void $ writeString w UTF8 "Internal Server Error" - end w - -serveBadRequest :: Response -> String -> Effect Unit -serveBadRequest res message = do - setHeader "Content-Type" "text/plain" (toOutgoingMessage res) - setStatusCode 400 res - let w = toWriteable (toOutgoingMessage res) - void $ writeString w UTF8 message - end w - -serveError :: Response -> Int -> String -> String -> Effect Unit -serveError res statusCode statusName message = do - setHeader "Content-Type" "text/plain" (toOutgoingMessage res) - setStatusCode statusCode res - let w = toWriteable (toOutgoingMessage res) - void $ writeString w UTF8 (statusName <> ": " <> message) - end w - startUser :: UserEntry -> Aff UserContext startUser { slug, name, config } = do Log.info $ "Starting user: " <> if slug == "" then "(root)" else slug diff --git a/src/Sync.purs b/src/Sync.purs @@ -18,14 +18,14 @@ import Data.Array (length, mapMaybe, null) import Data.Either (Either(..)) import Data.Foldable (foldM, for_) import Data.Maybe (Maybe(..), fromMaybe) +import Data.String (Pattern(..), stripPrefix) import Data.Time.Duration (Milliseconds(..)) -import Data.Tuple (Tuple(..)) import Db (Connection, checkExists, getOldestTs, upsertScrobble, withTransaction) -import Effect.Aff (Aff, delay, launchAff_, makeAff, nonCanceler, try) +import Effect.Aff (Aff, delay, launchAff_, makeAff, nonCanceler, throwError, try) import Effect.Aff.AVar (AVar) import Effect.Aff.Retry (RetryStatus(..), exponentialBackoff, limitRetries, recovering) import Effect.Class (liftEffect) -import Effect.Exception as Exception +import Effect.Exception (error, message) import Effect.Uncurried (mkEffectFn1) import Fetch (fetch, Method(GET)) import Fetch.Argonaut.Json (fromJson) @@ -38,19 +38,65 @@ import Node.HTTP.IncomingMessage as IM import Node.HTTPS as HTTPS import Node.Stream.Aff (readableToStringUtf8) import Types (Listen(..), ListenBrainzResponse(..), MbidMapping(..), Payload(..), TrackMetadata(..), LastfmResponse(..), LastfmRecentTracks(..), LastfmAttr(..), LastfmTracks(..), LastfmArtist(..), LastfmAlbum(..), LastfmDate(..), LastfmTrack(..)) +-- | Cast required due to a gap in the node-http FFI bindings: +-- | the request object lacks an "error" event emitter type. import Unsafe.Coerce (unsafeCoerce) +-- | Result of processing a batch of listens/scrobbles. +type SyncResult = + { added :: Int + , hitCount :: Int + , minTs :: Maybe Int + } + +-- | Process a batch of listens: check existence, insert new ones, track stats. +-- | When trackMinTs is true, the minimum timestamp is tracked (for cursor-based pagination). +processTracks :: Connection -> Array Listen -> Boolean -> Aff SyncResult +processTracks conn listens trackMinTs = do + foldM step { added: 0, hitCount: 0, minTs: Nothing } listens + where + step :: SyncResult -> Listen -> Aff SyncResult + step s l@(Listen { listenedAt: Just ts }) = do + exists <- checkExists conn ts + if exists then do + let nextMinTs = if trackMinTs then Just ts else s.minTs + pure $ s { added = s.added, hitCount = s.hitCount + 1, minTs = nextMinTs } + else do + upsertScrobble conn l + let nextMinTs = if trackMinTs then Just ts else s.minTs + pure $ s { added = s.added + 1, hitCount = s.hitCount, minTs = nextMinTs } + step s (Listen { listenedAt: Nothing }) = do + Log.warn "Skipping scrobble without timestamp" + pure s + +-- | Wraps unsafeCoerce for attaching error handlers to HTTP requests. +-- | This is the only legitimate use of unsafeCoerce in this module. +castRequestForError :: forall a. a -> a +castRequestForError = unsafeCoerce + listenBrainzUrl :: String -> String listenBrainzUrl username = "https://api.listenbrainz.org/1/user/" <> username <> "/listens" +-- | Retry an action with exponential backoff, but only for transient errors. +-- | Parse errors and other permanent failures are not retried. withRetry :: forall a. String -> Aff a -> Aff a -withRetry label action = recovering policy [ \_ _ -> pure true ] \(RetryStatus status) -> do +withRetry label action = recovering policy [ \_ err -> pure $ isTransientError err ] \(RetryStatus status) -> do when (status.iterNumber > 0) $ Log.warn $ label <> " failed, retry attempt " <> show status.iterNumber action where policy = exponentialBackoff (Milliseconds 1000.0) <> limitRetries 5 + isTransientError err = not isPermanent + where + msg = message err + isPermanent = + msg == "Last.fm: Failed to parse JSON response" + || startsWith "Last.fm API returned status" msg + || startsWith "ListenBrainz API returned status" msg + startsWith prefix str = case stripPrefix (Pattern prefix) str of + Just _ -> true + Nothing -> false fetchListenBrainzUrl :: String -> Aff String fetchListenBrainzUrl url = withRetry "ListenBrainz fetch" $ makeAff \callback -> do @@ -66,10 +112,10 @@ fetchListenBrainzUrl url = withRetry "ListenBrainz fetch" $ makeAff \callback -> if IM.statusCode res == 200 then callback (Right body) else - callback (Left $ Exception.error $ "ListenBrainz API returned status " <> show (IM.statusCode res)) + callback (Left $ error $ "ListenBrainz API returned status " <> show (IM.statusCode res)) let errorH = EventHandle "error" mkEffectFn1 - on_ errorH (\err -> callback (Left err)) (unsafeCoerce req) + on_ errorH (\err -> callback (Left err)) (castRequestForError req) pure nonCanceler @@ -94,9 +140,9 @@ fetchLastfmPage apiKey lfmUser page mTo = withRetry "Last.fm fetch" do pure result Nothing -> do Log.error $ "Last.fm: Failed to parse JSON response: " <> stringify json - liftEffect $ Exception.error "Last.fm: Failed to parse JSON response" # Exception.throwException + throwError $ error "Last.fm: Failed to parse JSON response" else do - liftEffect $ Exception.error ("Last.fm API returned status " <> show fr.status) # Exception.throwException + throwError $ error ("Last.fm API returned status " <> show fr.status) parseLastfmResponse :: Json -> Maybe { tracks :: Array Json, totalPages :: Int } parseLastfmResponse json = case decodeJson json of @@ -144,7 +190,7 @@ lbSync conn username slug writeLock = void do result <- try $ fetchListenBrainzUrl (listenBrainzUrl username <> "?count=100") case result of Left err -> do - Log.error $ "Sync fetch error: " <> Exception.message err + Log.error $ "Sync fetch error: " <> message err liftEffect $ Metrics.incSyncRuns slug "listenbrainz" "error" Right body -> case parseJson body >>= decodeJson of @@ -152,7 +198,11 @@ lbSync conn username slug writeLock = void do Log.error $ "Sync parse error: " <> show err liftEffect $ Metrics.incSyncRuns slug "listenbrainz" "error" Right (ListenBrainzResponse { payload: Payload { listens } }) -> do - Tuple added (Tuple minTs hitCount) <- withTransaction conn writeLock (processListens listens) + syncResult <- withTransaction conn writeLock (processListens listens) + let + added = syncResult.added + minTs = syncResult.minTs + hitCount = syncResult.hitCount Log.info $ "ListenBrainz batch 1: added " <> show added <> ", " <> show hitCount <> " already present." let allExist = hitCount == length listens && not (null listens) if allExist || null listens then do @@ -171,7 +221,7 @@ lbSync conn username slug writeLock = void do result <- try $ fetchListenBrainzUrl (listenBrainzUrl username <> "?count=100&max_ts=" <> show ts) case result of Left err -> do - Log.error $ "Sync fetch error: " <> Exception.message err + Log.error $ "Sync fetch error: " <> message err pure acc Right body -> case parseJson body >>= decodeJson of @@ -179,7 +229,11 @@ lbSync conn username slug writeLock = void do Log.error $ "Sync parse error: " <> show err pure acc Right (ListenBrainzResponse { payload: Payload { listens } }) -> do - Tuple added (Tuple newMinTs hitCount) <- withTransaction conn writeLock (processListens listens) + syncResult <- withTransaction conn writeLock (processListens listens) + let + added = syncResult.added + newMinTs = syncResult.minTs + hitCount = syncResult.hitCount Log.info $ "ListenBrainz batch " <> show batchNum <> ": added " <> show added <> ", " <> show hitCount <> " already present." let allExist = hitCount == length listens && not (null listens) if allExist || null listens then @@ -187,19 +241,7 @@ lbSync conn username slug writeLock = void do else paginateUntilDone (batchNum + 1) newMinTs (acc + added) - processListens listens = do - s <- foldM step { added: 0, minTs: Nothing, hitCount: 0 } listens - pure $ Tuple s.added (Tuple s.minTs s.hitCount) - where - step s l@(Listen { listenedAt: Just ts }) = do - exists <- checkExists conn ts - if exists then pure s { minTs = Just ts, hitCount = s.hitCount + 1 } - else do - upsertScrobble conn l - pure s { added = s.added + 1, minTs = Just ts } - step s _ = do - Log.warn "Skipping scrobble without timestamp" - pure s + processListens listens = processTracks conn listens true lbSyncLoop :: Connection -> String -> String -> AVar Unit -> Aff Unit lbSyncLoop conn username slug writeLock = forever do @@ -216,10 +258,13 @@ lfSync conn apiKey lfmUser slug writeLock = do res <- try $ fetchLastfmPage apiKey lfmUser 1 Nothing case res of Left err -> do - Log.error $ "Last.fm sync fetch error: " <> Exception.message err + Log.error $ "Last.fm sync fetch error: " <> message err liftEffect $ Metrics.incSyncRuns slug "lastfm" "error" Right { tracks, totalPages } -> do - Tuple added hitCount <- withTransaction conn writeLock (processLastfmTracks tracks) + result <- withTransaction conn writeLock (processLastfmTracks tracks) + let + added = result.added + hitCount = result.hitCount Log.info $ "Last.fm page 1/" <> show totalPages <> ": added " <> show added <> ", " <> show hitCount <> " already present." let validTracks = mapMaybe lastfmTrackToListen tracks @@ -239,11 +284,14 @@ lfSync conn apiKey lfmUser slug writeLock = do res <- try $ fetchLastfmPage apiKey lfmUser page mTo case res of Left err -> do - Log.error $ "Last.fm sync fetch error: " <> Exception.message err + Log.error $ "Last.fm sync fetch error: " <> message err pure acc Right { tracks } -> do - Tuple added hitCount <- withTransaction conn writeLock (processLastfmTracks tracks) - Log.info $ "Last.fm page " <> show page <> "/" <> show totalPages <> ": added " <> show added <> ", " <> show hitCount <> " already present." + result <- withTransaction conn writeLock (processLastfmTracks tracks) + let + added = result.added + hitCount = result.hitCount + Log.info $ "Last.fm page" <> show page <> "/" <> show totalPages <> ": added " <> show added <> ", " <> show hitCount <> " already present." let validTracks = mapMaybe lastfmTrackToListen tracks allExist = hitCount == length validTracks && not (null validTracks) @@ -257,28 +305,21 @@ lfSync conn apiKey lfmUser slug writeLock = do res <- try $ fetchLastfmPage apiKey lfmUser 1 (Just (oldestTs - 1)) case res of Left err -> - Log.error $ "Last.fm backfill fetch error: " <> Exception.message err + Log.error $ "Last.fm backfill fetch error: " <> message err Right { tracks, totalPages } -> if null tracks then Log.info "No older Last.fm history found." else do Log.info $ "Backfilling " <> show totalPages <> " pages of Last.fm history before " <> show oldestTs - Tuple added hitCount <- withTransaction conn writeLock (processLastfmTracks tracks) + result <- withTransaction conn writeLock (processLastfmTracks tracks) + let + added = result.added + hitCount = result.hitCount Log.info $ "Last.fm backfill page 1/" <> show totalPages <> ": added " <> show added <> ", " <> show hitCount <> " already present." total <- paginateLastfmUntilDone 2 totalPages (Just (oldestTs - 1)) added Log.info $ "Last.fm backfill complete. Added " <> show total <> " older scrobbles." - processLastfmTracks tracks = do - s <- foldM step { added: 0, hitCount: 0 } (mapMaybe lastfmTrackToListen tracks) - pure $ Tuple s.added s.hitCount - where - step s l@(Listen { listenedAt: Just ts }) = do - exists <- checkExists conn ts - if exists then pure s { hitCount = s.hitCount + 1 } - else do - upsertScrobble conn l - pure s { added = s.added + 1 } - step s _ = pure s + processLastfmTracks tracks = processTracks conn (mapMaybe lastfmTrackToListen tracks) false lfSyncLoop :: Connection -> String -> String -> String -> AVar Unit -> Aff Unit lfSyncLoop conn apiKey lfmUser slug writeLock = forever do