packages feed

liblastfm 0.3.2.0 → 0.4.0.0

raw patch · 45 files changed

+3108/−154 lines, 45 filesdep +HUnitdep +hspecdep +hspec-expectations-lensdep −http-conduitdep −http-typesdep −taggeddep ~basedep ~containers

Dependencies added: HUnit, hspec, hspec-expectations-lens, http-client, http-client-tls, lens, liblastfm, profunctors, xml-conduit, xml-html-conduit-lens

Dependencies removed: http-conduit, http-types, tagged

Dependency ranges changed: base, containers

Files

CHANGELOG.md view
@@ -1,13 +1,21 @@+0.4.0.0+=======++  * `http-conduit` exceptions aren't propagate further into user code anymore++  * XML responses are parsed into `Document` type from `xml-conduit`+ 0.3.2.0 ======= -  * Fix `auth.getMobileSession` method-  * Provide `lastfm_` function for making requests without parsing response. That is most useful for `POST` requests like `N.L.Track.love`+  * Fixed `auth.getMobileSession` API method +  * Provided `lastfm_` function for making requests without parsing response. That is most useful for `POST` requests like `N.L.Track.love`+ 0.3.0.0 ======= -  * Support batch operations for `N.L.Track.scrobble`, `N.L.Library.{addAlbum,addArtist}`+  * Supported batch operations for `N.L.Track.scrobble`, `N.L.Library.{addAlbum,addArtist}`  0.2.0.0 =======
LICENSE view
@@ -1,4 +1,4 @@-Copyright (C) 2012 Matvey Aksenov, Dmitry Malikov+Copyright (C) 2012-2014 Matvey Aksenov, Dmitry Malikov  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in
README.md view
@@ -1,25 +1,35 @@-# liblastfm-[![Hackage](https://budueba.com/hackage/liblastfm)](http://hackage.haskell.org/package/liblastfm)+liblastfm+=========+[![Hackage](https://budueba.com/hackage/liblastfm)](https://hackage.haskell.org/package/liblastfm) [![Build Status](https://drone.io/github.com/supki/liblastfm/status.png)](https://drone.io/github.com/supki/liblastfm/latest)-[![Build Status](https://secure.travis-ci.org/supki/liblastfm.png?branch=develop)](http://travis-ci.org/supki/liblastfm)+[![Build Status](https://secure.travis-ci.org/supki/liblastfm.png?branch=develop)](https://travis-ci.org/supki/liblastfm) -Complete API interface to [last.fm][1] service.+Complete API interface to [last.fm][last.fm] service. Documentation is available in two flavours:-  * original [API reference][2]-  * liblastfm [haddocks][3] -## General introduction+  * original [API reference][last.fm/api]++  * liblastfm [haddocks][liblastfm/haddocks]++Introduction+------------ liblastfm provides Applicative interface for constructing requests. Also, it handles all machinery needed to prepare request for sending:-  * [signing][4]++  * [signing][last.fm/sign]+   * url-encoding+   * miscellaneous stuff like choosing correct HTTP method, etc -Once request is ready, liblastfm may send it and get you back a response.+Once request is ready, liblastfm can send it and get you back a response. Response format might be:-  * `Maybe Value` from [aeson][5] for json queries (nice interaction with [aeson-lens][6] for free!)-  * raw `ByteString` for xml queries -## Installation+  * [aeson][aeson] `Value` for json queries++  * [xml-conduit][xml-conduit] `Document` for xml queries++Installation+------------ To install either use hackage:      % cabal install liblastfm@@ -30,13 +40,14 @@     % cd liblastfm     % cabal install -## Usage-Suppose, you need to use [`tag.search`](http://www.last.fm/api/show/tag.search) API method.-First find it in liblastfm: `Tag` would be the name of the module and `search` would be the name of function. [Here it is][7].+Usage+-----+Suppose, you need to use [`tag.search`][last.fm/api-usage] API method.+First find it in liblastfm: `Tag` would be the name of the module and `search` would be the name of function. [Here it is][liblastfm/haddocks-usage]. So import a couple of modules: -    ghci> import Network.Lastfm -- a bunch of useful utilities-    ghci> import qualified Network.Lastfm.Tag as Tag -- for Tag.search+    >>> import           Network.Lastfm            -- a bunch of useful utilities+    >>> import qualified Network.Lastfm.Tag as Tag -- for Tag.search  Now you may you applicative `<*>` for required and `<*` or `*>` for optional parameters to construct desired request:@@ -45,57 +56,73 @@  To send constructed request use `lastfm`: -    ghci> lastfm $ Tag.search <*> tag "russian-folk" <* limit 10 <*> apiKey "29effec263316a1f8a97f753caaa83e0" <* json+    >>> lastfm $ Tag.search <*> tag "russian-folk" <* limit 10 <*> apiKey "29effec263316a1f8a97f753caaa83e0" <* json     Just (Object fromList [("results",Object fromList [("tagmatches", ... -How to parse responses is described [in wiki][8].+[Wiki][liblastfm/wiki] describes how to parse responses. -## FAQ+FAQ+---+ **Q: I'm getting the following error. How do I fix it?**+ ```-> Artist.getInfo <*> artist "Pink Floyd" <*> apiKey "29effec263316a1f8a97f753caaa83e0"+>>> Artist.getInfo <*> artist "Pink Floyd" <*> apiKey "29effec263316a1f8a97f753caaa83e0"  <interactive>:8:27:     Couldn't match expected type `Data.Text.Lazy.Internal.Text'                 with actual type `[Char] ```+ A: This means you haven't OverloadedStrings extension enabled. To enable it (either one works):+   * type in `:set -XOverloadedStrings` while in ghci session.+   * add `{-# LANGUAGE OverloadedStrings #-}` to the top of the file+   * compile with `-XOverloadedStrings` switch  **Q: I'm getting the following error. How do I fix it?**+ ```-> lastfm (Artist.getInfo <*> artist "Pink Floyd" <*> apiKey "29effec263316a1f8a97f753caaa83e0")+>>> lastfm (Artist.getInfo <*> artist "Pink Floyd" <*> apiKey "29effec263316a1f8a97f753caaa83e0")  <interactive>:13:1:-    No instance for (Network.Lastfm.Response.Supported f0)-      arising from a use of `lastfm'-    The type variable `f0' is ambiguous-    Possible fix: add a type signature that fixes these type variable(s)-    Note: there are several potential instances:-      instance Network.Lastfm.Response.Supported 'XML-        -- Defined at src/Network/Lastfm/Response.hs:66:10-      instance Network.Lastfm.Response.Supported 'JSON-        -- Defined at src/Network/Lastfm/Response.hs:51:10+    No instance for (Show (IO (Either LastfmError r0)))+      arising from a use of `print'+    Possible fix:+      add an instance declaration for (Show (IO (Either LastfmError r0)))+    In the first argument of `print', namely `it'+    In a stmt of an interactive GHCi command: print it ```-A: This error message indicates that GHC cannot infer response format for `Request`.++A: This error message indicates that GHC cannot infer response format for the `Request`. To fix it, add use `json` or `xml` helpers, depending on your needs -```-> lastfm (Artist.getInfo <*> artist "Pink Floyd" <*> apiKey "29effec263316a1f8a97f753caaa83e0" <* json)-Just (Object fromList [("artist" ...-> lastfm (Artist.getInfo <*> artist "Pink Floyd" <*> apiKey "29effec263316a1f8a97f753caaa83e0" <* xml)-"<?xml version=\"1.0\" ...+A note on testing+-----------------++To test Lastfm API compatibility (`api` test suite)—specifically, authentication requiring+examples—you will need to set `HASKELL_LIBLASTFM_APIKEY`, `HASKELL_LIBLASTFM_SESSIONKEY`,+and `HASKELL_LIBLASTFM_SECRET` environment variables to your api key, session key, and+secret respectively; for example (bash):++```bash+export HASKELL_LIBLASTFM_APIKEY="__API_KEY__"+export HASKELL_LIBLASTFM_SESSIONKEY="__SESSION_KEY__"+export HASKELL_LIBLASTFM_SECRET="__SECRET__" ```-- - [1]: http://www.last.fm/- [2]: http://www.last.fm/api/intro- [3]: http://supki.github.com/liblastfm/- [4]: http://www.last.fm/api/authspec#8- [5]: http://hackage.haskell.org/package/aeson- [6]: http://hackage.haskell.org/package/aeson-lens- [7]: http://supki.github.com/liblastfm/Network-Lastfm-Tag.html#v:search- [8]: https://github.com/supki/liblastfm/wiki/How-to-parse-JSON-response+Please, consult Lastfm API documentation and  `examples/*-authentication.hs`+examples if you don't know where to get your credentials.++ [last.fm]: http://www.last.fm/+ [last.fm/api]: http://www.last.fm/api/intro+ [last.fm/api-usage]: http://www.last.fm/api/show/tag.search+ [last.fm/sign]: http://www.last.fm/api/authspec#8+ [liblastfm/haddocks]: http://supki.github.io/liblastfm/+ [liblastfm/haddocks-usage]: http://supki.github.com/liblastfm/Network-Lastfm-Tag.html#v:search+ [liblastfm/wiki]: https://github.com/supki/liblastfm/wiki/How-to-parse-JSON-response+ [aeson]: https://hackage.haskell.org/package/aeson+ [xml-conduit]: https://hackage.haskell.org/package/xml-conduit
+ examples/README.md view
@@ -0,0 +1,82 @@+# liblastfm examples++## sort-friends++Shows how to work with lastfm api and async to speed up getting 5 most compliable friends.++  * [async][async]++  * [lens-aeson][lens-aeson]++---++## multitag-search++Shows how to work lastfm api around to get search for multiple tags at once.++  * [aeson][aeson]++---++## playcount++Shows how to get user playcount.++  * [aeson][aeson]++  * [lens-aeson][lens-aeson]++---++## recommendations++Shows how to use signed queries++  * [lens-aeson][lens-aeson]++---++## desktop-authentication++Demonstrates [desktop application authentication][last.fm/desktop] flow.++  * [lens-aeson][lens-aeson]++---++## web-authentication++Demonstrates [web application authentication][last.fm/web] flow.++  * [happstack-server][happstack-server]++---++## mobile-authentication++Demonstrates [mobile application authentication][last.fm/mobile] flow.++  * [lens-aeson][lens-aeson]++---++## [scrobblers][scrobblers]++Advanced scrobbling techniques.++  * [netwire][netwire]++  * [libmpd][libmpd]++  * [lens-aeson][lens-aeson]++ [aeson]: https://hackage.haskell.org/package/aeson+ [async]: https://hackage.haskell.org/package/async+ [happstack-server]: https://hackage.haskell.org/package/happstack-server+ [lens-aeson]: https://hackage.haskell.org/package/lens-aeson+ [libmpd]: https://hackage.haskell.org/package/libmpd+ [netwire]: https://hackage.haskell.org/package/netwire+ [scrobblers]: https://github.com/supki/scrobblers+ [last.fm/desktop]: http://www.last.fm/api/desktopauth+ [last.fm/web]: http://www.last.fm/api/webauth+ [last.fm/mobile]: http://www.last.fm/api/mobileauth
+ examples/desktop-authentication.hs view
@@ -0,0 +1,25 @@+{-# LANGUAGE OverloadedStrings #-}+-- | Desktop application authentication flow example+--+-- Please remember to substitute __YOUR_API_KEY__+-- and __YOUR_SECRET__ for real values+import Control.Lens                  -- lens+import Data.Aeson.Lens               -- lens+import Data.Foldable (for_)          -- base+import Data.Text (unpack)            -- text+import Network.Lastfm                -- liblastfm+import Network.Lastfm.Authentication -- liblastfm+++main :: IO ()+main = do+  r <- lastfm $ getToken <*> apiKey ak <* json+  for_ (r ^? folded.key "token"._String) $ \t -> do+    putStrLn $ "approve: " ++ link (apiKey ak <* token t)+    _ <- getChar+    r' <- lastfm . sign s $ getSession <*> token t <*> apiKey ak <* json+    for_ (r' ^? folded.key "session".key "key"._String) $ \sk ->+      putStrLn $ "session key: " ++ unpack sk+ where+  ak = "__YOUR_API_KEY__"+  s  = "__YOUR_SECRET__"
+ examples/liblastfm-examples.cabal view
@@ -0,0 +1,99 @@+name: liblastfm-examples+version: 0.4.0.0+synopsis: Examples for liblastfm+license: MIT+license-file: LICENSE+author: Matvey Aksenov, Dmitry Malikov+maintainer: Matvey Aksenov <matvey.aksenov@gmail.com>+category: Network APIs+description: Haddock documentation isn't enough to get feeling of the library+cabal-version: >= 1.10+build-type: Simple++executable sort-friends+  default-language: Haskell2010+  build-depends:+      base             >= 4 && < 5+    , liblastfm        >= 0.4.0.0+    , async+    , text+    , aeson+    , lens             >= 4.0+  main-is: sort-friends.hs+  ghc-options:+    -Wall+    -fno-warn-unused-do-bind+    -threaded++executable multitag-search+  default-language: Haskell2010+  build-depends:+      base             >= 4 && < 5+    , liblastfm        >= 0.4.0.0+    , text+    , lens             >= 4.0+    , aeson+  main-is: multitag-search.hs+  ghc-options:+    -Wall+    -fno-warn-unused-do-bind++executable web-authentication+  default-language: Haskell2010+  build-depends:+      base             >= 4 && < 5+    , liblastfm        >= 0.4.0.0+    , lens             >= 4.0+    , happstack-server >= 7.3+    , transformers+  main-is: web-authentication.hs+  ghc-options:+    -Wall+    -fno-warn-unused-do-bind++executable desktop-authentication+  default-language: Haskell2010+  build-depends:+      base             >= 4 && < 5+    , liblastfm        >= 0.4.0.0+    , text+    , lens             >= 4.0+  main-is: desktop-authentication.hs+  ghc-options:+    -Wall+    -fno-warn-unused-do-bind++executable mobile-authentication+  default-language: Haskell2010+  build-depends:+      base             >= 4 && < 5+    , liblastfm        >= 0.4.0.0+    , lens             >= 4.0+    , text+  main-is: mobile-authentication.hs+  ghc-options:+    -Wall++executable playcount+  default-language: Haskell2010+  build-depends:+      base             >= 4 && < 5+    , liblastfm        >= 0.4.0.0+    , text+    , lens             >= 4.0+  main-is: playcount.hs+  ghc-options:+    -Wall+    -fno-warn-unused-do-bind++executable recommendations+  default-language: Haskell2010+  build-depends:+      base             >= 4 && < 5+    , liblastfm        >= 0.4.0.0+    , text+    , aeson+    , lens             >= 4.0+  main-is: recommendations.hs+  ghc-options: -Wall+               -fno-warn-unused-do-bind
+ examples/mobile-authentication.hs view
@@ -0,0 +1,25 @@+{-# LANGUAGE OverloadedStrings #-}+-- | Mobile application authentication flow example+--+-- Please remember to substitute __YOUR_API_KEY__,+-- __YOUR_SECRET__, __USERNAME__ and __PASSWORD__+-- for real values+import           Control.Lens                  -- lens+import           Data.Aeson.Lens               -- lens+import qualified Data.Text as Text             -- text+import qualified Data.Text.IO as Text          -- text+import           Network.Lastfm                -- liblastfm+import           Network.Lastfm.Authentication -- liblastfm++main :: IO ()+main = do+  r <- lastfm . sign s $ getMobileSession <*> username u <*> password p <*> apiKey ak <* json+  let maybeSk = r ^? folded.key "session".key "key"._String+  Text.putStrLn $ case maybeSk of+    Just sk -> "Mobile session key: " `Text.append` sk+    Nothing -> "Mobile session key wasn't retrieved, something goes wrong"+ where+  ak = "__YOUR_API_KEY__"+  s  = "__YOUR_SECRET__"+  u  = "__USERNAME__"+  p  = "__PASSWORD__"
+ examples/multitag-search.hs view
@@ -0,0 +1,43 @@+{-# LANGUAGE OverloadedStrings #-}+{- This example shows how to do multitag search even+ - if it's not provided by Lastfm API+ -+ - Sample output:+ -+ - $> ./examples/multitag-search.hs+ - 坂本真綾+ - KOTOKO+ - Lisa+ - 茅原実里+ -}+import           Data.Aeson.Types          -- aeson+import           Data.List (intersect)     -- base+import           Data.Maybe                -- base+import           Data.Text (Text)          -- text+import qualified Data.Text.IO as Text      -- text+import           Network.Lastfm            -- liblastfm+import qualified Network.Lastfm.Tag as Tag -- liblastfm++{-# ANN module ("HLint: ignore Use camelCase" :: String) #-}+++main :: IO ()+main = mapM_ Text.putStrLn =<< get_artists multitags+++get_artists :: [Text] -> IO [Text]+get_artists ts = do+  names <- mapM request ts+  case catMaybes names of+    [] -> return []+    ns -> return (foldl1 intersect ns)+ where+  request = fmap (either (const Nothing) (parseMaybe gta)) . query++  query t = lastfm $ Tag.getTopArtists <*> tag t <* limit 100 <*> apiKey "29effec263316a1f8a97f753caaa83e0" <* json++gta :: Value -> Parser [Text]+gta o = parseJSON o >>= (.: "topartists") >>= (.: "artist") >>= mapM (.: "name")++multitags :: [Text]+multitags = ["j-pop", "anime", "anime-ost"]
+ examples/playcount.hs view
@@ -0,0 +1,31 @@+{-# LANGUAGE OverloadedStrings #-}+{- This example shows how to get user playcount with lens-aeson+ -+ - Sample session:+ -+ - $> ./examples/playcount.hs+ - >>> MCDOOMDESTROYER+ - 96698+ - >>> smpcln+ - 95518+ - >>> ^C+ - $>+ -}+import           Control.Lens                -- lens+import           Data.Aeson.Lens             -- lens+import           Control.Monad               -- base+import           Data.Maybe                  -- base+import           Data.Text (Text)            -- text+import qualified Data.Text.IO as Text        -- text+import           Network.Lastfm              -- liblastfm+import qualified Network.Lastfm.User as User -- liblastfm+import           System.IO (hFlush, stdout)  -- base+++main :: IO ()+main = forever $ Text.putStr ">>> " >> hFlush stdout >> Text.getLine >>= playcount >>= Text.putStrLn++playcount :: Text -> IO Text+playcount u =+  lastfm (User.getInfo <*> user u <*> apiKey "29effec263316a1f8a97f753caaa83e0" <* json) <&> \rsp ->+    fromMaybe "Playcount not found" (rsp ^? folded.key "user".key "playcount"._String)
+ examples/recommendations.hs view
@@ -0,0 +1,48 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+{- This example shows how to use signed queries+ - to get user recommendations+ -+ - Sample output:+ -+ - Roger Rotor+ - Neurosis+ - Asche+ - SKYHARBOR+ - Cylob+ - Mouth of the Architect+ -}++import           Control.Lens                -- lens+import           Data.Aeson.Lens             -- lens+import           Data.Traversable (for)      -- base+import           Data.Aeson (Value)          -- aeson+import           Data.Foldable (Foldable)    -- base+import           Data.Int (Int64)            -- base+import           Data.Text (Text)            -- text+import qualified Data.Text.IO as Text        -- text+import           Network.Lastfm              -- liblastfm+import qualified Network.Lastfm.User as User -- liblastfm+++main :: IO ()+main = do+  r <- for [1..pages] $ \p -> parse `fmap` query (User.getRecommendedArtists <* page p)+  mapM_ Text.putStrLn (concat r)++-- Construct signed query+query :: Request JSON (APIKey -> SessionKey -> Sign) -> IO (Either LastfmError Value)+query r = lastfm $ sign secret (r <*> ak <*> sk <* json)+ where+  ak     = apiKey "__YOUR_API_KEY__"+  sk     = sessionKey "__YOUR_SESSION_KEY__"+  secret = "__YOUR_SECRET__"++-- Other query parameters+total, pages :: Int64+total = 250+pages = total `div` 50 -- 50 is the default number of recommendations per page++-- Parse artist names from response+parse :: Foldable f => f Value -> [Text]+parse x = x ^.. folded.key "recommendations".key "artist"._Array.folded.key "name"._String
+ examples/sort-friends.hs view
@@ -0,0 +1,73 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+{- This example shows how to extract all+ - user friends, get their similarity score and+ - then sort them based on it outputting top 5+ -+ - User friends of which are searched is 'target'+ -+ - Most of hard work there is on 'aeson-lens' actually :)+ -+ - Sample output:+ -+ - % ./examples/sort-friends.hs+ - Artvart: 0.9969767332077+ - smpcln: 0.9965825676918+ - nv0id: 0.86804795265198+ - QueenrXy: 0.5932799577713+ - GhostOsaka: 0.2875879406929+ -+ - Notice: you may want to adjust maximum open files limit+ - if testing on users with relatively large friends count+ -}+import           Control.Concurrent.Async                  -- async+import           Control.Lens                              -- lens+import           Data.Aeson.Lens                           -- lens+import           Data.Aeson (Value)                        -- aeson+import           Data.Function (on)                        -- base+import           Data.List (sortBy)                        -- base+import           Data.Maybe (fromMaybe)                    -- base+import           Data.Monoid ((<>))                        -- base+import           Data.Text (Text)                          -- text+import           Data.Text.Lens (unpacked)                 -- lens+import qualified Data.Text.IO as Text                      -- text+import           Network.Lastfm hiding (to)                -- liblastfm+import qualified Network.Lastfm.User as User               -- liblastfm+import qualified Network.Lastfm.Tasteometer as Tasteometer -- liblastfm+import           Text.Read (readMaybe)                     -- base+++type Score = Text+++main :: IO ()+main = pages >>= scores . names >>= pretty+ where+  names x = x ^.. folded.folded.key "friends".key "user"._Array.folded.key "name"._String++pages :: IO [Either LastfmError Value]+pages = do+  first <- query (User.getFriends <*> user target)+  let ps = fromMaybe 1 (preview total first)+  (first :) <$> forConcurrently [2..ps] (\p -> query (User.getFriends <*> user target <* page p))+ where+  total = folded.key "friends".key "@attr".key "totalPages"._String.unpacked.to readMaybe.folded++scores :: [Text] -> IO [(Text, Score)]+scores xs = zip xs <$> forConcurrently xs (\x -> do+  r <- query (Tasteometer.compare (user target) (user x))+  return (fromMaybe "0" (preview score r)))+ where+  score = folded.key "comparison".key "result".key "score"._String++forConcurrently :: [a] -> (a -> IO b) -> IO [b]+forConcurrently = flip mapConcurrently++pretty :: [(Text, Score)] -> IO ()+pretty = mapM_ (\(n,s) -> Text.putStrLn $ n <> ": " <> s) . take 5 . sortBy (flip compare `on` snd)++query :: Request JSON (APIKey -> Ready) -> IO (Either LastfmError Value)+query r = lastfm (r <*> apiKey "234fc6e0f41f6ef99b7bd62ebaf8d318" <* json)++target :: Text+target = "MCDOOMDESTROYER"
+ examples/web-authentication.hs view
@@ -0,0 +1,32 @@+{-# LANGUAGE OverloadedStrings #-}+-- | Web application authentication flow example+--+-- Please remember to substitute __YOUR_API_KEY__,+-- __YOUR_SECRET__ and __YOUR_CALLBACK__ for real values+import Control.Lens                    -- lens+import Data.Aeson.Lens                 -- lens+import Control.Monad                   -- base+import Control.Monad.IO.Class (liftIO) -- transformers+import Data.IORef                      -- base+import Happstack.Server                -- happstack-server+import Network.Lastfm                  -- liblastfm+import Network.Lastfm.Authentication   -- liblastfm+++main :: IO ()+main = do+  sessions <- newIORef []+  simpleHTTP nullConf $ msum+    [ dir "authenticate" $ seeOther (link $ apiKey ak <* callback "__YOUR_CALLBACK__") ""+    , dir "save" $ do+        t <- lookText' "token"+        r <- liftIO . lastfm . sign s $ getSession <*> token t <*> apiKey ak <* json+        case r ^? folded.key "session".key "key"._String of+          Just sk -> liftIO $ modifyIORef' sessions (sk:)+          Nothing -> return ()+        ok "Saved."+    , dir "show" $ liftIO (readIORef sessions) >>= ok . show+    ]+ where+  ak = "__YOUR_API_KEY__"+  s  = "__YOUR_SECRET__"
liblastfm.cabal view
@@ -1,5 +1,5 @@ name:          liblastfm-version:       0.3.2.0+version:       0.4.0.0 synopsis:      Lastfm API interface license:       MIT license-file:  LICENSE@@ -13,30 +13,46 @@ extra-source-files:   README.md   CHANGELOG.md+  examples/README.md+  examples/desktop-authentication.hs+  examples/liblastfm-examples.cabal+  examples/mobile-authentication.hs+  examples/multitag-search.hs+  examples/playcount.hs+  examples/recommendations.hs+  examples/sort-friends.hs+  examples/web-authentication.hs  source-repository head   type:     git   location: https://github.com/supki/liblastfm +flag test-api+  description: a real API test+  default: False+  manual: True+ library   default-language: Haskell2010   build-depends:       aeson-    , base >= 3 && < 5+    , base            >= 4 && < 5     , bytestring     , cereal-    , containers >= 0.5+    , containers      >= 0.5     , contravariant     , crypto-api-    , http-conduit >= 1.9-    , http-types+    , http-client     >= 0.3+    , http-client-tls >= 0.2     , network+    , profunctors     , pureMD5     , semigroups-    , tagged     , text     , void-  hs-source-dirs: src+    , xml-conduit     >= 1.1+  hs-source-dirs:+   src   exposed-modules:     Network.Lastfm     Network.Lastfm.Album@@ -61,3 +77,88 @@     -Wall     -fno-warn-unused-do-bind     -funbox-strict-fields++test-suite api+  if !flag(test-api)+    buildable: False+  default-language: Haskell2010+  build-depends:+      aeson+    , base                  >= 4 && < 5+    , bytestring+    , HUnit+    , lens                  >= 4.0.1+    , liblastfm+    , hspec+    , text+    , xml-html-conduit-lens >= 0.3+  type: exitcode-stdio-1.0+  hs-source-dirs:+    test/api+  main-is: Spec.hs+  other-modules:+    SpecHelper+    Json.AlbumSpec+    Json.ArtistSpec+    Json.ChartSpec+    Json.EventSpec+    Json.GeoSpec+    Json.GroupSpec+    Json.LibrarySpec+    Json.PlaylistSpec+    Json.TagSpec+    Json.TasteometerSpec+    Json.TrackSpec+    Json.UserSpec+    Json.VenueSpec+    Xml.AlbumSpec+    Xml.ArtistSpec+    Xml.ChartSpec+    Xml.EventSpec+    Xml.GeoSpec+    Xml.GroupSpec+    Xml.LibrarySpec+    Xml.PlaylistSpec+    Xml.TagSpec+    Xml.TasteometerSpec+    Xml.TrackSpec+    Xml.UserSpec+    Xml.VenueSpec+  ghc-options:+    -Wall+    -fno-warn-orphans+    -fno-warn-unused-do-bind++test-suite spec+  default-language: Haskell2010+  build-depends:+      aeson+    , base                    >= 4 && < 5+    , bytestring+    , cereal+    , containers+    , contravariant+    , crypto-api+    , hspec+    , hspec-expectations-lens >= 0.3.0.0+    , http-client+    , http-client-tls+    , lens                    >= 4.0+    , network+    , profunctors+    , pureMD5+    , text+    , void+    , xml-conduit+    , xml-html-conduit-lens   >= 0.3+  type: exitcode-stdio-1.0+  hs-source-dirs:+    src+    test/spec+  main-is: Spec.hs+  other-modules:+    Network.Lastfm.ResponseSpec+  ghc-options:+    -Wall+  cpp-options:+    -DTEST
src/Network/Lastfm/Internal.hs view
@@ -1,3 +1,7 @@+{-# LANGUAGE CPP #-}+#if __GLASGOW_HASKELL__ >= 708+{-# LANGUAGE AutoDeriveTypeable #-}+#endif {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE KindSignatures #-}@@ -5,26 +9,35 @@ -- -- You shouldn't need to import this module unless you are doing something interesting. module Network.Lastfm.Internal-  ( Request(..), Format(..), Ready, Sign-  , R(..), wrap, unwrap, render, coerce-  , absorbQuery, indexedWith+  ( Request(..)+  , Format(..)+  , Ready+  , Sign+  , R(..)+  , wrap+  , unwrap+  , render+  , coerce+  , absorbQuery+  , indexedWith     -- * Lenses-  , host, method, query+  , host+  , method+  , query   ) where -import Control.Applicative-import Data.Foldable (Foldable(..))-import Data.Monoid-import Data.Traversable (Traversable(..))-+import           Control.Applicative import           Data.ByteString (ByteString)+import           Data.Foldable (Foldable(..)) import           Data.Functor.Contravariant (Contravariant(..)) import           Data.Map.Strict (Map) import qualified Data.Map.Strict as M+import           Data.Monoid import           Data.Serialize (Serialize(..)) import           Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as T+import           Data.Traversable (Traversable(..)) import           Data.Void (absurd) import           Network.URI (escapeURIChar, isUnreserved) @@ -50,11 +63,10 @@  -- | Lastfm API request data type ----- @a@ is authentication state. Might be 'Send' which indicates--- that you may send this request already or 'Sign', when request signature--- isn't computed yet+-- @a@ is the authentication state. Can be 'Ready', which means this 'Request' is+-- ready to be sent, or 'Sign', if the request signature hasn't been computed yet ----- @f@ is response format. liblastfm currently supports 'JSON' or 'XML'+-- @f@ is the response format (liblastfm supports both 'JSON' and 'XML') newtype Request f a = Request { unRequest :: Const (Dual (Endo (R f))) a }  instance Functor (Request f) where@@ -130,16 +142,16 @@   put r = do     put $ T.encodeUtf8 (_host r)     put $ _method r-    put $ mapmap T.encodeUtf8 T.encodeUtf8 (_query r)+    put $ bimap T.encodeUtf8 T.encodeUtf8 (_query r)   get = do     h <- T.decodeUtf8 <$> get     m <- get-    q <- mapmap T.decodeUtf8 T.decodeUtf8 <$> get+    q <- bimap T.decodeUtf8 T.decodeUtf8 <$> get     return R { _host = h, _method = m, _query = q } -mapmap :: (Ord s, Ord t) => (s -> t) -> (a -> b) -> Map s a -> Map t b-mapmap f g = M.mapKeys f . M.map g-{-# INLINE mapmap #-}+bimap :: (Ord s, Ord t) => (s -> t) -> (a -> b) -> Map s a -> Map t b+bimap f g = M.mapKeys f . M.map g+{-# INLINE bimap #-}   -- | 'Request' '_host'
src/Network/Lastfm/Response.hs view
@@ -1,136 +1,231 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE DeriveDataTypeable #-}+{-# LANGUAGE FunctionalDependencies #-}+{-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE TypeFamilies #-} -- | Request sending and Response parsing module Network.Lastfm.Response-  ( -- * Sign Request+  ( -- * Compute request signature     -- $sign-    Secret(..), sign-    -- * Get 'Response'-  , Response, Supported, lastfm, lastfm_+    Secret(..)+  , sign+    -- * Get response+  , Supported+  , Format(..)+  , lastfm+  , lastfm_+    -- ** Errors+  , LastfmError(..)+  , _LastfmBadResponse+  , _LastfmEncodedError+  , _LastfmHttpError     -- ** Internal-  , lastfm', finalize+  , lastfmWith+  , finalize+#ifdef TEST+  , parse+  , md5+#endif   ) where -import Control.Applicative-import Control.Exception (throw)-import Control.Monad-import Data.Monoid-import Data.String (IsString(..))-import Data.Proxy (Proxy(..))-+import           Control.Applicative+import           Control.Exception (SomeException(..), Exception(..), catch) import           Crypto.Classes (hash')-import           Data.Aeson ((.:), Value, decode, parseJSON)+import           Data.Aeson ((.:), Value(..), decode) import           Data.Aeson.Types (parseMaybe) import qualified Data.ByteString.Lazy as Lazy import qualified Data.ByteString as Strict import           Data.Digest.Pure.MD5 (MD5Digest) import           Data.Map (Map) import qualified Data.Map as M+import           Data.Monoid+import           Data.Profunctor (Choice, dimap, right')+import           Data.String (IsString(..)) import           Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as T-import qualified Network.HTTP.Conduit as C-import qualified Network.HTTP.Types as C+import qualified Data.Text.Read as T+import           Data.Typeable (Typeable, cast)+import qualified Network.HTTP.Client as N+import qualified Network.HTTP.Client.TLS as N+import           Text.XML (Document, parseLBS, def)+import           Text.XML.Cursor -import Network.Lastfm.Internal+import           Network.Lastfm.Internal   -- $sign ----- Signing is important part of every--- authentication requiring API request.--- Basically, every such request is appended--- with md5 footprint of its arguments as+-- The signature is required for every+-- authenticated API request. Basically,+-- every such request appends the md5 footprint+-- of its arguments to the query as -- described at <http://www.last.fm/api/authspec#8>  --- | 'Supported' provides parsing for a chosen 'Format'+-- | 'Supported' provides parsing for the chosen 'Format' ----- 'JSON' is parsed to aeson's 'Value', 'XML' is to lazy 'ByteString'--- (in other words, parsing XML is left to the user)-class Supported (f :: Format) where-  type Response f-  parse :: proxy f -> Lazy.ByteString -> C.ResponseHeaders -> Response f-  base :: R f+-- 'JSON' is parsed to 'Value' type from aeson, while 'XML'+-- is parsed to 'Document' from xml-conduit+class Supported f r | f -> r, r -> f where+  prepareRequest :: R f -> R f+  parseResponseBody :: Lazy.ByteString -> Maybe r+  parseResponseEncodedError :: r -> Maybe LastfmError +instance Supported JSON Value where+  prepareRequest r = r { _query = M.singleton "format" "json" `M.union` _query r }+  parseResponseBody = decode+  parseResponseEncodedError = parseMaybe $ \(Object o) -> do+    code <- o .: "error"+    msg  <- o .: "message"+    return (LastfmEncodedError code msg) -instance Supported JSON where-  type Response JSON = Maybe Value-  parse _ b hs = do-    v <- decode b-    case parseMaybe ((.: "error") <=< parseJSON) v :: Maybe Int of-      Just _ ->-        throw (C.StatusCodeException C.status400 (("Response", Strict.concat $ Lazy.toChunks b) : hs) (C.createCookieJar []))-      _ -> return v-  base = R-    { _host = "https://ws.audioscrobbler.com/2.0/"-    , _method = "GET"-    , _query = M.fromList [("format", "json")]-    }-  {-# INLINE base #-}+instance Supported XML Document where+  prepareRequest = id+  parseResponseBody = either (const Nothing) Just . parseLBS def+  parseResponseEncodedError doc = case fromDocument doc of+    cur+      | [mcode]         <- cur $| element "lfm" >=> child >=> element "error" >=> attribute "code"+      , Right (code, _) <- T.decimal mcode+      , [msg]           <- cur $| element "lfm" >=> child >=> element "error" >=> child >=> content+      -> Just (LastfmEncodedError code (T.strip msg))+      |+      otherwise -> Nothing -instance Supported XML where-  type Response XML = Lazy.ByteString-  parse _ b _ = b-  {-# INLINE parse #-}-  base = R-    { _host = "https://ws.audioscrobbler.com/2.0/"-    , _method = "GET"-    , _query = mempty-    }-  {-# INLINE base #-}+parse :: Supported f r => Lazy.ByteString -> Either LastfmError r+parse body = case parseResponseBody body of+  Just v+    | Just e <- parseResponseEncodedError v -> Left e+    | otherwise -> Right v+  Nothing -> Left (LastfmBadResponse body) +base :: R f+base = R+  { _host   = "https://ws.audioscrobbler.com/2.0/"+  , _method = "GET"+  , _query  = mempty+  } ++-- | Different ways last.fm response can be unusable+data LastfmError =+    -- | last.fm thinks it responded with something legible, but it really isn't+    LastfmBadResponse Lazy.ByteString+    -- | last.fm error code and message string+  | LastfmEncodedError Int Text+    -- | wrapped http-conduit exception+  | LastfmHttpError N.HttpException+    deriving (Show, Typeable)++-- | Admittedly, this isn't the best 'Eq' instance ever+-- but not having 'Eq' 'C.HttpException' does not leave much a choice+instance Eq LastfmError where+  LastfmBadResponse bs      == LastfmBadResponse bs'      = bs == bs'+  LastfmEncodedError e s    == LastfmEncodedError e' t    = e == e' && s == t+  LastfmHttpError _         == LastfmHttpError _          = True+  _                         == _                          = False++instance Exception LastfmError where+  fromException e@(SomeException se)+    | Just e' <- fromException e = Just (LastfmHttpError e')+    | otherwise                  = cast se++class AsLastfmError t where+  _LastfmError :: (Choice p, Applicative m) => p LastfmError (m LastfmError) -> p t (m t)++instance AsLastfmError LastfmError where+  _LastfmError = id+  {-# INLINE _LastfmError #-}++instance AsLastfmError SomeException where+  _LastfmError = dimap (\e -> maybe (Left e) Right (fromException e)) (either pure (fmap toException)) . right'+  {-# INLINE _LastfmError #-}++-- | This is a @ Prism' 'LastfmError' 'Lazy.ByteString' @ in disguise+_LastfmBadResponse+  :: (Choice p, Applicative m, AsLastfmError e)+  => p Lazy.ByteString (m Lazy.ByteString) -> p e (m e)+_LastfmBadResponse = _LastfmError . dimap go (either pure (fmap LastfmBadResponse)) . right' where+  go (LastfmBadResponse bs) = Right bs+  go x                      = Left x+  {-# INLINE go #-}+{-# INLINE _LastfmBadResponse #-}++-- | This is a @ Prism' 'LastfmError' ('Int', 'String') @ in disguise+_LastfmEncodedError+  :: (Choice p, Applicative m, AsLastfmError e)+  => p (Int, Text) (m (Int, Text)) -> p e (m e)+_LastfmEncodedError = _LastfmError . dimap go (either pure (fmap (uncurry LastfmEncodedError))) . right' where+  go (LastfmEncodedError n v) = Right (n, v)+  go x                        = Left x+  {-# INLINE go #-}+{-# INLINE _LastfmEncodedError #-}++-- | This is a @ Prism' 'LastfmError' 'C.HttpException' @ in disguise+_LastfmHttpError+  :: (Choice p, Applicative m, AsLastfmError e)+  => p N.HttpException (m N.HttpException) -> p e (m e)+_LastfmHttpError = _LastfmError . dimap go (either pure (fmap LastfmHttpError)) . right' where+  go (LastfmHttpError e) = Right e+  go x                   = Left x+  {-# INLINE go #-}+{-# INLINE _LastfmHttpError #-}++ -- | Application secret-newtype Secret = Secret Text deriving (Show)+newtype Secret = Secret Text deriving (Show, Eq, Typeable)  instance IsString Secret where   fromString = Secret . fromString --- | Sign 'Request' with 'Secret'+-- | Sign the 'Request' with the 'Secret' so it's ready to be sent sign :: Secret -> Request f Sign -> Request f Ready sign s = coerce . (<* signature)  where   signature = wrap $-    \r@R { _query = q } -> r { _query = api_sig s . authToken $ q }+    \r@R { _query = q } -> r { _query = apiSig s . authToken $ q }  authToken :: Map Text Text -> Map Text Text authToken q = maybe q (M.delete "password") $ do   password <- M.lookup "password" q   username <- M.lookup "username" q-  return (M.insert "authToken" (md5 (username <> (md5 password))) q)+  return (M.insert "authToken" (md5 (username <> md5 password)) q) -api_sig :: Secret -> Map Text Text -> Map Text Text-api_sig (Secret s) q = M.insert "api_sig" (signer (foldr M.delete q ["format", "callback"])) q+apiSig :: Secret -> Map Text Text -> Map Text Text+apiSig (Secret s) q = M.insert "api_sig" (signer (foldr M.delete q ["format", "callback"])) q  where-  signer = md5 . M.foldrWithKey(\k v xs -> k <> v <> xs) s+  signer = md5 . M.foldrWithKey (\k v xs -> k <> v <> xs) s +-- | Get supplied string md5 hash hex representation md5 :: Text -> Text md5 = T.pack . show . (hash' :: Strict.ByteString -> MD5Digest) . T.encodeUtf8  --- | Send 'Request' and parse the 'Response'-lastfm :: Supported f => Request f Ready -> IO (Response f)-lastfm = lastfm' parse . finalize---- | Send 'Request' without parsing the 'Response'-lastfm_ :: Supported f => Request f Ready -> IO ()-lastfm_ = lastfm' (\_ _ _ -> ()) . finalize----- | Get 'R' from 'Request'-finalize :: Supported f => Request f Ready -> R f-finalize = ($ base) . unwrap+-- | Send the 'Request' and parse the 'Response'+lastfm :: Supported f r => Request f Ready -> IO (Either LastfmError r)+lastfm = lastfmWith parse . finalize +-- | Send the 'Request' without parsing the 'Response'+lastfm_ :: Supported f r => Request f Ready -> IO (Either LastfmError ())+lastfm_ = lastfmWith (\_ -> Right ()) . finalize --- | Send 'R' and parse 'Response' with the supplied function-lastfm' :: Supported f => (Proxy f -> Lazy.ByteString -> C.ResponseHeaders -> a) -> R f -> IO a-lastfm' f request = C.withManager $ \manager -> do-  req <- C.parseUrl (render request)+-- | Send the 'R' and parse the 'Response' with the supplied parser+lastfmWith+  :: Supported f r+  => (Lazy.ByteString -> Either LastfmError a)+  -> R f+  -> IO (Either LastfmError a)+lastfmWith p r = N.withManager N.tlsManagerSettings $ \manager -> do+  req <- N.parseUrl (render r)   let req' = req-       { C.method          = _method request-       , C.responseTimeout = Just 10000000+       { N.method          = _method r+       , N.responseTimeout = Just 10000000        }-  res <- C.httpLbs req' manager-  return $ f Proxy (C.responseBody res) (C.responseHeaders res)+  p . N.responseBody <$> N.httpLbs req' manager+ `catch`+  (return . Left)++-- | Get the 'R' from the 'Request'+finalize :: Supported f r => Request f Ready -> R f+finalize x = (prepareRequest . unwrap x) base
+ test/api/Json/AlbumSpec.hs view
@@ -0,0 +1,107 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Json.AlbumSpec (spec) where++import Data.Aeson (Value)+import Data.Aeson.Lens+import Data.Text (Text)+import Network.Lastfm+import Network.Lastfm.Album+import Test.Hspec++import SpecHelper+++spec :: Spec+spec = do+  it "addTags" $+    shouldHaveJson_ . privately $+      addTags <*> artist "Pink Floyd" <*> album "The Wall" <*> tags ["70s", "awesome", "classic"]++  it "getTags-authenticated" $+    privately (getTags <*> artist "Pink Floyd" <*> album "The Wall")+   `shouldHaveJson`+    key "tags".key "tag".values.key "name"._String++  it "removeTag" $+    shouldHaveJson_ .  privately $+      removeTag <*> artist "Pink Floyd" <*> album "The Wall" <*> tag "awesome"++  it "share" $+    shouldHaveJson_ .  privately $+      share <*> album "Jerusalem" <*> artist "Sleep" <*> recipient "liblastfm" <* message "Just listen!"++  describe "getBuyLinks*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "affiliations".key "physicals".key "affiliation".values.key "supplierName"._String++    it "getBuyLinks" $+      publicly (getBuyLinks <*> country "United Kingdom" <*> artist "Pink Floyd" <*> album "The Wall")+     `shouldHaveJson`+      jsonQuery++    it "getBuyLinks_mbid" $+      publicly (getBuyLinks <*> country "United Kingdom" <*> mbid "816abcd3-924a-3565-92b9-7ab750688f34")+     `shouldHaveJson`+      jsonQuery++  describe "getInfo*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "album".key "toptags".key "tag".values.key "name"._String++    it "getInfo" $+      publicly (getInfo <*> artist "Pink Floyd" <*> album "The Wall")+     `shouldHaveJson`+      jsonQuery++    it "getInfo_mbid" $+      publicly (getInfo <*> mbid "816abcd3-924a-3565-92b9-7ab750688f34")+     `shouldHaveJson`+      jsonQuery++  describe "getShouts*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "shouts".key "shout".values.key "body"._String++    it "getShouts" $+      publicly (getShouts <*> artist "Pink Floyd" <*> album "The Wall" <* limit 7)+     `shouldHaveJson`+      jsonQuery++    it "getShouts_mbid" $+      publicly (getShouts <*> mbid "816abcd3-924a-3565-92b9-7ab750688f34" <* limit 7)+     `shouldHaveJson`+      jsonQuery++  describe "getTags*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "tags".key "tag".values.key "name"._String++    it "getTags" $+      publicly (getTags <*> artist "Pink Floyd" <*> album "The Wall" <* user "liblastfm")+     `shouldHaveJson`+      jsonQuery++    it "getTags_mbid" $+      publicly (getTags <*> mbid "816abcd3-924a-3565-92b9-7ab750688f34" <* user "liblastfm")+     `shouldHaveJson`+      jsonQuery++  describe "getTopTags*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "toptags".key "tag".values.key "count"._String++    it "getTopTags" $+      publicly (getTopTags <*> artist "Pink Floyd" <*> album "The Wall")+     `shouldHaveJson`+      jsonQuery++    it "getTopTags_mbid" $+      publicly (getTopTags <*> mbid "816abcd3-924a-3565-92b9-7ab750688f34")+     `shouldHaveJson`+      jsonQuery++  it "search" $+    publicly (search <*> album "wall" <* limit 5)+   `shouldHaveJson`+    key "results".key "albummatches".key "album".values.key "name"._String
+ test/api/Json/ArtistSpec.hs view
@@ -0,0 +1,196 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Json.ArtistSpec (spec) where++import Data.Aeson (Value)+import Data.Aeson.Lens+import Data.Text (Text)+import Network.Lastfm+import Network.Lastfm.Artist+import Test.Hspec++import SpecHelper+++spec :: Spec+spec = do+  it "addTags" $+    shouldHaveJson_ . privately $+      addTags <*> artist "Егор Летов" <*> tags ["russian", "black metal"]++  it "getTags-authenticated" $+    privately (getTags <*> artist "Егор Летов")+   `shouldHaveJson`+    key "tags".key "tag".values.key "name"._String++  it "removeTag" $+    shouldHaveJson_ . privately $+      removeTag <*> artist "Егор Летов" <*> tag "russian"++  it "share" $+    shouldHaveJson_ . privately $+      share <*> artist "Sleep" <*> recipient "liblastfm" <* message "Just listen!"++  it "getCorrection" $+    publicly (getCorrection <*> artist "Meshugah")+   `shouldHaveJson`+    key "corrections".key "correction".key "artist".key "name"._String++  describe "getEvents*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "events".key "artist"._String++    it "getEvents" $+      publicly (getEvents <*> artist "Meshuggah" <* limit 2)+     `shouldHaveJson`+      jsonQuery++    it "getEvents_mbid" $+      publicly (getEvents <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413" <* limit 2)+     `shouldHaveJson`+      jsonQuery++  describe "getInfo*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "artist".key "stats".key "listeners"._String++    it "getInfo" $+      publicly (getInfo <*> artist "Meshuggah")+     `shouldHaveJson`+      jsonQuery++    it "getInfo_mbid" $+      publicly (getInfo <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413")+     `shouldHaveJson`+      jsonQuery++  describe "getPastEvents*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "events".key "event".values.key "title"._String++    it "getPastEvents" $+      publicly (getPastEvents <*> artist "Meshuggah" <* autocorrect True)+     `shouldHaveJson`+      jsonQuery++    it "getPastEvents_mbid" $+      publicly (getPastEvents <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413" <* autocorrect True)+     `shouldHaveJson`+      jsonQuery++  describe "getPodcast*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "rss".key "channel".key "description"._String++    it "getPodcast" $+      publicly (getPodcast <*> artist "Meshuggah")+     `shouldHaveJson`+      jsonQuery++    it "getPodcast_mbid" $+      publicly (getPodcast <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413")+     `shouldHaveJson`+      jsonQuery++  describe "getShouts*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "shouts".key "shout".values.key "author"._String++    it "getShouts" $+      publicly (getShouts <*> artist "Meshuggah" <* limit 5)+     `shouldHaveJson`+      jsonQuery++    it "getShouts_mbid" $+      publicly (getShouts <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413" <* limit 5)+     `shouldHaveJson`+      jsonQuery++  describe "getSimilar*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "similarartists".key "artist".values.key "name"._String++    it "getSimilar" $+      publicly (getSimilar <*> artist "Meshuggah" <* limit 3)+     `shouldHaveJson`+      jsonQuery++    it "getSimilar_mbid" $+      publicly (getSimilar <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413" <* limit 3)+     `shouldHaveJson`+      jsonQuery++  describe "getTags*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "tags".key "tag".values.key "name"._String++    it "getTags" $+      publicly (getTags <*> artist "Егор Летов" <* user "liblastfm")+     `shouldHaveJson`+      jsonQuery++    it "getTags_mbid" $+      publicly (getTags <*> mbid "cfb3d32e-d095-4d63-946d-9daf06932180" <* user "liblastfm")+     `shouldHaveJson`+      jsonQuery++  describe "getTopAlbums*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "topalbums".key "album".values.key "name"._String++    it "getTopAlbums" $+      publicly (getTopAlbums <*> artist "Meshuggah" <* limit 3)+     `shouldHaveJson`+      jsonQuery++    it "getTopAlbums_mbid" $+      publicly (getTopAlbums <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413" <* limit 3)+     `shouldHaveJson`+      jsonQuery++  describe "getTopFans*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "topfans".key "user".values.key "name"._String++    it "getTopFans" $+      publicly (getTopFans <*> artist "Meshuggah")+     `shouldHaveJson`+      jsonQuery++    it "getTopFans_mbid" $+      publicly (getTopFans <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413")+     `shouldHaveJson`+      jsonQuery++  describe "getTopTags*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "toptags".key "tag".values.key "name"._String++    it "getTopTags" $+      publicly (getTopTags <*> artist "Meshuggah")+     `shouldHaveJson`+      jsonQuery++    it "getTopTags_mbid" $+      publicly (getTopTags <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413")+     `shouldHaveJson`+      jsonQuery++  describe "getTopTracks*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "toptracks".key "track".values.key "name"._String++    it "getTopTracks" $+      publicly (getTopTracks <*> artist "Meshuggah" <* limit 3)+     `shouldHaveJson`+      jsonQuery++    it "getTopTracks_mbid" $+      publicly (getTopTracks <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413" <* limit 3)+     `shouldHaveJson`+      jsonQuery++  it "search" $+    publicly (search <*> artist "Mesh" <* limit 3)+   `shouldHaveJson`+    key "results".key "artistmatches".key "artist".values.key "name"._String
+ test/api/Json/ChartSpec.hs view
@@ -0,0 +1,53 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Json.ChartSpec (spec) where++import Data.Aeson (Value)+import Data.Aeson.Lens+import Data.Text (Text)+import Network.Lastfm+import Network.Lastfm.Chart+import Test.Hspec++import SpecHelper+++spec :: Spec+spec = do+  describe "get*Artists" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "artists".key "artist".values.key "name"._String++    it "getHypedArtists" $+      publicly (getHypedArtists <* limit 3)+     `shouldHaveJson`+      jsonQuery++    it "getTopArtists" $+      publicly (getTopArtists <* limit 4)+     `shouldHaveJson`+      jsonQuery++  describe "get*Tracks" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "tracks".key "track".values.key "name"._String++    it "getHypedTracks" $+      publicly (getHypedTracks <* limit 2)+     `shouldHaveJson`+      jsonQuery++    it "getLovedTracks" $+      publicly (getLovedTracks <* limit 3)+     `shouldHaveJson`+      jsonQuery++    it "getTopTracks" $+      publicly (getTopTracks <* limit 2)+     `shouldHaveJson`+      jsonQuery++  it "getTopTags" $+    publicly (getTopTags <* limit 5)+   `shouldHaveJson`+    key "tags".key "tag".values.key "name"._String
+ test/api/Json/EventSpec.hs view
@@ -0,0 +1,36 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Json.EventSpec (spec) where++import Data.Aeson.Lens+import Network.Lastfm+import Network.Lastfm.Event+import Test.Hspec++import SpecHelper+++spec :: Spec+spec = do+  it "attend" $+    shouldHaveJson_ . privately $+      attend <*> event 3142549 <*> status Attending++  it "share" $+    shouldHaveJson_ . privately $+      share <*> event 3142549 <*> recipient "liblastfm" <* message "Just listen!"++  it "getAttendees" $+    publicly (getAttendees <*> event 3142549 <* limit 2)+   `shouldHaveJson`+    key "attendees".key "user".values.key "name"._String++  it "getInfo" $+    publicly (getInfo <*> event 3142549)+   `shouldHaveJson`+    key "event".key "venue".key "location".key "city"._String++  it "getShouts" $+    publicly (getShouts <*> event 3142549 <* limit 1)+   `shouldHaveJson`+    key "shouts".key "shout".key "body"._String
+ test/api/Json/GeoSpec.hs view
@@ -0,0 +1,78 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Json.GeoSpec (spec) where++import Data.Aeson (Value)+import Data.Aeson.Lens+import Data.Text (Text)+import Network.Lastfm+import Network.Lastfm.Geo+import Test.Hspec++import SpecHelper+++spec :: Spec+spec = do+  it "getEvents" $+    publicly (getEvents <* location "Moscow" <* limit 5)+   `shouldHaveJson`+    key "events".key "event".values.key "id"._String++  describe "get*Artist*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "topartists".key "artist".values.key "name"._String++    it "getMetroArtistChart" $+      publicly (getMetroArtistChart <*> metro "Saint Petersburg" <*> country "Russia")+     `shouldHaveJson`+      jsonQuery++    it "getMetroHypeArtistChart" $+      publicly (getMetroHypeArtistChart <*> metro "New York" <*> country "United States")+     `shouldHaveJson`+      jsonQuery++    it "getMetroUniqueArtistChart" $+      publicly (getMetroUniqueArtistChart <*> metro "Minsk" <*> country "Belarus")+     `shouldHaveJson`+      jsonQuery++    it "getTopArtists" $+      publicly (getTopArtists <*> country "Belarus" <* limit 3)+     `shouldHaveJson`+      jsonQuery++  it "getMetroWeeklyChartlist" $+    publicly (getMetroWeeklyChartlist <*> metro "Moscow")+   `shouldHaveJson`+    key "weeklychartlist".key "chart".values.key "from"._String++  it "getMetros" $+    publicly (getMetros <* country "Russia")+   `shouldHaveJson`+    key "metros".key "metro".values.key "name"._String++  describe "get*Track*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "toptracks".key "track".values.key "name"._String++    it "getTopTracks" $+      publicly (getTopTracks <*> country "Ukraine" <* limit 2)+     `shouldHaveJson`+      jsonQuery++    it "getMetroUniqueTrackChart" $+      publicly (getMetroUniqueTrackChart <*> metro "Moscow" <*> country "Russia")+     `shouldHaveJson`+      jsonQuery++    it "getMetroHypeTrackChart" $+      publicly (getMetroHypeTrackChart <*> metro "Moscow" <*> country "Russia")+     `shouldHaveJson`+      jsonQuery++    it "getMetroTrackChart" $+      publicly (getMetroTrackChart <*> metro "Boston" <*> country "United States")+     `shouldHaveJson`+      jsonQuery
+ test/api/Json/GroupSpec.hs view
@@ -0,0 +1,47 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Json.GroupSpec (spec) where++import Data.Aeson.Lens+import Network.Lastfm+import Network.Lastfm.Group+import Test.Hspec++import SpecHelper+++spec :: Spec+spec = do+  it "getHype" $+    publicly (getHype <*> groupname)+   `shouldHaveJson`+    key "weeklyartistchart".key "artist".values.key "mbid"._String++  it "getMembers" $+    publicly (getMembers <*> groupname <* limit 10)+   `shouldHaveJson`+    key "members".key "user".values.key "name"._String++  it "getWeeklyAlbumChart" $+    publicly (getWeeklyAlbumChart <*> groupname)+   `shouldHaveJson`+    key "weeklyalbumchart".key "album".values.key "playcount"._String++  it "getWeeklyArtistChart" $+    publicly (getWeeklyArtistChart <*> groupname)+   `shouldHaveJson`+    key "weeklyartistchart".key "artist".values.key "name"._String++  it "getWeeklyChartList" $+    publicly (getWeeklyChartList <*> groupname)+   `shouldHaveJson`+    key "weeklychartlist".key "chart".values.key "from"._String++  it "getWeeklyTrackChart" $+    publicly (getWeeklyTrackChart <*> groupname)+   `shouldHaveJson`+    key "weeklytrackchart".key "track".values.key "url"._String++groupname :: Request f Group+groupname = group+  "People with no social lives that listen to more music than is healthy who are slightly scared of spiders and can never seem to find a pen"
+ test/api/Json/LibrarySpec.hs view
@@ -0,0 +1,56 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Json.LibrarySpec (spec) where++import Data.Aeson.Lens+import Network.Lastfm+import Network.Lastfm.Library+import Test.Hspec++import SpecHelper+++spec :: Spec+spec = do+  it "addAlbum" $+    shouldHaveJson_ . privately $+      addAlbum (pure (albumItem <*> artist "Franz Ferdinand" <*> album "Franz Ferdinand"))++  it "addArtist" $+    shouldHaveJson_ . privately $+      addArtist (pure (artistItem <*> artist "Mobthrow"))++  it "addTrack" $+    shouldHaveJson_ . privately $+      addTrack <*> artist "Eminem" <*> track "Kim"++  it "removeAlbum" $+    shouldHaveJson_ . privately $+      removeAlbum <*> artist "Franz Ferdinand" <*> album "Franz Ferdinand"++  it "removeArtist" $+    shouldHaveJson_ . privately $+      removeArtist <*> artist "Burzum"++  it "removeTrack" $+    shouldHaveJson_ . privately $+      removeTrack <*> artist "Eminem" <*> track "Kim"++  it "removeScrobble" $+    shouldHaveJson_ . privately $+      removeScrobble <*> artist "Gojira" <*> track "Ocean" <*> timestamp 1328905590++  it "getAlbums" $+    publicly (getAlbums <*> user "smpcln" <* artist "Burzum" <* limit 5)+   `shouldHaveJson`+    key "albums".key "album".values.key "name"._String++  it "getArtists" $+    publicly (getArtists <*> user "smpcln" <* limit 7)+   `shouldHaveJson`+    key "artists".key "artist".values.key "name"._String++  it "getTracks" $+    publicly (getTracks <*> user "smpcln" <* artist "Burzum" <* limit 4)+   `shouldHaveJson`+    key "tracks".key "track".values.key "name"._String
+ test/api/Json/PlaylistSpec.hs view
@@ -0,0 +1,43 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Json.PlaylistSpec (spec) where++import Control.Lens+import Data.Aeson (Value)+import Data.Aeson.Lens+import Data.Int (Int64)+import Data.Text.Lens (unpacked)+import Network.Lastfm hiding (to)+import Network.Lastfm.Playlist+import Network.Lastfm.User+import Test.Hspec+import Test.HUnit (assertFailure)+import Text.Printf+import Text.Read (readMaybe)++import SpecHelper+++spec :: Spec+spec = do+  it "create" $ -- Order matters.+    privately (create <* title "Awesome playlist")+   `shouldHaveJson`+    key "playlists".key "playlist".key "title"._String++  it "addTrack" $ do+    r <- lastfm $ getPlaylists <*> user "liblastfm" <*> ak' <* json+    case r of+      Left e ->+        assertFailure (printf "last.fm error: %s" (show e))+      Right val ->+        case preview pl val of+          Just (Just pid) -> shouldHaveJson_ . privately $+            addTrack <*> playlist pid <*> artist "Ruby my dear" <*> track "Chazz"+          _ -> assertFailure (printf "bad JSON object: %s" (show val))++ak' :: Request f APIKey+ak' = apiKey "29effec263316a1f8a97f753caaa83e0"++pl :: Fold Value (Maybe Int64)+pl = key "playlists".key "playlist".values.key "id"._String.unpacked.to readMaybe
+ test/api/Json/TagSpec.hs view
@@ -0,0 +1,58 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Json.TagSpec (spec) where++import Data.Aeson.Lens+import Network.Lastfm+import Network.Lastfm.Tag+import Test.Hspec++import SpecHelper+++spec :: Spec+spec = do+  it "getInfo" $+    publicly (getInfo <*> tag "depressive")+   `shouldHaveJson`+    key "tag".key "taggings"._String++  it "getSimilar" $+    publicly (getSimilar <*> tag "depressive")+   `shouldHaveJson`+    key "similartags".key "tag".values.key "name"._String++  it "getTopAlbums" $+    publicly (getTopAlbums <*> tag "depressive" <* limit 2)+   `shouldHaveJson`+    key "topalbums".key "album".values.key "url"._String++  it "getTopArtists" $+    publicly (getTopArtists <*> tag "depressive" <* limit 3)+   `shouldHaveJson`+    key "topartists".key "artist".values.key "url"._String++  it "getTopTags" $+    publicly getTopTags+   `shouldHaveJson`+    key "toptags".key "tag".values.key "name"._String++  it "getTopTracks" $+    publicly (getTopTracks <*> tag "depressive" <* limit 2)+   `shouldHaveJson`+    key "toptracks".key "track".values.key "url"._String++  it "getWeeklyArtistChart" $+    publicly (getWeeklyArtistChart <*> tag "depressive" <* limit 3)+   `shouldHaveJson`+    key "weeklyartistchart".key "artist".values.key "name"._String++  it "getWeeklyChartList" $+    publicly (getWeeklyChartList <*> tag "depressive")+   `shouldHaveJson`+    key "weeklychartlist".key "chart".values.key "from"._String++  it "search" $+    publicly (search <*> tag "depressive" <* limit 3)+   `shouldHaveJson`+    key "results".key "tagmatches".key "tag".values.key "name"._String
+ test/api/Json/TasteometerSpec.hs view
@@ -0,0 +1,38 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Json.TasteometerSpec (spec) where++import           Data.Aeson (Value)+import           Data.Aeson.Lens+import           Data.Text (Text)+import           Network.Lastfm+import qualified Network.Lastfm.Tasteometer as Taste+import           Test.Hspec++import           SpecHelper+++spec :: Spec+spec = do+  it "compare" $+    publicly (Taste.compare (user "smpcln") (user "MCDOOMDESTROYER"))+   `shouldHaveJson`+    jsonQuery++  it "compare" $+    publicly (Taste.compare (user "smpcln") (artists ["enduser", "venetian snares"]))+   `shouldHaveJson`+    jsonQuery++  it "compare" $+    publicly (Taste.compare (artists ["enduser", "venetian snares"]) (user "smpcln"))+   `shouldHaveJson`+    jsonQuery++  it "compare" $+    publicly (Taste.compare (artists ["enduser", "venetian snares"]) (artists ["enduser", "venetian snares"]))+   `shouldHaveJson`+    jsonQuery++jsonQuery :: Fold Value Text+jsonQuery = key "comparison".key "result".key "score"._String
+ test/api/Json/TrackSpec.hs view
@@ -0,0 +1,157 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Json.TrackSpec (spec) where++import Data.Aeson (Value)+import Data.Aeson.Lens+import Data.Text (Text)+import Network.Lastfm+import Network.Lastfm.Track+import Test.Hspec++import SpecHelper+++spec :: Spec+spec = do+  it "addTags" $+    shouldHaveJson_ . privately $+      addTags <*> artist "Jefferson Airplane" <*> track "White rabbit" <*> tags ["60s", "awesome"]++  it "ban" $+    shouldHaveJson_ . privately $+      ban <*> artist "Eminem" <*> track "Kim"++  it "love" $+    shouldHaveJson_ . privately $+      love <*> artist "Gojira" <*> track "Ocean"++  it "removeTag" $+    shouldHaveJson_ . privately $+      removeTag <*> artist "Jefferson Airplane" <*> track "White rabbit" <*> tag "awesome"++  it "share" $+    shouldHaveJson_ . privately $+      share <*> artist "Led Zeppelin" <*> track "When the Levee Breaks" <*> recipient "liblastfm" <* message "Just listen!"++  it "unban" $+    shouldHaveJson_ . privately $+      unban <*> artist "Eminem" <*> track "Kim"++  it "unlove" $+    shouldHaveJson_ . privately $+      unlove <*> artist "Gojira" <*> track "Ocean"++  it "scrobble" $+    privately (scrobble (pure (item <*> artist "Gojira" <*> track "Ocean" <*> timestamp 1300000000)))+   `shouldHaveJson`+    key "scrobbles".key "scrobble".key "track".key "#text"._String++  it "updateNowPlaying" $+    privately (updateNowPlaying <*> artist "Gojira" <*> track "Ocean")+   `shouldHaveJson`+    key "nowplaying".key "track".key "#text"._String++  it "getBuylinks" $+    publicly (getBuyLinks <*> country "United Kingdom" <*> artist "Pink Floyd" <*> track "Brain Damage")+   `shouldHaveJson`+    key "affiliations".key "downloads".key "affiliation".values.key "supplierName"._String++  it "getCorrection" $+    publicly (getCorrection <*> artist "Pink Ployd" <*> track "Brain Damage")+   `shouldHaveJson`+    key "corrections".key "correction".key "track".key "artist".key "name"._String++  it "getFingerprintMetadata" $+    publicly (getFingerprintMetadata <*> fingerprint 1234)+   `shouldHaveJson`+    key "tracks".key "track".values.key "name"._String++  describe "getInfo*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "track".key "userplaycount"._String++    it "getInfo" $+      publicly (getInfo <*> artist "Pink Floyd" <*> track "Comfortably Numb" <* username "aswalrus")+     `shouldHaveJson`+      jsonQuery++    it "getInfo_mbid" $+      publicly (getInfo <*> mbid "52d7c9ff-6ae4-48a6-acec-4c1a486f8c92" <* username "aswalrus")+     `shouldHaveJson`+      jsonQuery++  describe "getShouts*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "shouts".key "shout".values.key "author"._String++    it "getShouts" $+      publicly (getShouts <*> artist "Pink Floyd" <*> track "Comfortably Numb" <* limit 7)+     `shouldHaveJson`+      jsonQuery++    it "getShouts_mbid" $+      publicly (getShouts <*> mbid "52d7c9ff-6ae4-48a6-acec-4c1a486f8c92" <* limit 7)+     `shouldHaveJson`+      jsonQuery++  describe "getSimilar*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "similartracks".key "track".values.key "name"._String++    it "getSimilar" $+      publicly (getSimilar <*> artist "Pink Floyd" <*> track "Comfortably Numb" <* limit 4)+     `shouldHaveJson`+      jsonQuery++    it "getSimilar_mbid" $+      publicly (getSimilar <*> mbid "52d7c9ff-6ae4-48a6-acec-4c1a486f8c92" <* limit 4)+     `shouldHaveJson`+      jsonQuery++  describe "getTags*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "tags".key "@attr".key "track"._String++    it "getTags" $+      publicly (getTags <*> artist "Jefferson Airplane" <*> track "White Rabbit" <* user "liblastfm")+     `shouldHaveJson`+      jsonQuery++    it "getTags_mbid" $+      publicly (getTags <*> mbid "001b3337-faf4-421a-a11f-45e0b60a7703"  <* user "liblastfm")+     `shouldHaveJson`+      jsonQuery++  describe "getTopFans*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "topfans".key "user".values.key "name"._String++    it "getTopFans" $+      publicly (getTopFans <*> artist "Pink Floyd" <*> track "Comfortably Numb")+     `shouldHaveJson`+      jsonQuery++    it "getTopFans_mbid" $+      publicly (getTopFans <*> mbid "52d7c9ff-6ae4-48a6-acec-4c1a486f8c92")+     `shouldHaveJson`+      jsonQuery++  describe "getTopTags*" $ do+    let jsonQuery :: Fold Value Text+        jsonQuery = key "toptags".key "tag".values.key "name"._String++    it "getTopTags" $+      publicly (getTopTags <*> artist "Pink Floyd" <*> track "Comfortably Numb")+     `shouldHaveJson`+      jsonQuery++    it "getTopTags_mbid" $+      publicly (getTopTags <*> mbid "52d7c9ff-6ae4-48a6-acec-4c1a486f8c92")+     `shouldHaveJson`+      jsonQuery++  it "search" $+    publicly (search <*> track "Believe" <* limit 12)+   `shouldHaveJson`+    key "results".key "trackmatches".key "track".values.key "name"._String
+ test/api/Json/UserSpec.hs view
@@ -0,0 +1,137 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Json.UserSpec (spec) where++import Data.Aeson.Lens+import Network.Lastfm+import Network.Lastfm.User+import Test.Hspec++import SpecHelper+++spec :: Spec+spec = do+  it "getRecentStations" $+    privately (getRecentStations <*> user "liblastfm" <* limit 10)+   `shouldHaveJson`+    key "recentstations".key "station".values.key "name"._String++  it "getRecommendedArtists" $+    privately (getRecommendedArtists <* limit 10)+   `shouldHaveJson`+    key "recommendations".key "artist".values.key "name"._String++  it "getRecommendedEvents" $+    privately (getRecommendedEvents <* limit 10)+   `shouldHaveJson`+    key "events".key "event".key "url"._String++  it "shout" $+    shouldHaveJson_ . privately $+      shout <*> user "liblastfm" <*> message "test message"++  it "getArtistTracks" $+    publicly (getArtistTracks <*> user "smpcln" <*> artist "Dvar")+   `shouldHaveJson`+    key "artisttracks".key "track".values.key "name"._String++  it "getBannedTracks" $+    publicly (getBannedTracks <*> user "smpcln" <* limit 10)+   `shouldHaveJson`+    key "bannedtracks".key "track".values.key "name"._String++  it "getEvents" $+    publicly (getEvents <*> user "chansonnier" <* limit 5)+   `shouldHaveJson`+    key "events".key "event".values.key "venue".key "url"._String++  it "getFriends" $+    publicly (getFriends <*> user "smpcln" <* limit 10)+   `shouldHaveJson`+    key "friends".key "user".values.key "name"._String++  it "getPlayCount" $+    publicly (getInfo <*> user "smpcln")+   `shouldHaveJson`+    key "user".key "playcount"._String++  it "getGetLovedTracks" $+    publicly (getLovedTracks <*> user "smpcln" <* limit 10)+   `shouldHaveJson`+    key "lovedtracks".key "track".values.key "name"._String++  it "getNeighbours" $+    publicly (getNeighbours <*> user "smpcln" <* limit 10)+   `shouldHaveJson`+    key "neighbours".key "user".values.key "name"._String++  it "getNewReleases" $+    publicly (getNewReleases <*> user "rj")+   `shouldHaveJson`+    key "albums".key "album".values.key "url"._String++  it "getPastEvents" $+    publicly (getPastEvents <*> user "mokele" <* limit 5)+   `shouldHaveJson`+    key "events".key "event".values.key "url"._String++  it "getPersonalTags" $+    publicly (getPersonalTags <*> user "crackedcore" <*> tag "rhythmic noise" <*> taggingType "artist" <* limit 10)+   `shouldHaveJson`+    key "taggings".key "artists".key "artist".values.key "name"._String++  it "getPlaylists" $+    publicly (getPlaylists <*> user "mokele")+   `shouldHaveJson`+    key "playlists".key "playlist".values.key "title"._String++  it "getRecentTracks" $+    publicly (getRecentTracks <*> user "smpcln" <* limit 10)+   `shouldHaveJson`+    key "recenttracks".key "track".values.key "name"._String++  it "getShouts" $+    publicly (getShouts <*> user "smpcln" <* limit 2)+   `shouldHaveJson`+    key "shouts".key "shout".values.key "body"._String++  it "getTopAlbums" $+    publicly (getTopAlbums <*> user "smpcln" <* limit 5)+   `shouldHaveJson`+    key "topalbums".key "album".values.key "artist".key "name"._String++  it "getTopArtists" $+    publicly (getTopArtists <*> user "smpcln" <* limit 5)+   `shouldHaveJson`+    key "topartists".key "artist".values.key "name"._String++  it "getTopTags" $+    publicly (getTopTags <*> user "smpcln" <* limit 10)+   `shouldHaveJson`+    key "toptags".key "tag".values.key "name"._String++  it "getTopTracks" $+    publicly (getTopTracks <*> user "smpcln" <* limit 10)+   `shouldHaveJson`+    key "toptracks".key "track".values.key "url"._String++  it "getWeeklyAlbumChart" $+    publicly (getWeeklyAlbumChart <*> user "smpcln")+   `shouldHaveJson`+    key "weeklyalbumchart".key "album".values.key "url"._String++  it "getWeeklyArtistChart" $+    publicly (getWeeklyArtistChart <*> user "smpcln")+   `shouldHaveJson`+    key "weeklyartistchart".key "artist".values.key "url"._String++  it "getWeeklyChartList" $+    publicly (getWeeklyChartList <*> user "smpcln")+   `shouldHaveJson`+    key "weeklychartlist".key "chart".values.key "from"._String++  it "getWeeklyTrackChart" $+    publicly (getWeeklyTrackChart <*> user "smpcln")+   `shouldHaveJson`+    key "weeklytrackchart".key "track".values.key "url"._String
+ test/api/Json/VenueSpec.hs view
@@ -0,0 +1,28 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Json.VenueSpec (spec) where++import Data.Aeson.Lens+import Network.Lastfm+import Network.Lastfm.Venue+import Test.Hspec++import SpecHelper+++spec :: Spec+spec = do+  it "getEvents" $+    publicly (getEvents <*> venue 9163107)+   `shouldHaveJson`+    key "events".key "event".values.key "venue".key "name"._String++  it "getPastEvents" $+    publicly (getPastEvents <*> venue 9163107 <* limit 2)+   `shouldHaveJson`+    key "events".key "event".values.key "title"._String++  it "search" $+    publicly (search <*> venueName "Arena")+   `shouldHaveJson`+    key "results".key "venuematches".key "venue".values.key "id"._String
+ test/api/Spec.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
+ test/api/SpecHelper.hs view
@@ -0,0 +1,98 @@+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE DeriveDataTypeable #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+module SpecHelper+  ( -- * Expectations+    Fold+  , shouldHaveResponse+  , shouldHaveJson+  , shouldHaveJson_+  , shouldHaveXml+  , shouldHaveXml_+    -- * public data+  , publicly+  , publicKey+    -- * private data+    -- $awful+  , privately+  , privateAPIKey+  , privateSessionKey+  , privateSecret+  ) where++import Control.Exception (Exception, throwIO)+import Control.Lens+import Data.Aeson (Value)+import Data.Aeson.Lens+import Data.Text (Text, pack)+import Data.Typeable (Typeable)+import Network.Lastfm+import System.Environment+import System.IO.Unsafe (unsafePerformIO)+import Test.Hspec+import Test.HUnit (assertFailure)+import Text.Printf+import Text.Xml.Lens++infixl 1 `shouldHaveJson`, `shouldHaveXml`+++-- | Inspect 'Response' with 'Query'+shouldHaveResponse :: (Show r, Supported f r) => Request f Ready -> Fold r a -> Expectation+shouldHaveResponse q l = do+  r <- lastfm q+  case preview (_Right.l) r of+    Just _  -> return ()+    Nothing -> assertFailure (printf "Query failed on %s" (show r))++shouldHaveJson :: Request JSON Ready -> Fold Value a -> Expectation+shouldHaveJson = shouldHaveResponse++shouldHaveXml :: Request XML Ready -> Fold Document a -> Expectation+shouldHaveXml = shouldHaveResponse++-- | Check success stuff for POST requests+shouldHaveJson_ :: Request JSON Ready -> Expectation+shouldHaveJson_ l = shouldHaveResponse l (key "status".only "ok")++shouldHaveXml_ :: Request XML Ready -> Expectation+shouldHaveXml_ l = shouldHaveResponse l (root.attributed (ix "status".only "ok"))++-- | Make a request using public API key+publicly :: Request f (APIKey -> Ready) -> Request f Ready+publicly r = r <*> publicKey++-- | API Key used for requests that do not require authentification+publicKey :: Request f APIKey+publicKey = apiKey "234fc6e0f41f6ef99b7bd62ebaf8d318"++-- | Environment is missing a variable+data EnvironmentMissing = EnvironmentMissing String deriving (Show, Eq, Typeable)++instance Exception EnvironmentMissing++-- $awful++-- | Make a request signed by a secret using private API and session keys+privately :: Request f (APIKey -> SessionKey -> Sign) -> Request f Ready+privately r = sign privateSecret $ r <*> privateAPIKey <*> privateSessionKey++privateAPIKey :: Request f APIKey+privateAPIKey = apiKey (liblastfmEnv "HASKELL_LIBLASTFM_APIKEY")++privateSessionKey :: Request f SessionKey+privateSessionKey = sessionKey (liblastfmEnv "HASKELL_LIBLASTFM_SESSIONKEY")++privateSecret :: Secret+privateSecret = Secret (liblastfmEnv "HASKELL_LIBLASTFM_SECRET")++-- | Get an environment variable or throw a 'EnvironmentMissing' exception+liblastfmEnv :: String -> Text+liblastfmEnv var = unsafePerformIO $ do+  mv <- lookupEnv var+  case mv of+    Just v  -> return (pack v)+    Nothing -> throwIO (EnvironmentMissing var)
+ test/api/Xml/AlbumSpec.hs view
@@ -0,0 +1,106 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Xml.AlbumSpec (spec) where++import Data.Text (Text)+import Network.Lastfm+import Network.Lastfm.Album+import Test.Hspec+import Text.Xml.Lens++import SpecHelper+++spec :: Spec+spec = do+  it "addTags" $+    shouldHaveXml_ . privately $+      addTags <*> artist "Pink Floyd" <*> album "The Wall" <*> tags ["70s", "awesome", "classic"]++  it "getTags-authenticated" $+    privately (getTags <*> artist "Pink Floyd" <*> album "The Wall")+   `shouldHaveXml`+    root.node "tags".node "tag".node "name".text++  it "removeTag" $+    shouldHaveXml_ .  privately $+      removeTag <*> artist "Pink Floyd" <*> album "The Wall" <*> tag "awesome"++  it "share" $+    shouldHaveXml_ .  privately $+      share <*> album "Jerusalem" <*> artist "Sleep" <*> recipient "liblastfm" <* message "Just listen!"++  describe "getBuyLinks*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "affiliations".node "physicals".node "affiliation".node "supplierName".text++    it "getBuyLinks" $+      publicly (getBuyLinks <*> country "United Kingdom" <*> artist "Pink Floyd" <*> album "The Wall")+     `shouldHaveXml`+      xmlQuery++    it "getBuyLinks_mbid" $+      publicly (getBuyLinks <*> country "United Kingdom" <*> mbid "816abcd3-924a-3565-92b9-7ab750688f34")+     `shouldHaveXml`+      xmlQuery++  describe "getInfo*" $ do+    let jsonQuery :: Fold Document Text+        jsonQuery = root.node "album".node "toptags".node "tag".node "name".text++    it "getInfo" $+      publicly (getInfo <*> artist "Pink Floyd" <*> album "The Wall")+     `shouldHaveXml`+      jsonQuery++    it "getInfo_mbid" $+      publicly (getInfo <*> mbid "816abcd3-924a-3565-92b9-7ab750688f34")+     `shouldHaveXml`+      jsonQuery++  describe "getShouts*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "shouts".node "shout".node "body".text++    it "getShouts" $+      publicly (getShouts <*> artist "Pink Floyd" <*> album "The Wall" <* limit 7)+     `shouldHaveXml`+      xmlQuery++    it "getShouts_mbid" $+      publicly (getShouts <*> mbid "816abcd3-924a-3565-92b9-7ab750688f34" <* limit 7)+     `shouldHaveXml`+      xmlQuery++  describe "getTags*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "tags".node "tag".node "name".text++    it "getTags" $+      publicly (getTags <*> artist "Pink Floyd" <*> album "The Wall" <* user "liblastfm")+     `shouldHaveXml`+      xmlQuery++    it "getTags_mbid" $+      publicly (getTags <*> mbid "816abcd3-924a-3565-92b9-7ab750688f34" <* user "liblastfm")+     `shouldHaveXml`+      xmlQuery++  describe "getTopTags*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "toptags".node "tag".node "count".text++    it "getTopTags" $+      publicly (getTopTags <*> artist "Pink Floyd" <*> album "The Wall")+     `shouldHaveXml`+      xmlQuery++    it "getTopTags_mbid" $+      publicly (getTopTags <*> mbid "816abcd3-924a-3565-92b9-7ab750688f34")+     `shouldHaveXml`+      xmlQuery++  it "search" $+    publicly (search <*> album "wall" <* limit 5)+   `shouldHaveXml`+    root.node "results".node "albummatches".node "album".node "name".text
+ test/api/Xml/ArtistSpec.hs view
@@ -0,0 +1,196 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Xml.ArtistSpec (spec) where++import Data.Text (Text)+import Data.Traversable (traverse)+import Network.Lastfm+import Network.Lastfm.Artist+import Test.Hspec+import Text.Xml.Lens++import SpecHelper+++spec :: Spec+spec = do+  it "addTags" $+    shouldHaveXml_ . privately $+      addTags <*> artist "Егор Летов" <*> tags ["russian", "black metal"]++  it "getTags-authenticated" $+    privately (getTags <*> artist "Егор Летов")+   `shouldHaveXml`+    root.node "tags".node "tag".node "name".text++  it "removeTag" $+    shouldHaveXml_ . privately $+      removeTag <*> artist "Егор Летов" <*> tag "russian"++  it "share" $+    shouldHaveXml_ . privately $+      share <*> artist "Sleep" <*> recipient "liblastfm" <* message "Just listen!"++  it "getCorrection" $+    publicly (getCorrection <*> artist "Meshugah")+   `shouldHaveXml`+    root.node "corrections".node "correction".node "artist".node "name".text++  describe "getEvents*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "events".attr "artist".traverse++    it "getEvents" $+      publicly (getEvents <*> artist "Meshuggah" <* limit 2)+     `shouldHaveXml`+      xmlQuery++    it "getEvents_mbid" $+      publicly (getEvents <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413" <* limit 2)+     `shouldHaveXml`+      xmlQuery++  describe "getInfo*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "artist".node "stats".node "listeners".text++    it "getInfo" $+      publicly (getInfo <*> artist "Meshuggah")+     `shouldHaveXml`+      xmlQuery++    it "getInfo_mbid" $+      publicly (getInfo <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413")+     `shouldHaveXml`+      xmlQuery++  describe "getPastEvents*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "events".node "event".node "title".text++    it "getPastEvents" $+      publicly (getPastEvents <*> artist "Meshuggah" <* autocorrect True)+     `shouldHaveXml`+      xmlQuery++    it "getPastEvents_mbid" $+      publicly (getPastEvents <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413" <* autocorrect True)+     `shouldHaveXml`+      xmlQuery++  describe "getPodcast*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "rss".node "channel".node "description".text++    it "getPodcast" $+      publicly (getPodcast <*> artist "Meshuggah")+     `shouldHaveXml`+      xmlQuery++    it "getPodcast_mbid" $+      publicly (getPodcast <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413")+     `shouldHaveXml`+      xmlQuery++  describe "getShouts*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "shouts".node "shout".node "author".text++    it "getShouts" $+      publicly (getShouts <*> artist "Meshuggah" <* limit 5)+     `shouldHaveXml`+      xmlQuery++    it "getShouts_mbid" $+      publicly (getShouts <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413" <* limit 5)+     `shouldHaveXml`+      xmlQuery++  describe "getSimilar*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "similarartists".node "artist".node "name".text++    it "getSimilar" $+      publicly (getSimilar <*> artist "Meshuggah" <* limit 3)+     `shouldHaveXml`+      xmlQuery++    it "getSimilar_mbid" $+      publicly (getSimilar <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413" <* limit 3)+     `shouldHaveXml`+      xmlQuery++  describe "getTags*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "tags".node "tag".node "name".text++    it "getTags" $+      publicly (getTags <*> artist "Егор Летов" <* user "liblastfm")+     `shouldHaveXml`+      xmlQuery++    it "getTags_mbid" $+      publicly (getTags <*> mbid "cfb3d32e-d095-4d63-946d-9daf06932180" <* user "liblastfm")+     `shouldHaveXml`+      xmlQuery++  describe "getTopAlbums*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "topalbums".node "album".node "name".text++    it "getTopAlbums" $+      publicly (getTopAlbums <*> artist "Meshuggah" <* limit 3)+     `shouldHaveXml`+      xmlQuery++    it "getTopAlbums_mbid" $+      publicly (getTopAlbums <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413" <* limit 3)+     `shouldHaveXml`+      xmlQuery++  describe "getTopFans*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "topfans".node "user".node "name".text++    it "getTopFans" $+      publicly (getTopFans <*> artist "Meshuggah")+     `shouldHaveXml`+      xmlQuery++    it "getTopFans_mbid" $+      publicly (getTopFans <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413")+     `shouldHaveXml`+      xmlQuery++  describe "getTopTags*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "toptags".node "tag".node "name".text++    it "getTopTags" $+      publicly (getTopTags <*> artist "Meshuggah")+     `shouldHaveXml`+      xmlQuery++    it "getTopTags_mbid" $+      publicly (getTopTags <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413")+     `shouldHaveXml`+      xmlQuery++  describe "getTopTracks*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "toptracks".node "track".node "name".text++    it "getTopTracks" $+      publicly (getTopTracks <*> artist "Meshuggah" <* limit 3)+     `shouldHaveXml`+      xmlQuery++    it "getTopTracks_mbid" $+      publicly (getTopTracks <*> mbid "cf8b3b8c-118e-4136-8d1d-c37091173413" <* limit 3)+     `shouldHaveXml`+      xmlQuery++  it "search" $+    publicly (search <*> artist "Mesh" <* limit 3)+   `shouldHaveXml`+    root.node "results".node "artistmatches".node "artist".node "name".text
+ test/api/Xml/ChartSpec.hs view
@@ -0,0 +1,52 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Xml.ChartSpec (spec) where++import Data.Text (Text)+import Network.Lastfm+import Network.Lastfm.Chart+import Test.Hspec+import Text.Xml.Lens++import SpecHelper+++spec :: Spec+spec = do+  describe "get*Artists" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "artists".node "artist".node "name".text++    it "getHypedArtists" $+      publicly (getHypedArtists <* limit 3)+     `shouldHaveXml`+      xmlQuery++    it "getTopArtists" $+      publicly (getTopArtists <* limit 4)+     `shouldHaveXml`+      xmlQuery++  describe "get*Tracks" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "tracks".node "track".node "name".text++    it "getHypedTracks" $+      publicly (getHypedTracks <* limit 2)+     `shouldHaveXml`+      xmlQuery++    it "getLovedTracks" $+      publicly (getLovedTracks <* limit 3)+     `shouldHaveXml`+      xmlQuery++    it "getTopTracks" $+      publicly (getTopTracks <* limit 2)+     `shouldHaveXml`+      xmlQuery++  it "getTopTags" $+    publicly (getTopTags <* limit 5)+   `shouldHaveXml`+    root.node "tags".node "tag".node "name".text
+ test/api/Xml/EventSpec.hs view
@@ -0,0 +1,36 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Xml.EventSpec (spec) where++import Network.Lastfm+import Network.Lastfm.Event+import Test.Hspec+import Text.Xml.Lens++import SpecHelper+++spec :: Spec+spec = do+  it "attend" $+    shouldHaveXml_ . privately $+      attend <*> event 3142549 <*> status Attending++  it "share" $+    shouldHaveXml_ . privately $+      share <*> event 3142549 <*> recipient "liblastfm" <* message "Just listen!"++  it "getAttendees" $+    publicly (getAttendees <*> event 3142549 <* limit 2)+   `shouldHaveXml`+    root.node "attendees".node "user".node "name".text++  it "getInfo" $+    publicly (getInfo <*> event 3142549)+   `shouldHaveXml`+    root.node "event".node "venue".node "location".node "country".text++  it "getShouts" $+    publicly (getShouts <*> event 3142549 <* limit 1)+   `shouldHaveXml`+    root.node "shouts".node "shout".node "body".text
+ test/api/Xml/GeoSpec.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Xml.GeoSpec (spec) where++import Data.Text (Text)+import Network.Lastfm+import Network.Lastfm.Geo+import Test.Hspec+import Text.Xml.Lens++import SpecHelper+++spec :: Spec+spec = do+  it "getEvents" $+    publicly (getEvents <* location "Moscow" <* limit 5)+   `shouldHaveXml`+    root.node "events".node "event".node "id".text++  describe "get*Artist*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "topartists".node "artist".node "name".text++    it "getMetroArtistChart" $+      publicly (getMetroArtistChart <*> metro "Saint Petersburg" <*> country "Russia")+     `shouldHaveXml`+      xmlQuery++    it "getMetroHypeArtistChart" $+      publicly (getMetroHypeArtistChart <*> metro "New York" <*> country "United States")+     `shouldHaveXml`+      xmlQuery++    it "getMetroUniqueArtistChart" $+      publicly (getMetroUniqueArtistChart <*> metro "Minsk" <*> country "Belarus")+     `shouldHaveXml`+      xmlQuery++    it "getTopArtists" $+      publicly (getTopArtists <*> country "Belarus" <* limit 3)+     `shouldHaveXml`+      xmlQuery++  it "getMetroWeeklyChartlist" $+    publicly (getMetroWeeklyChartlist <*> metro "Moscow")+   `shouldHaveXml`+    root.node "weeklychartlist".node "chart".attr "from"++  it "getMetros" $+    publicly (getMetros <* country "Russia")+   `shouldHaveXml`+    root.node "metros".node "metro".node "name".text++  describe "get*Track*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "toptracks".node "track".node "name".text++    it "getTopTracks" $+      publicly (getTopTracks <*> country "Ukraine" <* limit 2)+     `shouldHaveXml`+      xmlQuery++    it "getMetroUniqueTrackChart" $+      publicly (getMetroUniqueTrackChart <*> metro "Moscow" <*> country "Russia")+     `shouldHaveXml`+      xmlQuery++    it "getMetroHypeTrackChart" $+      publicly (getMetroHypeTrackChart <*> metro "Moscow" <*> country "Russia")+     `shouldHaveXml`+      xmlQuery++    it "getMetroTrackChart" $+      publicly (getMetroTrackChart <*> metro "Boston" <*> country "United States")+     `shouldHaveXml`+      xmlQuery
+ test/api/Xml/GroupSpec.hs view
@@ -0,0 +1,47 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Xml.GroupSpec (spec) where++import Network.Lastfm+import Network.Lastfm.Group+import Test.Hspec+import Text.Xml.Lens++import SpecHelper+++spec :: Spec+spec = do+  it "getHype" $+    publicly (getHype <*> groupname)+   `shouldHaveXml`+    root.node "weeklyartistchart".node "artist".node "mbid".text++  it "getMembers" $+    publicly (getMembers <*> groupname <* limit 10)+   `shouldHaveXml`+    root.node "members".node "user".node "name".text++  it "getWeeklyAlbumChart" $+    publicly (getWeeklyAlbumChart <*> groupname)+   `shouldHaveXml`+    root.node "weeklyalbumchart".node "album".node "playcount".text++  it "getWeeklyArtistChart" $+    publicly (getWeeklyArtistChart <*> groupname)+   `shouldHaveXml`+    root.node "weeklyartistchart".node "artist".node "name".text++  it "getWeeklyChartList" $+    publicly (getWeeklyChartList <*> groupname)+   `shouldHaveXml`+    root.node "weeklychartlist".node "chart".attr "from"++  it "getWeeklyTrackChart" $+    publicly (getWeeklyTrackChart <*> groupname)+   `shouldHaveXml`+    root.node "weeklytrackchart".node "track".node "url".text++groupname :: Request f Group+groupname = group+  "People with no social lives that listen to more music than is healthy who are slightly scared of spiders and can never seem to find a pen"
+ test/api/Xml/LibrarySpec.hs view
@@ -0,0 +1,56 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Xml.LibrarySpec (spec) where++import Network.Lastfm+import Network.Lastfm.Library+import Test.Hspec+import Text.Xml.Lens++import SpecHelper+++spec :: Spec+spec = do+  it "addAlbum" $+    shouldHaveXml_ . privately $+      addAlbum (pure (albumItem <*> artist "Franz Ferdinand" <*> album "Franz Ferdinand"))++  it "addArtist" $+    shouldHaveXml_ . privately $+      addArtist (pure (artistItem <*> artist "Mobthrow"))++  it "addTrack" $+    shouldHaveXml_ . privately $+      addTrack <*> artist "Eminem" <*> track "Kim"++  it "removeAlbum" $+    shouldHaveXml_ . privately $+      removeAlbum <*> artist "Franz Ferdinand" <*> album "Franz Ferdinand"++  it "removeArtist" $+    shouldHaveXml_ . privately $+      removeArtist <*> artist "Burzum"++  it "removeTrack" $+    shouldHaveXml_ . privately $+      removeTrack <*> artist "Eminem" <*> track "Kim"++  it "removeScrobble" $+    shouldHaveXml_ . privately $+      removeScrobble <*> artist "Gojira" <*> track "Ocean" <*> timestamp 1328905590++  it "getAlbums" $+    publicly (getAlbums <*> user "smpcln" <* artist "Burzum" <* limit 5)+   `shouldHaveXml`+    root.node "albums".node "album".node "name".text++  it "getArtists" $+    publicly (getArtists <*> user "smpcln" <* limit 7)+   `shouldHaveXml`+    root.node "artists".node "artist".node "name".text++  it "getTracks" $+    publicly (getTracks <*> user "smpcln" <* artist "Burzum" <* limit 4)+   `shouldHaveXml`+    root.node "tracks".node "track".node "name".text
+ test/api/Xml/PlaylistSpec.hs view
@@ -0,0 +1,42 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Xml.PlaylistSpec (spec) where++import Control.Lens+import Data.Int (Int64)+import Data.Text.Lens (unpacked)+import Network.Lastfm hiding (to)+import Network.Lastfm.Playlist+import Network.Lastfm.User+import Test.Hspec+import Test.HUnit (assertFailure)+import Text.Printf+import Text.Read (readMaybe)+import Text.Xml.Lens++import SpecHelper+++spec :: Spec+spec = do+  it "create" $ -- Order matters.+    privately (create <* title "Awesome playlist")+   `shouldHaveXml`+    root.node "playlists".node "playlist".node "title".text++  it "addTrack" $ do+    r <- lastfm $ getPlaylists <*> user "liblastfm" <*> ak' <* Network.Lastfm.xml+    case r of+      Left e ->+        assertFailure (printf "last.fm error: %s" (show e))+      Right val ->+        case preview pl val of+          Just (Just pid) -> shouldHaveXml_ . privately $+            addTrack <*> playlist pid <*> artist "Ruby my dear" <*> track "Chazz"+          _ -> assertFailure (printf "bad XML object: %s" (show val))++ak' :: Request f APIKey+ak' = apiKey "29effec263316a1f8a97f753caaa83e0"++pl :: Fold Document (Maybe Int64)+pl = root.node "playlists".node "playlist".node "id".text.unpacked.to readMaybe
+ test/api/Xml/TagSpec.hs view
@@ -0,0 +1,58 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Xml.TagSpec (spec) where++import Network.Lastfm+import Network.Lastfm.Tag+import Test.Hspec+import Text.Xml.Lens++import SpecHelper+++spec :: Spec+spec = do+  it "getInfo" $+    publicly (getInfo <*> tag "depressive")+   `shouldHaveXml`+    root.node "tag".node "taggings".text++  it "getSimilar" $+    publicly (getSimilar <*> tag "depressive")+   `shouldHaveXml`+    root.node "similartags".node "tag".node "name".text++  it "getTopAlbums" $+    publicly (getTopAlbums <*> tag "depressive" <* limit 2)+   `shouldHaveXml`+    root.node "topalbums".node "album".node "url".text++  it "getTopArtists" $+    publicly (getTopArtists <*> tag "depressive" <* limit 3)+   `shouldHaveXml`+    root.node "topartists".node "artist".node "url".text++  it "getTopTags" $+    publicly getTopTags+   `shouldHaveXml`+    root.node "toptags".node "tag".node "name".text++  it "getTopTracks" $+    publicly (getTopTracks <*> tag "depressive" <* limit 2)+   `shouldHaveXml`+    root.node "toptracks".node "track".node "url".text++  it "getWeeklyArtistChart" $+    publicly (getWeeklyArtistChart <*> tag "depressive" <* limit 3)+   `shouldHaveXml`+    root.node "weeklyartistchart".node "artist".node "name".text++  it "getWeeklyChartList" $+    publicly (getWeeklyChartList <*> tag "depressive")+   `shouldHaveXml`+    root.node "weeklychartlist".node "chart".attr "from"++  it "search" $+    publicly (search <*> tag "depressive" <* limit 3)+   `shouldHaveXml`+    root.node "results".node "tagmatches".node "tag".node "name".text
+ test/api/Xml/TasteometerSpec.hs view
@@ -0,0 +1,37 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Xml.TasteometerSpec (spec) where++import           Data.Text (Text)+import           Network.Lastfm+import qualified Network.Lastfm.Tasteometer as Taste+import           Test.Hspec+import           Text.Xml.Lens++import           SpecHelper+++spec :: Spec+spec = do+  it "compare" $+    publicly (Taste.compare (user "smpcln") (user "MCDOOMDESTROYER"))+   `shouldHaveXml`+    xmlQuery++  it "compare" $+    publicly (Taste.compare (user "smpcln") (artists ["enduser", "venetian snares"]))+   `shouldHaveXml`+    xmlQuery++  it "compare" $+    publicly (Taste.compare (artists ["enduser", "venetian snares"]) (user "smpcln"))+   `shouldHaveXml`+    xmlQuery++  it "compare" $+    publicly (Taste.compare (artists ["enduser", "venetian snares"]) (artists ["enduser", "venetian snares"]))+   `shouldHaveXml`+    xmlQuery++xmlQuery :: Fold Document Text+xmlQuery = root.node "comparison".node "result".node "score".text
+ test/api/Xml/TrackSpec.hs view
@@ -0,0 +1,157 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Xml.TrackSpec (spec) where++import Data.Text (Text)+import Data.Traversable (traverse)+import Network.Lastfm+import Network.Lastfm.Track+import Test.Hspec+import Text.Xml.Lens++import SpecHelper+++spec :: Spec+spec = do+  it "addTags" $+    shouldHaveXml_ . privately $+      addTags <*> artist "Jefferson Airplane" <*> track "White rabbit" <*> tags ["60s", "awesome"]++  it "ban" $+    shouldHaveXml_ . privately $+      ban <*> artist "Eminem" <*> track "Kim"++  it "love" $+    shouldHaveXml_ . privately $+      love <*> artist "Gojira" <*> track "Ocean"++  it "removeTag" $+    shouldHaveXml_ . privately $+      removeTag <*> artist "Jefferson Airplane" <*> track "White rabbit" <*> tag "awesome"++  it "share" $+    shouldHaveXml_ . privately $+      share <*> artist "Led Zeppelin" <*> track "When the Levee Breaks" <*> recipient "liblastfm" <* message "Just listen!"++  it "unban" $+    shouldHaveXml_ . privately $+      unban <*> artist "Eminem" <*> track "Kim"++  it "unlove" $+    shouldHaveXml_ . privately $+      unlove <*> artist "Gojira" <*> track "Ocean"++  it "scrobble" $+    privately (scrobble (pure (item <*> artist "Gojira" <*> track "Ocean" <*> timestamp 1300000000)))+   `shouldHaveXml`+    root.node "scrobbles".node "scrobble".node "track".text++  it "updateNowPlaying" $+    privately (updateNowPlaying <*> artist "Gojira" <*> track "Ocean")+   `shouldHaveXml`+    root.node "nowplaying".node "track".text++  it "getBuylinks" $+    publicly (getBuyLinks <*> country "United Kingdom" <*> artist "Pink Floyd" <*> track "Brain Damage")+   `shouldHaveXml`+    root.node "affiliations".node "downloads".node "affiliation".node "supplierName".text++  it "getCorrection" $+    publicly (getCorrection <*> artist "Pink Ployd" <*> track "Brain Damage")+   `shouldHaveXml`+    root.node "corrections".node "correction".node "track".node "artist".node "name".text++  it "getFingerprintMetadata" $+    publicly (getFingerprintMetadata <*> fingerprint 1234)+   `shouldHaveXml`+    root.node "tracks".node "track".node "name".text++  describe "getInfo*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "track".node "userplaycount".text++    it "getInfo" $+      publicly (getInfo <*> artist "Pink Floyd" <*> track "Comfortably Numb" <* username "aswalrus")+     `shouldHaveXml`+      xmlQuery++    it "getInfo_mbid" $+      publicly (getInfo <*> mbid "52d7c9ff-6ae4-48a6-acec-4c1a486f8c92" <* username "aswalrus")+     `shouldHaveXml`+      xmlQuery++  describe "getShouts*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "shouts".node "shout".node "author".text++    it "getShouts" $+      publicly (getShouts <*> artist "Pink Floyd" <*> track "Comfortably Numb" <* limit 7)+     `shouldHaveXml`+      xmlQuery++    it "getShouts_mbid" $+      publicly (getShouts <*> mbid "52d7c9ff-6ae4-48a6-acec-4c1a486f8c92" <* limit 7)+     `shouldHaveXml`+      xmlQuery++  describe "getSimilar*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "similartracks".node "track".node "name".text++    it "getSimilar" $+      publicly (getSimilar <*> artist "Pink Floyd" <*> track "Comfortably Numb" <* limit 4)+     `shouldHaveXml`+      xmlQuery++    it "getSimilar_mbid" $+      publicly (getSimilar <*> mbid "52d7c9ff-6ae4-48a6-acec-4c1a486f8c92" <* limit 4)+     `shouldHaveXml`+      xmlQuery++  describe "getTags*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "tags".attr "track".traverse++    it "getTags" $+      publicly (getTags <*> artist "Jefferson Airplane" <*> track "White Rabbit" <* user "liblastfm")+     `shouldHaveXml`+      xmlQuery++    it "getTags_mbid" $+      publicly (getTags <*> mbid "001b3337-faf4-421a-a11f-45e0b60a7703"  <* user "liblastfm")+     `shouldHaveXml`+      xmlQuery++  describe "getTopFans*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "topfans".node "user".node "name".text++    it "getTopFans" $+      publicly (getTopFans <*> artist "Pink Floyd" <*> track "Comfortably Numb")+     `shouldHaveXml`+      xmlQuery++    it "getTopFans_mbid" $+      publicly (getTopFans <*> mbid "52d7c9ff-6ae4-48a6-acec-4c1a486f8c92")+     `shouldHaveXml`+      xmlQuery++  describe "getTopTags*" $ do+    let xmlQuery :: Fold Document Text+        xmlQuery = root.node "toptags".node "tag".node "name".text++    it "getTopTags" $+      publicly (getTopTags <*> artist "Pink Floyd" <*> track "Comfortably Numb")+     `shouldHaveXml`+      xmlQuery++    it "getTopTags_mbid" $+      publicly (getTopTags <*> mbid "52d7c9ff-6ae4-48a6-acec-4c1a486f8c92")+     `shouldHaveXml`+      xmlQuery++  it "search" $+    publicly (search <*> track "Believe" <* limit 12)+   `shouldHaveXml`+    root.node "results".node "trackmatches".node "track".node "name".text
+ test/api/Xml/UserSpec.hs view
@@ -0,0 +1,137 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Xml.UserSpec (spec) where++import Network.Lastfm+import Network.Lastfm.User+import Test.Hspec+import Text.Xml.Lens++import SpecHelper+++spec :: Spec+spec = do+  it "getRecentStations" $+    privately (getRecentStations <*> user "liblastfm" <* limit 10)+   `shouldHaveXml`+    root.node "recentstations".node "station".node "name".text++  it "getRecommendedArtists" $+    privately (getRecommendedArtists <* limit 10)+   `shouldHaveXml`+    root.node "recommendations".node "artist".node "name".text++  it "getRecommendedEvents" $+    privately (getRecommendedEvents <* limit 10)+   `shouldHaveXml`+    root.node "events".node "event".node "url".text++  it "shout" $+    shouldHaveXml_ . privately $+      shout <*> user "liblastfm" <*> message "test message"++  it "getArtistTracks" $+    publicly (getArtistTracks <*> user "smpcln" <*> artist "Dvar")+   `shouldHaveXml`+    root.node "artisttracks".node "track".node "name".text++  it "getBannedTracks" $+    publicly (getBannedTracks <*> user "smpcln" <* limit 10)+   `shouldHaveXml`+    root.node "bannedtracks".node "track".node "name".text++  it "getEvents" $+    publicly (getEvents <*> user "chansonnier" <* limit 5)+   `shouldHaveXml`+    root.node "events".node "event".node "venue".node "url".text++  it "getFriends" $+    publicly (getFriends <*> user "smpcln" <* limit 10)+   `shouldHaveXml`+    root.node "friends".node "user".node "name".text++  it "getPlayCount" $+    publicly (getInfo <*> user "smpcln")+   `shouldHaveXml`+    root.node "user".node "playcount".text++  it "getGetLovedTracks" $+    publicly (getLovedTracks <*> user "smpcln" <* limit 10)+   `shouldHaveXml`+    root.node "lovedtracks".node "track".node "name".text++  it "getNeighbours" $+    publicly (getNeighbours <*> user "smpcln" <* limit 10)+   `shouldHaveXml`+    root.node "neighbours".node "user".node "name".text++  it "getNewReleases" $+    publicly (getNewReleases <*> user "rj")+   `shouldHaveXml`+    root.node "albums".node "album".node "url".text++  it "getPastEvents" $+    publicly (getPastEvents <*> user "mokele" <* limit 5)+   `shouldHaveXml`+    root.node "events".node "event".node "url".text++  it "getPersonalTags" $+    publicly (getPersonalTags <*> user "crackedcore" <*> tag "rhythmic noise" <*> taggingType "artist" <* limit 10)+   `shouldHaveXml`+    root.node "taggings".node "artists".node "artist".node "name".text++  it "getPlaylists" $+    publicly (getPlaylists <*> user "mokele")+   `shouldHaveXml`+    root.node "playlists".node "playlist".node "title".text++  it "getRecentTracks" $+    publicly (getRecentTracks <*> user "smpcln" <* limit 10)+   `shouldHaveXml`+    root.node "recenttracks".node "track".node "name".text++  it "getShouts" $+    publicly (getShouts <*> user "smpcln" <* limit 2)+   `shouldHaveXml`+    root.node "shouts".node "shout".node "body".text++  it "getTopAlbums" $+    publicly (getTopAlbums <*> user "smpcln" <* limit 5)+   `shouldHaveXml`+    root.node "topalbums".node "album".node "artist".node "name".text++  it "getTopArtists" $+    publicly (getTopArtists <*> user "smpcln" <* limit 5)+   `shouldHaveXml`+    root.node "topartists".node "artist".node "name".text++  it "getTopTags" $+    publicly (getTopTags <*> user "smpcln" <* limit 10)+   `shouldHaveXml`+    root.node "toptags".node "tag".node "name".text++  it "getTopTracks" $+    publicly (getTopTracks <*> user "smpcln" <* limit 10)+   `shouldHaveXml`+    root.node "toptracks".node "track".node "url".text++  it "getWeeklyAlbumChart" $+    publicly (getWeeklyAlbumChart <*> user "smpcln")+   `shouldHaveXml`+    root.node "weeklyalbumchart".node "album".node "url".text++  it "getWeeklyArtistChart" $+    publicly (getWeeklyArtistChart <*> user "smpcln")+   `shouldHaveXml`+    root.node "weeklyartistchart".node "artist".node "url".text++  it "getWeeklyChartList" $+    publicly (getWeeklyChartList <*> user "smpcln")+   `shouldHaveXml`+    root.node "weeklychartlist".node "chart".attr "from"++  it "getWeeklyTrackChart" $+    publicly (getWeeklyTrackChart <*> user "smpcln")+   `shouldHaveXml`+    root.node "weeklytrackchart".node "track".node "url".text
+ test/api/Xml/VenueSpec.hs view
@@ -0,0 +1,28 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}+module Xml.VenueSpec (spec) where++import Network.Lastfm+import Network.Lastfm.Venue+import Test.Hspec+import Text.Xml.Lens++import SpecHelper+++spec :: Spec+spec = do+  it "getEvents" $+    publicly (getEvents <*> venue 9163107)+   `shouldHaveXml`+    root.node "events".node "event".node "venue".node "name".text++  it "getPastEvents" $+    publicly (getPastEvents <*> venue 9163107 <* limit 2)+   `shouldHaveXml`+    root.node "events".node "event".node "title".text++  it "search" $+    publicly (search <*> venueName "Arena")+   `shouldHaveXml`+    root.node "results".node "venuematches".node "venue".node "id".text
+ test/spec/Network/Lastfm/ResponseSpec.hs view
@@ -0,0 +1,90 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# OPTIONS_GHC -fno-warn-orphans #-}+module Network.Lastfm.ResponseSpec (spec) where++import Control.Exception (ArithException(..), throwIO, try)+import Control.Exception.Lens+import Control.Lens+import Data.Aeson (Value)+import Data.ByteString.Lazy (ByteString)+import Data.Aeson.Lens+import Test.Hspec.Lens+import Text.Xml.Lens+import Network.HTTP.Client (HttpException(..))++import Network.Lastfm.Response+++instance Eq HttpException where+  _ == _ = True+++spec :: Spec+spec = do+  describe "md5" $ do+    it "calculates the right hash for an empty string" $+      md5 "" `shouldBe` "d41d8cd98f00b204e9800998ecf8427e"++    it "calculates the right hash for an ascii string" $+      md5 "Enduser" `shouldBe` "28ced1fafec20ae302a04e9f27f4800f"++    it "calculates the right hash for a unicode string" $+      md5 "ДМИТРИЙ МАЛИКОВ" `shouldBe` "e02e5affef1004a02d9762619dc2a585"++  describe "try for LastfmError" $ do+    let tryLastfmError :: IO a -> IO (Either LastfmError a)+        tryLastfmError = try++    it "catches 'HttpException'" $ do+      val <- tryLastfmError (throwIO ResponseTimeout) :: IO (Either LastfmError ())+      val `shouldHave` _Left._LastfmHttpError.only ResponseTimeout++    it "does not catch other exceptions" $+      tryLastfmError (throwIO DivideByZero) `shouldThrow` _DivideByZero++  describe "parse" $ do+    context "JSON" $ do+      let parseJSON :: ByteString -> Either LastfmError Value+          parseJSON = parse++      it "handles good input" $+        let+          good = "{ \"a\": { \"b\": 4 } }"+        in+          parseJSON good `shouldHave` _Right.key "a".key "b"._Integer.only 4++      it "handles malformed input" $+        let+          malformed = "not a json"+        in+          parseJSON malformed `shouldHave` _Left._LastfmBadResponse.only malformed++      it "handles input with encoded errors" $+        let+          encodedError = "{ \"error\": 5, \"message\": \"foo\" }"+        in+          parseJSON encodedError `shouldHave` _Left._LastfmEncodedError.only (5, "foo")++    context "XML" $ do+      let parseXML :: ByteString -> Either LastfmError Document+          parseXML = parse++      it "handles good input" $+        let+          good = "<root><foo><bar>baz</bar></foo></root>"+        in+          parseXML good `shouldHave` _Right.root.node "foo".node "bar".text.only "baz"++      it "handles malformed input" $+        let+          malformed = "not a xml"+        in+          parseXML malformed `shouldHave` _Left._LastfmBadResponse.only malformed++      it "handles input with encoded errors" $+        let+          encodedError = "<lfm><error code=\"5\">foo</error></lfm>"+        in+          parseXML encodedError `shouldHave` _Left._LastfmEncodedError.only (5, "foo")
+ test/spec/Spec.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}