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 +5/−0
- LICENSE +30/−0
- README.md +11/−0
- examples/Main.hs +52/−0
- examples/Spotify/CheckPlaylistOverlap.hs +31/−0
- examples/Spotify/CreateArtistLikedSongsPlaylist.hs +59/−0
- examples/Spotify/CreatePlaylist.hs +75/−0
- examples/Spotify/DeleteRecentPlaylists.hs +28/−0
- examples/Spotify/TransferPlayback.hs +21/−0
- lib/Spotify.hs +298/−0
- servant/Orphans/Servant/Lucid.hs +13/−0
- servant/Spotify/Servant/Albums.hs +31/−0
- servant/Spotify/Servant/Artists.hs +15/−0
- servant/Spotify/Servant/Categories.hs +19/−0
- servant/Spotify/Servant/Core.hs +108/−0
- servant/Spotify/Servant/Player.hs +100/−0
- servant/Spotify/Servant/Playlists.hs +58/−0
- servant/Spotify/Servant/Search.hs +24/−0
- servant/Spotify/Servant/Tracks.hs +35/−0
- servant/Spotify/Servant/Users.hs +25/−0
- spotify.cabal +144/−0
- test/Test.hs +72/−0
- types/Spotify/Types/Albums.hs +32/−0
- types/Spotify/Types/Artists.hs +22/−0
- types/Spotify/Types/Auth.hs +54/−0
- types/Spotify/Types/Categories.hs +17/−0
- types/Spotify/Types/Internal/CustomJSON.hs +34/−0
- types/Spotify/Types/Internal/EnumJSON.hs +8/−0
- types/Spotify/Types/Misc.hs +253/−0
- types/Spotify/Types/Player.hs +78/−0
- types/Spotify/Types/Playlists.hs +28/−0
- types/Spotify/Types/Search.hs +44/−0
- types/Spotify/Types/Simple.hs +83/−0
- types/Spotify/Types/Tracks.hs +68/−0
- types/Spotify/Types/Users.hs +24/−0
+ 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