corpus

Log | Files | Refs | README | LICENSE

commit 96b22d1c3a5e8459c4807eb86d06533a48233cfb
parent 12fde33d119bb89bc332c4e17551d03feeadb328
Author: mtmn <miro@haravara.org>
Date:   Mon,  1 Jun 2026 21:26:25 +0200

fix: better type safety

Diffstat:
Msrc/Db.purs | 34++++++++++++++++++++--------------
Msrc/Main.purs | 21++++++---------------
Msrc/Sync.purs | 79++++++++++++++++++++++++++++++++-----------------------------------------------
Msrc/Types.purs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtest/Main.purs | 20++++++++++----------
5 files changed, 203 insertions(+), 88 deletions(-)

diff --git a/src/Db.purs b/src/Db.purs @@ -11,8 +11,10 @@ import Data.Either (Either(..)) import Data.Foldable (for_) import Data.Formatter.DateTime (formatDateTime) import Data.Function.Uncurried (Fn2, Fn4, runFn2, runFn4) -import Data.Int (fromString, round) +import Data.Int as Int import Data.Maybe (Maybe(..), fromMaybe) +import Data.Generic.Rep (class Generic) +import Data.Show.Generic (genericShow) import Data.Nullable (Nullable, toMaybe, toNullable) import Data.String (Pattern(..), split, stripSuffix) import Data.String.Common (joinWith) @@ -40,14 +42,18 @@ foreign import data Connection :: Type data FilterField = FilterArtist | FilterAlbum | FilterLabel | FilterYear | FilterGenre | FilterTrack derive instance Eq FilterField - +derive instance Generic FilterField _ instance Show FilterField where - show FilterArtist = "FilterArtist" - show FilterAlbum = "FilterAlbum" - show FilterLabel = "FilterLabel" - show FilterYear = "FilterYear" - show FilterGenre = "FilterGenre" - show FilterTrack = "FilterTrack" + show = genericShow + +fromString :: String -> Maybe FilterField +fromString "artist" = Just FilterArtist +fromString "album" = Just FilterAlbum +fromString "label" = Just FilterLabel +fromString "year" = Just FilterYear +fromString "genre" = Just FilterGenre +fromString "track" = Just FilterTrack +fromString _ = Nothing foreign import connectImpl :: Fn2 String (Nullable Error -> Nullable Connection -> Effect Unit) (Effect Unit) foreign import runImpl :: Fn4 Connection String (Array Foreign) (Nullable Error -> Effect Unit) (Effect Unit) @@ -170,7 +176,7 @@ getOldestTs conn = do row <- rows !! 0 obj <- toObject row n <- Object.lookup "min_ts" obj >>= toNumber - let ts = round n + let ts = Int.round n if ts == 0 then Nothing else Just ts upsertScrobble :: Connection -> Listen -> Aff Unit @@ -299,10 +305,10 @@ getStats conn mPeriod mFrom mTo mSection = do buildTimeFilterAndParams :: { timeFilter :: String, params :: Array Foreign } buildTimeFilterAndParams = case mFrom, mTo of Just from, Just to -> - { timeFilter: " AND s.listened_at >= CAST(epoch(TIMESTAMP '" <> from <> " 00:00:00') AS INTEGER) AND s.listened_at < CAST(epoch(TIMESTAMP '" <> to <> " 00:00:00') AS INTEGER) + 86400" - , params: [] + { 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 ] } - _, _ -> case mPeriod >>= fromString of + _, _ -> case mPeriod >>= Int.fromString of Just days -> { timeFilter: " AND s.listened_at >= CAST(epoch(now()) AS INTEGER) - ?" , params: [ unsafeCoerce (days * 86400) ] @@ -331,7 +337,7 @@ rowToEntry :: Json -> Maybe StatsEntry rowToEntry json = do obj <- toObject json name <- Object.lookup "name" obj >>= toString - count <- map round $ Object.lookup "count" obj >>= toNumber + count <- map Int.round $ Object.lookup "count" obj >>= toNumber pure $ StatsEntry { name, count } getArtistReleasesByMbids :: Connection -> Array String -> Aff (Object.Object { artist :: String, release :: String }) @@ -357,7 +363,7 @@ getArtistReleasesByMbids conn mbids = do rowToListen :: Json -> Maybe Listen rowToListen json = do obj <- toObject json - listenedAt <- map round $ Object.lookup "listened_at" obj >>= toNumber + listenedAt <- map Int.round $ Object.lookup "listened_at" obj >>= toNumber trackName <- Object.lookup "track_name" obj >>= toString artistName <- Object.lookup "artist_name" obj >>= toString releaseName <- Object.lookup "release_name" obj >>= toString diff --git a/src/Main.purs b/src/Main.purs @@ -10,13 +10,13 @@ import Data.Array (find, length) import Data.Array as Data.Array import Data.Either (Either(..), fromRight') import Data.Foldable (for_, traverse_) -import Data.Int (fromString, toNumber) +import Data.Int as Int import Data.Maybe (Maybe(..), fromMaybe) import Data.String (Pattern(..), stripPrefix) import Data.String.Regex (regex, replace, parseFlags) import Partial.Unsafe (unsafeCrashWith) import Data.Traversable (traverse) -import Db (Connection, FilterField(..), backupDb, connect, getOrCreateToken, getTokenUser, getScrobbles, getStats, initDb, initReleaseMetadata, ping, upsertScrobble, withTransaction) +import Db (Connection, backupDb, connect, fromString, getOrCreateToken, getScrobbles, getStats, getTokenUser, initDb, initReleaseMetadata, ping, upsertScrobble, withTransaction) import Effect (Effect) import Effect.Aff (Aff, Fiber, forkAff, joinFiber, killFiber, launchAff_, try) import Effect.Aff.AVar (AVar) @@ -212,26 +212,17 @@ serveClientJs res = do getQueryParam :: String -> URL -> Maybe String getQueryParam name url = URLSearchParams.get name (URL.searchParams url) -parseFilterField :: String -> Maybe FilterField -parseFilterField "artist" = Just FilterArtist -parseFilterField "album" = Just FilterAlbum -parseFilterField "label" = Just FilterLabel -parseFilterField "year" = Just FilterYear -parseFilterField "genre" = Just FilterGenre -parseFilterField "track" = Just FilterTrack -parseFilterField _ = Nothing - serveProxy :: String -> Connection -> URL -> Response -> Effect Unit serveProxy corsOrigin db url res = do setHeader "Content-Type" "application/json" (toOutgoingMessage res) setHeader "Access-Control-Allow-Origin" corsOrigin (toOutgoingMessage res) launchAff_ do - let limit = fromMaybe 25 (getQueryParam "limit" url >>= fromString) - let offset = fromMaybe 0 (getQueryParam "offset" url >>= fromString) + let limit = fromMaybe 25 (getQueryParam "limit" url >>= Int.fromString) + let offset = fromMaybe 0 (getQueryParam "offset" url >>= Int.fromString) let mFilter = do - field <- getQueryParam "filterField" url >>= parseFilterField + field <- getQueryParam "filterField" url >>= fromString value <- getQueryParam "filterValue" url pure { field, value } @@ -418,7 +409,7 @@ startUser { slug, name, config } = do enrichMetadataFiber <- forkAff $ enrichMetadata conn config slug backupFiber <- - if config.backupEnabled then Just <$> forkAff (backupDb conn config.databaseFile (s3ConfigFromUser config) (toNumber config.backupIntervalHours * 3600000.0) slug) + if config.backupEnabled then Just <$> forkAff (backupDb conn config.databaseFile (s3ConfigFromUser config) (Int.toNumber config.backupIntervalHours * 3600000.0) slug) else pure Nothing let displayName = fromMaybe (if slug == "" then "root" else slug) name diff --git a/src/Sync.purs b/src/Sync.purs @@ -13,15 +13,13 @@ import Prelude import Control.Monad.Rec.Class (forever) import Data.Argonaut (decodeJson, parseJson) -import Data.Argonaut.Core (Json, toArray, toObject, toString, toNumber, stringify) +import Data.Argonaut.Core (Json, stringify) import Data.Array (length, mapMaybe, null) import Data.Either (Either(..)) import Data.Foldable (foldM, for_) -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) @@ -31,7 +29,6 @@ import Effect.Exception as Exception import Effect.Uncurried (mkEffectFn1) import Fetch (fetch, Method(GET)) import Fetch.Argonaut.Json (fromJson) -import Foreign.Object as Object import JSURI (encodeURIComponent) import Log as Log import Metrics as Metrics @@ -40,7 +37,7 @@ import Node.HTTP.ClientRequest as Client import Node.HTTP.IncomingMessage as IM import Node.HTTPS as HTTPS import Node.Stream.Aff (readableToStringUtf8) -import Types (Listen(..), ListenBrainzResponse(..), MbidMapping(..), Payload(..), TrackMetadata(..)) +import Types (Listen(..), ListenBrainzResponse(..), MbidMapping(..), Payload(..), TrackMetadata(..), LastfmResponse(..), LastfmRecentTracks(..), LastfmAttr(..), LastfmTracks(..), LastfmArtist(..), LastfmAlbum(..), LastfmDate(..), LastfmTrack(..)) import Unsafe.Coerce (unsafeCoerce) listenBrainzUrl :: String -> String @@ -102,50 +99,38 @@ fetchLastfmPage apiKey lfmUser page mTo = withRetry "Last.fm fetch" 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 +parseLastfmResponse json = case decodeJson json of + Right (LastfmResponse { recenttracks: LastfmRecentTracks { track, attr: LastfmAttr { totalPages } } }) -> + let + tracks = case track of + LastfmTrackArray' ts -> ts + LastfmTrackSingle' t -> [ t ] + in + Just { tracks, totalPages: totalPages } + Left _ -> Nothing 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 - 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 - , label: Nothing - , mbidMapping: Just $ MbidMapping - { releaseMbid: releaseMbid - , caaReleaseMbid: releaseMbid - } - } - } +lastfmTrackToListen json = case decodeJson json of + Right (LastfmTrack { name, artist: LastfmArtist { text: artistName }, album, date }) -> do + ts <- date >>= \(LastfmDate { uts }) -> Just uts + let + releaseName = album >>= \(LastfmAlbum { text }) -> text + releaseMbid = album >>= \(LastfmAlbum { mbid }) -> if mbid == "" then Nothing else Just mbid + pure $ Listen + { listenedAt: Just ts + , trackMetadata: TrackMetadata + { trackName: Just name + , artistName: Just artistName + , releaseName: releaseName + , genre: Nothing + , label: Nothing + , mbidMapping: Just $ MbidMapping + { releaseMbid: releaseMbid + , caaReleaseMbid: releaseMbid + } + } + } + Left _ -> Nothing recordSyncSuccess :: String -> String -> Int -> Aff Unit recordSyncSuccess slug source n = do diff --git a/src/Types.purs b/src/Types.purs @@ -3,8 +3,11 @@ module Types where import Prelude import Data.Argonaut (class DecodeJson, class EncodeJson, decodeJson, encodeJson, (.:), (.:?), (:=), (~>)) -import Data.Argonaut.Core (jsonEmptyObject) -import Data.Maybe (Maybe) +import Data.Argonaut.Decode.Error (JsonDecodeError(..)) +import Data.Either (Either(..)) +import Data.Argonaut.Core (Json, toArray, jsonEmptyObject, toNumber, toString) +import Data.Int (fromString, fromNumber) +import Data.Maybe (Maybe(..), fromMaybe) import Data.Generic.Rep (class Generic) import Data.Show.Generic (genericShow) @@ -203,6 +206,136 @@ instance EncodeJson MbidMapping where ~> "caa_release_mbid" := encodeJson caaReleaseMbid ~> jsonEmptyObject +-- Last.fm API types + +newtype LastfmAttr = LastfmAttr + { totalPages :: Int + } + +derive instance genericLastfmAttr :: Generic LastfmAttr _ +instance showLastfmAttr :: Show LastfmAttr where + show = genericShow + +instance decodeJsonLastfmAttr :: DecodeJson LastfmAttr where + decodeJson json = do + obj <- decodeJson json + tpJson <- obj .: "totalPages" + let + tpStr = toString tpJson + tpNum = toNumber tpJson + parsed = case tpStr >>= fromString of + Just n -> Just n + Nothing -> tpNum >>= fromNumber + case parsed of + Just n -> pure $ LastfmAttr { totalPages: n } + Nothing -> Left $ TypeMismatch "Invalid totalPages" + +newtype LastfmTrackArray = LastfmTrackArray (Array Json) +newtype LastfmTrackSingle = LastfmTrackSingle Json + +data LastfmTracks = LastfmTrackArray' (Array Json) | LastfmTrackSingle' Json + +instance showLastfmTracks :: Show LastfmTracks where + show (LastfmTrackArray' _) = "LastfmTrackArray' _" + show (LastfmTrackSingle' _) = "LastfmTrackSingle' _" + +newtype LastfmRecentTracks = LastfmRecentTracks + { track :: LastfmTracks + , attr :: LastfmAttr + } + +derive instance genericLastfmRecentTracks :: Generic LastfmRecentTracks _ +instance showLastfmRecentTracks :: Show LastfmRecentTracks where + show = genericShow + +instance decodeJsonLastfmRecentTracks :: DecodeJson LastfmRecentTracks where + decodeJson json = do + obj <- decodeJson json + attr <- obj .: "@attr" + trackResult <- obj .:? "track" + tracks <- case trackResult of + Nothing -> pure $ LastfmTrackArray' [] + Just trackJson -> case toArray trackJson of + Just arr -> pure $ LastfmTrackArray' arr + Nothing -> pure $ LastfmTrackSingle' trackJson + pure $ LastfmRecentTracks { track: tracks, attr } + +newtype LastfmResponse = LastfmResponse + { recenttracks :: LastfmRecentTracks + } + +derive instance genericLastfmResponse :: Generic LastfmResponse _ +instance showLastfmResponse :: Show LastfmResponse where + show = genericShow + +instance decodeJsonLastfmResponse :: DecodeJson LastfmResponse where + decodeJson json = do + obj <- decodeJson json + recenttracks <- obj .: "recenttracks" + pure $ LastfmResponse { recenttracks } + +newtype LastfmArtist = LastfmArtist { text :: String } + +derive instance genericLastfmArtist :: Generic LastfmArtist _ +instance showLastfmArtist :: Show LastfmArtist where + show = genericShow + +instance decodeJsonLastfmArtist :: DecodeJson LastfmArtist where + decodeJson json = do + obj <- decodeJson json + text <- obj .: "#text" + pure $ LastfmArtist { text } + +newtype LastfmAlbum = LastfmAlbum + { text :: Maybe String + , mbid :: String + } + +derive instance genericLastfmAlbum :: Generic LastfmAlbum _ +instance showLastfmAlbum :: Show LastfmAlbum where + show = genericShow + +instance decodeJsonLastfmAlbum :: DecodeJson LastfmAlbum where + decodeJson json = do + obj <- decodeJson json + text <- obj .:? "#text" + mbid <- fromMaybe "" <$> obj .:? "mbid" + pure $ LastfmAlbum { text, mbid } + +newtype LastfmDate = LastfmDate { uts :: Int } + +derive instance genericLastfmDate :: Generic LastfmDate _ +instance showLastfmDate :: Show LastfmDate where + show = genericShow + +instance decodeJsonLastfmDate :: DecodeJson LastfmDate where + decodeJson json = do + obj <- decodeJson json + utsStr <- obj .: "uts" + case fromString utsStr of + Just n -> pure $ LastfmDate { uts: n } + Nothing -> Left $ TypeMismatch $ "Invalid uts: " <> utsStr + +newtype LastfmTrack = LastfmTrack + { name :: String + , artist :: LastfmArtist + , album :: Maybe LastfmAlbum + , date :: Maybe LastfmDate + } + +derive instance genericLastfmTrack :: Generic LastfmTrack _ +instance showLastfmTrack :: Show LastfmTrack where + show = genericShow + +instance decodeJsonLastfmTrack :: DecodeJson LastfmTrack where + decodeJson json = do + obj <- decodeJson json + name <- obj .: "name" + artist <- obj .: "artist" + album <- obj .:? "album" + date <- obj .:? "date" + pure $ LastfmTrack { name, artist, album, date } + newtype StatsEntry = StatsEntry { name :: String , count :: Int diff --git a/test/Main.purs b/test/Main.purs @@ -14,10 +14,10 @@ import Data.String.Regex (regex, parseFlags) import Test.Spec.Reporter.Console (consoleReporter) import Test.Spec.Runner.Node (runSpecAndExitProcess) import Types (Listen(..), ListenBrainzResponse(..), MbidMapping(..), Payload(..), Stats(..), StatsEntry(..), TrackMetadata(..), ListenBrainzSubmitPayload(..), ListenBrainzSubmitListen(..), ListenBrainzSubmitTrackMetadata(..), ListenBrainzAdditionalInfo(..)) -import Db (FilterField(..), connect, initDb, checkExists, upsertScrobble, getScrobbles, initReleaseMetadata, upsertReleaseMetadata, getStats, dbBaseName, getOldestTs, getUnenrichedMbids, getEmptyGenreMbids, getArtistReleasesByMbids, touchGenreCheckedAt, getOrCreateToken, getTokenUser) +import Db (FilterField(..), connect, initDb, checkExists, upsertScrobble, getScrobbles, initReleaseMetadata, upsertReleaseMetadata, getStats, dbBaseName, getOldestTs, getUnenrichedMbids, getEmptyGenreMbids, getArtistReleasesByMbids, touchGenreCheckedAt, getOrCreateToken, getTokenUser, fromString) import Data.Argonaut.Core (Json) import Foreign.Object as Object -import Main (parseFilterField, submitListenToListen, findUserByToken, sanitizeDate) +import Main (submitListenToListen, findUserByToken, sanitizeDate) import Cover (sanitizeKey) import Sync (listenBrainzUrl, lastfmTrackToListen, parseLastfmResponse) import S3 (getS3Url) @@ -49,17 +49,17 @@ main = runSpecAndExitProcess [consoleReporter] do sanitizeKey "a...b" `shouldEqual` "a...b" sanitizeKey "UPPER lower" `shouldEqual` "UPPER_lower" - describe "parseFilterField" do + describe "fromString" do it "maps all valid field names" do - parseFilterField "artist" `shouldEqual` Just FilterArtist - parseFilterField "album" `shouldEqual` Just FilterAlbum - parseFilterField "label" `shouldEqual` Just FilterLabel - parseFilterField "year" `shouldEqual` Just FilterYear - parseFilterField "genre" `shouldEqual` Just FilterGenre + fromString "artist" `shouldEqual` Just FilterArtist + fromString "album" `shouldEqual` Just FilterAlbum + fromString "label" `shouldEqual` Just FilterLabel + fromString "year" `shouldEqual` Just FilterYear + fromString "genre" `shouldEqual` Just FilterGenre it "returns Nothing for unknown or empty input" do - parseFilterField "unknown" `shouldEqual` Nothing - parseFilterField "" `shouldEqual` Nothing + fromString "unknown" `shouldEqual` Nothing + fromString "" `shouldEqual` Nothing describe "sanitizeDate" do it "strips non-numeric characters except hyphens" do