corpus

Log | Files | Refs | README | LICENSE

commit 697864f57afd7be5fe324529926d373c81de3704
parent e64489d67d318cdd63118fb083a5eed33606a4ef
Author: mtmn <miro@haravara.org>
Date:   Wed, 22 Apr 2026 22:54:55 +0200

fix: unhandled last.fm json serialization

Diffstat:
Msrc/Metadata.purs | 3++-
Msrc/Sync.purs | 41+++++++++++++++++++++++++++--------------
Mtest/Main.purs | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 96 insertions(+), 16 deletions(-)

diff --git a/src/Metadata.purs b/src/Metadata.purs @@ -105,7 +105,8 @@ fetchLastfmGenre (Just k) artist release = do obj <- toObject json album <- Object.lookup "album" obj >>= toObject tags <- Object.lookup "tags" album >>= toObject - tagArray <- Object.lookup "tag" tags >>= toArray + mTag <- Object.lookup "tag" tags + let tagArray = fromMaybe [ mTag ] (toArray mTag) firstTag <- tagArray !! 0 >>= toObject Object.lookup "name" firstTag >>= toString pure genre diff --git a/src/Sync.purs b/src/Sync.purs @@ -1,6 +1,8 @@ module Sync ( listenBrainzUrl , lastfmTrackToListen + , parseLastfmResponse + , fetchLastfmPage , lbSync , lbSyncLoop , lfSync @@ -11,14 +13,15 @@ import Prelude import Control.Monad.Rec.Class (forever) import Data.Argonaut (decodeJson, parseJson) -import Data.Argonaut.Core (Json, toArray, toObject, toString) +import Data.Argonaut.Core (Json, toArray, toObject, toString, toNumber, stringify) import Data.Array (length, mapMaybe, null) import Data.Either (Either(..)) import Data.Foldable (foldM, for_) -import Data.Int (fromString) +import Data.Int (fromNumber, fromString) import Data.Maybe (Maybe(..), fromMaybe) import Data.Time.Duration (Milliseconds(..)) import Data.Tuple (Tuple(..)) +import Control.Alt ((<|>)) import Db (Connection, checkExists, getOldestTs, upsertScrobble, withTransaction) import Effect.Aff (Aff, delay, launchAff_, makeAff, nonCanceler, try) import Effect.Aff.AVar (AVar) @@ -88,21 +91,31 @@ fetchLastfmPage apiKey lfmUser page mTo = withRetry "Last.fm fetch" do fr <- fetch url { method: GET } if fr.status == 200 then do json <- fromJson fr.json - 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 } - case parsed of - Just result -> pure result - Nothing -> liftEffect $ Exception.error "Last.fm: Failed to parse JSON response" # Exception.throwException + case parseLastfmResponse json of + Just result -> 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 else do liftEffect $ Exception.error ("Last.fm API returned status " <> show fr.status) # Exception.throwException +parseLastfmResponse :: Json -> Maybe { tracks :: Array Json, totalPages :: Int } +parseLastfmResponse json = do + let + obj = toObject json + rt = obj >>= Object.lookup "recenttracks" >>= toObject + mTracks = rt >>= Object.lookup "track" + tracks = case mTracks of + Just t -> fromMaybe [ t ] (toArray t) + Nothing -> [] + attr = rt >>= Object.lookup "@attr" >>= toObject + totalPages = fromMaybe 0 $ attr >>= Object.lookup "totalPages" >>= \tp -> + (toString tp >>= fromString) <|> (toNumber tp >>= fromNumber) + case rt, attr of + Just _, Just _ -> Just { tracks, totalPages } + _, _ -> Nothing + lastfmTrackToListen :: Json -> Maybe Listen lastfmTrackToListen json = do obj <- toObject json diff --git a/test/Main.purs b/test/Main.purs @@ -17,7 +17,7 @@ import Data.Argonaut.Core (Json) import Foreign.Object as Object import Main (parseFilterField) import Cover (sanitizeKey) -import Sync (listenBrainzUrl, lastfmTrackToListen) +import Sync (listenBrainzUrl, lastfmTrackToListen, parseLastfmResponse) import S3 (getS3Url) main :: Effect Unit @@ -433,6 +433,72 @@ main = runSpecAndExitProcess [consoleReporter] do Right j -> j Left _ -> encodeJson ([] :: Array Int) -- fallback that will produce Nothing + describe "parseLastfmResponse" do + it "parses standard response with multiple tracks" do + let j = parseTrack """ + { + "recenttracks": { + "track": [ + { "name": "Track 1" }, + { "name": "Track 2" } + ], + "@attr": { "totalPages": "10" } + } + } + """ + case parseLastfmResponse j of + Just { tracks, totalPages } -> do + length tracks `shouldEqual` 2 + totalPages `shouldEqual` 10 + Nothing -> do + fail "Should have parsed" + + it "parses response with a single track (as object)" do + let j = parseTrack """ + { + "recenttracks": { + "track": { "name": "Single Track" }, + "@attr": { "totalPages": "1" } + } + } + """ + case parseLastfmResponse j of + Just { tracks, totalPages } -> do + length tracks `shouldEqual` 1 + totalPages `shouldEqual` 1 + Nothing -> do + fail "Should have parsed single track object" + + it "parses response with no tracks" do + let j = parseTrack """ + { + "recenttracks": { + "@attr": { "totalPages": "0" } + } + } + """ + case parseLastfmResponse j of + Just { tracks, totalPages } -> do + length tracks `shouldEqual` 0 + totalPages `shouldEqual` 0 + Nothing -> do + fail "Should have parsed empty tracks" + + it "parses response where totalPages is a number" do + let j = parseTrack """ + { + "recenttracks": { + "track": [], + "@attr": { "totalPages": 5 } + } + } + """ + case parseLastfmResponse j of + Just { totalPages } -> do + totalPages `shouldEqual` 5 + Nothing -> do + fail "Should have parsed numeric totalPages" + describe "lastfmTrackToListen" do it "parses a valid track with MBID" do let j = parseTrack """