hscdio-0.1.0.0: src/Sound/Libcdio/Read/CdText.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-|
Description: Functions providing metadata about the contents of a disc.
Copyright: (c) 2019-2021 Sam May
License: GPL-3.0-or-later
Maintainer: ag@eitilt.life
Stability: stable
Portability: non-portable (requires libcdio)
Metadata is stored in a binary format both library-internally and on the disc.
Most audio archivists will recognize it as "those information fields in a CUE
file" (though there are [other
formats](https://www.gnu.org/software/libcdio/cd-text-format.html#Sony-Text-File-Format-_0028Input-Sheet-Version-0_002e7T_0029)
as well), and casual listeners will recognize it as the scrolling text that
you're always happy to see, on the rare times your music player shows it.
Little-used and even-less-known, however, is that a single disc can
theoretically contain metadata in up to eight different languages; because of
the complexity that introduces, it makes more sense to use a second monadic
interface than to try to provide a single monolithic datatype within 'Cdio'.
-}
module Sound.Libcdio.Read.CdText
( -- * Types
CdText
, CdTextError ( .. )
, CdTextErrorType ( .. )
, Info ( .. )
, emptyInfo
, Foreign.Genre ( .. )
, Foreign.Language ( .. )
-- * Evaluation
, cdText
, parseCdText
, withLanguage
, withIndex
, withAll
, runCdText
-- * Data
, language
, languages
, firstTrack
, lastTrack
, info
, discId
, genre
, cdTextRaw
) where
import qualified Control.Applicative as A
#if MIN_VERSION_mtl(2,2,1)
import qualified Control.Monad.Except as N.E
#else
import qualified Control.Monad.Error as N.E
#endif
import qualified Control.Monad.Fail as N.F
import qualified Data.ByteString as BS
import qualified Data.Maybe as Y
import qualified Data.Text as T
import qualified Foreign.Libcdio.CdText as Foreign
import qualified Foreign.Libcdio.Disc as Foreign
import qualified Foreign.Libcdio.Logging as Foreign
import qualified Text.Show as R
import Sound.Libcdio.Logging
import Sound.Libcdio.Track
import Sound.Libcdio.Types.Cdio
import Control.Applicative ( (<|>) )
import Data.Functor ( ($>) )
-- | A computation within the environment of metadata (in a particular
-- language) stored on a CD. The options for affecting that environment
-- from within are limited by design, as this library is intended for /reading/
-- discs rather than /authoring/ them.
newtype CdText a = CdText (Maybe InitialLanguage -> Foreign.Cdio -> IO (Either CdTextError a))
instance Functor CdText where
fmap f (CdText a) = CdText $ \l c -> fmap f <$> a l c
instance Applicative CdText where
pure a = CdText $ \_ _ -> return $ Right a
CdText f <*> CdText a = CdText $ \l c -> do
f' <- f l c
a' <- a l c
return $ f' <*> a'
-- | 'A.empty' fails with 'CdTextEmpty'.
instance A.Alternative CdText where
empty = CdText $ \_ _ -> return . Left . CdTextError CdTextEmpty $ T.pack "empty"
CdText f <|> CdText g = CdText $ \l c -> f l c <|> g l c
instance Monad CdText where
CdText a >>= f = CdText $ \l c -> do
a' <- a l c
let bind' a'' = let CdText b = f a'' in b l c
either (return . Left) bind' a'
-- | Wraps the text in a 'FreeformCdTextError', for recovery with 'N.E.catchError'.
instance N.F.MonadFail CdText where
fail e = CdText $ \_ _ ->
return . Left $ CdTextError (FreeformCdTextError $ T.pack e) (T.pack "fail")
instance N.E.MonadError CdTextError CdText where
throwError err = CdText $ \_ _ -> return $ Left err
catchError (CdText f) e = CdText $ \l c -> f l c >>= \a' -> case a' of
Left err ->
let CdText g = e err
in g l c
Right a -> return $ Right a
instance LibcdioLogger CdText where
logCutoff = CdText $ \_ _ -> Right <$> Foreign.logCutoff
setLogCutoff l = CdText $ \_ _ -> Right <$> Foreign.setLogCutoff l
readLog = CdText $ \_ _ -> Right <$> Foreign.readLog
clearLog = CdText $ \_ _ -> Right <$> Foreign.clearLog
putLog e = CdText $ \_ _ -> Right <$> Foreign.putLog e
-- | Lift a metadata computation from the C-style "Foreign.Libcdio" interface
-- into the monadic "Sound.Libcdio".
liftCdText :: (Foreign.Cdio -> IO a) -> CdText a
liftCdText f = CdText $ \_ c -> Right <$> f c
-- | Associates a well-typed error with human-readable context information.
data CdTextError = CdTextError CdTextErrorType T.Text
deriving ( Eq, Show, Read )
-- | Potential situations which may cause a computation to fail.
data CdTextErrorType
= InvalidBlock Word
-- ^ The requested language index is outside of the bounds accessible
-- by the library (@[0..7]@ in libcdio 2.1 and after, or the smaller
-- range of languages returned by 'languages' before that version).
| LanguageNotFound Foreign.Language
-- ^ The CdText data does not contain the requested language, or
-- 'Foreign.UnknownLanguage' was requested (prior to libcdio 2.1 there
-- was no way to select the latter blocks; even after that version,
-- 'withIndex' must be used instead).
| BadBinaryRead
-- ^ The binary data can not be parsed into a CdText object.
| NoCdText
-- ^ The CD contains no CdText data.
| CdTextEmpty
-- ^ 'A.empty' was called and no better alternative was encountered.
| FreeformCdTextError T.Text
-- ^ Escape hatch from structured typing to allow user-specified
-- (and user-triggered) errors.
deriving ( Eq, Show, Read )
-- | The language which was active at the beginning of the 'CdText'
-- computation.
data InitialLanguage
= Lang Foreign.Language
-- ^ The language was chosen specifically.
| Index Word
-- ^ The language was chosen by index.
| Default
-- ^ No language was explicitly set.
-- | Restore the active language to whatever it was before a 'CdText'
-- computation was run.
resetLanguage :: Maybe InitialLanguage -> Foreign.Cdio -> IO ()
resetLanguage Nothing _ = mempty
resetLanguage (Just (Lang l)) c = Foreign.selectLanguage c l >> mempty
resetLanguage (Just (Index i)) c = Foreign.selectLanguageIndex c i >> mempty
resetLanguage (Just Default) c = Foreign.selectLanguageIndex c 0 >> mempty
-- | Textual metadata describing a single track on a disc, or the disc itself.
data Info = Info
{ title :: Maybe T.Text
, performer :: Maybe T.Text
, songwriter :: Maybe T.Text
, composer :: Maybe T.Text
, arranger :: Maybe T.Text
, message :: Maybe T.Text
-- ^ An otherwise-uncategorized comment.
, code :: Maybe T.Text
-- ^ Either a UPC/EAN (for the disc) or an ISRC (for a track).
}
deriving ( Eq )
-- | Modeled after the standard record syntax, but omitting any 'Nothing'
-- fields for space reasons.
instance Show Info where
showsPrec d ts = R.showParen (d > 10)
$ R.showString "Info { "
. maybeShows title "title"
. maybeShows performer "performer"
. maybeShows songwriter "songwriter"
. maybeShows composer "composer"
. maybeShows arranger "arranger"
. maybeShows message "message"
. maybeShows code "code"
. R.showString "}"
where maybeShows f n
| Just t <- f ts =
R.showString (n ++ " = ")
. shows t
. R.showString ", "
| otherwise = id
instance Read Info where
readsPrec p =
readParen (p > 10) $ \r ->
[ (Info t f w c a m o, r10)
| ("Info", r1) <- lex r
, ("{", r2) <- lex r1
, (t, r3) <- maybeLex "title" r2
, (f, r4) <- maybeLex "performer" r3
, (w, r5) <- maybeLex "songwriter" r4
, (c, r6) <- maybeLex "composer" r5
, (a, r7) <- maybeLex "arranger" r6
, (m, r8) <- maybeLex "message" r7
, (o, r9) <- maybeLex "code" r8
, ("}", r10) <- lex r9
]
where maybeLex t s' = do
(t', s1) <- lex s'
if t == t'
then do
("=", s2) <- lex s1
(x, s3) <- reads s2
(",", s4) <- lex s3
return (Just x, s4)
else return (Nothing, s')
-- | An 'Info' object with values suitable as defaults.
emptyInfo :: Info
emptyInfo = Info
{ title = Nothing
, performer = Nothing
, songwriter = Nothing
, composer = Nothing
, arranger = Nothing
, message = Nothing
, code = Nothing
}
-- | Use a C-style 'Foreign.Cdio' object as the base to run a Haskell-style
-- 'CdText' computation.
--
-- Note that some invariants of the monadic interface may not work as expected
-- when used with the mutable objects, usually due to changing the active
-- language block:
--
-- @
-- l <- 'Foreign.language' cdio
-- _ <- 'runCdText' cdio $ 'withIndex' i g
-- l' <- 'Foreign.language' cdio
-- (l == l') == undefined
-- @
runCdText :: Foreign.Cdio -> CdText a -> IO (Either CdTextError a)
runCdText c (CdText f) = isolateLogs $ f Nothing c
-- | Run the given computation within the CdText data associated with the
-- 'Cdio' session. At this top level, a @'withIndex' 0@ specifically will
-- almost always have the same effect as running the computation directly.
-- Fails with 'NoCdText' if the disc doesn't provide any metadata.
cdText :: CdText a -> Cdio (Either CdTextError a)
cdText (CdText f) = liftCdio $ \c' -> if Foreign.hasCdText c'
then isolateLogs $ f (Just Default) c'
else return . Left . CdTextError NoCdText $ T.pack "cdText"
-- | Given a binary stream, attempt to parse it as a CdText block and run the
-- given computation. Returns @'Left' 'BadBinaryRead'@ if that parse fails.
-- At this top level, a @'withIndex' 0@ specifically will almost always have
-- the same effect as running the computation directly.
--
-- Note that binary CdText dumps will frequently include four bytes at the
-- beginning indicating the size of the file; this implementation expects that
-- those bytes /are not/ included. If your dump does indeed begin with them,
-- @'BS.drop' 4@ before passing the 'BS.ByteString' to this function.
--
-- /Before libcdio 0.94: Always returns @'Left' 'BadBinaryRead'@/
parseCdText :: BS.ByteString -> CdText a -> IO (Either CdTextError a)
parseCdText bs (CdText f) = isolateLogs $ do
c <- Foreign.cdTextDataInit bs
case c of
Just c' -> f (Just Default) c'
Nothing -> return . Left . CdTextError BadBinaryRead $ T.pack "parseCdText"
-- | Try to use a specific language as the context for the given computation;
-- as this will frequently fail with 'LanguageNotFound' if given anything other
-- than 'Foreign.English', it is recommended that you provide a fallback with
-- 'A.<|>' or at least allow recovery with 'A.optional'. Note that
-- 'Foreign.UnknownLanguage' will always fail.
withLanguage :: Foreign.Language -> CdText a -> CdText a
withLanguage l (CdText f) = CdText $ \l' c -> isolateLogs $ do
b <- Foreign.selectLanguage c l
a <- if b
then f (Just $ Lang l) c
else return . Left . CdTextError (LanguageNotFound l) $ T.pack "withLanguage"
resetLanguage l' c
return a
-- | Run the given computation over the data in a specific block. Fails with
-- 'InvalidBlock' if the index can't be accessed.
withIndex :: Word -> CdText a -> CdText a
withIndex i x = CdText $ \l c -> do
a <- withIndex' "withIndex" c x i
resetLanguage l c
return a
-- | Underlying logic for index-based block selection. Fails with
-- 'InvalidBlock' if the index can't be accessed.
withIndex'
:: String
-- ^ The function calling this, for error printing.
-> Foreign.Cdio
-- ^ The underlying disc session.
-> CdText a
-- ^ The computation to run.
-> Word
-- ^ The index to select.
-> IO (Either CdTextError a)
withIndex' s c (CdText f) i = do
b <- Foreign.selectLanguageIndex c i
if b
then f (Just $ Index i) c
else return . Left . CdTextError (InvalidBlock i) $ T.pack s
-- | Run the given computation over /all/ of the accessible languages in the
-- CdText data. When successful, the resulting list can be associated exactly
-- with the list of 'languages':
--
-- >>> ls <- 'languages'
-- >>> Right ls' <- 'cdText' $ 'withAll' 'language'
-- >>> ls == ls'
-- True
withAll :: CdText a -> CdText [Maybe a]
withAll x = CdText $ \l c -> do
ls <- Foreign.listAllLanguages c
let is = zipWith ($>) ls [0..]
as <- mapM (withBlock c) is
resetLanguage l c
return $ sequence as
where withBlock c (Just i) = fmap Just <$> withIndex' "withAll" c x i
withBlock _ Nothing = return $ Right Nothing
-- | Get the raw binary data making up the CdText data, if any exists on the
-- disc.
cdTextRaw :: Cdio (Maybe BS.ByteString)
cdTextRaw = liftCdio Foreign.cdTextRaw
-- | Get the language in which any info is currently being retrieved.
language :: CdText Foreign.Language
language = liftCdText $ fmap (Y.fromMaybe Foreign.UnknownLanguage) . Foreign.language
-- | List every language with associated data in the CdText data associated
-- with the session. Drops any 'Foreign.UnknownLanguage' values from the end
-- of the list. For a version which operates within the 'CdText' monad, the
-- construction @'withAll' 'language'@ will have an identical effect.
--
-- /Before libcdio 2.1.0: Silently drops any 'Nothing' or/
-- /@'Just' 'Foreign.UnknownLanguage'@ values, even in the middle of the list,/
-- /and only lists any single language a maximum of one time./
languages :: Cdio [Maybe Foreign.Language]
languages = liftCdio Foreign.listAllLanguages
-- | The earliest track with any associated metadata in the current language.
-- Note that this may differ from 'Sound.Libcdio.Track.minTrack' which gives
-- the first track on the disk, CDTEXT or not.
firstTrack :: CdText Track
firstTrack = liftCdText $ fmap (Y.fromMaybe 1) . Foreign.firstTrack
-- | The final track with any associated metadata in the current language.
-- Note that this may differ from 'Sound.Libcdio.Track.maxTrack' which gives
-- the last track on the disc, CDTEXT or not.
lastTrack :: CdText Track
lastTrack = liftCdText $ fmap (Y.fromMaybe 1) . Foreign.lastTrack
-- | Publisher-specific catalogue number, or some other context-specific
-- identifier. Note that this may be different than @'code' $ 'info'
-- 'Nothing'@ which is (assumed to be) the unambiguous bar code unique to this
-- disc release.
discId :: CdText (Maybe T.Text)
discId = liftCdText $ \c -> fmap T.pack <$> Foreign.cdTextGet c Foreign.DiscId Nothing
-- | The genre describing the music on this disc, and any associated
-- human-readable name or subgenre.
genre :: CdText (Maybe Foreign.Genre, Maybe T.Text)
genre = liftCdText $ \c -> do
g <- Foreign.genre c
s <- Foreign.cdTextGet c Foreign.GenreName Nothing
return (g, T.pack <$> s)
-- | Retrieve the collection of data associated with a specific track, or if
-- 'Nothing', the disc itself.
info :: Maybe Track -> CdText Info
info t = liftCdText $ \c -> do
l <- Foreign.cdTextGet c Foreign.Title t
p <- Foreign.cdTextGet c Foreign.Performer t
s <- Foreign.cdTextGet c Foreign.Songwriter t
w <- Foreign.cdTextGet c Foreign.Composer t
a <- Foreign.cdTextGet c Foreign.Arranger t
m <- Foreign.cdTextGet c Foreign.Message t
o <- Foreign.cdTextGet c (if Y.isNothing t then Foreign.UpcEan else Foreign.Isrc) t
return $ Info
{ title = T.pack <$> l
, performer = T.pack <$> p
, songwriter = T.pack <$> s
, composer = T.pack <$> w
, arranger = T.pack <$> a
, message = T.pack <$> m
, code = T.pack <$> o
}