packages feed

monatone-0.4.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 Data.Bits ((.&.), shiftL)
import Data.Int (Int32)
import Data.Maybe (listToMaybe)
import Data.Word (Word64)
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(..), hFileSize, 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

-- | Refuse to reassemble packets beyond this size (a comment packet with
-- embedded art is a few MB; this only guards against malformed lacing)
maxPacketSize :: Int
maxPacketSize = 64 * 1024 * 1024

-- | Parse OGG file efficiently - only the first two logical packets are
-- read: the identification header and the comment header (this layout is
-- shared by Vorbis and Opus)
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
        hSeek handle AbsoluteSeek 0
        packets <- readFirstPackets handle 2
        let metadata = foldl (flip applyPacket) (emptyMetadata OGG) packets
        maybeGranule <- findLastGranule handle
        return $ Right $ applyDuration packets maybeGranule metadata

  case result of
    Left err -> throwError err
    Right m -> return m

-- | Duration comes from the last page's granule position: total samples
-- for Vorbis, 48kHz sample count (including pre-skip) for Opus
applyDuration :: [BS.ByteString] -> Maybe Word64 -> Metadata -> Metadata
applyDuration packets maybeGranule metadata =
  case (maybeGranule, codec (audioProperties metadata)) of
    (Just granule, Just CodecOpus) ->
      setDuration (fromIntegral granule - opusPreSkip) 48000
    (Just granule, Just CodecVorbis) ->
      case sampleRate (audioProperties metadata) of
        Just rate | rate > 0 -> setDuration (fromIntegral granule) rate
        _ -> metadata
    _ -> metadata
  where
    setDuration :: Int -> Int -> Metadata
    setDuration samples rate
      | samples <= 0 = metadata
      | otherwise =
          let ms = round (fromIntegral samples * 1000 / fromIntegral rate :: Double)
          in metadata { audioProperties = (audioProperties metadata) { duration = Just ms } }

    opusPreSkip :: Int
    opusPreSkip = case [p | p <- packets, "OpusHead" `BS.isPrefixOf` p] of
      (p:_) | BS.length p >= 12 ->
        fromIntegral (BS.index p 10) + 256 * fromIntegral (BS.index p 11)
      _ -> 0

-- | Granule position of the file's last page (read from the tail without
-- scanning the whole file). Pages with the "no packet ends here" sentinel
-- granule (-1) are skipped.
findLastGranule :: Handle -> IO (Maybe Word64)
findLastGranule handle = do
  fileSize <- hFileSize handle
  let readLen = fromIntegral (min fileSize 65536)
  hSeek handle SeekFromEnd (negate (fromIntegral readLen))
  tailBytes <- BS.hGet handle readLen
  return $ case [g | off <- pageOffsets tailBytes
                   , Just g <- [granuleAt tailBytes off]
                   , g /= maxBound] of
    [] -> Nothing
    granules -> Just (last granules)
  where
    pageOffsets bs = go 0
      where
        go from = case BS.breakSubstring "OggS" (BS.drop from bs) of
          (before, rest)
            | BS.null rest -> []
            | otherwise ->
                let off = from + BS.length before
                in off : go (off + 4)

    granuleAt bs off
      | off + 14 > BS.length bs = Nothing
      | otherwise = Just $ sum
          [ fromIntegral (BS.index bs (off + 6 + i)) `shiftL` (8 * i) | i <- [0 .. 7] ]

-- | Apply a metadata packet to the metadata being built
applyPacket :: BS.ByteString -> Metadata -> Metadata
applyPacket packet metadata
  | "\x01vorbis" `BS.isPrefixOf` packet = parseVorbisInfo packet metadata
  | "OpusHead" `BS.isPrefixOf` packet = parseOpusInfo packet metadata
  | otherwise = case commentPayload packet of
      Just payload -> case runGetOrFail (parseVorbisCommentGet metadata) payload of
        Left _ -> metadata
        Right (_, _, result) -> result
      Nothing -> metadata

-- | Strip the header magic from a comment packet, if it is one. Vorbis and
-- Opus comment packets share the Vorbis comment structure after the magic
commentPayload :: BS.ByteString -> Maybe L.ByteString
commentPayload packet
  | "\x03vorbis" `BS.isPrefixOf` packet = Just $ L.fromStrict $ BS.drop 7 packet
  | "OpusTags" `BS.isPrefixOf` packet = Just $ L.fromStrict $ BS.drop 8 packet
  | otherwise = Nothing

-- | Read the first n complete logical packets of an OGG stream,
-- reassembling packets that span multiple segments and pages: a packet is
-- the concatenation of consecutive segments up to and including the first
-- segment shorter than 255 bytes, and may continue across pages (header
-- flag 0x01 on the continuing page)
readFirstPackets :: Handle -> Int -> IO [BS.ByteString]
readFirstPackets handle limit = goPage [] []
  where
    -- completed and partial both hold newest-first chunks
    goPage completed partial
      | length completed >= limit = return $ take limit $ reverse completed
      | otherwise = do
          maybePage <- readOggPage handle
          case maybePage of
            Nothing -> return $ take limit $ reverse completed  -- EOF or corrupt page
            Just (continued, segments) -> do
              -- A page that does not continue a packet implicitly abandons
              -- any leftover partial data (malformed stream)
              let partial0 = if continued then partial else []
                  (completed', partial') = foldl step (completed, partial0) segments
              if sum (map BS.length partial') > maxPacketSize
                then return $ take limit $ reverse completed'
                else goPage completed' partial'

    step (completed, partial) (segmentData, endsPacket)
      | endsPacket = (BS.concat (reverse (segmentData : partial)) : completed, [])
      | otherwise = (completed, segmentData : partial)

-- | Read one OGG page: the continuation flag and the segments with a marker
-- for whether each segment ends a packet (lacing value < 255)
readOggPage :: Handle -> IO (Maybe (Bool, [(BS.ByteString, Bool)]))
readOggPage handle = do
  headerBytes <- BS.hGet handle oggPageHeaderSize
  if BS.length headerBytes < oggPageHeaderSize || BS.take 4 headerBytes /= "OggS"
    then return Nothing
    else do
      let continued = BS.index headerBytes 5 .&. 0x01 /= 0
          numSegments = fromIntegral $ BS.index headerBytes 26
      segmentTable <- BS.hGet handle numSegments
      if BS.length segmentTable < numSegments
        then return Nothing
        else do
          let lacings = map fromIntegral $ BS.unpack segmentTable :: [Int]
          pageData <- BS.hGet handle (sum lacings)
          if BS.length pageData < sum lacings
            then return Nothing
            else return $ Just (continued, splitSegments lacings pageData)
  where
    splitSegments [] _ = []
    splitSegments (lacing:rest) bs =
      let (segment, unread) = BS.splitAt lacing bs
      in (segment, lacing < 255) : splitSegments rest unread

-- | 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
  -- The bitrate fields are signed per the Vorbis spec; -1 means "unset"
  -- and must not be treated as a huge unsigned value
  bitrateMaximum <- fromIntegral <$> getWord32le :: Get Int32
  bitrateNominal <- fromIntegral <$> getWord32le :: Get Int32
  bitrateMinimum <- fromIntegral <$> getWord32le :: Get Int32

  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
      , codec = Just CodecVorbis
      }
    }

-- | Parse Opus identification header (the "OpusHead" packet)
parseOpusInfo :: BS.ByteString -> Metadata -> Metadata
parseOpusInfo bs metadata =
  if BS.length bs < 19  -- 8 (magic) + version + channels + pre-skip + input rate + gain + mapping
    then metadata
    else
      let lazyBs = L.fromStrict bs
      in case runGetOrFail (parseOpusInfoGet metadata) (L.drop 8 lazyBs) of
        Left _ -> metadata
        Right (_, _, result) -> result

parseOpusInfoGet :: Metadata -> Get Metadata
parseOpusInfoGet metadata = do
  _ <- getWord8            -- version
  opusChannels <- getWord8
  _ <- getWord16le         -- pre-skip
  _ <- getWord32le         -- input sample rate (informational only, RFC 7845)
  return $ metadata
    { audioProperties = emptyAudioProperties
      -- Opus always decodes at 48kHz; the header's input rate is just a
      -- record of what the encoder was fed
      { sampleRate = Just 48000
      , channels = Just $ fromIntegral opusChannels
      , codec = Just CodecOpus
      }
    }

-- | Parse the Vorbis comment structure (shared by Vorbis and Opus, after
-- the packet magic has been stripped)
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)

  -- Vorbis comments may repeat a key (e.g. several ARTIST entries); keep
  -- every value. The scalar metadata fields take the first one.
  let tagMap = HM.fromListWith (flip (<>)) [(k, [v]) | (k, v) <- comments]
      firstOf key = HM.lookup key tagMap >>= listToMaybe

  -- Extract standard fields
  return $ metadata
    { title = firstOf "TITLE"
    , artist = firstOf "ARTIST"
    , album = firstOf "ALBUM"
    , albumArtist = firstOf "ALBUMARTIST"
    , year = (firstOf "YEAR" >>= readInt)
             <|> (firstOf "DATE" >>= extractYearFromDate)
    , date = firstOf "DATE"
    , comment = firstOf "COMMENT"
    , genre = firstOf "GENRE"
    , trackNumber = firstOf "TRACKNUMBER" >>= readInt
    , totalTracks = firstOf "TRACKTOTAL" >>= readInt
    , discNumber = firstOf "DISCNUMBER" >>= readInt
    , totalDiscs = firstOf "DISCTOTAL" >>= readInt
    , releaseCountry = firstOf "RELEASECOUNTRY"
    , recordLabel = firstOf "LABEL"
    , catalogNumber = firstOf "CATALOGNUMBER"
    , barcode = firstOf "BARCODE"
    , releaseStatus = firstOf "RELEASESTATUS"
    , releaseType = firstOf "RELEASETYPE"
    , albumArtInfo = firstOf "METADATA_BLOCK_PICTURE" >>= parseVorbisPictureInfo
    , musicBrainzIds = MusicBrainzIds
      { mbTrackId = firstOf "MUSICBRAINZ_RELEASETRACKID"
      , mbRecordingId = firstOf "MUSICBRAINZ_TRACKID"
      , mbReleaseId = firstOf "MUSICBRAINZ_ALBUMID"
      , mbReleaseGroupId = firstOf "MUSICBRAINZ_RELEASEGROUPID"
      , mbArtistId = firstOf "MUSICBRAINZ_ARTISTID"
      , mbAlbumArtistId = firstOf "MUSICBRAINZ_ALBUMARTISTID"
      , mbWorkId = firstOf "MUSICBRAINZ_WORKID"
      , mbDiscId = firstOf "MUSICBRAINZ_DISCID"
      }
    , rawTags = 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
  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 Nothing
      else do
        -- The comment packet is the second logical packet in the stream
        hSeek handle AbsoluteSeek 0
        packets <- readFirstPackets handle 2
        return $ case [p | packet <- packets, Just p <- [commentPayload packet]] of
          (payload:_) -> extractPictureFromComment payload
          [] -> Nothing
  where
    extractPictureFromComment :: L.ByteString -> Maybe AlbumArt
    extractPictureFromComment payload =
      case runGetOrFail parseVorbisCommentForPicture payload 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:rest) ->
          -- Rejoin on '=' so base64 padding at the end of the value survives
          let keyText = T.toUpper $ TE.decodeUtf8With TEE.lenientDecode key
              valueText = TE.decodeUtf8With TEE.lenientDecode (BS.intercalate "=" (value:rest))
          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