packages feed

lord 1.20131115 → 1.20131124

raw patch · 7 files changed

+762/−2 lines, 7 files

Files

+ 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,