netease-fm-1.2.2: lib/FM/CacheManager.hs
{-# LANGUAGE RecordWildCards, OverloadedStrings #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module FM.CacheManager (
MonadCache, runCache
, Cache
, initCache
, waitAllCacheTasks
, cacheSong
, deleteSong
, lookupCache
, initSession
, fetchCache
, fetchUrl
, fetchLyrics
) where
import Control.Concurrent (forkIO)
import Control.Concurrent.STM.TQueue
import Control.Concurrent.STM.Lock
import Control.Exception (try, SomeException)
import Control.Monad.Reader
import Control.Monad.STM (atomically)
import qualified Crypto.Hash as C
import qualified Data.Aeson as JSON
import Data.Aeson ((.:), (.=))
import Data.Aeson.Extra
import qualified Data.Aeson.Types as JSON
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as BL
import Data.Default.Class (def)
import Data.Maybe (fromJust, isJust)
import qualified Data.Vector as V
import System.Directory (doesFileExist, getDirectoryContents, removeFile)
import System.Exit (ExitCode (..))
import System.IO (hClose)
import System.Process (runInteractiveProcess, waitForProcess)
import qualified FM.NetEase as NetEase
import FM.Session
import qualified FM.Song as Song
newtype MonadCache a = MonadCache (ReaderT Cache IO a)
deriving (Functor, Applicative, Monad, MonadIO, MonadReader Cache)
runCache :: Cache -> MonadCache a -> IO a
runCache cache (MonadCache m) = runReaderT m cache
hashSongId :: String -> String
hashSongId uid = uid ++ "-" ++ hash
where hash = show $ C.hashWith C.MD5 $ BS8.pack uid
data SongWithLyrics = SongWithLyrics Song.Song Song.Lyrics
instance JSON.FromJSON SongWithLyrics where
parseJSON = onObject $ \v -> do
let
parseArray :: (JSON.FromJSON a) => JSON.Value -> JSON.Parser [a]
parseArray = onArray $ \v -> mapM JSON.parseJSON (V.toList v)
uid <- v .: "uid"
title <- v .: "title"
album <- v .: "album"
artists <- parseArray =<< v .: "artists"
let url = Nothing
let parseLyrics = onObject $ \v -> do
time <- parseArray =<< v .: "time"
body <- parseArray =<< v .: "body"
return $ Song.Lyrics $ zip time body
lyrics <- parseLyrics =<< v .: "lyrics"
return $ SongWithLyrics Song.Song {..} lyrics
instance JSON.ToJSON SongWithLyrics where
toJSON (SongWithLyrics Song.Song {..} (Song.Lyrics lyrics)) =
JSON.object [ "uid" .= uid
, "title" .= title
, "album" .= album
, "artists" .= array artists
, "lyrics" .= JSON.object [ "time" .= array (map fst lyrics), "body" .= array (map snd lyrics) ]
]
where array xs = JSON.Array (V.fromList (JSON.toJSON <$> xs))
data Cache = Cache {
cachePath :: FilePath
, songQueue :: TQueue Song.Song
, queueLock :: Lock
}
initCache :: (MonadIO m) => FilePath -> m Cache
initCache cachePath = do
songQueue <- liftIO newTQueueIO
queueLock <- liftIO newLockIO
netEaseSession <- NetEase.initSession True
liftIO $ forkIO $ forever $ do
song@Song.Song {..} <- atomically $ do
result <- peekTQueue songQueue
state <- viewLock queueLock
when (state == Released) (acquireLock queueLock)
return result
let hashPath = hashSongId (show uid)
(_, outHandle, errHandle, processHandle) <- runInteractiveProcess "aria2c" [ "--auto-file-renaming=false"
, "-d"
, cachePath
, "-o"
, hashPath ++ ".mp3"
, fromJust url ]
Nothing Nothing
exitCode <- waitForProcess processHandle
hClose outHandle
hClose errHandle
if exitCode == ExitSuccess
then do
lyrics <- runSession netEaseSession (NetEase.fetchLyrics song)
BL.writeFile (cachePath ++ "/" ++ hashPath ++ ".json") (JSON.encode $ SongWithLyrics song lyrics)
else do
let path = cachePath ++ "/" ++ hashPath ++ ".mp3"
void (try (removeFile path) :: IO (Either SomeException ()))
atomically $ do
readTQueue songQueue
isEmpty <- isEmptyTQueue songQueue
when isEmpty (releaseLock queueLock)
return Cache {..}
waitAllCacheTasks :: (MonadIO m) => Cache -> m ()
waitAllCacheTasks Cache {..} = liftIO $ atomically $ waitLock queueLock Released
cacheSong :: (MonadIO m, MonadReader Cache m) => Song.Song -> String -> m ()
cacheSong song url = do
Cache {..} <- ask
liftIO $ atomically $ writeTQueue songQueue (song { Song.url = Just url })
deleteSong :: (MonadIO m, MonadReader Cache m) => Song.Song -> m ()
deleteSong Song.Song {..} = do
Cache {..} <- ask
let path = cachePath ++ "/" ++ hashSongId (show uid)
void $ forM [".mp3", ".json"] $ \suffix -> do
let fullPath = path ++ suffix
liftIO (try $ removeFile fullPath :: IO (Either SomeException ()))
lookupCache :: (MonadIO m, MonadReader Cache m) => Int -> m (Maybe String)
lookupCache uid = do
Cache {..} <- ask
let fullPrefix = cachePath ++ "/" ++ hashSongId (show uid)
let mp3Path = fullPrefix ++ ".mp3"
let jsonPath = fullPrefix ++ ".json"
mp3Exists <- liftIO $ doesFileExist mp3Path
jsonExists <- liftIO $ doesFileExist jsonPath
return $ if mp3Exists && jsonExists
then Just mp3Path
else Nothing
data Session = Session {
sessionCachePath :: FilePath
}
instance IsSession Session
initSession :: (MonadIO m) => Cache -> m SomeSession
initSession Cache {..} = return $ SomeSession (Session cachePath)
fetchCache :: (MonadIO m, MonadReader Session m) => m [Song.Song]
fetchCache = do
Session {..} <- ask
files <- liftIO $ filterM (isValid sessionCachePath) =<< getDirectoryContents sessionCachePath
songs <- forM files $ \path -> do
let fullPath = sessionCachePath ++ "/" ++ path
song <- liftIO $ JSON.decode <$> BL.readFile fullPath
case song of
Just (SongWithLyrics song _) -> do
let url = take (length fullPath - 4) fullPath ++ "mp3"
return $ Just song { Song.url = Just url }
Nothing -> return Nothing
return $ map fromJust $ filter isJust songs
where
isValid dir path = do
let fullPath = dir ++ "/" ++ path
isFile <- doesFileExist fullPath
let uid = takeWhile (/= '-') path
let hashPath = hashSongId uid
let validFile = path == (hashPath ++ ".json")
mp3Exists <- doesFileExist (dir ++ "/" ++ hashPath ++ ".mp3")
return $ isFile && validFile && mp3Exists
fetchUrl :: (MonadIO m, MonadReader Session m) => Song.Song -> m (Maybe String)
fetchUrl Song.Song {..} = return url
fetchLyrics :: (MonadIO m, MonadReader Session m) => Song.Song -> m Song.Lyrics
fetchLyrics Song.Song {..} = do
song <- liftIO $ JSON.decode <$> BL.readFile (take (length (fromJust url) - 3) (fromJust url) ++ "json")
return $ case song of
Just (SongWithLyrics _ lyrics) -> lyrics
Nothing -> def