packages feed

lrclib-client-0.3.0: src/LrcLib/Client.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

-- | Module for calling LRCLIB API (<https://lrclib.net/docs>)
module LrcLib.Client
  ( module LrcLib.Types,
    runAPI,
    runDefaultAPI,
    getLyrics,
    getCachedLyrics,
    getLyricsById,
    searchLyrics,
    publish',
    publish,
    requestChallenge,
    solveChallenge,
  )
where

import Control.Monad.Reader (runReaderT)
import Crypto.Hash.SHA256 (hash)
import Data.Aeson qualified as A
import Data.ByteString (ByteString)
import Data.ByteString.Base16 qualified as B16
import Data.ByteString.Char8 qualified as BC
import Data.Either (fromRight)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (encodeUtf8)
import GHC.Stack
import LrcLib.HTTP as HTTP
import LrcLib.Types
import Network.HTTP.Client
  ( Request,
    Response (responseBody, responseStatus),
    parseRequest_,
  )
import Network.HTTP.Types (Status (statusCode))
import Prelude hiding (id)

decode :: (A.FromJSON a, HasCallStack) => ByteStringL -> a
decode x = case A.eitherDecode x of
  Left err -> error $ "Error while decoding JSON: " <> err
  Right y -> y

-- | Run API action with given API url
runAPI :: Request -> API a -> IO a
runAPI url f = runReaderT f url

-- | Run API action ('runAPI') with default API url
--
-- >>> runDefaultAPI $ getLyricsById 1337
-- Right Track, id: 1337, name: Speak Français, artist: Ellis feat. NOËP, album: Speak Français
runDefaultAPI :: API a -> IO a
runDefaultAPI = runAPI $ parseRequest_ "https://lrclib.net/api"

get' :: ByteString -> Text -> Text -> Text -> Integer -> API (Either GetError TrackData)
get' subpath track artist album duration = do
  resp <- HTTP.get subpath query
  case resp.responseStatus.statusCode of
    404 -> pure $ Left NotFound
    200 -> pure $ Right $ decode resp.responseBody
    s -> error $ "Unexpected status code on get endpoint: " <> show s
  where
    query =
      [ ("track_name", track),
        ("artist_name", artist),
        ("album_name", album),
        ("duration", T.show duration)
      ]

getLyrics,
  getCachedLyrics ::
    Text -> Text -> Text -> Integer -> API (Either GetError TrackData)

-- | Get lyrics by track name, artist, album and duration
-- Calls @\/api\/get@
--
-- >>> runDefaultAPI $ getLyrics "ThisTrackDoesntExist" "UnknownArtist" "UnknownAlbum" 1337
-- Left NotFound
--
-- >>> runDefaultAPI $ getLyrics "Speak Français" "Ellis feat. NOËP" "Speak Français" 201
-- Right Track, id: 1337, name: Speak Français, artist: Ellis feat. NOËP, album: Speak Français
getLyrics = get' "/get"

-- | The same as 'getLyrics', but for cached lyrics.
-- Calls @\/api\/get-cached@
getCachedLyrics = get' "/get-cached"

-- | Get lyrics by id
-- Calls @\/api\/get\/\<id\>@
--
-- >>> runDefaultAPI $ getLyricsById 1337
-- Right Track, id: 1337, name: Speak Français, artist: Ellis feat. NOËP, album: Speak Français
getLyricsById :: (HasCallStack) => Integer -> API (Either GetError TrackData)
getLyricsById id = do
  resp <- HTTP.get ("/get/" <> BC.pack (show id)) []
  case resp.responseStatus.statusCode of
    404 -> pure $ Left NotFound
    200 -> pure $ Right $ decode resp.responseBody
    _ -> error "Unexpected status code on get endpoint"

-- | Search lyrics by either text query or track-artist-album
-- Calls @\/api\/search@
--
-- >>> head <$> (runDefaultAPI $ searchLyrics $ TextQuery "Speak Français")
-- Track, id: 1337, name: Speak Français, artist: Ellis feat. NOËP, album: Speak Français
searchLyrics :: SearchQuery -> API SearchResponse
searchLyrics q = do
  resp <- HTTP.get "/search" query
  pure $ decode resp.responseBody
  where
    query = case q of
      TextQuery t -> [("q", t)]
      TrackQuery {..} ->
        [("track_name", queryName)]
          ++ [("artist_name", artist) | Just artist <- pure queryArtist]
          ++ [("album_name", album) | Just album <- pure queryAlbum]

-- | Publish Lyrics ('publish') without requesting and solving challenge
-- Calls @\/api\/publish@
publish' :: (HasCallStack) => PublishToken -> PublishRequest -> API (Either PublishError ())
publish' token request = do
  resp <-
    HTTP.post "/publish" [("X-Publish-Token", encodeUtf8 token)] $
      A.encode request
  case resp.responseStatus.statusCode of
    400 -> pure $ Left IncorrectToken
    201 -> pure $ Right ()
    s -> error $ "Unexpected status code on publish endpoint: " <> show s

-- | Request proof-of-work challenge for publish
-- Calls @\/api\/request-challenge@
--
-- >>> runDefaultAPI requestChallenge
-- Challenge {prefix = "BVNC...XoVu1H", target = "000000FF0...00000"}
requestChallenge :: API Challenge
requestChallenge = do
  resp <- HTTP.post "/request-challenge" [] ""
  pure $ decode resp.responseBody

-- | Solve proof-of-work challenge for publish
-- Solution is a one-time publish token
solveChallenge :: Challenge -> Text
solveChallenge Challenge {prefix, target} = go 0
  where
    prefix' = encodeUtf8 prefix
    target' = fromRight (error "Can't decode target from crypto-challenge") $ B16.decode $ encodeUtf8 target
    go :: Int -> Text
    go n | hash (prefix' <> BC.pack (show n)) < target' = prefix <> ":" <> T.show n
    go n = go (n + 1)

-- | Publish Lyrics (request and solve challenge)
-- Calls @\/api\/publish@
publish :: (HasCallStack) => PublishRequest -> API (Either PublishError ())
publish r = do
  c <- requestChallenge
  publish' (solveChallenge c) r