packages feed

spotify (empty) → 0.1.0.0

raw patch · 35 files changed

+1999/−0 lines, 35 filesdep +Cabaldep +aesondep +base

Dependencies added: Cabal, aeson, base, base64-bytestring, bytestring, composition, containers, directory, exceptions, extra, filepath, http-api-data, http-client, http-client-tls, http-types, lucid, monad-loops, mtl, pretty-simple, servant, servant-client, servant-lucid, spotify, text, time, transformers, unordered-containers

Files

+ CHANGELOG.md view
@@ -0,0 +1,5 @@+# Revision history for spotify++## 0.1.0.0 -- 2023-06-22++* First release. Unstable.
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2020, George Thomas++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of George Thomas nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,11 @@+# Spotify Web API bindings for Haskell++Built around Servant + Aeson.++Work in progress. Will be put on Hackage once remotely stable.++Designed to be very high-level (eg. handling token refresh etc. automatically). I can think about exposing the lower level functionality if anyone requests it.++Designed for use with lenses and/or modern record extensions, eg. to work around duplicated identifiers. This allows us to mirror the Spotify API in a methodical way without being too verbose. `RecordDotSyntax` will suit us nicely whenever it lands.++All request take place inside a monad implementing `MonadSpotify`. In particular, this abstracts away authentication - we don't need to explicitly pass tokens around. A concrete monad `Spotify` is provided, though you'll often want to roll your own. There is also an instance `MonadSpotify IO` - this caches authentication data to disk, so one should be careful about security. It also creates a new connection `Manager` for every request, which can be expensive. Thus a `State`-like monad is preferred for serious code. Nonetheless, being able to run the functions directly in `IO` can be very convenient, especially from `GHCI`.
+ examples/Main.hs view
@@ -0,0 +1,52 @@+module Main (main) where++import Spotify+import Spotify.CheckPlaylistOverlap qualified+import Spotify.CreateArtistLikedSongsPlaylist qualified+import Spotify.CreatePlaylist qualified+import Spotify.DeleteRecentPlaylists qualified+import Spotify.Servant.Playlists (CreatePlaylistOpts (..))+import Spotify.TransferPlayback qualified++import Data.String (fromString)+import Data.Text qualified as T+import System.Environment (getArgs)+import System.Exit (exitFailure)+import Text.Pretty.Simple (pPrintForceColor)+import Text.Read (readMaybe)++main :: IO ()+main = do+    example <-+        getArgs >>= \case+            "CheckPlaylistOverlap" : args -> case args of+                [fromString -> p1, fromString -> p2] -> pure $ Spotify.CheckPlaylistOverlap.main p1 p2+                _ -> badArgs+            "CreateArtistLikedSongsPlaylist" : artists ->+                pure $ Spotify.CreateArtistLikedSongsPlaylist.main $ map fromString artists+            "CreatePlaylist" : args -> case args of+                [readMaybe . (<> "Search") -> Just searchType, T.pack -> name, readMaybe -> Just public, readMaybe -> Just collaborative, T.pack -> description] ->+                    pure $+                        Spotify.CreatePlaylist.main+                            searchType+                            CreatePlaylistOpts+                                { name+                                , public+                                , collaborative+                                , description+                                }+                _ -> badArgs+            "DeleteRecentPlaylists" : args ->+                case args of+                    [readMaybe -> Just n] -> pure $ Spotify.DeleteRecentPlaylists.main n+                    _ -> badArgs+            "TransferPlayback" : args ->+                case args of+                    [T.pack -> t, readMaybe -> Just b] -> pure $ Spotify.TransferPlayback.main t b+                    _ -> badArgs+            x : _ -> putStrLn ("unknown example: " <> x) >> exitFailure+            [] -> putStrLn "no args" >> exitFailure+    auth <- getAuth -- we use the `MonadSpotify IO` instance, as an easy way to get credentials+    either pPrintForceColor pure =<< runSpotify auth example+  where+    badArgs = putStrLn "bad args" >> exitFailure
+ examples/Spotify/CheckPlaylistOverlap.hs view
@@ -0,0 +1,31 @@+module Spotify.CheckPlaylistOverlap (main) where++import Spotify+import Spotify.Types.Misc+import Spotify.Types.Playlists+import Spotify.Types.Tracks++import Control.Monad (join, (<=<))+import Control.Monad.IO.Class (liftIO)+import Data.Bitraversable (Bitraversable (bitraverse))+import Data.Foldable (traverse_)+import Data.List.NonEmpty (nonEmpty)+import Data.Map qualified as Map+import Data.Text.IO qualified as T+import Data.Tuple.Extra ((&&&))++main :: (MonadSpotify m) => PlaylistID -> PlaylistID -> m ()+main =+    curry $+        maybe (putT "No overlap found.") ((putT "Found songs in both playlists:" >>) . traverse_ putT)+            . nonEmpty+            . Map.elems+            . uncurry Map.intersection+            <=< join+                bitraverse+                ( pure . Map.fromList . map (((.id) &&& (.name)) . (.track))+                    <=< (allPages Nothing . const . pure . (.tracks))+                    <=< getPlaylist+                )+  where+    putT = liftIO . T.putStrLn
+ examples/Spotify/CreateArtistLikedSongsPlaylist.hs view
@@ -0,0 +1,59 @@+module Spotify.CreateArtistLikedSongsPlaylist (main) where++import Spotify+import Spotify.Servant.Playlists+import Spotify.Types.Artists+import Spotify.Types.Misc+import Spotify.Types.Simple+import Spotify.Types.Tracks+import Spotify.Types.Users++import Control.Monad (void)+import Control.Monad.IO.Class (liftIO)+import Data.Foldable (for_, toList)+import Data.List.NonEmpty (NonEmpty)+import Data.Map qualified as Map+import Data.Text qualified as T+import Data.Text.IO qualified as T++main :: (MonadSpotify m) => [ArtistID] -> m ()+main artists = do+    allSaved <- allPages (Just logger) getSavedTracks+    let savedTracksByArtist =+            Map.fromListWith (<>) $+                allSaved >>= \t ->+                    map (\a -> (a.id, pure @NonEmpty t)) t.track.artists+    putT $ "Found tracks by " <> showT (length savedTracksByArtist) <> " artists."+    me <- getMe+    for_ artists \a' -> do+        a <- getArtist a'+        putT $ "Creating playlist for artist: " <> a.name+        p <-+            createPlaylist+                me.id+                CreatePlaylistOpts+                    { name = a.name+                    , public = False+                    , collaborative = False+                    , description = "All my liked songs by this artist. Auto-generated with Haskell."+                    }+        void+            . addToPlaylist p.id Nothing+            . map (\t -> toURI t.track.id)+            . maybe [] toList+            $ Map.lookup a' savedTracksByArtist+  where+    showT = T.pack . show+    putT = liftIO . T.putStrLn+    logger p = do+        putT $+            mconcat+                [ "Getting saved tracks "+                , showT (p.offset + 1)+                , " to "+                , showT (min p.total (p.offset + p.limit))+                , " of "+                , showT p.total+                , "..."+                ]+        pure True
+ examples/Spotify/CreatePlaylist.hs view
@@ -0,0 +1,75 @@+{- HLINT ignore "Redundant <$>" -}+module Spotify.CreatePlaylist (main) where++import Spotify+import Spotify.Servant.Playlists+import Spotify.Types.Albums+import Spotify.Types.Misc+import Spotify.Types.Search+import Spotify.Types.Simple+import Spotify.Types.Tracks+import Spotify.Types.Users++import Control.Monad ((<=<))+import Control.Monad.Except (MonadError (throwError), runExceptT)+import Control.Monad.State (MonadIO (liftIO), MonadTrans (lift))+import Data.Foldable (traverse_)+import Data.Function (on)+import Data.List (find)+import Data.List.Extra (chunksOf)+import Data.Maybe (isJust)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.IO qualified as T+import Data.Traversable (for)+import System.Exit (exitFailure)++main :: (MonadSpotify m) => SearchType -> CreatePlaylistOpts -> m ()+main searchType opts = do+    SomeSearchTypeInfo @a (itemName, extractItems, getResult, getUris) <- case searchType of+        TrackSearch -> pure $ SomeSearchTypeInfo @Track ("track", (.tracks), (.artists), pure . pure . (.uri))+        AlbumSearch -> pure $ SomeSearchTypeInfo @AlbumSimple ("album", (.albums), (.artists), fmap (map (.uri) . (.tracks.items)) . getAlbum . (.id))+        _ -> exit "unsupported search type"+    liftIO $ T.putStrLn $ "Enter lines of artist;" <> itemName+    parsedLines <-+        (map (T.splitOn ";") . T.lines <$> liftIO T.getContents) >>= traverse \case+            [a, b] -> pure (a, b)+            _ -> exit "parse failure"+    items <- for parsedLines \(artist, item) ->+        runExceptT @a -- we actually use this monad to indicate success - it's just a way to return early+            ( allPages+                ( Just \p -> do+                    if p.offset > searchLimit+                        then pure False+                        else case find (isJust . find (((==) `on` T.toCaseFold) artist) . map (.name) . getResult) p.items of+                            Just t -> throwError t+                            Nothing -> pure True+                )+                ( maybe (exit $ "no " <> itemName <> "s") pure . extractItems+                    <=< lift . search (T.unwords [item, artist]) [searchType] Nothing Nothing+                )+            )+            >>= either+                pure+                ( exit+                    . T.unlines+                    . (("No " <> itemName <> " \"" <> item <> "\" with artist: \"" <> artist <> "\". Found:") :)+                    . map (T.intercalate "; " . map (.name) . getResult)+                )+    playlist <- flip createPlaylist opts . (.id) =<< getMe+    traverse_ (addToPlaylist playlist.id Nothing) =<< chunksOf playlistMaxBatchLimit . concat <$> traverse getUris items+  where+    playlistMaxBatchLimit = 100 -- API won't accept more than this+    exit s = liftIO $ T.putStrLn s >> exitFailure++data SomeSearchTypeInfo where+    SomeSearchTypeInfo ::+        ( Text+        , SearchResult -> Maybe (Paging a)+        , a -> [ArtistSimple]+        , forall m. (MonadSpotify m) => a -> m [URI]+        ) ->+        SomeSearchTypeInfo++searchLimit :: Int+searchLimit = 250
+ examples/Spotify/DeleteRecentPlaylists.hs view
@@ -0,0 +1,28 @@+module Spotify.DeleteRecentPlaylists (main) where++import Spotify+import Spotify.Types.Misc+import Spotify.Types.Simple++import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.Foldable (for_)+import Data.Text (Text)+import Data.Text.IO qualified as T+import System.IO (hFlush, stdout)++main :: (MonadSpotify m) => Int -> m ()+main n = do+    playlists <- allPages earlyCutoff getMyPlaylists+    for_ (take n playlists) \p -> promptForConfirmation p.name $ unfollowPlaylist p.id+  where+    -- NB. this is just used as an optimisation, to ensure we don't get pages beyond what we need+    earlyCutoff = Just \p -> pure $ p.offset + p.limit < n++promptForConfirmation :: (MonadIO m) => Text -> m () -> m ()+promptForConfirmation t x = do+    liftIO $ T.putStr (t <> "? [Y/n] ") >> hFlush stdout+    liftIO T.getLine >>= \case+        "y" -> x+        "Y" -> x+        "" -> x+        _ -> pure ()
+ examples/Spotify/TransferPlayback.hs view
@@ -0,0 +1,21 @@+module Spotify.TransferPlayback (main) where++import Spotify+import Spotify.Types.Player++import Control.Monad.IO.Class (liftIO)+import Data.Foldable (for_)+import Data.List (find)+import Data.Text (Text)+import Data.Text.IO qualified as T+import System.Exit (exitFailure)++main :: (MonadSpotify m) => Text -> Bool -> m ()+main name play = do+    devs <- getAvailableDevices+    case find ((== name) . (.name)) devs of+        Nothing -> liftIO do+            putStrLn "No device found with requested name. Only:"+            for_ devs \d -> T.putStrLn d.name+            exitFailure+        Just d -> transferPlayback [d.id] play
+ lib/Spotify.hs view
@@ -0,0 +1,298 @@+module Spotify where++import Spotify.Servant.Albums+import Spotify.Servant.Artists+import Spotify.Servant.Categories+import Spotify.Servant.Core+import Spotify.Servant.Player+import Spotify.Servant.Playlists+import Spotify.Servant.Search+import Spotify.Servant.Tracks+import Spotify.Servant.Users+import Spotify.Types.Albums+import Spotify.Types.Artists+import Spotify.Types.Auth+import Spotify.Types.Categories+import Spotify.Types.Misc+import Spotify.Types.Player+import Spotify.Types.Playlists+import Spotify.Types.Search+import Spotify.Types.Simple+import Spotify.Types.Tracks+import Spotify.Types.Users++import Control.Applicative ((<|>))+import Control.Exception (throwIO)+import Control.Monad.Except (ExceptT, MonadError, liftEither, runExceptT, throwError)+import Control.Monad.IO.Class (MonadIO, liftIO)+import Control.Monad.Loops (unfoldrM)+import Control.Monad.Reader (MonadReader, ReaderT, asks, runReaderT)+import Control.Monad.State (MonadState, StateT, get, put, runStateT)+import Data.Aeson (FromJSON, eitherDecode)+import Data.Bifunctor (bimap)+import Data.Coerce (coerce)+import Data.Composition ((.:), (.:.))+import Data.Proxy (Proxy (Proxy))+import Data.Set (Set)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.IO qualified as T+import GHC.Generics (Generic)+import Network.HTTP.Client (Manager)+import Network.HTTP.Client.TLS (newTlsManager)+import Network.HTTP.Types (Status (statusCode))+import Servant.API (NoContent (NoContent))+import Servant.Client (BaseUrl (BaseUrl, baseUrlHost), ClientError (DecodeFailure, FailureResponse), ClientM, HasClient (Client), Scheme (Http), client, mkClientEnv, responseBody, responseStatusCode, runClientM)+import Servant.Links (allLinks, linkURI)+import System.Directory (XdgDirectory (XdgConfig), createDirectoryIfMissing, getTemporaryDirectory, getXdgDirectory)+import System.FilePath ((</>))+import System.IO (hFlush, stdout)++class (MonadIO m) => MonadSpotify m where+    getAuth :: m Auth+    getManager :: m Manager+    getToken :: m AccessToken+    putToken :: AccessToken -> m ()+    throwClientError :: ClientError -> m a++instance MonadSpotify IO where+    throwClientError = liftIO . throwIO+    getAuth = do+        dir <- getXdgDirectory XdgConfig "spotify-haskell"+        let getData file prompt =+                -- look for file - otherwise get from stdin+                T.readFile path <|> do+                    res <- T.putStr (prompt <> ": ") >> T.getLine+                    createDirectoryIfMissing False dir+                    T.writeFile path res+                    pure res+              where+                path = dir </> file+        Auth+            <$> (RefreshToken <$> getData "refresh" "Refresh token")+            <*> (ClientId <$> getData "id" "Client id")+            <*> (ClientSecret <$> getData "secret" "Client secret")+    getManager = newTlsManager+    getToken = do+        path <- monadSpotifyIOTokenPath+        AccessToken <$> T.readFile path <|> do+            TokenResponse{accessToken} <- newToken+            putToken accessToken+            pure accessToken+    putToken (AccessToken t) = do+        path <- monadSpotifyIOTokenPath+        T.writeFile path t+monadSpotifyIOTokenPath :: IO FilePath+monadSpotifyIOTokenPath = (</> "spotify-haskell-token") <$> getTemporaryDirectory++newtype Spotify a = Spotify+    { unwrap :: StateT AccessToken (ReaderT (Auth, Manager) (ExceptT ClientError IO)) a+    }+    deriving newtype+        ( Functor+        , Applicative+        , Monad+        , MonadIO+        , MonadState AccessToken+        , MonadReader (Auth, Manager)+        , MonadError ClientError+        )++instance MonadSpotify Spotify where+    getAuth = asks fst+    getManager = asks snd+    getToken = get+    putToken = put+    throwClientError = throwError++runSpotify :: Auth -> Spotify a -> IO (Either ClientError a)+runSpotify = fmap (fmap fst) .: runSpotify' Nothing Nothing+runSpotify' :: Maybe Manager -> Maybe AccessToken -> Auth -> Spotify a -> IO (Either ClientError (a, AccessToken))+runSpotify' mm mt a x = do+    man <- maybe newTlsManager pure mm+    let tok = maybe (fmap (.accessToken) . liftEither =<< liftIO (newTokenIO a man)) pure mt+    runExceptT $ runReaderT (runStateT x.unwrap =<< tok) (a, man)++liftEitherSpot :: (MonadSpotify m) => Either ClientError a -> m a+liftEitherSpot = either throwClientError pure++inSpot :: forall m a. (MonadSpotify m) => (AccessToken -> ClientM a) -> m a+inSpot x = do+    tok <- getToken+    man <- getManager+    liftIO (runClientM (x tok) $ mkClientEnv man mainBase) >>= \case+        Left e ->+            expiry e >>= \case+                True -> retry+                False -> throwClientError e+        Right r -> pure r+  where+    -- get a new token and try again+    retry = do+        putToken . (.accessToken) =<< newToken+        inSpot x+    -- does the error indicate that the access token has expired?+    expiry = \case+        FailureResponse _ resp -> do+            if statusCode (responseStatusCode resp) == 401+                then do+                    Error{message} <- liftEitherSpot $ bimap mkError (.error) $ eitherDecode @Error' $ responseBody resp+                    if message == "The access token expired"+                        then pure True+                        else no+                else no+          where+            mkError s = DecodeFailure ("Failed to decode a spotify error: " <> T.pack s) resp+        _ -> no+      where+        no = pure False+newtype Error' = Error' {error :: Error} -- internal - used for decoding the errors we get from Spotify responses+    deriving stock (Eq, Ord, Show, Generic)+    deriving anyclass (FromJSON)++data Auth = Auth+    { refreshToken :: RefreshToken+    , clientId :: ClientId+    , clientSecret :: ClientSecret+    }+    deriving (Show)++mainBase, accountsBase :: BaseUrl+mainBase = BaseUrl Http "api.spotify.com" 80 "v1"+accountsBase = BaseUrl Http "accounts.spotify.com" 80 "api"++-- helpers for wrapping Servant API+cli :: forall api. (HasClient ClientM api) => Client ClientM api+cli = client $ Proxy @api+noContent :: (Functor f) => f NoContent -> f ()+noContent = fmap \NoContent -> ()+marketFromToken :: Maybe Market+marketFromToken = Just "from_token"+withPagingParams :: PagingParams -> (Maybe Int -> Maybe Int -> t) -> t+withPagingParams PagingParams{limit, offset} f = f limit offset++data PagingParams = PagingParams+    { limit :: Maybe Int+    , offset :: Maybe Int+    }+noPagingParams :: PagingParams+noPagingParams = PagingParams Nothing Nothing++newToken :: (MonadSpotify m) => m TokenResponse+newToken = liftEitherSpot =<< liftIO =<< (newTokenIO <$> getAuth <*> getManager)+newTokenIO :: Auth -> Manager -> IO (Either ClientError TokenResponse)+newTokenIO a m = runClientM (requestToken a) (mkClientEnv m accountsBase)+  where+    requestToken (Auth t i s) =+        cli @RefreshAccessToken+            (RefreshAccessTokenForm t)+            (IdAndSecret i s)+newTokenIO' :: (MonadIO m) => Manager -> ClientId -> ClientSecret -> URL -> AuthCode -> m (Either ClientError TokenResponse')+newTokenIO' man clientId clientSecret redirectURI authCode =+    liftIO $+        runClientM+            ( cli @RequestAccessToken+                (RequestAccessTokenForm authCode redirectURI)+                (IdAndSecret clientId clientSecret)+            )+            (mkClientEnv man accountsBase)++-- spotipy-esque+getAuthCodeInteractive :: ClientId -> URL -> Maybe (Set Scope) -> IO (Maybe AuthCode)+getAuthCodeInteractive clientId redirectURI scopes = do+    T.putStrLn $ "Go to this URL: " <> (authorizeUrl clientId redirectURI scopes).unwrap+    T.putStr "Copy the URL you are redirected to: " >> hFlush stdout+    fmap AuthCode . T.stripPrefix (redirectURI.unwrap <> "/?code=") <$> T.getLine+authorizeUrl :: ClientId -> URL -> Maybe (Set Scope) -> URL+authorizeUrl clientId redirectURI scopes =+    URL $+        "https://"+            <> T.pack+                ( baseUrlHost accountsBase+                    <> "/"+                    <> show (linkURI link)+                )+  where+    link =+        allLinks+            (Proxy @Authorize)+            clientId+            "code"+            redirectURI+            Nothing+            (ScopeSet <$> scopes)+            Nothing++getAlbum :: (MonadSpotify m) => AlbumID -> m Album+getAlbum a = inSpot $ cli @GetAlbum a marketFromToken+getAlbumTracks :: (MonadSpotify m) => AlbumID -> PagingParams -> m (Paging TrackSimple)+getAlbumTracks a pps = inSpot $ withPagingParams pps $ cli @GetAlbumTracks a marketFromToken+removeAlbums :: (MonadSpotify m) => [AlbumID] -> m ()+removeAlbums = noContent . inSpot . cli @RemoveAlbums++getArtist :: (MonadSpotify m) => ArtistID -> m Artist+getArtist = inSpot . cli @GetArtist++getTrack :: (MonadSpotify m) => TrackID -> m Track+getTrack t = inSpot $ cli @GetTrack t marketFromToken+getSavedTracks :: (MonadSpotify m) => PagingParams -> m (Paging SavedTrack)+getSavedTracks pps = inSpot $ withPagingParams pps $ cli @GetSavedTracks marketFromToken+saveTracks :: (MonadSpotify m) => [TrackID] -> m ()+saveTracks = noContent . inSpot . cli @SaveTracks+removeTracks :: (MonadSpotify m) => [TrackID] -> m ()+removeTracks = noContent . inSpot . cli @RemoveTracks++search :: (MonadSpotify m) => Text -> [SearchType] -> Maybe Text -> Maybe Market -> PagingParams -> m SearchResult+search q t e m = inSpot . flip withPagingParams \limit offset -> cli @GetSearch q t e limit m offset++getMe :: (MonadSpotify m) => m User+getMe = inSpot $ cli @GetMe+getUser :: (MonadSpotify m) => UserID -> m User+getUser u = inSpot $ cli @GetUser u+unfollowPlaylist :: (MonadSpotify m) => PlaylistID -> m ()+unfollowPlaylist = noContent . inSpot . cli @UnfollowPlaylist++getPlaylist :: (MonadSpotify m) => PlaylistID -> m Playlist+getPlaylist = inSpot . cli @GetPlaylist+addToPlaylist :: (MonadSpotify m) => PlaylistID -> Maybe Int -> [URI] -> m Text+addToPlaylist p position uris = fmap coerce $ inSpot $ cli @AddToPlaylist p AddToPlaylistBody{..}+getMyPlaylists :: (MonadSpotify m) => PagingParams -> m (Paging PlaylistSimple)+getMyPlaylists pps = inSpot $ withPagingParams pps $ cli @GetMyPlaylists+createPlaylist :: (MonadSpotify m) => UserID -> CreatePlaylistOpts -> m PlaylistSimple+createPlaylist u opts = inSpot $ cli @CreatePlaylist u opts++getCategories :: (MonadSpotify m) => CategoryID -> Maybe Country -> Maybe Locale -> m Category+getCategories = inSpot .:. cli @GetCategories++getPlaybackState :: (MonadSpotify m) => Maybe Market -> m PlaybackState+getPlaybackState = inSpot . cli @GetPlaybackState+transferPlayback :: (MonadSpotify m) => [DeviceID] -> Bool -> m ()+transferPlayback device_ids play = noContent . inSpot $ cli @TransferPlayback TransferPlaybackBody{..}+getAvailableDevices :: (MonadSpotify m) => m [Device]+getAvailableDevices = fmap (.devices) . inSpot $ cli @GetAvailableDevices+getCurrentlyPlayingTrack :: (MonadSpotify m) => Maybe Market -> m CurrentlyPlayingTrack+getCurrentlyPlayingTrack = inSpot . cli @GetCurrentlyPlayingTrack+startPlayback :: (MonadSpotify m) => Maybe DeviceID -> StartPlaybackOpts -> m ()+startPlayback = noContent . inSpot .: cli @StartPlayback+pausePlayback :: (MonadSpotify m) => Maybe DeviceID -> m ()+pausePlayback = noContent . inSpot . cli @PausePlayback+skipToNext :: (MonadSpotify m) => Maybe DeviceID -> m ()+skipToNext = noContent . inSpot . cli @SkipToNext+skipToPrevious :: (MonadSpotify m) => Maybe DeviceID -> m ()+skipToPrevious = noContent . inSpot . cli @SkipToPrevious+seekToPosition :: (MonadSpotify m) => Int -> Maybe DeviceID -> m ()+seekToPosition = noContent . inSpot .: cli @SeekToPosition++-- higher-level wrappers around main API+-- takes a callback which can be used for side effects, or to return False for early exit+allPages :: (Monad m) => Maybe (Paging a -> m Bool) -> (PagingParams -> m (Paging a)) -> m [a]+allPages callback x =+    concat <$> flip unfoldrM (0, Nothing, True) \(i, total, keepGoing) -> do+        if keepGoing && maybe True (i <) total+            then do+                p <- x $ PagingParams{limit = Just limit, offset = Just i}+                keepGoing' <- maybe (pure True) ($ p) callback+                pure $ Just (p.items, (i + limit, Just p.total, keepGoing'))+            else pure Nothing+  where+    limit = 50 -- API docs say this is the max
+ servant/Orphans/Servant/Lucid.hs view
@@ -0,0 +1,13 @@+{-# OPTIONS_GHC -Wno-orphans #-}++module Orphans.Servant.Lucid where++import Data.ByteString.Lazy qualified as BL+import Data.Text (Text)+import Data.Text.Encoding (decodeUtf8)+import Servant.API (MimeUnrender (mimeUnrender))+import Servant.HTML.Lucid (HTML)++-- https://github.com/haskell-servant/servant-lucid/issues/14#issuecomment-1250060470+instance MimeUnrender HTML Text where+    mimeUnrender _ = pure . decodeUtf8 . BL.toStrict
+ servant/Spotify/Servant/Albums.hs view
@@ -0,0 +1,31 @@+module Spotify.Servant.Albums where++import Spotify.Servant.Core+import Spotify.Types.Albums+import Spotify.Types.Misc+import Spotify.Types.Simple++import Servant.API (+    Capture,+    QueryParam,+    type (:>),+ )++type GetAlbum =+    "albums"+        :> Capture "id" AlbumID+        :> QueryParam "market" Market+        :> SpotGet Album++type GetAlbumTracks =+    "albums"+        :> Capture "id" AlbumID+        :> "tracks"+        :> QueryParam "market" Market+        :> SpotPaging TrackSimple++type RemoveAlbums =+    "me"+        :> "albums"+        :> SpotBody [AlbumID]+        :> SpotDeleteNoContent
+ servant/Spotify/Servant/Artists.hs view
@@ -0,0 +1,15 @@+module Spotify.Servant.Artists where++import Spotify.Servant.Core+import Spotify.Types.Artists+import Spotify.Types.Misc++import Servant.API (+    Capture,+    type (:>),+ )++type GetArtist =+    "artists"+        :> Capture "id" ArtistID+        :> SpotGet Artist
+ servant/Spotify/Servant/Categories.hs view
@@ -0,0 +1,19 @@+module Spotify.Servant.Categories where++import Spotify.Servant.Core+import Spotify.Types.Categories+import Spotify.Types.Misc++import Servant.API (+    Capture,+    QueryParam,+    type (:>),+ )++type GetCategories =+    "browse"+        :> "categories"+        :> Capture "category_id" CategoryID+        :> QueryParam "country" Country+        :> QueryParam "locale" Locale+        :> SpotGet Category
+ servant/Spotify/Servant/Core.hs view
@@ -0,0 +1,108 @@+module Spotify.Servant.Core where++import Orphans.Servant.Lucid ()+import Spotify.Types.Auth+import Spotify.Types.Internal.CustomJSON+import Spotify.Types.Misc++import Data.Aeson (FromJSON)+import Data.HashMap.Strict qualified as HM+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import GHC.Generics (Generic)+import Servant.API (+    Delete,+    DeleteNoContent,+    FormUrlEncoded,+    Get,+    Header',+    JSON,+    Post,+    PostCreated,+    PostNoContent,+    Put,+    PutAccepted,+    PutNoContent,+    QueryParam,+    QueryParam',+    ReqBody,+    Required,+    Strict,+    ToHttpApiData (toUrlPiece),+    type (:>),+ )+import Servant.HTML.Lucid (HTML)+import Web.FormUrlEncoded (Form (Form), ToForm (toForm))++type Authorize =+    "authorize"+        :> QueryParam' '[Strict, Required] "client_id" ClientId+        :> QueryParam' '[Strict, Required] "response_type" Text+        :> QueryParam' '[Strict, Required] "redirect_uri" URL+        :> QueryParam "state" Text+        :> QueryParam "scope" ScopeSet+        :> QueryParam "show_dialog" Bool+        :> Get '[HTML] Text++type RequestAccessToken =+    "token"+        :> ReqBody '[FormUrlEncoded] RequestAccessTokenForm+        :> Header' '[Strict, Required] "Authorization" IdAndSecret+        :> Post '[JSON] TokenResponse'+data RequestAccessTokenForm = RequestAccessTokenForm AuthCode URL+instance ToForm RequestAccessTokenForm where+    toForm (RequestAccessTokenForm (AuthCode t) r) =+        Form $+            HM.fromList+                [ ("grant_type", ["authorization_code"])+                , ("code", [t])+                , ("redirect_uri", [r.unwrap])+                ]+data TokenResponse' = TokenResponse'+    { accessToken :: AccessToken+    , tokenType :: TokenType+    , expiresIn :: Int+    , scope :: Text+    , refreshToken :: RefreshToken+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON TokenResponse'++type RefreshAccessToken =+    "token"+        :> ReqBody '[FormUrlEncoded] RefreshAccessTokenForm+        :> Header' '[Strict, Required] "Authorization" IdAndSecret+        :> Post '[JSON] TokenResponse+newtype RefreshAccessTokenForm = RefreshAccessTokenForm RefreshToken+instance ToForm RefreshAccessTokenForm where+    toForm (RefreshAccessTokenForm (RefreshToken t)) =+        Form $+            HM.fromList+                [ ("grant_type", ["refresh_token"])+                , ("refresh_token", [t])+                ]++type AuthHeader = Header' '[Strict, Required] "Authorization" AccessToken++-- various patterns which appear throughout the API+type SpotGet a = AuthHeader :> Get '[JSON] a+type SpotPut a = AuthHeader :> Put '[JSON] a+type SpotPutAccepted a = AuthHeader :> PutAccepted '[JSON] a+type SpotPutNoContent = AuthHeader :> PutNoContent+type SpotPost a = AuthHeader :> Post '[JSON] a+type SpotPostCreated a = AuthHeader :> PostCreated '[JSON] a+type SpotPostNoContent = AuthHeader :> PostNoContent+type SpotDelete a = AuthHeader :> Delete '[JSON] a+type SpotDeleteNoContent = AuthHeader :> DeleteNoContent+type SpotBody = ReqBody '[JSON]+type SpotPaging a =+    QueryParam "limit" Int+        :> QueryParam "offset" Int+        :> SpotGet (Paging a)++-- types that only exist for the instances+newtype ScopeSet = ScopeSet {unwrap :: Set Scope}+instance ToHttpApiData ScopeSet where+    toUrlPiece = toUrlPiece . T.intercalate " " . map showScope . Set.toList . (.unwrap)
+ servant/Spotify/Servant/Player.hs view
@@ -0,0 +1,100 @@+module Spotify.Servant.Player where++import Spotify.Servant.Core+import Spotify.Types.Internal.CustomJSON+import Spotify.Types.Misc+import Spotify.Types.Player++import Data.Aeson (FromJSON, ToJSON)+import GHC.Generics (Generic)+import Servant.API (+    JSON,+    QueryParam,+    QueryParam',+    ReqBody,+    Required,+    Strict,+    type (:>),+ )++type GetPlaybackState =+    "me"+        :> "player"+        :> QueryParam "market" Market+        :> SpotGet PlaybackState++type TransferPlayback =+    "me"+        :> "player"+        :> ReqBody '[JSON] TransferPlaybackBody+        :> SpotPutNoContent+data TransferPlaybackBody = TransferPlaybackBody+    { device_ids :: [DeviceID]+    , play :: Bool+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (ToJSON)++type GetAvailableDevices =+    "me"+        :> "player"+        :> "devices"+        :> SpotGet GetAvailableDevicesResponse+newtype GetAvailableDevicesResponse = GetAvailableDevicesResponse+    { devices :: [Device]+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON GetAvailableDevicesResponse++type GetCurrentlyPlayingTrack =+    "me"+        :> "player"+        :> "currently-playing"+        :> QueryParam "market" Market+        :> SpotGet CurrentlyPlayingTrack++type StartPlayback =+    "me"+        :> "player"+        :> "play"+        :> QueryParam "device_id" DeviceID+        :> ReqBody '[JSON] StartPlaybackOpts+        :> SpotPutNoContent+data StartPlaybackOpts = StartPlaybackOpts+    { context_uri :: Maybe URI+    , uris :: Maybe [URI]+    , offset :: Maybe Offset+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (ToJSON)+emptyStartPlaybackOpts :: StartPlaybackOpts+emptyStartPlaybackOpts = StartPlaybackOpts Nothing Nothing Nothing++type PausePlayback =+    "me"+        :> "player"+        :> "pause"+        :> QueryParam "device_id" DeviceID+        :> SpotPutNoContent++type SkipToNext =+    "me"+        :> "player"+        :> "next"+        :> QueryParam "device_id" DeviceID+        :> SpotPostNoContent++type SkipToPrevious =+    "me"+        :> "player"+        :> "previous"+        :> QueryParam "device_id" DeviceID+        :> SpotPostNoContent++type SeekToPosition =+    "me"+        :> "player"+        :> "seek"+        :> QueryParam' '[Strict, Required] "position_ms" Int+        :> QueryParam "device_id" DeviceID+        :> SpotPutNoContent
+ servant/Spotify/Servant/Playlists.hs view
@@ -0,0 +1,58 @@+module Spotify.Servant.Playlists where++import Spotify.Servant.Core+import Spotify.Types.Internal.CustomJSON+import Spotify.Types.Misc+import Spotify.Types.Playlists+import Spotify.Types.Simple++import Data.Aeson (FromJSON, ToJSON)+import Data.Text (Text)+import GHC.Generics (Generic)+import Servant.API (+    Capture,+    type (:>),+ )++type GetPlaylist =+    "playlists"+        :> Capture "playlist_id" PlaylistID+        :> SpotGet Playlist++type AddToPlaylist =+    "playlists"+        :> Capture "playlist_id" PlaylistID+        :> "tracks"+        :> SpotBody AddToPlaylistBody+        :> SpotPostCreated AddToPlaylistResponse+data AddToPlaylistBody = AddToPlaylistBody+    { position :: Maybe Int+    , uris :: [URI]+    }+    deriving (Generic)+    deriving (ToJSON)+newtype AddToPlaylistResponse = AddToPlaylistResponse+    { snapshotId :: SnapshotID+    }+    deriving (Generic)+    deriving (FromJSON) via CustomJSON AddToPlaylistResponse++type GetMyPlaylists =+    "me"+        :> "playlists"+        :> SpotPaging PlaylistSimple++type CreatePlaylist =+    "users"+        :> Capture "user_id" UserID+        :> "playlists"+        :> SpotBody CreatePlaylistOpts+        :> SpotPostCreated PlaylistSimple+data CreatePlaylistOpts = CreatePlaylistOpts+    { name :: Text+    , public :: Bool+    , collaborative :: Bool+    , description :: Text+    }+    deriving (Generic)+    deriving (ToJSON)
+ servant/Spotify/Servant/Search.hs view
@@ -0,0 +1,24 @@+module Spotify.Servant.Search where++import Spotify.Servant.Core+import Spotify.Types.Misc+import Spotify.Types.Search++import Data.Text (Text)+import Servant.API (+    QueryParam,+    QueryParam',+    Required,+    Strict,+    type (:>),+ )++type GetSearch =+    "search"+        :> QueryParam' '[Strict, Required] "q" Text+        :> QueryParam' '[Strict, Required] "type" [SearchType]+        :> QueryParam "include_external" Text+        :> QueryParam "limit" Int+        :> QueryParam "market" Market+        :> QueryParam "offset" Int+        :> SpotGet SearchResult
+ servant/Spotify/Servant/Tracks.hs view
@@ -0,0 +1,35 @@+module Spotify.Servant.Tracks where++import Spotify.Servant.Core+import Spotify.Types.Misc+import Spotify.Types.Tracks++import Servant.API (+    Capture,+    QueryParam,+    type (:>),+ )++type GetTrack =+    "tracks"+        :> Capture "id" TrackID+        :> QueryParam "market" Market+        :> SpotGet Track++type GetSavedTracks =+    "me"+        :> "tracks"+        :> QueryParam "market" Market+        :> SpotPaging SavedTrack++type SaveTracks =+    "me"+        :> "tracks"+        :> SpotBody [TrackID]+        :> SpotDeleteNoContent++type RemoveTracks =+    "me"+        :> "tracks"+        :> SpotBody [TrackID]+        :> SpotDeleteNoContent
+ servant/Spotify/Servant/Users.hs view
@@ -0,0 +1,25 @@+module Spotify.Servant.Users where++import Spotify.Servant.Core+import Spotify.Types.Misc+import Spotify.Types.Users++import Servant.API (+    Capture,+    type (:>),+ )++type GetMe =+    "me"+        :> SpotGet User++type GetUser =+    "users"+        :> Capture "user_id" UserID+        :> SpotGet User++type UnfollowPlaylist =+    "playlists"+        :> Capture "playlist_id" PlaylistID+        :> "followers"+        :> SpotDeleteNoContent
+ spotify.cabal view
@@ -0,0 +1,144 @@+cabal-version:      3.0+name:               spotify+version:            0.1.0.0+author:             George Thomas+maintainer:         George Thomas+description:        Bindings for interacting with the Spotify Web API+synopsis:           Spotify Web API+homepage:           https://github.com/georgefst/spotify+license:            BSD-3-Clause+license-file:       LICENSE+category:           Music, Web+build-type:         Simple+extra-source-files:+    CHANGELOG.md+    README.md++source-repository head+    type: git+    location: git://github.com/georgefst/spotify.git++common common+    build-depends:+        base >= 4.16 && < 5,+        aeson ^>= 2.1.0,+        bytestring ^>= 0.11.3,+        composition ^>= 1.0.2,+        containers ^>= 0.6.5,+        directory ^>= 1.3.6,+        exceptions ^>= 0.10,+        extra ^>= 1.7.12,+        filepath ^>= 1.4.2,+        monad-loops ^>= 0.4.3,+        mtl ^>= {2.2.2, 2.3},+        pretty-simple ^>= 4.1,+        servant ^>= 0.19,+        text ^>= {1.2.5, 2.0},+        transformers ^>= {0.5.6, 0.6},+        time ^>= {1.11.1, 1.12},+        unordered-containers ^>= 0.2.19,+    default-language: GHC2021+    default-extensions:+        AllowAmbiguousTypes+        BlockArguments+        DataKinds+        DeriveAnyClass+        DerivingStrategies+        DerivingVia+        DuplicateRecordFields+        ExplicitNamespaces+        ImpredicativeTypes+        LambdaCase+        NoFieldSelectors+        NoMonomorphismRestriction+        OverloadedLabels+        OverloadedRecordDot+        OverloadedStrings+        PartialTypeSignatures+        RecordWildCards+        ViewPatterns+    ghc-options:+        -Wall+        -threaded++library spotify-types+    import: common+    hs-source-dirs: types+    exposed-modules:+        Spotify.Types.Albums+        Spotify.Types.Artists+        Spotify.Types.Tracks+        Spotify.Types.Search+        Spotify.Types.Users+        Spotify.Types.Playlists+        Spotify.Types.Categories+        Spotify.Types.Player+        Spotify.Types.Simple+        Spotify.Types.Misc+        Spotify.Types.Auth+        Spotify.Types.Internal.CustomJSON+        Spotify.Types.Internal.EnumJSON+    build-depends:+        base64-bytestring ^>= 1.2.1,++library spotify-servant+    import: common+    hs-source-dirs: servant+    exposed-modules:+        Spotify.Servant.Core+        Spotify.Servant.Albums+        Spotify.Servant.Artists+        Spotify.Servant.Tracks+        Spotify.Servant.Search+        Spotify.Servant.Users+        Spotify.Servant.Playlists+        Spotify.Servant.Categories+        Spotify.Servant.Player+    other-modules:+        Orphans.Servant.Lucid+    build-depends:+        spotify-types,+        http-api-data ^>= {0.4.3, 0.5},+        servant-client ^>= 0.19,+        servant-lucid ^>= 0.9,++library+    import: common+    hs-source-dirs: lib+    exposed-modules:+        Spotify+    build-depends:+        spotify-types,+        spotify-servant,+        http-client ^>= 0.7.13,+        http-client-tls ^>= 0.3.6,+        http-types ^>= 0.12.3,+        lucid ^>= 2.11,+        servant-client ^>= 0.19,+        servant-lucid ^>= 0.9,++executable examples+    import: common+    hs-source-dirs: examples+    main-is: Main.hs+    other-modules:+        Spotify.CheckPlaylistOverlap+        Spotify.CreateArtistLikedSongsPlaylist+        Spotify.CreatePlaylist+        Spotify.DeleteRecentPlaylists+        Spotify.TransferPlayback+    build-depends:+        spotify-types,+        spotify-servant,+        spotify,++test-suite tests+    import: common+    type: detailed-0.9+    test-module: Test+    hs-source-dirs: test+    build-depends:+        spotify,+        spotify-types,+        spotify-servant,+        Cabal ^>= {3.6, 3.8, 3.10},
+ test/Test.hs view
@@ -0,0 +1,72 @@+{-# OPTIONS_GHC -Wno-unused-do-bind #-}++module Test (tests) where++import Spotify+import Spotify.Servant.Playlists+import Spotify.Types.Misc+import Spotify.Types.Simple+import Spotify.Types.Users++import Control.Monad+import Data.Functor+import Distribution.TestSuite++tests :: IO [Test]+tests = do+    auth <- getAuth+    pure . pure . Test $+        TestInstance+            { run =+                runSpotify auth runAll+                    <&> Finished . \case+                        Left s -> Fail $ show s+                        Right _ -> Pass+            , name = "Spotify tests"+            , tags = []+            , options = []+            , setOption = \_ _ -> Left "no options"+            }++runAll :: Spotify ()+runAll = do+    User{id = me} <- getMe+    getUser me++    getAlbum album1+    getAlbumTracks album1 noPagingParams++    getSavedTracks noPagingParams+    removeAlbums [album2, album3]+    removeTracks [track1, track2]+    saveTracks [track1, track2]++    Paging{items} <- getMyPlaylists $ PagingParams{limit = Just 50, offset = Nothing}+    forM_ (filter ((== playlistName) . (.name)) items) $ unfollowPlaylist . (.id)+    PlaylistSimple{id = playlistId} <-+        createPlaylist+            me+            CreatePlaylistOpts+                { name = playlistName+                , public = False+                , collaborative = False+                , description = "a description"+                }+    addToPlaylist playlistId Nothing $+        map toURI [track3 :: TrackID, track4, track5]+            <> map toURI [podEpisode1 :: EpisodeID]++    getTrack track1++    pure ()+  where+    album1 = "6l8tlcL7JOuG03fAAmcohv" -- push barman+    album2 = "4sIViEgySoHMaqsDLXN45p" -- aerosmith 1+    album3 = "5NHCg9rnAwBPDKOu2LSSUq" -- aerosmith 2+    track1 = "4rTurOCj7TorrnvmacjAFc" -- aerosmith 1.1+    track2 = "6z2uJuztb6zgLpUJJHupv7" -- aerosmith 1.2+    track3 = "7h8bnu16P1gLNCwtiQ1Nk9" -- OTN/MGF+    track4 = "1Qr6phLmzW5Z4k3ggATKh5" -- DSN+    track5 = "2twka2Q4vhCaBMJgtcqLR1" -- CHC+    podEpisode1 = "2T8XOBBkTrqtw8ikXFB2eM" -- Parched+    playlistName = "Haskell test playlist"
+ types/Spotify/Types/Albums.hs view
@@ -0,0 +1,32 @@+module Spotify.Types.Albums where++import Spotify.Types.Internal.CustomJSON+import Spotify.Types.Misc+import Spotify.Types.Simple++import Data.Aeson (FromJSON)+import Data.Text (Text)+import GHC.Generics (Generic)++data Album = Album+    { albumType :: AlbumType+    , artists :: [ArtistSimple]+    , availableMarkets :: Maybe [Text]+    , copyrights :: [Copyright]+    , externalIds :: ExternalIDs+    , externalUrls :: ExternalURLs+    , genres :: [Text]+    , href :: Href+    , id :: AlbumID+    , images :: [Image]+    , label :: Text+    , name :: Text+    , popularity :: Int+    , releaseDate :: Text+    , releaseDatePrecision :: DatePrecision+    , restrictions :: Maybe Restrictions+    , tracks :: Paging TrackSimple+    , uri :: URI+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Album
+ types/Spotify/Types/Artists.hs view
@@ -0,0 +1,22 @@+module Spotify.Types.Artists where++import Spotify.Types.Internal.CustomJSON+import Spotify.Types.Misc++import Data.Aeson (FromJSON)+import Data.Text (Text)+import GHC.Generics (Generic)++data Artist = Artist+    { externalUrls :: ExternalURLs+    , followers :: Followers+    , genres :: [Genre]+    , href :: Href+    , id :: ArtistID+    , images :: [Image]+    , name :: Text+    , popularity :: Int+    , uri :: URI+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Artist
+ types/Spotify/Types/Auth.hs view
@@ -0,0 +1,54 @@+module Spotify.Types.Auth where++import Spotify.Types.Internal.CustomJSON++import Control.Monad ((>=>))+import Data.Aeson (FromJSON, parseJSON)+import Data.ByteString.Base64 qualified as B64+import Data.String (IsString)+import Data.Text (Text)+import Data.Text.Encoding (decodeUtf8, encodeUtf8)+import GHC.Generics (Generic)+import Servant.API (FromHttpApiData, ToHttpApiData (toUrlPiece))++newtype ClientId = ClientId {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, IsString, ToHttpApiData)++newtype ClientSecret = ClientSecret {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, IsString)++newtype AccessToken = AccessToken {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, IsString)+    deriving newtype (FromJSON)+instance ToHttpApiData AccessToken where+    toUrlPiece (AccessToken t) = toUrlPiece $ "Bearer " <> t++newtype RefreshToken = RefreshToken {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, IsString)+    deriving newtype (FromJSON)+newtype AuthCode = AuthCode {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, IsString, FromHttpApiData)+    deriving newtype (FromJSON)++data IdAndSecret = IdAndSecret ClientId ClientSecret+instance ToHttpApiData IdAndSecret where+    toUrlPiece (IdAndSecret (ClientId i) (ClientSecret s)) =+        toUrlPiece . ("Basic " <>) . decodeUtf8 . B64.encode $ encodeUtf8 $ i <> ":" <> s++data TokenType+    = TokenTypeBearer+    deriving (Eq, Ord, Show, Generic)+instance FromJSON TokenType where+    parseJSON =+        parseJSON >=> \case+            "Bearer" -> pure TokenTypeBearer+            s -> fail $ "unknown type: " <> s++data TokenResponse = TokenResponse+    { accessToken :: AccessToken+    , tokenType :: TokenType+    , expiresIn :: Int+    , scope :: Text+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON TokenResponse
+ types/Spotify/Types/Categories.hs view
@@ -0,0 +1,17 @@+module Spotify.Types.Categories where++import Spotify.Types.Internal.CustomJSON+import Spotify.Types.Misc++import Data.Aeson (FromJSON)+import Data.Text (Text)+import GHC.Generics (Generic)++data Category = Category+    { href :: Href+    , icons :: [Image]+    , id :: CategoryID+    , name :: Text+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Category
+ types/Spotify/Types/Internal/CustomJSON.hs view
@@ -0,0 +1,34 @@+{-# LANGUAGE UndecidableInstances #-}++module Spotify.Types.Internal.CustomJSON where++import Data.Aeson (+    FromJSON (parseJSON),+    GFromJSON,+    Options (constructorTagModifier, fieldLabelModifier),+    Zero,+    defaultOptions,+    genericParseJSON,+ )+import Data.Char (isUpper, toLower)+import Data.List (dropWhileEnd, stripPrefix)+import Data.Maybe (fromMaybe)+import GHC.Generics (Generic (Rep))+import Type.Reflection (Typeable, typeRep)++newtype CustomJSON a = CustomJSON a deriving (Generic)+instance (Generic a, GFromJSON Zero (Rep a), Typeable a) => FromJSON (CustomJSON a) where+    parseJSON = fmap CustomJSON . genericParseJSON defaultOptions{constructorTagModifier, fieldLabelModifier}+      where+        fieldLabelModifier = camelToSnake . dropWhileEnd (== '_')+        constructorTagModifier = camelToSnake . (fromMaybe <*> stripPrefix (show $ typeRep @a))+        camelToSnake = \case+            [] -> []+            x : xs -> toLower x : go xs+          where+            go = \case+                [] -> []+                x : xs ->+                    if isUpper x+                        then '_' : toLower x : go xs+                        else x : go xs
+ types/Spotify/Types/Internal/EnumJSON.hs view
@@ -0,0 +1,8 @@+module Spotify.Types.Internal.EnumJSON where++import Data.Aeson (FromJSON (parseJSON))++newtype EnumJSON a = EnumJSON a++instance (Enum a) => FromJSON (EnumJSON a) where+    parseJSON = fmap (EnumJSON . toEnum @a) . parseJSON
+ types/Spotify/Types/Misc.hs view
@@ -0,0 +1,253 @@+{- HLINT ignore "Use newtype instead of data" -}+{-# LANGUAGE UndecidableInstances #-}++module Spotify.Types.Misc where++import Spotify.Types.Internal.CustomJSON+import Spotify.Types.Internal.EnumJSON++import Data.Aeson (FromJSON, ToJSON)+import Data.List.Extra (enumerate)+import Data.Map (Map)+import Data.Proxy (Proxy (Proxy))+import Data.Set qualified as Set+import Data.String (IsString)+import Data.Text (Text)+import Data.Text qualified as T+import GHC.Generics (Generic)+import GHC.Records (HasField)+import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)+import Servant.API (ToHttpApiData)++data Copyright = Copyright+    { text :: Text+    , type_ :: CopyrightType+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Copyright++data CopyrightType+    = C+    | P+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON)++data Error = Error+    { status :: HTTPError+    , message :: Text+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Error++data Followers = Followers+    { href :: Maybe Text+    , total :: Int+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Followers++data Image = Image+    { height :: Maybe Int+    , url :: Text+    , width :: Maybe Int+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Image++data Paging a = Paging+    { href :: Href+    , items :: [a]+    , limit :: Int+    , next :: Maybe Text+    , offset :: Int+    , previous :: Maybe Text+    , total :: Int+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON (Paging a)++data Tracks = Tracks+    { href :: Maybe Text+    , total :: Int+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Tracks++data DatePrecision+    = DatePrecisionYear+    | DatePrecisionMonth+    | DatePrecisionDay+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON DatePrecision++data Key+    = KeyC+    | KeyCSharp+    | KeyD+    | KeyDSharp+    | KeyE+    | KeyF+    | KeyFSharp+    | KeyG+    | KeyGSharp+    | KeyA+    | KeyASharp+    | KeyB+    deriving (Eq, Ord, Show, Generic, Enum)+    deriving (FromJSON) via EnumJSON Key++data TrackLink = TrackLink+    { externalUrls :: ExternalURLs+    , href :: Href+    , id :: TrackID+    , url :: Maybe Text+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON TrackLink++data AlbumGroup+    = GroupAlbum+    | GroupSingle+    | GroupCompilation+    | AppearsOn+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON AlbumGroup++data AlbumType+    = AlbumTypeAlbum+    | AlbumTypeSingle+    | AlbumTypeCompilation+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON AlbumType++data ExplicitContent = ExplicitContent+    { filterEnabled :: Bool+    , filterLocked :: Bool+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON ExplicitContent++data Product+    = Premium+    | Free+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Product++data Offset = Offset+    { position :: Int+    , uri :: Maybe URI+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (ToJSON)++newtype Market = Market {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, ToHttpApiData, IsString)++newtype Genre = Genre {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, IsString)++newtype Href = Href {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, IsString)++class ToURI a where+    toURI :: a -> URI+newtype URIPrefix (s :: Symbol) a = URIPrefix a+instance (KnownSymbol s, HasField "unwrap" a Text) => ToURI (URIPrefix s a) where+    toURI (URIPrefix x) = URI $ "spotify:" <> T.pack (symbolVal (Proxy @s)) <> ":" <> x.unwrap+newtype DeviceID = DeviceID {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)+newtype AlbumID = AlbumID {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)+    deriving (ToURI) via URIPrefix "album" AlbumID+newtype ArtistID = ArtistID {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)+    deriving (ToURI) via URIPrefix "artist" ArtistID+newtype EpisodeID = EpisodeID {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)+    deriving (ToURI) via URIPrefix "episode" EpisodeID+newtype TrackID = TrackID {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)+    deriving (ToURI) via URIPrefix "track" TrackID+newtype UserID = UserID {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)+    deriving (ToURI) via URIPrefix "user" UserID+newtype PlaylistID = PlaylistID {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)+    deriving (ToURI) via URIPrefix "playlist" PlaylistID+newtype CategoryID = CategoryID {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)+newtype SnapshotID = SnapshotID {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)++newtype URL = URL {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, IsString, ToHttpApiData)++newtype URI = URI {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, IsString)++newtype Country = Country {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)++newtype Locale = Locale {unwrap :: Text}+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)++newtype HTTPError = HTTPError {unwrap :: Int}+    deriving (Show)+    deriving newtype (Eq, Ord, FromJSON)++newtype Restrictions = Restrictions {unwrap :: Map Text Text}+    deriving (Show)+    deriving newtype (Eq, Ord, FromJSON)++newtype ExternalIDs = ExternalIDs {unwrap :: Map Text Text}+    deriving (Show)+    deriving newtype (Eq, Ord, FromJSON)++newtype ExternalURLs = ExternalURLs {unwrap :: Map Text Text}+    deriving (Show)+    deriving newtype (Eq, Ord, FromJSON)++data Scope+    = UgcImageUpload+    | UserModifyPlaybackState+    | UserReadPlaybackState+    | UserReadCurrentlyPlaying+    | UserFollowModify+    | UserFollowRead+    | UserReadRecentlyPlayed+    | UserReadPlaybackPosition+    | UserTopRead+    | PlaylistReadCollaborative+    | PlaylistModifyPublic+    | PlaylistReadPrivate+    | PlaylistModifyPrivate+    | AppRemoteControl+    | Streaming+    | UserReadEmail+    | UserReadPrivate+    | UserLibraryModify+    | UserLibraryRead+    deriving (Eq, Ord, Show, Enum, Bounded)+allScopes :: Set.Set Scope+allScopes = Set.fromList enumerate+showScope :: Scope -> Text+showScope = \case+    UgcImageUpload -> "ugc-image-upload"+    UserModifyPlaybackState -> "user-modify-playback-state"+    UserReadPlaybackState -> "user-read-playback-state"+    UserReadCurrentlyPlaying -> "user-read-currently-playing"+    UserFollowModify -> "user-follow-modify"+    UserFollowRead -> "user-follow-read"+    UserReadRecentlyPlayed -> "user-read-recently-played"+    UserReadPlaybackPosition -> "user-read-playback-position"+    UserTopRead -> "user-top-read"+    PlaylistReadCollaborative -> "playlist-read-collaborative"+    PlaylistModifyPublic -> "playlist-modify-public"+    PlaylistReadPrivate -> "playlist-read-private"+    PlaylistModifyPrivate -> "playlist-modify-private"+    AppRemoteControl -> "app-remote-control"+    Streaming -> "streaming"+    UserReadEmail -> "user-read-email"+    UserReadPrivate -> "user-read-private"+    UserLibraryModify -> "user-library-modify"+    UserLibraryRead -> "user-library-read"
+ types/Spotify/Types/Player.hs view
@@ -0,0 +1,78 @@+module Spotify.Types.Player where++import Spotify.Types.Internal.CustomJSON+import Spotify.Types.Misc+import Spotify.Types.Tracks++import Data.Aeson (FromJSON)+import Data.Text (Text)+import GHC.Generics (Generic)++data PlaybackState = PlaybackState+    { device :: Device+    , repeatState :: Repeat+    , shuffleState :: Bool+    , context :: Context+    , timestamp :: Int+    , progressMs :: Int+    , isPlaying :: Bool+    , item :: Track+    , currentlyPlayingType :: Text+    , actions :: Actions+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON PlaybackState+data Repeat+    = RepeatOff+    | RepeatContext+    | RepeatTrack+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Repeat++data CurrentlyPlayingTrack = CurrentlyPlayingTrack+    { context :: Context+    , timestamp :: Int+    , progressMs :: Int+    , isPlaying :: Bool+    , item :: Track+    , currentlyPlayingType :: Text+    , actions :: Actions+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON CurrentlyPlayingTrack++data Device = Device+    { id :: DeviceID+    , isActive :: Bool+    , isPrivateSession :: Bool+    , isRestricted :: Bool+    , name :: Text+    , type_ :: Text+    , volumePercent :: Int+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Device++data Context = Context+    { type_ :: Text+    , href :: Href+    , externalUrls :: ExternalURLs+    , uri :: URI+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Context++data Actions = Actions+    { interruptingPlayback :: Maybe Bool+    , pausing :: Maybe Bool+    , resuming :: Maybe Bool+    , seeking :: Maybe Bool+    , skippingNext :: Maybe Bool+    , skippingPrev :: Maybe Bool+    , togglingRepeatContext :: Maybe Bool+    , togglingShuffle :: Maybe Bool+    , togglingRepeatTrack :: Maybe Bool+    , transferringPlayback :: Maybe Bool+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Actions
+ types/Spotify/Types/Playlists.hs view
@@ -0,0 +1,28 @@+module Spotify.Types.Playlists where++import Spotify.Types.Internal.CustomJSON+import Spotify.Types.Misc+import Spotify.Types.Simple+import Spotify.Types.Tracks++import Data.Aeson (FromJSON)+import Data.Text (Text)+import GHC.Generics (Generic)++data Playlist = Playlist+    { collaborative :: Bool+    , description :: Maybe Text+    , externalUrls :: ExternalURLs+    , followers :: Followers+    , href :: Href+    , id :: PlaylistID+    , images :: [Image]+    , name :: Text+    , owner :: UserSimple+    , public :: Maybe Bool+    , snapshotId :: SnapshotID+    , tracks :: Paging SavedTrack+    , uri :: URI+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Playlist
+ types/Spotify/Types/Search.hs view
@@ -0,0 +1,44 @@+module Spotify.Types.Search where++import Spotify.Types.Artists+import Spotify.Types.Internal.CustomJSON+import Spotify.Types.Misc+import Spotify.Types.Simple+import Spotify.Types.Tracks++import Data.Aeson (FromJSON, Value)+import Data.Text qualified as T+import GHC.Generics (Generic)+import Servant.API (ToHttpApiData (toUrlPiece))++data SearchType+    = AlbumSearch+    | ArtistSearch+    | PlaylistSearch+    | TrackSearch+    | ShowSearch+    | EpisodeSearch+    | AudiobookSearch+    deriving (Eq, Ord, Show, Read, Generic)+instance ToHttpApiData [SearchType] where+    toUrlPiece =+        T.intercalate "," . map \case+            AlbumSearch -> "album"+            ArtistSearch -> "artist"+            PlaylistSearch -> "playlist"+            TrackSearch -> "track"+            ShowSearch -> "show"+            EpisodeSearch -> "episode"+            AudiobookSearch -> "audiobook"++data SearchResult = SearchResult+    { tracks :: Maybe (Paging Track)+    , artists :: Maybe (Paging Artist)+    , albums :: Maybe (Paging AlbumSimple)+    , playlists :: Maybe (Paging PlaylistSimple)+    , shows :: Maybe (Paging Value)+    , episodes :: Maybe (Paging Value)+    , audiobooks :: Maybe (Paging Value)+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON SearchResult
+ types/Spotify/Types/Simple.hs view
@@ -0,0 +1,83 @@+module Spotify.Types.Simple where++import Spotify.Types.Internal.CustomJSON+import Spotify.Types.Misc++import Data.Aeson (FromJSON)+import Data.Text (Text)+import GHC.Generics (Generic)++data UserSimple = UserSimple+    { displayName :: Maybe Text+    , externalUrls :: ExternalURLs+    , followers :: Maybe Followers+    , href :: Href+    , id :: UserID+    , images :: Maybe [Image]+    , uri :: URI+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON UserSimple++data TrackSimple = TrackSimple+    { artists :: [ArtistSimple]+    , availableMarkets :: Maybe [Text]+    , discNumber :: Int+    , durationMs :: Int+    , explicit :: Bool+    , externalUrls :: ExternalURLs+    , href :: Href+    , id :: TrackID+    , isPlayable :: Maybe Bool+    , linkedFrom :: Maybe TrackLink+    , name :: Text+    , previewUrl :: Maybe Text+    , trackNumber :: Int+    , uri :: URI+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON TrackSimple++data AlbumSimple = AlbumSimple+    { albumType :: AlbumType+    , artists :: [ArtistSimple]+    , availableMarkets :: Maybe [Text]+    , externalUrls :: ExternalURLs+    , albumGroup :: Maybe AlbumGroup+    , href :: Href+    , id :: AlbumID+    , images :: [Image]+    , name :: Text+    , releaseDate :: Text+    , releaseDatePrecision :: DatePrecision+    , restrictions :: Maybe Restrictions+    , uri :: URI+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON AlbumSimple++data ArtistSimple = ArtistSimple+    { externalUrls :: ExternalURLs+    , href :: Href+    , id :: ArtistID+    , name :: Text+    , uri :: URI+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON ArtistSimple++data PlaylistSimple = PlaylistSimple+    { collaborative :: Bool+    , externalUrls :: ExternalURLs+    , href :: Href+    , id :: PlaylistID+    , images :: [Image]+    , name :: Text+    , owner :: UserSimple+    , public :: Maybe Bool+    , snapshotId :: SnapshotID+    , tracks :: Tracks+    , uri :: URI+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON PlaylistSimple
+ types/Spotify/Types/Tracks.hs view
@@ -0,0 +1,68 @@+module Spotify.Types.Tracks where++import Spotify.Types.Internal.CustomJSON+import Spotify.Types.Internal.EnumJSON+import Spotify.Types.Misc+import Spotify.Types.Simple++import Data.Aeson (FromJSON)+import Data.Text (Text)+import GHC.Generics (Generic)++data Track = Track+    { album :: AlbumSimple+    , artists :: [ArtistSimple]+    , availableMarkets :: Maybe [Text]+    , discNumber :: Int+    , durationMs :: Int+    , explicit :: Bool+    , externalIds :: ExternalIDs+    , externalUrls :: ExternalURLs+    , href :: Href+    , id :: TrackID+    , isPlayable :: Maybe Bool+    , linkedFrom :: Maybe TrackLink+    , restrictions :: Maybe Restrictions+    , name :: Text+    , popularity :: Int+    , previewUrl :: Maybe Text+    , trackNumber :: Int+    , uri :: URI+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON Track++data AudioFeatures = AudioFeatures+    { acousticness :: Float+    , analysisUrl :: URL+    , danceability :: Float+    , durationMs :: Int+    , energy :: Float+    , id :: TrackID+    , instrumentalness :: Float+    , key :: Key+    , liveness :: Float+    , loudness :: Float+    , mode :: Modality+    , speechiness :: Float+    , tempo :: Float+    , timeSignature :: Int+    , trackHref :: Href+    , uri :: URI+    , valence :: Float+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON AudioFeatures++data Modality+    = Minor+    | Major+    deriving (Eq, Ord, Show, Generic, Enum)+    deriving (FromJSON) via EnumJSON Modality++data SavedTrack = SavedTrack+    { addedAt :: Text+    , track :: Track+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON SavedTrack
+ types/Spotify/Types/Users.hs view
@@ -0,0 +1,24 @@+module Spotify.Types.Users where++import Spotify.Types.Internal.CustomJSON+import Spotify.Types.Misc++import Data.Aeson (FromJSON)+import Data.Text (Text)+import GHC.Generics (Generic)++data User = User+    { country :: Maybe Text+    , displayName :: Maybe Text+    , email :: Maybe Text+    , explicitContent :: Maybe ExplicitContent+    , externalUrls :: ExternalURLs+    , followers :: Maybe Followers+    , href :: Href+    , id :: UserID+    , images :: Maybe [Image]+    , product :: Maybe Product+    , uri :: URI+    }+    deriving (Eq, Ord, Show, Generic)+    deriving (FromJSON) via CustomJSON User