mplayer-spot-0.1.0.0: src/MPlayer/Spot.hs
module MPlayer.Spot where
import Conduit (ConduitT, awaitForever, iterMC, runConduit, yield, (.|))
import Control.Applicative ((<|>))
import Control.Concurrent.Async (Concurrently(..))
import Control.Concurrent.MVar (MVar, modifyMVar_, newMVar, tryReadMVar)
import Control.Exception (IOException, finally, try)
import Control.Monad (void)
import Control.Monad.IO.Class (liftIO)
import Data.Attoparsec.ByteString.Char8
( Parser, char, notInClass, parseOnly, rational, skipSpace, skipWhile
, string, takeWhile1
)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as C8
import Data.Conduit.Combinators (stderr, stdin, stdout)
import Data.Conduit.Process (streamingProcess, waitForStreamingProcess)
import Data.Monoid ((<>))
import Data.Streaming.Process (StreamingProcessHandle)
import Data.Text (unpack)
import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Void (Void)
import System.Directory (createDirectoryIfMissing, getHomeDirectory, removeFile)
import System.Environment (getArgs)
import System.Exit (exitWith)
import System.FilePath (takeFileName, (</>))
import System.IO (BufferMode(..), hSetBuffering)
import qualified System.IO as IO
import System.Process (proc)
-- Define the the types that should be defaulted to. We can define one
-- type for string-like things, and one type for integer-like things. It
-- doesn't matter what order they are in.
default (T.Text, Int)
-- | A config for our program.
data Config = Config { configMPlayerSpotRCDir :: FilePath -- ^ @~/.mplayer-spots@ dir
, configSpotsDir :: FilePath -- ^ @~/.mplayer-spots/spots@ dir
, configIgnoreSeconds :: Float -- how many seconds to ignore
-- in media before creating
-- spot file
}
deriving Show
-- | Create a default 'Config'.
defaultConfig :: IO Config
defaultConfig = do
homeDir <- getHomeDirectory
let rcDir = homeDir </> ".mplayer-spot"
let spotsDir = rcDir </> "spots"
let ignoreSeconds = 180
pure $ Config rcDir spotsDir ignoreSeconds
-- | Info about our media.
data MediaInfo = MediaInfo { mediaInfoLength :: Maybe Float -- ^ length of the media
, mediaInfoFilename :: Maybe ByteString -- ^ filename of the media
, mediaInfoCurPos :: Maybe Float -- ^ current position
, mediaInfoAlreadySetOldLocation :: Bool -- ^ whether we have already
-- set the old location
}
deriving Show
defaultMediaInfo :: MediaInfo
defaultMediaInfo = MediaInfo { mediaInfoLength = Nothing
, mediaInfoFilename = Nothing
, mediaInfoCurPos = Nothing
, mediaInfoAlreadySetOldLocation = False
}
-- | Datatype to hold mplayer's stdin, stdout, etc conduits.
data MPlayer = MPlayer { mplayerStdin :: ConduitT ByteString Void IO ()
, mplayerStdout :: ConduitT () ByteString IO ()
, mplayerStderr :: ConduitT () ByteString IO ()
, mplayerProcHandle :: StreamingProcessHandle
}
-- | Take in a bunch of arguments and use them to create the mplayer process.
createMPlayerProcess :: [String] -> IO MPlayer
createMPlayerProcess programArgs = do
let mplayerArgs = ["-identify", "-slave"] <> programArgs
( processStdin :: ConduitT ByteString Void IO ()
, processStdout :: ConduitT () ByteString IO ()
, processStderr :: ConduitT () ByteString IO ()
, processHandle) <-
streamingProcess (proc "mplayer" mplayerArgs)
return $! MPlayer processStdin processStdout processStderr processHandle
-- streamingProcess :: (MonadIO m, InputSource stdin, OutputSink stdout, OutputSink stderr) => CreateProcess -> m (stdin, stdout, stderr, StreamingProcessHandle)
--
-- (r ~ (), r' ~ (), MonadIO m, MonadIO n, i ~ ByteString) => InputSource (ConduitM i o m r, n r')
-- (r ~ (), r' ~ (), MonadIO m, MonadIO n, o ~ ByteString) => OutputSink (ConduitM i o m r, n r')
-- InputSource (ConduitM ByteString o IO ())
-- OutputSink (ConduitM i ByteString IO ())
--
--
-- | Parser for a value prefixed by a bytestring. Uses skipWhile to make it
-- faster.
genericParser :: forall a . ByteString -> Parser a -> ByteString -> Maybe a
genericParser str parser mplayerLine =
either (const Nothing) Just $ parseOnly go mplayerLine
where
go :: Parser a
go = do
-- Skip until we find the first character of the prefix that we are looking for.
skipWhile (/= C8.head str)
-- Try to match the prefix. If it matches, run the parser.
string str *> parser
-- If it doesn't match, then strip the first character and recurse.
<|> char (C8.head str) *> go
-- | Parser for the length of the of the media.
getLength :: ByteString -> Maybe Float
getLength = genericParser "ID_LENGTH=" rational
-- | Parser for the filename of the of the media.
getFilename :: ByteString -> Maybe ByteString
getFilename = genericParser "ID_FILENAME=" $ takeWhile1 (notInClass "\n")
-- | Parser for the current location in the media.
getCurPos :: ByteString -> Maybe Float
getCurPos = genericParser "A:" $ skipSpace *> rational
updateLength :: MVar MediaInfo -> ByteString -> IO ()
updateLength mediaInfoMVar mplayerLine = do
maybeMediaInfo <- tryReadMVar mediaInfoMVar
case maybeMediaInfo of
Just (MediaInfo (Just _) _ _ _) -> pure ()
_ ->
case getLength mplayerLine of
Nothing -> pure ()
Just mediaLength ->
modifyMVar_ mediaInfoMVar $ updateMediaInfo mediaLength
where
updateMediaInfo :: Float -> MediaInfo -> IO MediaInfo
updateMediaInfo mediaLength mediaInfo =
pure mediaInfo { mediaInfoLength = Just mediaLength }
updateFilename :: MVar MediaInfo -> ByteString -> IO ()
updateFilename mediaInfoMVar mplayerLine = do
maybeMediaInfo <- tryReadMVar mediaInfoMVar
case maybeMediaInfo of
Just (MediaInfo _ (Just _) _ _) -> pure ()
_ ->
case getFilename mplayerLine of
Nothing -> pure ()
Just mediaFilename ->
modifyMVar_ mediaInfoMVar $ updateMediaInfo mediaFilename
where
updateMediaInfo :: ByteString -> MediaInfo -> IO MediaInfo
updateMediaInfo mediaFilename mediaInfo =
pure mediaInfo { mediaInfoFilename = Just mediaFilename }
updateCurPos :: MVar MediaInfo -> ByteString -> IO ()
updateCurPos mediaInfoMVar mplayerLine =
maybe (pure ()) (modifyMVar_ mediaInfoMVar . updateMediaInfo) $ getCurPos mplayerLine
where
updateMediaInfo :: Float -> MediaInfo -> IO MediaInfo
updateMediaInfo mediaCurPos mediaInfo =
pure mediaInfo { mediaInfoCurPos = Just mediaCurPos }
-- | Try to read 3 different things from the mplayer stdout:
--
-- - length of the media
-- - media filename
-- - current position in the media
processMPlayerStdout :: MVar MediaInfo -> ByteString -> IO ()
processMPlayerStdout mediaInfoMVar mplayerLine = do
updateLength mediaInfoMVar mplayerLine
updateFilename mediaInfoMVar mplayerLine
updateCurPos mediaInfoMVar mplayerLine
-- | 'Conduit' that reads in key presses (from stdin), and yields mplayer
-- commands.
--
-- In order for this to work right when hooked up to stdin, stdin should be set
-- to 'NoBuffering'.
sendMPlayerCommands :: ConduitT ByteString ByteString IO ()
sendMPlayerCommands = awaitForever go
where
go :: ByteString -> ConduitT ByteString ByteString IO ()
go inputChar
| inputChar == "q" = yield "quit\n"
| inputChar == "p" = yield "pause\n"
| inputChar == " " = yield "pause\n"
| inputChar == "\ESC[D" = yield "seek -10\n"
| inputChar == "\ESC[C" = yield "seek 10\n"
| inputChar == "\ESC[B" = yield "seek -60\n"
| inputChar == "\ESC[A" = yield "seek 60\n"
| inputChar == "\ESC[6~" = yield "seek -600\n"
| inputChar == "\ESC[5~" = yield "seek 600\n"
| otherwise =
liftIO $ print $ "got keypress " <> inputChar <> " but don't know what to do with it"
calculateFullSpotsPath :: Config -> ByteString -> FilePath
calculateFullSpotsPath (Config _ spotsDir _) filename =
spotsDir </> takeFileName (unpack (decodeUtf8 filename))
-- | Producer that tries to read the filename from the @'MVar' 'MediaInfo'@,
-- and if it succeeds, tries to open the spot file and read in the saved
-- position. Produce it as a value.
setOldLocation :: Config -> MVar MediaInfo -> ConduitT i ByteString IO ()
setOldLocation config mediaInfoMVar = do
maybeMediaInfo <- liftIO $ tryReadMVar mediaInfoMVar
case maybeMediaInfo of
Just (MediaInfo _ _ _ True) -> pure ()
Just (MediaInfo _ (Just filename) _ False) -> do
let fullPath = calculateFullSpotsPath config filename
filecontents <- liftIO $ try $ readFile fullPath
case filecontents of
Right oldLocation -> do
yield $ "seek " <> encodeUtf8 (T.pack oldLocation) <> "\n"
Left (_ :: IOException) -> pure ()
liftIO $ modifyMVar_ mediaInfoMVar $ \mediaInfo -> pure mediaInfo { mediaInfoAlreadySetOldLocation = True }
_ -> setOldLocation config mediaInfoMVar
-- | Run the mplayer process while doing 3 things:
--
-- - Read mplayer's stdout, looking for the length, filename, and current
-- position of the media.
-- - Once the filename has been found, look for a spot file, and if it exists,
-- make mplayer seek to the location.
-- - Take keys from stdin and translate them to mplayer commands. Since mplayer
-- is in slave mode, it can't take in commands like normal.
runMPlayerUpdateMediaInfo :: Config -> MPlayer -> MVar MediaInfo -> IO ()
runMPlayerUpdateMediaInfo config mplayer mediaInfoMVar = do
-- set stdin to not be buffered so we only get one character at a time
hSetBuffering IO.stdin NoBuffering
-- a conduit for sending the old location to mplayer
let oldLocationToMplayerStdin =
runConduit $ setOldLocation config mediaInfoMVar .| mplayerStdin mplayer
-- Read keypresses from stdin, translate to mplayer commands, and pipe to mplayer.
let stdinToMplayerStdin =
runConduit $
stdin .|
sendMPlayerCommands .|
mplayerStdin mplayer
-- Process mplayer's stdout to find filename, length, and current position.
-- Print mplayer's stdout to our stdout.
let mplayerStdoutToStdout =
runConduit $
mplayerStdout mplayer .|
iterMC (processMPlayerStdout mediaInfoMVar) .|
stdout
-- Print mplayer's stderr to our stderr.
let mplayerStderrToStderr = runConduit $ mplayerStderr mplayer .| stderr
-- Handle for controlling the mplayer process.
let mplayerHandle = mplayerProcHandle mplayer
-- Run all conduits concurrently.
runConcurrently $
Concurrently mplayerStdoutToStdout *>
Concurrently mplayerStderrToStderr *>
Concurrently oldLocationToMplayerStdin *>
Concurrently stdinToMplayerStdin *>
Concurrently (waitForStreamingProcess mplayerHandle >>= exitWith)
-- | Create the .mplayer-spots/ and spots/ directories from the config file.
createMPlayerSpotsDir :: Config -> IO ()
createMPlayerSpotsDir (Config rcDir spotsDir _) = do
createDirectoryIfMissing True rcDir
createDirectoryIfMissing True spotsDir
-- | Write out a spot file to the spots directory if all the required fields
-- have been filled in the MediaInfo, and if our current position in the media
-- file is not too early or not too late.
writeSpotFile :: Config -> MVar MediaInfo -> IO ()
writeSpotFile config@(Config _ _ ignoreLength) mediaInfoMVar = do
maybeMediaInfo <- tryReadMVar mediaInfoMVar
case maybeMediaInfo of
Just (MediaInfo (Just mediaLength) (Just filename) (Just exitPos) _) -> do
let spotFilename = calculateFullSpotsPath config filename
writeSpotFile' mediaLength spotFilename exitPos
_ -> putStrLn "When exiting, do not currently have all fields of media info, so cannot write out spot file."
where
-- | Check that the length is not too early or too late. If it is not,
-- then write out the spot file.
writeSpotFile' :: Float -> FilePath -> Float -> IO ()
writeSpotFile' mediaLength spotFilename exitPos
| exitPos <= ignoreLength = do
putStrLn $ "exit position is " <> show exitPos <> " seconds so not writing spot file (not far enough)"
| exitPos >= (mediaLength - ignoreLength) = do
putStrLn $ "exit position is " <> show exitPos <> " seconds so not writing spot file (too close to end)"
removeOldSpotFile spotFilename
| otherwise =
writeFloatToFile spotFilename (max (exitPos - 10) 0)
-- | Write a Float to a FilePath.
writeFloatToFile :: FilePath -> Float -> IO ()
writeFloatToFile spotFilename exitPos = do
putStrLn $ "writing to file: " <> spotFilename <> " (" <> show exitPos <> ")"
writeFile spotFilename $ show exitPos
-- | Remove a file and ignore errors (like if the file doesn't exist).
removeOldSpotFile :: FilePath -> IO ()
removeOldSpotFile =
void . (try :: IO () -> IO (Either IOException ())) . removeFile
defaultMain :: IO ()
defaultMain = do
-- read in program arguments
programArgs <- getArgs
-- create the config we will be using
config <- defaultConfig
-- create the .mplayer-spots directory if it doesn't exist
createMPlayerSpotsDir config
-- create the mplayer process
mplayerProcess <- createMPlayerProcess programArgs
-- create the MediaInfo MVar we will be using to do concurrent stuff
mediaInfoMVar <- newMVar defaultMediaInfo
finally (runMPlayerUpdateMediaInfo config mplayerProcess mediaInfoMVar) $
-- write the spot file after exiting
writeSpotFile config mediaInfoMVar