scrobble 0.1.0.1 → 0.2.1.0
raw patch · 5 files changed
+264/−10 lines, 5 filesdep +MissingHdep +containersdep +curlPVP ok
version bump matches the API change (PVP)
Dependencies added: MissingH, containers, curl, pureMD5
API changes (from Hackage documentation)
+ Scrobble.Client: defaultServer :: URI
+ Scrobble.Client: instance Show CurlGrab
+ Scrobble.Client: newClient :: Details -> IO Client
+ Scrobble.Client: nowPlaying :: Client -> NowPlaying -> IO ()
+ Scrobble.Client: submitTracks :: Client -> [Submission] -> IO ()
+ Scrobble.Types: BADAUTH :: Response
+ Scrobble.Types: BADSESSION :: Response
+ Scrobble.Types: BANNED :: Response
+ Scrobble.Types: Ban :: Rating
+ Scrobble.Types: Client :: String -> URI -> URI -> Client
+ Scrobble.Types: Config :: PortNumber -> String -> NominalDiffTime -> Config
+ Scrobble.Types: Details :: String -> String -> String -> String -> URI -> Details
+ Scrobble.Types: FAILED :: String -> Response
+ Scrobble.Types: Handlers :: (Session -> IO ()) -> (Session -> IO ()) -> (Session -> NowPlaying -> IO ()) -> (Session -> [Submission] -> IO Bool) -> Handlers
+ Scrobble.Types: LastFm :: Source
+ Scrobble.Types: Love :: Rating
+ Scrobble.Types: NonPersonlizedBroadcast :: Source
+ Scrobble.Types: NowPlaying :: String -> String -> Maybe String -> Maybe Integer -> Maybe Integer -> Maybe String -> NowPlaying
+ Scrobble.Types: OK :: Response
+ Scrobble.Types: Personalized :: Source
+ Scrobble.Types: ScrobblerBadAuth :: ScrobblerError
+ Scrobble.Types: ScrobblerBadTime :: ScrobblerError
+ Scrobble.Types: ScrobblerBanned :: ScrobblerError
+ Scrobble.Types: ScrobblerFailed :: String -> ScrobblerError
+ Scrobble.Types: ScrobblerHardFail :: ScrobblerError
+ Scrobble.Types: ScrobblerNowPlayingFail :: String -> ScrobblerError
+ Scrobble.Types: ScrobblerSubmitFail :: String -> ScrobblerError
+ Scrobble.Types: Session :: Bool -> String -> String -> String -> String -> UTCTime -> String -> Session
+ Scrobble.Types: Skip :: Rating
+ Scrobble.Types: Submission :: String -> String -> UTCTime -> Source -> Maybe Rating -> Maybe Integer -> Maybe String -> Maybe Integer -> Maybe String -> Submission
+ Scrobble.Types: Unknown :: Source
+ Scrobble.Types: UserChosen :: Source
+ Scrobble.Types: cfgExpire :: Config -> NominalDiffTime
+ Scrobble.Types: cfgHost :: Config -> String
+ Scrobble.Types: cfgPort :: Config -> PortNumber
+ Scrobble.Types: cliNowPlaying :: Client -> URI
+ Scrobble.Types: cliSubmit :: Client -> URI
+ Scrobble.Types: cliToken :: Client -> String
+ Scrobble.Types: data Client
+ Scrobble.Types: data Config
+ Scrobble.Types: data Details
+ Scrobble.Types: data Handlers
+ Scrobble.Types: data NowPlaying
+ Scrobble.Types: data Rating
+ Scrobble.Types: data Response
+ Scrobble.Types: data ScrobblerError
+ Scrobble.Types: data Session
+ Scrobble.Types: data Source
+ Scrobble.Types: data Submission
+ Scrobble.Types: detClient :: Details -> String
+ Scrobble.Types: detPassword :: Details -> String
+ Scrobble.Types: detServer :: Details -> URI
+ Scrobble.Types: detUsername :: Details -> String
+ Scrobble.Types: detVersion :: Details -> String
+ Scrobble.Types: handleExpire :: Handlers -> Session -> IO ()
+ Scrobble.Types: handleHandshake :: Handlers -> Session -> IO ()
+ Scrobble.Types: handleNowPlaying :: Handlers -> Session -> NowPlaying -> IO ()
+ Scrobble.Types: handleSubmissions :: Handlers -> Session -> [Submission] -> IO Bool
+ Scrobble.Types: instance Data ScrobblerError
+ Scrobble.Types: instance Enum Rating
+ Scrobble.Types: instance Enum Source
+ Scrobble.Types: instance Eq Rating
+ Scrobble.Types: instance Eq Source
+ Scrobble.Types: instance Exception ScrobblerError
+ Scrobble.Types: instance Read Rating
+ Scrobble.Types: instance Read Source
+ Scrobble.Types: instance Show Client
+ Scrobble.Types: instance Show Config
+ Scrobble.Types: instance Show Details
+ Scrobble.Types: instance Show NowPlaying
+ Scrobble.Types: instance Show Rating
+ Scrobble.Types: instance Show Response
+ Scrobble.Types: instance Show ScrobblerError
+ Scrobble.Types: instance Show Session
+ Scrobble.Types: instance Show Source
+ Scrobble.Types: instance Show Submission
+ Scrobble.Types: instance Typeable ScrobblerError
+ Scrobble.Types: npAlbum :: NowPlaying -> Maybe String
+ Scrobble.Types: npArtist :: NowPlaying -> String
+ Scrobble.Types: npLength :: NowPlaying -> Maybe Integer
+ Scrobble.Types: npMusicBrainz :: NowPlaying -> Maybe String
+ Scrobble.Types: npPosition :: NowPlaying -> Maybe Integer
+ Scrobble.Types: npTrack :: NowPlaying -> String
+ Scrobble.Types: sesClientId :: Session -> String
+ Scrobble.Types: sesClientVer :: Session -> String
+ Scrobble.Types: sesHandshake :: Session -> Bool
+ Scrobble.Types: sesTimestamp :: Session -> UTCTime
+ Scrobble.Types: sesToken :: Session -> String
+ Scrobble.Types: sesUser :: Session -> String
+ Scrobble.Types: sesVersion :: Session -> String
+ Scrobble.Types: subAlbum :: Submission -> Maybe String
+ Scrobble.Types: subArtist :: Submission -> String
+ Scrobble.Types: subLength :: Submission -> Maybe Integer
+ Scrobble.Types: subMusicBrainz :: Submission -> Maybe String
+ Scrobble.Types: subPosition :: Submission -> Maybe Integer
+ Scrobble.Types: subRating :: Submission -> Maybe Rating
+ Scrobble.Types: subSource :: Submission -> Source
+ Scrobble.Types: subTimestamp :: Submission -> UTCTime
+ Scrobble.Types: subTrack :: Submission -> String
Files
- scrobble.cabal +11/−5
- src/Scrobble.hs +3/−1
- src/Scrobble/Client.hs +213/−0
- src/Scrobble/Server.hs +2/−1
- src/Scrobble/Types.hs +35/−3
scrobble.cabal view
@@ -1,5 +1,5 @@ name: scrobble-version: 0.1.0.1+version: 0.2.1.0 synopsis: Scrobbling server. description: A library providing server-side support for the Audioscrobbler Realtime Submission protocol:@@ -19,13 +19,16 @@ library hs-source-dirs: src- exposed-modules: Scrobble.Server- other-modules: Scrobble.Types+ exposed-modules: Scrobble.Server, Scrobble.Client, Scrobble.Types build-depends: base >4 && <5, network, url, time,- old-locale+ old-locale,+ pureMD5,+ MissingH,+ curl,+ containers executable scrobble-server hs-source-dirs: src@@ -35,4 +38,7 @@ network, url, time,- old-locale+ old-locale,+ MissingH,+ curl,+ containers
src/Scrobble.hs view
@@ -2,8 +2,10 @@ module Scrobble (module Scrobble.Types- ,module Scrobble.Server)+ ,module Scrobble.Server+ ,module Scrobble.Client) where import Scrobble.Server+import Scrobble.Client import Scrobble.Types
+ src/Scrobble/Client.hs view
@@ -0,0 +1,213 @@+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE KindSignatures #-}+{-# LANGUAGE DeriveFunctor #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE ViewPatterns #-}+{-# LANGUAGE RecordWildCards #-}++-- | A client for scrobbling, based upon the Audioscrobbler Realtime+-- Submission protocol v1.2+-- <http://www.audioscrobbler.net/development/protocol/>+--+-- Example:+--+-- @+-- import Scrobble.Client+-- import Data.Time+--+-- example = do+-- client <- newClient Details+-- { detPassword = \"YOURPASS\"+-- , detUsername = \"YOURUSER\"+-- , detClient = \"qlb\"+-- , detVersion = \"0.9.2\"+-- , detServer = defaultServer+-- }+-- nowPlaying client NowPlaying+-- { npArtist = \"Kasabian\"+-- , npTrack = \"Ladies and Gentlemen\"+-- , npAlbum = Just \"West Ryder Pauper Lunatic Asylum\"+-- , npLength = Just 288+-- , npPosition = Nothing+-- , npMusicBrainz = Nothing+-- }+-- timestamp <- getCurrentTime+-- submitTracks client+-- [Submission { subArtist = \"Kasabian\"+-- , subTrack = \"Ladies and Gentlemen\"+-- , subTimestamp = timestamp+-- , subSource = UserChosen+-- , subRating = Nothing+-- , subLength = Just 288+-- , subAlbum = Just \"West Ryder Pauper Lunatic Asylum\"+-- , subPosition = Nothing+-- , subMusicBrainz = Nothing+-- }]+-- @++module Scrobble.Client+ (newClient+ ,nowPlaying+ ,submitTracks+ ,defaultServer+ ,module Scrobble.Types)+ where++import Scrobble.Types++import Control.Arrow+import Control.Exception+import Control.Monad+import Data.Hash.MD5 (Str(..),md5s)+import Data.List+import Data.Maybe+import Data.Time+import Data.Time.Clock.POSIX+import Network.Curl+import Network.URI+import System.Locale++-- | Get a session token.+--+-- The algorithm for generating this token is as follows:+-- token := md5(md5(password) + timestamp)+--+-- The md5() function takes a string and returns the 32-byte ASCII+-- hexadecimal representation of the MD5 hash, using lower case+-- characters for the hex values.+getToken :: String -- ^ The password.+ -> IO (String,String) -- ^ A session token and timestamp.+getToken password = do+ timestamp <- getCurrentTime+ let et = epoch timestamp+ return (md5 (md5 password ++ et),et)++ where md5 = md5s . Str++-- | Create a client session. Throws "ScrobblerError".+newClient :: Details -> IO Client+newClient Details{..} = withCurlDo $ do+ (token,timestamp) <- getToken detPassword+ let params = [("hs","true")+ ,("p","1.2")+ ,("c",detClient)+ ,("v",detVersion)+ ,("u",detUsername)+ ,("t",timestamp)+ ,("a",token)]+ response <- curlGrab (setQuery detServer params)+ [CurlHttpHeaders [ "Host: " ++ host+ | Just host <- [fmap uriRegName (uriAuthority detServer)] ]]+ parseAuth response++-- | Parse the auth response.+parseAuth :: CurlGrab -> IO Client+parseAuth CurlGrab{..} =+ case lines grabBody of+ ["OK",token,parseURI -> Just nowplaying,parseURI -> Just submit] ->+ return (Client token nowplaying submit)+ ["BANNED"] -> throw ScrobblerBanned+ ["BADAUTH"] -> throw ScrobblerBadAuth+ ["BADTIME"] -> throw ScrobblerBadTime+ [other] | isPrefixOf failed other ->+ throw (ScrobblerFailed (drop (length failed) other))+ | otherwise -> throw ScrobblerHardFail+ where failed = "FAILED "++-- | Default Audioscrobbler server: http://post.audioscrobbler.com/+defaultServer :: URI+defaultServer = fromJust (parseURI "http://post.audioscrobbler.com/")++-- | Send a now playing message. Throws "ScrobblerError".+nowPlaying :: Client -> NowPlaying -> IO ()+nowPlaying client@Client{..} nowplaying = do+ CurlGrab{grabBody} <- curlGrab cliNowPlaying+ [CurlPost True+ ,CurlPostFields (map keyval (makeNowPlaying client nowplaying))]+ unless (trim grabBody == "OK") $+ throw (ScrobblerNowPlayingFail grabBody)++-- | Make a now playing query.+makeNowPlaying :: Client -> NowPlaying -> [(String,String)]+makeNowPlaying Client{..} NowPlaying{..} =+ [("s",cliToken)+ ,("a",npArtist)+ ,("t",npTrack)+ ,("b",fromMaybe "" npAlbum)+ ,("l",maybe "" show npLength)+ ,("n",maybe "" show npPosition)+ ,("m",fromMaybe "" npMusicBrainz)]++-- | Submit track(s). Throws "ScrobblerError".+submitTracks :: Client -> [Submission] -> IO ()+submitTracks client@Client{..} submissions = do+ CurlGrab{grabBody} <- curlGrab cliSubmit+ [CurlPost True+ ,CurlPostFields (map keyval params)]+ unless (trim grabBody == "OK") $+ throw (ScrobblerSubmitFail grabBody)++ where params = [("s",cliToken)] +++ concat (zipWith (makeSubmission client) [0..] submissions)++-- | Make a now playing query.+makeSubmission :: Client -> Integer -> Submission -> [(String,String)]+makeSubmission Client{..} i Submission{..} = map hookup+ [("a",subArtist)+ ,("t",subTrack)+ ,("i",epoch subTimestamp)+ ,("o",fromMaybe "U" (lookup subSource sources))+ ,("r",fromMaybe "" (subRating >>= \r -> lookup r ratings))+ ,("l",maybe "" show subLength)+ ,("b",fromMaybe "" subAlbum)+ ,("n",maybe "" show subPosition)+ ,("m",fromMaybe "" subMusicBrainz)]++ where sources = [(UserChosen,"P")+ ,(NonPersonlizedBroadcast,"R")+ ,(Personalized,"E")+ ,(LastFm,"L")]+ ratings = [(Love,"L"),(Ban,"B"),(Skip,"S")]+ hookup (k,v) = (k ++ "[" ++ show i ++ "]",v)++--------------------------------------------------------------------------------+-- Utilities++-- | Encode post parameters to a string.+encodePost :: [(String,String)] -> String+encodePost = intercalate "&" . map (keyval . (encode *** encode)) where+ encode = escapeURIString isUnescapedInURI++-- | Make a key=val string.+keyval :: (String,String) -> String+keyval (key,val) = key ++ "=" ++ val++-- | Set a URI's query.+setQuery :: URI -> [(String,String)] -> URI+setQuery uri assoc = uri { uriQuery = "?" ++ encodePost assoc }++-- | Format a time to UNIX number.+epoch :: UTCTime -> String+epoch = formatTime defaultTimeLocale "%s"++-- | Just strip whitespace.+trim :: String -> String+trim = unwords . words++--------------------------------------------------------------------------------+-- Make Curl's API not crappy.++-- | Grab a URL with curl.+curlGrab :: URI -> [CurlOption] -> IO CurlGrab+curlGrab url options = do+ CurlResponse{..} <- curlGetResponse_ (show url) options+ return $ CurlGrab respCurlCode respStatus respStatusLine respHeaders respBody++-- | A sane data type.+data CurlGrab = CurlGrab+ { grabCode :: CurlCode+ , grabStatus :: Int+ , grabStatusLine :: String+ , grabHeaders :: [(String,String)]+ , grabBody :: String+ } deriving (Show)
src/Scrobble/Server.hs view
@@ -6,7 +6,8 @@ -- <http://www.audioscrobbler.net/development/protocol/> module Scrobble.Server- (startScrobbleServer)+ (startScrobbleServer+ ,module Scrobble.Types) where import Scrobble.Types
src/Scrobble/Types.hs view
@@ -1,9 +1,14 @@+{-# LANGUAGE DeriveDataTypeable #-}+ -- | Scrobbling data types. module Scrobble.Types where -import Data.Time-import Network+import Control.Exception+import Data.Data+import Data.Time+import Network+import Network.URI (URI) -- | Server configuration. data Config = Config@@ -93,7 +98,34 @@ deriving (Show,Enum,Eq,Read) - -- | Server response. data Response = OK | BANNED | BADAUTH | FAILED String | BADSESSION deriving Show++-- | A scrobbling client.+data Client = Client+ { cliToken :: String -- ^ Session token.+ , cliNowPlaying :: URI -- ^ Now playing URL to submit to.+ , cliSubmit :: URI -- ^ URL to submit listened tracks to.+ } deriving (Show)++-- | Details for creating a scrobbling client.+data Details = Details+ { detPassword :: String+ , detUsername :: String+ , detClient :: String -- ^ E.g. “qlb”.+ , detVersion :: String -- ^ E.g. “0.9.2”.+ , detServer :: URI -- ^ See defaultServer in "Scrobble.Client".+ } deriving (Show)++-- | Scrobble exception.+data ScrobblerError+ = ScrobblerBanned+ | ScrobblerBadAuth+ | ScrobblerBadTime+ | ScrobblerFailed String+ | ScrobblerHardFail+ | ScrobblerSubmitFail String+ | ScrobblerNowPlayingFail String+ deriving (Show,Typeable,Data)+instance Exception ScrobblerError