netease-fm-1.2.2: lib/FM/NetEase.hs
{-# LANGUAGE OverloadedStrings, RecordWildCards, LambdaCase #-}
{-# LANGUAGE FlexibleContexts #-}
module FM.NetEase (
initSession
, login
, encryptPassword
, fetchFM
, fetchRecommend
, fetchPlayLists
, fetchPlayList
, fetchUrl
, fetchLyrics
) where
import Control.Exception
import Control.Monad.IO.Class
import Control.Monad.Reader
import Control.Monad.Catch (throwM)
import qualified Data.Aeson as JSON
import Data.Aeson ((.:), (.:?))
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as BL
import Data.Char (isDigit)
import Data.Default.Class
import Data.IORef
import Data.List (find)
import Data.Time.Clock (getCurrentTime)
import Data.Typeable
import qualified Network.HTTP.Client as HTTP
import qualified Network.HTTP.Client.TLS as HTTP
import qualified Network.HTTP.Types.Header as HTTP
import qualified Network.HTTP.Types.Status as HTTP
import qualified Network.HTTP.Types.URI as HTTP
import System.Random (getStdRandom, randomR)
import FM.Session
import qualified FM.Song as Song
import FM.NetEase.JSON
import FM.NetEase.Crypto
class IsQuery q where
fromQuery :: q -> BS.ByteString
instance IsQuery () where
fromQuery () = BS.empty
data Session = Session {
sessionManager :: HTTP.Manager
, sessionUserId :: IORef Int
, sessionCsrf :: IORef String
, sessionSecure :: Bool
, sessionCookies :: IORef HTTP.CookieJar
} deriving (Typeable)
instance IsSession Session
data HTTPMethod = Post | Get | PostAndSaveCookies
data NetEaseException = NetEaseHTTPException HTTP.HttpException
| NetEaseStatusCodeException Int (HTTP.Response String)
| NetEaseParseException String
| NetEaseOtherExceptipon Int (Maybe String)
deriving (Typeable, Show)
instance Exception NetEaseException
data ResponseMessage = ResponseMessage Int (Maybe String)
instance JSON.FromJSON ResponseMessage where
parseJSON (JSON.Object v) = ResponseMessage <$> v .: "code" <*> v .:? "message"
parseJSON _ = fail "invalid response"
validateJSON :: (MonadIO m) => Either String a -> (a -> m b) -> m b
validateJSON r f = case r of
Right x -> f x
Left err -> liftIO $ throwM $ NetEaseParseException err
sendRequest :: (MonadIO m, IsQuery q) => Session -> HTTPMethod -> String -> q -> m BS.ByteString
sendRequest Session {..} method url query = liftIO $ case method of
Get -> catch get (throwM . NetEaseHTTPException)
Post -> catch (post False) (throwM . NetEaseHTTPException)
PostAndSaveCookies -> catch (post True) (throwM . NetEaseHTTPException)
where
initRequest request = do
cookies <- liftIO $ readIORef sessionCookies
return request { HTTP.requestHeaders = [ (HTTP.hAccept, "*/*")
, (HTTP.hAcceptEncoding, "gzip,deflate,sdch")
, (HTTP.hAcceptLanguage, "zh-CN,zh;q=0.8,gl;q=0.6,zh-TW;q=0.4")
, (HTTP.hConnection, "keep-alive")
, (HTTP.hContentType, "application/x-www-form-urlencoded")
, (HTTP.hHost, "music.163.com")
, (HTTP.hReferer, "http://music.163.com/search/")
]
, HTTP.cookieJar = Just cookies
, HTTP.secure = sessionSecure
, HTTP.port = if sessionSecure then 443 else 80
}
post saveCookies = do
initialRequest <- initRequest =<< HTTP.parseUrl url
send saveCookies $ initialRequest { HTTP.method = "POST"
, HTTP.requestBody = HTTP.RequestBodyBS $ fromQuery query
}
get = do
let action = mconcat [ url, "?", BS8.unpack $ fromQuery query ]
initialRequest <- initRequest =<< HTTP.parseUrl action
send False $ initialRequest { HTTP.method = "GET" }
send saveCookies request = do
response <- fmap BL.toStrict <$> HTTP.httpLbs request sessionManager
let HTTP.Status {..} = HTTP.responseStatus response
case statusCode of
200 -> do
when saveCookies $ do
now <- getCurrentTime
let updateCookieJar = fst . HTTP.updateCookieJar response request now
modifyIORef' sessionCookies updateCookieJar
let body = HTTP.responseBody response
validateJSON (JSON.eitherDecode (BL.fromStrict body)) $ \case
ResponseMessage 200 _ -> return body
ResponseMessage rc m -> throwM (NetEaseOtherExceptipon rc m)
_ -> throwM (NetEaseStatusCodeException statusCode (BS8.unpack <$> response))
initSession :: (MonadIO m) => Bool -> m SomeSession
initSession sessionSecure = liftIO $ do
sessionManager <- HTTP.newManager (if sessionSecure then HTTP.tlsManagerSettings else HTTP.defaultManagerSettings)
sessionUserId <- newIORef 0
sessionCsrf <- newIORef []
sessionCookies <- newIORef (HTTP.createCookieJar [])
return $ SomeSession Session {..}
data NetEaseQuery = FetchLyrics Song.SongId
| FetchPlayLists Int
| FetchPlayList Int
| EncryptData BS.ByteString BS.ByteString
instance IsQuery NetEaseQuery where
fromQuery (EncryptData text key) = HTTP.renderSimpleQuery False
[ ("params", text), ("encSecKey", key) ]
fromQuery (FetchLyrics id) = HTTP.renderSimpleQuery False
[ ("os", "osx"), ("id", BS8.pack (show id)), ("lv", "-1"), ("kv", "-1"), ("tv", "-1") ]
fromQuery (FetchPlayLists uid) = HTTP.renderSimpleQuery False
[ ("offset", "0"), ("limit", "1000"), ("uid", BS8.pack (show uid)) ]
fromQuery (FetchPlayList id) = HTTP.renderSimpleQuery False
[ ("id", BS8.pack (show id)) ]
createSecretKey :: (MonadIO m) => Int -> m BS.ByteString
createSecretKey n = mconcat <$> replicateM n (toHex <$> liftIO (getStdRandom $ randomR (0 :: Int, 15)))
encryptQuery :: (MonadIO m) => BS.ByteString -> m NetEaseQuery
encryptQuery q = do
secretKey <- createSecretKey 16
return $ EncryptData (encryptAES secretKey q) (encryptRSA secretKey)
login :: (MonadIO m, MonadReader Session m) => String -> String -> m ()
login userName password = do
session@Session {..} <- ask
let isPhone = length userName == 11 && all isDigit userName
let loginURL | isPhone = "http://music.163.com/weapi/login/cellphone"
| otherwise = "http://music.163.com/weapi/login"
request <- encryptQuery $ encodeJSON $ JSON.object $ if isPhone
then [ ("phone", JSON.toJSON $ BS8.pack userName)
, ("password", JSON.toJSON $ BS8.pack password)
, ("rememberLogin", JSON.toJSON False)
]
else [ ("username", JSON.toJSON $ BS8.pack userName)
, ("password", JSON.toJSON $ BS8.pack password)
, ("rememberLogin", JSON.toJSON False)
]
body <- sendRequest session PostAndSaveCookies loginURL request
validateJSON (decodeUserId body) (liftIO . writeIORef sessionUserId)
cookies <- liftIO $ HTTP.destroyCookieJar <$> readIORef sessionCookies
let csrf = BS8.unpack . HTTP.cookie_value <$> find (\HTTP.Cookie {..} -> cookie_name == "__csrf") cookies
case csrf of
Just csrf -> liftIO $ writeIORef sessionCsrf csrf
Nothing -> return ()
fetchFM :: (MonadIO m, MonadReader Session m) => m [Song.Song]
fetchFM = do
session <- ask
body <- sendRequest session Get "http://music.163.com/api/radio/get" ()
validateJSON (decodeFM body) return
fetchRecommend :: (MonadIO m, MonadReader Session m) => m [Song.Song]
fetchRecommend = do
session@Session {..} <- ask
csrf <- liftIO $ readIORef sessionCsrf
let url = "http://music.163.com/weapi/v1/discovery/recommend/songs?csrf_token=" ++ csrf
request <- encryptQuery $ encodeJSON $ JSON.object
[ ("offset", JSON.toJSON (0 :: Int))
, ("total", JSON.toJSON True)
, ("limit", JSON.toJSON (20 :: Int))
, ("csrf_token", JSON.toJSON csrf)
]
body <- sendRequest session Post url request
validateJSON (decodeRecommend body) return
fetchPlayLists :: (MonadIO m, MonadReader Session m) => m [(Int, String)]
fetchPlayLists = do
session@Session {..} <- ask
userId <- liftIO $ readIORef sessionUserId
if userId == 0
then return []
else do
body <- sendRequest session Get "http://music.163.com/api/user/playlist" (FetchPlayLists userId)
validateJSON (decodePlayLists body) return
fetchPlayList :: (MonadIO m, MonadReader Session m) => Int -> m [Song.Song]
fetchPlayList id = do
session <- ask
body <- sendRequest session Get "http://music.163.com/api/playlist/detail" (FetchPlayList id)
validateJSON (decodePlayList body) return
fetchUrl :: (MonadIO m, MonadReader Session m) => Song.Song -> m (Maybe String)
fetchUrl Song.Song {..} = do
session@Session {..} <- ask
csrf <- liftIO $ readIORef sessionCsrf
let url = "http://music.163.com/weapi/song/enhance/player/url?csrf_token=" ++ csrf
request <- encryptQuery $ encodeJSON $ JSON.object
[ ("ids", JSON.toJSON [uid])
, ("br", JSON.toJSON (320000 :: Int))
, ("csrf_token", JSON.toJSON csrf)
]
body <- sendRequest session Post url request
case decodeUrl body of
Right [url] -> return (Just url)
_ -> return Nothing
fetchLyrics :: (MonadIO m, MonadReader Session m) => Song.Song -> m Song.Lyrics
fetchLyrics Song.Song {..} = do
session <- ask
body <- sendRequest session Get "http://music.163.com/api/song/lyric" (FetchLyrics uid)
return $ either (const def) Song.parseLyrics (decodeLyrics body)