Cosine.purs (5410B)
1 module Cosine 2 ( fetchCosineSimilar 3 , serveSimilar 4 ) where 5 6 import Prelude 7 8 import Config (UserConfig) 9 import Control.Monad.Error.Class (throwError) 10 import Data.Array ((!!)) 11 import Data.Either (Either(..), hush) 12 import Data.Argonaut (parseJson) 13 import Data.Argonaut.Core (toArray, toObject, toString) 14 import Data.Maybe (Maybe(..), fromMaybe) 15 import Effect (Effect) 16 import Effect.Aff (Aff, launchAff_, try) 17 import Effect.Class (liftEffect) 18 import Effect.Exception as Exception 19 import Fetch (fetch, Method(GET)) 20 import Foreign.Object as Object 21 import JSURI (encodeURIComponent) 22 import Log as Log 23 import Metrics as Metrics 24 import Node.Encoding (Encoding(UTF8)) 25 import Node.HTTP.OutgoingMessage (setHeader, toWriteable) 26 import Node.HTTP.ServerResponse (setStatusCode, toOutgoingMessage) 27 import Node.HTTP.Types (ServerResponse) 28 import Node.Stream (end, writeString) 29 import Web.URL (URL) 30 import Web.URL.URLSearchParams as URLSearchParams 31 import Web.URL as URL 32 33 type Response = ServerResponse 34 35 getQueryParam :: String -> URL -> Maybe String 36 getQueryParam key url = URLSearchParams.get key (URL.searchParams url) 37 38 fetchCosineSimilar :: String -> UserConfig -> String -> Aff String 39 fetchCosineSimilar slug cfg query = do 40 let apiKey = fromMaybe "" cfg.cosineApiKey 41 if apiKey == "" then do 42 Log.warn "cosineApiKey not configured for similar tracks" 43 liftEffect $ Metrics.incCosineRequest slug "not_configured" 44 pure "{\"data\":{\"similar_tracks\":[]},\"success\":true}" 45 else do 46 let headers = { "User-Agent": "corpus/1.0 +https://sr.ht/~mtmn/corpus", "Authorization": "Bearer " <> apiKey } 47 let searchUrl = "https://cosine.club/api/v1/search?q=" <> (fromMaybe "" $ encodeURIComponent query) <> "&limit=1" 48 Log.info $ "Cosine Club: searching for: " <> query 49 searchResult <- try $ fetch searchUrl { method: GET, headers: headers } 50 case searchResult of 51 Left err -> do 52 liftEffect $ Metrics.incCosineRequest slug "error" 53 throwError err 54 Right searchRes -> 55 if searchRes.status == 429 then do 56 Log.warn "Cosine Club: rate limited on search" 57 liftEffect $ Metrics.incCosineRequest slug "rate_limited" 58 throwError (Exception.error "Rate limit exceeded") 59 else if searchRes.status /= 200 then do 60 Log.warn $ "Cosine Club: search returned " <> show searchRes.status 61 liftEffect $ Metrics.incCosineRequest slug "error" 62 throwError (Exception.error "Search API error") 63 else do 64 searchBody <- searchRes.text 65 let 66 mTrackId = do 67 json <- hush $ parseJson searchBody 68 obj <- toObject json 69 arr <- Object.lookup "data" obj >>= toArray 70 first <- arr !! 0 71 firstObj <- toObject first 72 Object.lookup "id" firstObj >>= toString 73 case mTrackId of 74 Nothing -> do 75 Log.info $ "Cosine Club: track not indexed: " <> query 76 liftEffect $ Metrics.incCosineRequest slug "not_indexed" 77 pure "{\"data\":{\"similar_tracks\":[]},\"success\":true}" 78 Just trackId -> do 79 let similarUrl = "https://cosine.club/api/v1/tracks/" <> trackId <> "/similar?limit=10" 80 Log.info $ "Cosine Club: fetching similar for ID " <> trackId 81 similarResult <- try $ fetch similarUrl { method: GET, headers: headers } 82 case similarResult of 83 Left err -> do 84 liftEffect $ Metrics.incCosineRequest slug "error" 85 throwError err 86 Right similarRes -> 87 if similarRes.status == 429 then do 88 Log.warn "Cosine Club: rate limited on similar" 89 liftEffect $ Metrics.incCosineRequest slug "rate_limited" 90 throwError (Exception.error "Rate limit exceeded") 91 else if similarRes.status /= 200 then do 92 Log.warn $ "Cosine Club: similar API returned " <> show similarRes.status 93 liftEffect $ Metrics.incCosineRequest slug "error" 94 throwError (Exception.error "Similar API error") 95 else do 96 liftEffect $ Metrics.incCosineRequest slug "success" 97 similarRes.text 98 99 serveSimilar 100 :: (Response -> String -> Effect Unit) 101 -> (Response -> Int -> String -> String -> Effect Unit) 102 -> String 103 -> UserConfig 104 -> URL 105 -> Response 106 -> Effect Unit 107 serveSimilar serveBadRequest serveError slug cfg url res = do 108 setHeader "Content-Type" "application/json" (toOutgoingMessage res) 109 launchAff_ do 110 let artist = fromMaybe "" (getQueryParam "artist" url) 111 let track = fromMaybe "" (getQueryParam "track" url) 112 if artist == "" || track == "" then 113 liftEffect $ serveBadRequest res "Artist and track parameters are required" 114 else do 115 let query = artist <> " - " <> track 116 result <- try $ fetchCosineSimilar slug cfg query 117 case result of 118 Left err -> do 119 Log.error $ "Cosine Club API error: " <> Exception.message err 120 liftEffect $ serveError res 502 "Bad Gateway" "Failed to fetch similar tracks" 121 Right responseBody -> 122 liftEffect $ do 123 setStatusCode 200 res 124 let w = toWriteable (toOutgoingMessage res) 125 void $ writeString w UTF8 responseBody 126 end w