packages feed

monatone-0.1.0.0: src/Monatone/OGG.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE BangPatterns #-}

module Monatone.OGG
  ( parseOGG
  , loadAlbumArtOGG
  ) where

import Control.Applicative ((<|>))
import Control.Monad.Except (throwError)
import Control.Monad.IO.Class (liftIO)
import Data.Binary.Get
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64 as B64
import qualified Data.ByteString.Lazy as L
import System.IO (Handle, IOMode(..), hSeek, SeekMode(..))
import System.OsPath
import System.File.OsPath (withBinaryFile)
import qualified Data.HashMap.Strict as HM
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Encoding.Error as TEE

import Monatone.Metadata  
import Monatone.Types

-- | OGG page header is always 27 bytes (before segment table)
oggPageHeaderSize :: Int
oggPageHeaderSize = 27

-- | Parse OGG file efficiently - only read metadata pages
parseOGG :: OsPath -> Parser Metadata
parseOGG filePath = do
  result <- liftIO $ withBinaryFile filePath ReadMode $ \handle -> do
    -- Read first page to check OGG signature
    firstHeader <- BS.hGet handle 27
    if BS.length firstHeader < 27 || BS.take 4 firstHeader /= "OggS"
      then return $ Left $ UnsupportedFormat "Not an OGG file"
      else do
        -- Parse pages until we find what we need
        hSeek handle AbsoluteSeek 0
        metadata <- parseOggPages handle (emptyMetadata OGG) False False
        return $ Right metadata
  
  case result of
    Left err -> throwError err
    Right m -> return m

-- | Parse OGG pages looking for Vorbis headers
parseOggPages :: Handle -> Metadata -> Bool -> Bool -> IO Metadata
parseOggPages handle metadata foundIdent foundComment
  -- Stop when we have both headers
  | foundIdent && foundComment = return metadata
  | otherwise = do
      -- Read page header
      headerBytes <- BS.hGet handle oggPageHeaderSize
      
      if BS.length headerBytes < oggPageHeaderSize
        then return metadata  -- EOF
        else do
          -- Verify OGG page signature
          if BS.take 4 headerBytes /= "OggS"
            then return metadata  -- Invalid page, stop
            else do
              -- Parse header to get segment table size
              let numSegments = fromIntegral $ BS.index headerBytes 26
              
              -- Read segment table
              segmentTable <- BS.hGet handle numSegments
              
              -- Calculate total page data size
              let pageDataSize = sum $ map fromIntegral $ BS.unpack segmentTable
              
              -- For the first few pages, read and check for Vorbis headers
              -- Vorbis headers are always in the first 3 pages
              if not foundIdent || not foundComment
                then do
                  pageData <- BS.hGet handle pageDataSize
                  
                  -- Check packet type
                  let (newMetadata, newFoundIdent, newFoundComment) = 
                        if "\x01vorbis" `BS.isPrefixOf` pageData && not foundIdent
                        then (parseVorbisInfo pageData metadata, True, foundComment)
                        else if "\x03vorbis" `BS.isPrefixOf` pageData && not foundComment  
                        then (parseVorbisComment pageData metadata, foundIdent, True)
                        else (metadata, foundIdent, foundComment)
                  
                  -- Continue to next page
                  parseOggPages handle newMetadata newFoundIdent newFoundComment
                else do
                  -- Skip this page's data since we have what we need
                  hSeek handle RelativeSeek (fromIntegral pageDataSize)
                  return metadata

-- | Parse Vorbis identification header (packet type 1)
parseVorbisInfo :: BS.ByteString -> Metadata -> Metadata
parseVorbisInfo bs metadata =
  if BS.length bs < 30  -- Minimum size for valid header
    then metadata
    else
      let lazyBs = L.fromStrict bs
      in case runGetOrFail (parseVorbisInfoGet metadata) (L.drop 7 lazyBs) of
        Left _ -> metadata
        Right (_, _, result) -> result

parseVorbisInfoGet :: Metadata -> Get Metadata
parseVorbisInfoGet metadata = do
  _ <- getWord32le  -- vorbisVersion
  audioChannels <- getWord8
  audioSampleRate <- getWord32le
  bitrateMaximum <- getWord32le
  bitrateNominal <- getWord32le
  bitrateMinimum <- getWord32le
  
  -- The nominal bitrate is the average bitrate
  let bitrate' = if bitrateNominal > 0
                then Just $ fromIntegral $ bitrateNominal `div` 1000
                else if bitrateMaximum > 0 && bitrateMinimum > 0
                then Just $ fromIntegral $ (bitrateMaximum + bitrateMinimum) `div` 2000
                else Nothing
  
  return $ metadata
    { audioProperties = AudioProperties
      { sampleRate = Just $ fromIntegral audioSampleRate
      , channels = Just $ fromIntegral audioChannels
      , bitrate = bitrate'
      , bitsPerSample = Nothing  -- Not in Vorbis info
      , duration = Nothing  -- Would need granule position from last page
      }
    }

-- | Parse Vorbis comment (packet type 3)
parseVorbisComment :: BS.ByteString -> Metadata -> Metadata
parseVorbisComment bs metadata =
  if BS.length bs < 7
    then metadata
    else
      let lazyBs = L.fromStrict bs
      in case runGetOrFail (parseVorbisCommentGet metadata) (L.drop 7 lazyBs) of
        Left _ -> metadata
        Right (_, _, result) -> result

parseVorbisCommentGet :: Metadata -> Get Metadata
parseVorbisCommentGet metadata = do
  -- Read vendor string length (little-endian 32-bit)
  vendorLength <- getWord32le
  -- Skip vendor string
  skip (fromIntegral vendorLength)
  
  -- Read number of comments
  numComments <- getWord32le
  
  -- Read each comment
  comments <- parseCommentList (fromIntegral numComments)
  
  -- Convert to HashMap for efficient lookup
  let tagMap = HM.fromList comments
  
  -- Extract standard fields
  return $ metadata
    { title = HM.lookup "TITLE" tagMap
    , artist = HM.lookup "ARTIST" tagMap
    , album = HM.lookup "ALBUM" tagMap
    , albumArtist = HM.lookup "ALBUMARTIST" tagMap
    , year = (HM.lookup "YEAR" tagMap >>= readInt)
             <|> (HM.lookup "DATE" tagMap >>= extractYearFromDate)
    , date = HM.lookup "DATE" tagMap
    , comment = HM.lookup "COMMENT" tagMap
    , genre = HM.lookup "GENRE" tagMap
    , trackNumber = HM.lookup "TRACKNUMBER" tagMap >>= readInt
    , totalTracks = HM.lookup "TRACKTOTAL" tagMap >>= readInt
    , discNumber = HM.lookup "DISCNUMBER" tagMap >>= readInt
    , totalDiscs = HM.lookup "DISCTOTAL" tagMap >>= readInt
    , releaseCountry = HM.lookup "RELEASECOUNTRY" tagMap
    , recordLabel = HM.lookup "LABEL" tagMap
    , catalogNumber = HM.lookup "CATALOGNUMBER" tagMap
    , barcode = HM.lookup "BARCODE" tagMap
    , releaseStatus = HM.lookup "RELEASESTATUS" tagMap
    , releaseType = HM.lookup "RELEASETYPE" tagMap
    , albumArtInfo = HM.lookup "METADATA_BLOCK_PICTURE" tagMap >>= parseVorbisPictureInfo
    , musicBrainzIds = MusicBrainzIds
      { mbTrackId = HM.lookup "MUSICBRAINZ_RELEASETRACKID" tagMap
      , mbRecordingId = HM.lookup "MUSICBRAINZ_TRACKID" tagMap
      , mbReleaseId = HM.lookup "MUSICBRAINZ_ALBUMID" tagMap
      , mbReleaseGroupId = HM.lookup "MUSICBRAINZ_RELEASEGROUPID" tagMap
      , mbArtistId = HM.lookup "MUSICBRAINZ_ARTISTID" tagMap
      , mbAlbumArtistId = HM.lookup "MUSICBRAINZ_ALBUMARTISTID" tagMap
      , mbWorkId = HM.lookup "MUSICBRAINZ_WORKID" tagMap
      , mbDiscId = HM.lookup "MUSICBRAINZ_DISCID" tagMap
      }
    }
  where
    parseCommentList :: Int -> Get [(Text, Text)]
    parseCommentList 0 = return []
    parseCommentList n = do
      -- Read comment length
      commentLength <- getWord32le
      -- Read comment data
      commentBytes <- getByteString (fromIntegral commentLength)
      -- Parse the comment (format: "KEY=value")
      let comment' = case BS.split 0x3D commentBytes of -- Split on '='
            (key:value:rest') -> 
              let keyText = T.toUpper $ TE.decodeUtf8With TEE.lenientDecode key
                  valueText = TE.decodeUtf8With TEE.lenientDecode (BS.intercalate "=" (value:rest'))
              in Just (keyText, valueText)
            _ -> Nothing
      rest <- parseCommentList (n - 1)
      return $ case comment' of
        Just c -> c : rest
        Nothing -> rest

-- | Parse Vorbis picture info (base64-encoded FLAC picture block, metadata only)
parseVorbisPictureInfo :: Text -> Maybe AlbumArtInfo
parseVorbisPictureInfo encodedData =
  case B64.decode (TE.encodeUtf8 encodedData) of
    Left _ -> Nothing
    Right pictureData -> parseFLACPictureBlockInfo pictureData
  where
    parseFLACPictureBlockInfo :: BS.ByteString -> Maybe AlbumArtInfo
    parseFLACPictureBlockInfo bs =
      let lazyBs = L.fromStrict bs
      in case runGetOrFail parsePictureInfo lazyBs of
        Left _ -> Nothing
        Right (_, _, artInfo) -> Just artInfo

    parsePictureInfo :: Get AlbumArtInfo
    parsePictureInfo = do
      pictureType <- getWord32be
      mimeLength <- getWord32be
      mimeType <- getByteString (fromIntegral mimeLength)
      descLength <- getWord32be
      description <- getByteString (fromIntegral descLength)
      _width <- getWord32be
      _height <- getWord32be
      _colorDepth <- getWord32be
      _numColors <- getWord32be
      pictureDataLength <- getWord32be
      -- Skip reading the actual picture data for performance
      -- skip (fromIntegral pictureDataLength)

      return $ AlbumArtInfo
        { albumArtInfoMimeType = TE.decodeUtf8With TEE.lenientDecode mimeType
        , albumArtInfoPictureType = fromIntegral pictureType
        , albumArtInfoDescription = TE.decodeUtf8With TEE.lenientDecode description
        , albumArtInfoSizeBytes = fromIntegral pictureDataLength
        }

-- | Load album art from OGG file (full binary data for writing)
loadAlbumArtOGG :: OsPath -> Parser (Maybe AlbumArt)
loadAlbumArtOGG filePath = do
  result <- liftIO $ withBinaryFile filePath ReadMode $ \handle -> do
    -- Read first page to check OGG signature
    firstHeader <- BS.hGet handle 27
    if BS.length firstHeader < 27 || BS.take 4 firstHeader /= "OggS"
      then return $ Right Nothing
      else do
        -- Parse pages looking for Vorbis comment with METADATA_BLOCK_PICTURE
        hSeek handle AbsoluteSeek 0
        Right <$> searchForPicture handle False False

  case result of
    Left err -> throwError err
    Right maybeArt -> return maybeArt
  where
    searchForPicture :: Handle -> Bool -> Bool -> IO (Maybe AlbumArt)
    searchForPicture handle foundIdent foundComment
      | foundIdent && foundComment = return Nothing  -- Checked all metadata, no picture
      | otherwise = do
          headerBytes <- BS.hGet handle oggPageHeaderSize
          if BS.length headerBytes < oggPageHeaderSize
            then return Nothing
            else do
              if BS.take 4 headerBytes /= "OggS"
                then return Nothing
                else do
                  let numSegments = fromIntegral $ BS.index headerBytes 26
                  segmentTable <- BS.hGet handle numSegments
                  let pageDataSize = sum $ map fromIntegral $ BS.unpack segmentTable

                  if not foundIdent || not foundComment
                    then do
                      pageData <- BS.hGet handle pageDataSize
                      let (newFoundIdent, newFoundComment, maybePicture) =
                            if "\x01vorbis" `BS.isPrefixOf` pageData && not foundIdent
                            then (True, foundComment, Nothing)
                            else if "\x03vorbis" `BS.isPrefixOf` pageData && not foundComment
                            then (foundIdent, True, extractPictureFromComment pageData)
                            else (foundIdent, foundComment, Nothing)

                      case maybePicture of
                        Just art -> return (Just art)
                        Nothing -> searchForPicture handle newFoundIdent newFoundComment
                    else do
                      hSeek handle RelativeSeek (fromIntegral pageDataSize)
                      return Nothing

    extractPictureFromComment :: BS.ByteString -> Maybe AlbumArt
    extractPictureFromComment bs =
      if BS.length bs < 7
        then Nothing
        else
          let lazyBs = L.fromStrict bs
          in case runGetOrFail (parseVorbisCommentForPicture) (L.drop 7 lazyBs) of
            Left _ -> Nothing
            Right (_, _, result) -> result

    parseVorbisCommentForPicture :: Get (Maybe AlbumArt)
    parseVorbisCommentForPicture = do
      vendorLength <- getWord32le
      skip (fromIntegral vendorLength)
      numComments <- getWord32le
      findPictureComment (fromIntegral numComments)

    findPictureComment :: Int -> Get (Maybe AlbumArt)
    findPictureComment 0 = return Nothing
    findPictureComment n = do
      commentLength <- getWord32le
      commentBytes <- getByteString (fromIntegral commentLength)
      case BS.split 0x3D commentBytes of
        (key:value:_) ->
          let keyText = T.toUpper $ TE.decodeUtf8With TEE.lenientDecode key
              valueText = TE.decodeUtf8With TEE.lenientDecode value
          in if keyText == "METADATA_BLOCK_PICTURE"
             then return $ parseVorbisPictureFull valueText
             else findPictureComment (n - 1)
        _ -> findPictureComment (n - 1)

    parseVorbisPictureFull :: Text -> Maybe AlbumArt
    parseVorbisPictureFull encodedData =
      case B64.decode (TE.encodeUtf8 encodedData) of
        Left _ -> Nothing
        Right pictureData -> parseFLACPictureBlockFull pictureData

    parseFLACPictureBlockFull :: BS.ByteString -> Maybe AlbumArt
    parseFLACPictureBlockFull bs =
      let lazyBs = L.fromStrict bs
      in case runGetOrFail parsePictureData lazyBs of
        Left _ -> Nothing
        Right (_, _, art) -> Just art

    parsePictureData :: Get AlbumArt
    parsePictureData = do
      pictureType <- getWord32be
      mimeLength <- getWord32be
      mimeType <- getByteString (fromIntegral mimeLength)
      descLength <- getWord32be
      description <- getByteString (fromIntegral descLength)
      _width <- getWord32be
      _height <- getWord32be
      _colorDepth <- getWord32be
      _numColors <- getWord32be
      pictureDataLength <- getWord32be
      pictureData <- getByteString (fromIntegral pictureDataLength)

      return $ AlbumArt
        { albumArtMimeType = TE.decodeUtf8With TEE.lenientDecode mimeType
        , albumArtPictureType = fromIntegral pictureType
        , albumArtDescription = TE.decodeUtf8With TEE.lenientDecode description
        , albumArtData = pictureData
        }

-- | Extract year from DATE field (YYYY-MM-DD or just YYYY)
extractYearFromDate :: T.Text -> Maybe Int
extractYearFromDate dateText =
  let yearStr = T.takeWhile (/= '-') dateText
  in readInt yearStr