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:
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 """