lord 1.20131115 → 1.20131124
raw patch · 7 files changed
+762/−2 lines, 7 files
Files
- CHANGELOG +4/−0
- Radio.hs +104/−0
- Radio/Cmd.hs +119/−0
- Radio/Douban.hs +192/−0
- Radio/Jing.hs +272/−0
- Radio/Reddit.hs +64/−0
- lord.cabal +7/−2
+ CHANGELOG view
@@ -0,0 +1,4 @@+CHANGELOG++1.20130928 -> 1.20131115+ - Added support to http://radioreddit.com
+ Radio.hs view
@@ -0,0 +1,104 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeFamilies #-}++-- | A generic interface to online radio services+module Radio+ ( SongMeta(..)+ , Radio(..)+ , getLordDir+ , getPidFile+ , getLogFile+ , getStateFile+ , writeLog+ ) where++import Codec.Binary.UTF8.String (encodeString)+import Control.Applicative ((<$>))+import Control.Concurrent.MVar+import Control.Monad (when)+import Data.Aeson (FromJSON, Value)+import qualified Data.ByteString.Char8 as C+import Network.MPD hiding (play, Value)+import qualified Network.MPD as MPD+import System.Directory (getHomeDirectory)+import System.IO.Unsafe (unsafePerformIO)+import System.Log.FastLogger++eof = unsafePerformIO newEmptyMVar++data SongMeta = SongMeta + { artist :: String+ , album :: String+ , title :: String+ }++class FromJSON a => Radio a where+ data Param a :: *++ parsePlaylist :: Value -> [a]++ getPlaylist :: Param a -> IO [a]++ songUrl :: Param a -> a -> IO String++ songMeta :: a -> SongMeta++ tagged :: a -> Bool++ play :: Logger -> Param a -> [a] -> IO ()+ play logger reqData [] = getPlaylist reqData >>= play logger reqData+ play logger reqData (x:xs) = do+ surl <- songUrl reqData x+ print surl+ when (surl /= "") $ do+ let song = artist (songMeta x) ++ " - " ++ title (songMeta x)+ writeLog logger song + getStateFile >>= flip writeFile song+ mpdLoad $ Path $ C.pack surl+ takeMVar eof -- Finished+ play logger reqData xs++mpdLoad :: Path -> IO ()+mpdLoad path = do+ withMPD $ do+ clear+ add path+ withMPD $ MPD.play Nothing+ mpdPlay++mpdPlay :: IO ()+mpdPlay = do+ withMPD $ idle [PlayerS] + -- This will block until paused/finished.++ st <- withMPD status+ let st' = fmap stState st+ print st'+ if st' == Right Stopped + then putMVar eof ()+ else mpdPlay++getLordDir :: IO FilePath+getLordDir = (++ "/.lord") <$> getHomeDirectory++getPidFile :: IO FilePath+getPidFile = (++ "/lord.pid") <$> getLordDir++getLogFile :: IO FilePath+getLogFile = (++ "/lord.log") <$> getLordDir++getStateFile :: IO FilePath+getStateFile = (++ "/lordstate") <$> getLordDir++formatLogMessage :: IO ZonedDate -> String -> IO [LogStr]+formatLogMessage getdate msg = do+ now <- getdate+ return + [ LB now+ , LB " : "+ , LS $ encodeString msg+ , LB "\n"+ ]++writeLog :: Logger -> String -> IO ()+writeLog l msg = formatLogMessage (loggerDate l) msg >>= loggerPutStr l
+ Radio/Cmd.hs view
@@ -0,0 +1,119 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeFamilies #-}+-- | Module of http://cmd.fm+module Radio.Cmd where++import qualified Control.Exception as E+import Control.Monad (liftM)+import Data.Aeson+import qualified Data.ByteString.Char8 as C+import Data.Conduit (($$+-))+import Data.Conduit.Attoparsec (sinkParser)+import qualified Data.HashMap.Strict as HM+import GHC.Generics (Generic)+import Network.HTTP.Conduit+import Network.HTTP.Types (renderQuery)+import Network.HTTP.Types.Header (hLocation)+import Network.HTTP.Types.Status (status302)++import qualified Radio++type Param a = Radio.Param Cmd++data Cmd = Cmd+ { sc_id :: Int+ , title :: String+ , artwork_url :: String+ , description :: String+ , duration :: Int+ , genre :: String+ , tag_list :: String+ , waveform_url :: String+ , stream_url :: String+ --, last_listened :: Int+ , main_type :: String+ --, created_at :: String+ --, updated_at :: String+ } deriving (Show, Generic)++instance FromJSON Cmd++instance Radio.Radio Cmd where+ data Param Cmd = Genre String++ parsePlaylist val = do+ case fromJSON val of+ Success s -> s+ Error err -> error $ "Parse playlist failed: " ++ show err++ getPlaylist (Genre g) = do+ let url = "http://cmd.fm/api/tracks/search"+ query = [ ("genre", Just $ C.pack g)+ , ("limit", Just $ C.pack "10") ]+ initReq <- parseUrl url+ let req = initReq { method = "GET"+ , queryString = renderQuery False query+ }+ withManager $ \manager -> do+ res <- http req manager+ liftM Radio.parsePlaylist (responseBody res $$+- sinkParser json)++ songUrl _ x = do+ initReq <- parseUrl url+ let req = initReq { method = "GET"+ , queryString = renderQuery False query+ }+ E.catch (do withManager $ \manager -> + httpLbs req { redirectCount = 0 } manager >> return "")+ (\e -> case e of+ (StatusCodeException s hdr _) ->+ if s == status302 then redirect hdr + else return ""+ otherException -> print otherException >> return ""+ )+ where+ url = stream_url x+ query = [("client_id", Just $ C.pack "2b659ea66970555922d89ce9c07b2d0d")]+ redirect hdr = case HM.lookup hLocation $ HM.fromList hdr of + Just u -> return $ C.unpack u+ Nothing -> return ""++ songMeta x = Radio.SongMeta "" "" (title x)++ tagged _ = False++-- Currently, no api is provided to retrieve genre list.+genres :: IO [String]+genres = return + ["80s","Abstract","Acid Jazz","Acoustic"+ ,"Acoustic Rock","Alternative","Ambient","Avantgarde"+ ,"Ballads","Blues","Blues Rock","Breakbeats"+ ,"Chillout","Chiptunes","Choir","Classic Rock"+ ,"Classical","Classical Guitar","Contemporary","Dance"+ ,"Dancehall","Death Metal","Dirty South","Disco"+ ,"Dream Pop","Drum & Bass","Dub","Dubstep"+ ,"Easy Listening","Electro House","Electronic","Electronic Pop"+ ,"Electronic Rock","Folk Rock","Funk","Glitch"+ ,"Grime","Grindcore","Grunge","Hard Rock",+ "Hardcore","Heavy Metal","Hip-Hop","House"+ ,"Indie","Indie Pop","Industrial Metal","Instrumental"+ ,"Instrumental Rock","J-Pop","Jazz","Jazz Funk"+ ,"Jazz Fusion","K-Pop","Latin Jazz","Metalcore"+ ,"Minimal","Modern Jazz","Moombahton","New Wave"+ ,"Nu Jazz","Opera","Orchestral","Piano"+ ,"Pop","Post Hardcore","Post Rock","Progressive House"+ ,"Progressive Metal","Progressive Rock","Punk"+ ,"R&B","Rap","Reggae","Reggaeton","Riddim"+ ,"Rock","Rock 'n' Roll","Shoegaze","Singer / Songwriter"+ ,"Smooth Jazz","Soul","Synth Pop","Tech House"+ ,"Techno","Thrash Metal","Trance","Trap"+ ,"Trip-hop","Turntablism","Underground"]++pprGenres :: [String] -> IO ()+pprGenres [] = return ()+pprGenres gs = do+ putStrLn $ foldr1 f (take 4 gs)+ pprGenres $ drop 4 gs+ where+ f a b = a ++ (concat $ take (20 - length(a)) $ repeat " ") ++ b
+ Radio/Douban.hs view
@@ -0,0 +1,192 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeFamilies #-}+-- | Module of http://douban.fm+module Radio.Douban where++import Codec.Binary.UTF8.String (encodeString)+import Control.Applicative ((<$>), (<*>))+import Control.Monad+import Data.Aeson+import qualified Data.ByteString.Char8 as C+import Data.Char (isDigit)+import Data.Conduit (($$+-))+import Data.Conduit.Attoparsec (sinkParser)+import qualified Data.HashMap.Strict as HM+import Data.Maybe (fromJust, fromMaybe)+import qualified Data.Text as T+import Network.HTTP.Conduit+import Network.HTTP.Types (urlEncode, renderQuery, Query)+import Prelude hiding (id)+import System.Console.ANSI+import Text.HTML.DOM (parseLBS)+import Text.XML.Cursor++import qualified Radio++type Param a = Radio.Param Douban++data Douban = Douban + { picture :: String+ , albumtitle :: String+ -- , company :: String+ -- , rating_avg :: Float+ -- , public_time :: String+ , ssid :: Maybe String+ , album :: String+ -- , like :: Int+ , artist :: String+ , url :: String+ , title :: String+ , subtype :: String+ -- , length :: Int+ , sid :: String+ , aid :: String+ } deriving Show++instance FromJSON Douban where+ parseJSON (Object v) = Douban <$>+ v .: "picture" <*>+ v .: "albumtitle" <*>+ v .:? "ssid" <*>+ v .: "album" <*>+ --v .: "like" <*>+ v .: "artist" <*>+ v .: "url" <*>+ v .: "title" <*>+ v .: "subtype" <*>+ v .: "sid" <*>+ v .: "aid"+ parseJSON _ = mzero+++getPlaylist' :: Query -> IO [Douban]+getPlaylist' query = do+ let url = "http://douban.fm/j/mine/playlist"+ initReq <- parseUrl url+ let req = initReq { method = "GET"+ , queryString = renderQuery False query+ }+ withManager $ \manager -> do+ res <- http req manager+ liftM Radio.parsePlaylist (responseBody res $$+- sinkParser json)++musicianID :: String -> IO (Maybe String)+musicianID name = do+ let url = "http://music.douban.com/search/" ++ + (C.unpack $ urlEncode True (C.pack $ encodeString name))+ rsp <- simpleHttp url+ let cursor = fromDocument $ parseLBS rsp+ href = cursor $// element "a" + >=> attributeIs "class" "ll musician_title "+ &| attribute "href"+ return $ Just $ filter isDigit $ T.unpack $ head $ head href++instance Radio.Radio Douban where+ data Param Douban = Cid Int | Musician String++ -- TODO: those without ssid filed are ads, filter them out!+ parsePlaylist (Object hm) = do+ let songs = HM.lookup "song" hm+ case fromJSON $ fromMaybe Null songs of+ Success s -> s+ Error _ -> []+ parsePlaylist _ = error "Unrecognized playlist format."++ getPlaylist (Cid cid) = do+ let query = [ ("type", Just "n")+ , ("channel", Just $ C.pack $ show cid)+ , ("from", Just "lord")+ ]+ getPlaylist' query+ getPlaylist (Musician name) = do+ mId <- musicianID name+ let query = [ ("type", Just "n")+ , ("channel", Just "0")+ , ("context", Just $ C.pack ("channel:0|musician_id:" ++ fromJust mId))+ , ("from", Just "lord")+ ]+ getPlaylist' query++ songUrl _ x = return $ url x++ songMeta x = Radio.SongMeta (artist x) (albumtitle x) (title x)++ -- Songs from douban.fm comes with no tags!+ tagged _ = False++data Channel = Channel + { intro :: String+ , name :: String+ , song_num :: Int+ -- , creator :: Creator+ , banner :: String+ , cover :: String+ , id :: Int+ , hot_songs :: [String]+ } deriving (Eq, Show)++instance FromJSON Channel where+ parseJSON (Object v) = Channel <$>+ v .: "intro" <*>+ v .: "name" <*>+ v .: "song_num" <*>+ v .: "banner" <*>+ v .: "cover" <*>+ v .: "id" <*>+ v .: "hot_songs"+ parseJSON _ = mzero+++pprChannels :: [Channel] -> IO ()+pprChannels chs =+ forM_ chs (\c -> do+ setSGR [SetConsoleIntensity BoldIntensity]+ putStr $ "* " ++ name c + setSGR [SetColor Foreground Vivid Green]+ putStrLn $ " cid=" ++ show (id c)+ setSGR [Reset]+ let folding = foldr (\x acc -> + if x `elem` "\r\n" then ' ':acc else x:acc) []+ putStrLn $ " Intro: " ++ folding (intro c) + putStr " Hot songs: " + forM_ (hot_songs c) (\s -> putStr $ s ++ ", ")+ putStrLn ""+ )++-- | Return a list of hot channels.+hot :: IO [Channel]+hot = search' url+ where+ url = "http://douban.fm/j/explore/hot_channels"+++-- | Return a list of up trending channels.+trending :: IO [Channel]+trending = search' url+ where+ url = "http://douban.fm/j/explore/up_trending_channels"+++-- | Return a list of channels matching provided keywords.+search :: String -> IO [Channel]+search [] = return []+search key = search' url+ where + url = "http://douban.fm/j/explore/search?query=" ++ + -- encodeString: encode chinese characters+ (C.unpack $ urlEncode True (C.pack $ encodeString key))+++search' :: String -> IO [Channel]+search' url = do+ req <- parseUrl url+ (Object hm) <- withManager $ \manager -> do+ res <- http req manager+ responseBody res $$+- sinkParser json++ let (Object hm') = fromJust $ HM.lookup "data" hm+ resData = fromJust $ HM.lookup "channels" hm'+ channels = fromJSON resData :: Result [Channel]+ case channels of+ Success c -> return c+ Error err -> putStrLn err >> print resData >> return []
+ Radio/Jing.hs view
@@ -0,0 +1,272 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeFamilies #-}+-- | Module of http://jing.fm+-- It's a bit tricky to play jing.fm+-- Notice the `play` function of **instance Radio**+module Radio.Jing where++import Codec.Binary.UTF8.String (encodeString)+import Control.Applicative ((<$>), (<*>))+import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.MVar+import qualified Control.Exception as E+import Control.Monad (liftM, mzero)+import Data.Aeson+import Data.ByteString (ByteString)+import qualified Data.ByteString.Char8 as C+import qualified Data.HashMap.Strict as HM+import Data.Maybe (fromJust, fromMaybe)+import qualified Data.Text as T+import Data.Yaml+import Data.CaseInsensitive (mk)+import Data.Conduit (runResourceT, ($$+-))+import Data.Conduit.Binary (sinkFile)+import Data.Conduit.Attoparsec (sinkParser)+import GHC.Generics (Generic)+import Network.HTTP.Types +import Network.HTTP.Conduit+import Network.MPD hiding (play, Value, Query)+import qualified Network.MPD as MPD+import System.IO+import System.IO.Unsafe (unsafePerformIO)+import System.Directory (doesFileExist)++import Radio++type Param a = Radio.Param Jing++downloaded = unsafePerformIO newEmptyMVar++data Jing = Jing + { abid :: Int -- album id+ , aid :: Int -- artist id+ , an :: String -- album name+ , atn :: String -- artist name+ , d :: String+ , fid :: String+ , fs :: Int -- file size+ , mid :: ByteString+ , n :: String -- song name+ , tid :: Int+ --, y :: Bool+ } deriving (Show, Generic)++instance FromJSON Jing++data Usr = Usr+ { userid :: Int+ , usernick :: ByteString+ } deriving Show++instance FromJSON Usr where+ parseJSON (Object v) = Usr <$>+ v .: "id" <*>+ v .: "nick"+ parseJSON _ = mzero++instance Radio.Radio Jing where+ data Param Jing = Token+ { aToken :: ByteString+ , rToken :: ByteString+ , uid :: Int+ , nick :: ByteString + , cmbt :: String+ , highquality :: Bool+ } deriving (Show, Generic)++ parsePlaylist (Object hm) = do+ let songs = HM.lookup "result" hm >>= + \(Object hm') -> HM.lookup "items" hm'+ case fromJSON $ fromMaybe Null songs of+ Success s -> s+ Error err -> error $ "Parse playlist failed: " ++ show err+ parsePlaylist _ = error "Unrecognized playlist format."++ getPlaylist tok = do+ let url = "http://jing.fm/api/v1/search/jing/fetch_pls"+ query = [ ("q", C.pack $ encodeString $ cmbt tok)+ , ("ps", "10")+ , ("st", "0")+ , ("u", C.pack $ show $ uid tok)+ , ("tid", "0")+ , ("mt", "")+ , ("ss", "true")+ ]+ aHdr = (mk "Jing-A-Token-Header", aToken tok) :: Header+ rHdr = (mk "Jing-R-Token-Header", rToken tok) :: Header++ initReq <- parseUrl url+ let req = initReq { requestHeaders = [aHdr, rHdr] }++ -- urlEncodeBody adds a content-type request header and+ -- changes the method to POST.+ let req' = urlEncodedBody query req+ withManager $ \manager -> do+ res <- http req' manager+ liftM Radio.parsePlaylist (responseBody res $$+- sinkParser json)++ songUrl tok x = E.catch + (do+ let url = "http://jing.fm/api/v1/media/song/surl"+ type_ = if highquality tok then "NO" else "MM"+ query = [ ("type", Just type_)+ , ("mid", Just $ mid x)+ ] :: Query+ aHdr = (mk "Jing-A-Token-Header", aToken tok) :: Header+ rHdr = (mk "Jing-R-Token-Header", rToken tok) :: Header++ initReq <- parseUrl url+ let req = initReq { method = "POST"+ , requestHeaders = [aHdr, rHdr]+ , queryString = renderQuery False query+ }+ (Object hm) <- withManager $ \manager -> do+ res <- http req manager+ responseBody res $$+- sinkParser json+ let (String surl) = fromJust $ HM.lookup "result" hm+ return $ T.unpack surl)+ (\e -> print (e :: E.SomeException) >> songUrl tok x)++ songMeta x = Radio.SongMeta (atn x) (an x) (n x)++ -- Songs from jing.fm comes with tags!+ tagged _ = True++ -- The media file jing.fm provides is of m4a type.+ -- MPD is unable to stream m4a file, as a result, lord streams it to + -- ~/.lord/lord.m4a+ play logger reqData [] = Radio.getPlaylist reqData >>= Radio.play logger reqData+ play logger reqData (x:xs) = do+ surl <- Radio.songUrl reqData x+ print surl+ req <- parseUrl surl+ home <- Radio.getLordDir+ manager <- newManager def+ threadId <- forkIO $ E.catch + (do+ let song = artist (songMeta x) ++ " - " ++ title (songMeta x)+ getStateFile >>= flip writeFile song++ runResourceT $ do + res <- http req manager+ responseBody res $$+- sinkFile (home ++ "/lord.m4a")+ -- This will block until downloaded.++ writeLog logger song + putMVar downloaded ())+ (\e -> do+ print (e :: E.SomeException)+ Radio.writeLog logger $ show e+ Radio.play logger reqData xs+ )+ --mtid <- newMVar threadId+ threadDelay 3000000+ mpdLoad+ Radio.play logger reqData xs++mpdLoad :: IO ()+mpdLoad = do+ --m4a <- (++ "/lord.m4a") <$> Radio.getLordDir+ let m4a = "lord/lord.m4a"+ s <- withMPD $ do+ clear+ update [Path "lord"]+ add m4a+ case s of+ Right _ -> do+ withMPD $ MPD.play Nothing+ mpdPlay+ _ -> mpdLoad++mpdPlay :: IO ()+mpdPlay = do+ withMPD $ idle [PlayerS] + -- This will block until paused/finished.++ st <- withMPD status+ let st' = fmap stState st+ print st'+ bd <- isEmptyMVar downloaded+ if st' == Right Stopped + then if bd + then do -- Slow Network+ withMPD $ MPD.play Nothing+ mpdPlay+ else do+ withMPD clear+ takeMVar downloaded -- Finished+ else mpdPlay -- Pause++instance FromJSON (Radio.Param Jing)+instance ToJSON (Radio.Param Jing)++login :: String -> IO (Radio.Param Jing)+login keywords = do+ hSetBuffering stdout NoBuffering+ hSetEcho stdin True + putStrLn "Please Log in"+ putStr "Email: "+ email <- getLine+ putStr "Password: "+ hSetEcho stdin False + pwd <- getLine+ hSetEcho stdin True + putStrLn ""+ mtoken <- createSession keywords email pwd+ case mtoken of+ Just tok -> do+ saveToken tok+ return tok+ Nothing -> do+ putStrLn "ERROR: Invalid email or password!"+ login keywords++createSession :: String -> String -> String -> IO (Maybe (Radio.Param Jing))+createSession keywords email pwd = do+ let url = "http://jing.fm/api/v1/sessions/create"+ query = [ ("email", C.pack email) , ("pwd", C.pack pwd) ]+ req <- parseUrl url+ let req' = urlEncodedBody query req+ res <- withManager $ \manager -> http req' manager+ let hmap = HM.fromList $ responseHeaders res+ atoken = HM.lookup "Jing-A-Token-Header" hmap+ rtoken = HM.lookup "Jing-R-Token-Header" hmap+ parseToken :: Value -> Maybe (Radio.Param Jing)+ parseToken (Object hm) = do+ let user = HM.lookup "result" hm >>= + \(Object hm') -> HM.lookup "usr" hm'+ case fromJSON $ fromMaybe Null user of+ Success u -> Token <$> atoken+ <*> rtoken+ <*> (Just $ userid u)+ <*> (Just $ usernick u)+ <*> Just keywords+ <*> Just True+ Error err -> error $ "Retrieve token failed: " ++ show err+ parseToken _ = error "Unrecognized token format."+ liftM parseToken (runResourceT $ responseBody res $$+- sinkParser json)++saveToken :: Radio.Param Jing -> IO ()+saveToken tok = do+ home <- Radio.getLordDir+ let yml = home ++ "/lord.yml"+ encodeFile yml tok+ putStrLn "Your token has been saved to ~/lord.yml"++readToken :: String -> IO (Maybe (Radio.Param Jing))+readToken keywords = do+ home <- Radio.getLordDir+ let yml = home ++ "/lord.yml"+ exist <- doesFileExist yml+ if exist+ then do+ conf <- decodeFile yml+ case conf of+ Nothing -> error $ "Invalid YAML file: " ++ show conf+ Just c -> + case fromJSON c of+ Success tok -> return $ Just $ tok { cmbt = keywords }+ Error err -> error $ "Parse token failed: " ++ show err+ else return Nothing
+ Radio/Reddit.hs view
@@ -0,0 +1,64 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeFamilies #-}+-- | Module of http://radioreddit.com+module Radio.Reddit where++import Control.Monad (liftM)+import Data.Aeson+import Data.Conduit (($$+-))+import Data.Conduit.Attoparsec (sinkParser)+import qualified Data.HashMap.Strict as HM+import Data.Maybe (fromMaybe)+import GHC.Generics (Generic)+import Network.HTTP.Conduit++import qualified Radio++type Param a = Radio.Param Reddit++data Reddit = Reddit+ { title :: String+ , artist :: String+ , preview_url :: String+ , download_url :: Maybe String+ } deriving (Show, Generic)++instance FromJSON Reddit++instance Radio.Radio Reddit where+ data Param Reddit = Genre String++ parsePlaylist (Object hm) = do+ let songs = HM.lookup "songs" hm >>=+ \(Object hm') -> HM.lookup "song" hm'+ case fromJSON $ fromMaybe Null songs of+ Success s -> s+ Error err -> error $ "Parse playlist failed: " ++ show err+ parsePlaylist _ = error "Unrecognized playlist format."++ getPlaylist (Genre g) = do+ let url = "http://radioreddit.com/api/" ++ g ++ "/status.json"+ req <- parseUrl url+ withManager $ \manager -> do+ res <- http req manager+ liftM Radio.parsePlaylist (responseBody res $$+- sinkParser json)++ songUrl _ x = return $ fromMaybe (preview_url x) (download_url x)++ songMeta x = Radio.SongMeta (artist x) "" (title x)++ tagged _ = False++genres :: IO [String]+genres = return + [ "main", "electronic", "rock", "metal"+ , "indie", "hiphop", "random", "talk" ]++pprGenres :: [String] -> IO ()+pprGenres [] = return ()+pprGenres gs = do+ putStrLn $ foldr1 f (take 4 gs)+ pprGenres $ drop 4 gs+ where+ f a b = a ++ (concat $ take (20 - length(a)) $ repeat " ") ++ b
lord.cabal view
@@ -1,5 +1,5 @@ name: lord-version: 1.20131115+version: 1.20131124 synopsis: A command line interface to online radios. description: A unified interface to online radio service providers, use mpd as backend.@@ -19,6 +19,7 @@ build-type: Simple cabal-version: >=1.10 tested-with: GHC == 7.6.3+Extra-Source-Files: CHANGELOG source-repository head type: git@@ -26,7 +27,11 @@ executable lord main-is: main.hs - -- other-modules: + other-modules: Radio,+ Radio.Cmd,+ Radio.Douban,+ Radio.Jing,+ Radio.Reddit ghc-options: -Wall -fno-warn-unused-do-bind build-depends: base >= 4 && < 5, aeson >= 0.6,