packages feed

monatone-0.3.0.0: src/Monatone/FLAC/Writer.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TupleSections #-}

module Monatone.FLAC.Writer
  ( writeFLACMetadata
  , WriteError(..)
  , Writer
  ) where

import Control.Applicative ((<|>))
import Control.Exception (catch, IOException)
import Control.Monad.Except (ExceptT, throwError, runExceptT)
import Control.Monad.IO.Class (liftIO)
import Data.Binary.Get
import Data.Binary.Put
import Data.Bits ((.|.), shiftL, shiftR, (.&.))
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as L
import qualified Data.HashMap.Strict as HM
import Data.Maybe (catMaybes)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Word
import System.IO hiding (withBinaryFile)
import System.OsPath
import System.File.OsPath (withBinaryFile)

import Monatone.Metadata
import Paths_monatone (version)
import Data.Version (showVersion)

-- Re-define WriteError and Writer locally to avoid circular imports
data WriteError
  = WriteIOError Text
  | UnsupportedWriteFormat AudioFormat  
  | InvalidMetadata Text
  | CorruptedWrite Text
  deriving (Show, Eq)

type Writer = ExceptT WriteError IO

-- | Buffer size for file operations (64KB)
bufferSize :: Int
bufferSize = 65536

-- | Write metadata to FLAC file incrementally
-- Takes optional AlbumArt separately since Metadata only stores AlbumArtInfo
writeFLACMetadata :: Metadata -> Maybe AlbumArt -> OsPath -> Writer ()
writeFLACMetadata metadata maybeAlbumArt filePath = do
  -- Open file in read/write mode
  result <- liftIO $ tryIO $ withBinaryFile filePath ReadWriteMode $ \handle -> do
    runExceptT $ writeFLACHandleIncremental metadata maybeAlbumArt handle
  case result of
    Left (e :: IOException) -> throwError $ WriteIOError $ T.pack $ show e
    Right (Left err) -> throwError err
    Right (Right ()) -> return ()
  where
    tryIO :: IO a -> IO (Either IOException a)
    tryIO action = catch (Right <$> action) (return . Left)

-- | Write FLAC metadata using a file handle incrementally
writeFLACHandleIncremental :: Metadata -> Maybe AlbumArt -> Handle -> Writer ()
writeFLACHandleIncremental metadata maybeAlbumArt handle = do
  -- Verify FLAC signature
  liftIO $ hSeek handle AbsoluteSeek 0
  sig <- liftIO $ BS.hGet handle 4
  case BS.unpack sig of
    [0x66, 0x4C, 0x61, 0x43] -> pure ()  -- "fLaC"
    _ -> throwError $ CorruptedWrite "Invalid FLAC signature"

  -- Extract original STREAMINFO block for preservation (it's always first, 34 bytes)
  streamInfoHeader <- liftIO $ BS.hGet handle 4
  streamInfoData <- liftIO $ BS.hGet handle 34
  let originalStreamInfo = L.fromStrict $ BS.append streamInfoHeader streamInfoData

  -- Find where the audio data starts, collecting blocks we must keep verbatim
  (audioDataOffset, preservedBlocks) <- scanMetadataBlocks handle 4  -- Start after "fLaC"

  -- Generate new metadata blocks with preserved STREAMINFO
  newMetadataBlocks <- generateMetadataBlocks metadata maybeAlbumArt originalStreamInfo preservedBlocks
  let newMetadataSize = fromIntegral $ L.length newMetadataBlocks
  
  -- Get file size
  _ <- liftIO $ hFileSize handle
  
  -- Calculate size difference (new metadata vs old metadata)
  let oldMetadataSize = audioDataOffset - 4  -- Subtract "fLaC" signature
  let sizeDiff = newMetadataSize - oldMetadataSize
  
  if sizeDiff == 0 then do
    -- Same size, just overwrite metadata blocks
    liftIO $ do
      hSeek handle AbsoluteSeek 4  -- Position after "fLaC"
      L.hPut handle newMetadataBlocks
  else if sizeDiff > 0 then do
    -- Need to insert bytes
    insertBytesInFile handle sizeDiff audioDataOffset
    -- Write new metadata blocks
    liftIO $ do
      hSeek handle AbsoluteSeek 4
      L.hPut handle newMetadataBlocks
  else do
    -- Need to delete bytes
    let bytesToDelete = negate sizeDiff
    -- Write new metadata first
    liftIO $ do
      hSeek handle AbsoluteSeek 4
      L.hPut handle newMetadataBlocks
    -- Then delete extra space
    deleteBytesInFile handle bytesToDelete (4 + newMetadataSize)

-- | Walk the metadata blocks: return the audio data offset plus the raw
-- bytes of every block we must carry over verbatim (SEEKTABLE, APPLICATION,
-- CUESHEET, unknown types). STREAMINFO, VORBIS_COMMENT and PICTURE are
-- regenerated; PADDING is dropped.
scanMetadataBlocks :: Handle -> Int -> Writer (Int, [L.ByteString])
scanMetadataBlocks handle startOffset = go startOffset []
  where
    regenerated = [0, 1, 4, 6]  -- STREAMINFO, PADDING, VORBIS_COMMENT, PICTURE

    go currentOffset acc = do
      liftIO $ hSeek handle AbsoluteSeek (fromIntegral currentOffset)
      headerBytes <- liftIO $ BS.hGet handle 4
      if BS.length headerBytes < 4
        then return (currentOffset, reverse acc)
        else do
          let header = runGet parseBlockHeader (L.fromStrict headerBytes)
              blockSize = fromIntegral (blockLength header)
              nextOffset = currentOffset + 4 + blockSize
          acc' <- if blockType header `elem` regenerated
            then return acc
            else do
              blockData <- liftIO $ BS.hGet handle blockSize
              -- Last-block flag is recomputed when the blocks are reassembled
              return $ L.fromStrict (headerBytes <> blockData) : acc
          if isLast header
            then return (nextOffset, reverse acc')
            else go nextOffset acc'

-- | Insert bytes into file at given offset
insertBytesInFile :: Handle -> Int -> Int -> Writer ()
insertBytesInFile handle size offset = do
  -- Get current file size
  fileSize <- liftIO $ hFileSize handle
  let moveSize = fileSize - fromIntegral offset
  
  if moveSize < 0 then
    throwError $ WriteIOError "Invalid offset for insert"
  else do
    -- First, extend the file
    liftIO $ hSetFileSize handle (fileSize + fromIntegral size)
    
    -- Move data from offset to offset+size, working backwards
    moveDataBackwards handle (fromIntegral offset) (fromIntegral $ offset + size) moveSize

-- | Delete bytes from file at given offset  
deleteBytesInFile :: Handle -> Int -> Int -> Writer ()
deleteBytesInFile handle size offset = do
  -- Get current file size
  fileSize <- liftIO $ hFileSize handle
  let moveSize = fileSize - fromIntegral offset - fromIntegral size
  
  if moveSize < 0 then
    throwError $ WriteIOError "Invalid size/offset for delete"
  else do
    -- Move data from offset+size to offset
    moveDataForwards handle (fromIntegral $ offset + size) (fromIntegral offset) moveSize
    
    -- Truncate the file
    liftIO $ hSetFileSize handle (fileSize - fromIntegral size)

-- | Move data backwards in file (for insertions)
moveDataBackwards :: Handle -> Integer -> Integer -> Integer -> Writer ()
moveDataBackwards handle src dest count = do
  let go remaining' = do
        if remaining' <= 0 then
          return ()
        else do
          let chunkSize = min (fromIntegral bufferSize) remaining'
          -- Read from end of source region
          hSeek handle AbsoluteSeek (src + remaining' - chunkSize)
          chunk <- BS.hGet handle (fromIntegral chunkSize)
          -- Write to end of dest region
          hSeek handle AbsoluteSeek (dest + remaining' - chunkSize)
          BS.hPut handle chunk
          go (remaining' - chunkSize)
  
  liftIO $ go count

-- | Move data forwards in file (for deletions)
moveDataForwards :: Handle -> Integer -> Integer -> Integer -> Writer ()
moveDataForwards handle src dest count = do
  let go moved = do
        if moved >= count then
          return ()
        else do
          let chunkSize = min (fromIntegral bufferSize) (count - moved)
          -- Read from source
          hSeek handle AbsoluteSeek (src + moved)
          chunk <- BS.hGet handle (fromIntegral chunkSize)
          -- Write to dest
          hSeek handle AbsoluteSeek (dest + moved)
          BS.hPut handle chunk
          go (moved + chunkSize)
  
  liftIO $ go 0

-- | FLAC metadata block header
data BlockHeader = BlockHeader
  { isLast :: Bool
  , blockType :: Word8
  , blockLength :: Word32
  } deriving (Show)

-- | Parse FLAC metadata block header
parseBlockHeader :: Get BlockHeader
parseBlockHeader = do
  firstByte <- getWord8
  let lastFlag = (firstByte .&. 0x80) /= 0
  let bType = firstByte .&. 0x7F
  
  -- Block length is 24 bits
  b1 <- getWord8
  b2 <- getWord8
  b3 <- getWord8
  let len = (fromIntegral b1 `shiftL` 16) .|. 
            (fromIntegral b2 `shiftL` 8) .|. 
            fromIntegral b3
  
  return $ BlockHeader lastFlag bType len

-- | Extract the original STREAMINFO block (already read from handle)
_extractStreamInfoBlock :: L.ByteString -> Writer L.ByteString
_extractStreamInfoBlock blockData = do
  if L.length blockData < 38  -- 4 byte header + 34 byte STREAMINFO
    then throwError $ CorruptedWrite "File too small for STREAMINFO block"
    else return $ L.take 38 blockData  -- Include header + data

-- | Generate new metadata blocks: STREAMINFO, preserved blocks, Vorbis
-- comment, and optionally a Picture block, with the last-block flag set on
-- the final block only
generateMetadataBlocks :: Metadata -> Maybe AlbumArt -> L.ByteString -> [L.ByteString] -> Writer L.ByteString
generateMetadataBlocks metadata maybeAlbumArt originalStreamInfo preservedBlocks = do
  vorbisBlock <- generateVorbisCommentBlock metadata False
  pictureBlocks <- case maybeAlbumArt of
    Nothing -> return []
    Just albumArt -> (: []) <$> generatePictureBlock albumArt False

  let blocks = [originalStreamInfo] ++ preservedBlocks ++ [vorbisBlock] ++ pictureBlocks
  return $ L.concat $ markLastBlock blocks
  where
    markLastBlock [] = []
    markLastBlock [block] = [setLastFlag True block]
    markLastBlock (block:rest) = setLastFlag False block : markLastBlock rest

    setLastFlag set block = case L.uncons block of
      Just (firstByte, rest) ->
        L.cons (if set then firstByte .|. 0x80 else firstByte .&. 0x7F) rest
      Nothing -> block

-- | Generate Vorbis comment block
generateVorbisCommentBlock :: Metadata -> Bool -> Writer L.ByteString
generateVorbisCommentBlock metadata isLastBlock = do
  -- Create vendor string
  let vendor = T.pack $ "Monatone " ++ showVersion version
  let vendorBytes = TE.encodeUtf8 vendor
  let vendorLenBytes = runPut $ putWord32le $ fromIntegral $ BS.length vendorBytes
  
  -- Create comment list
  comments <- generateVorbisComments metadata
  let commentCount = length comments
  let commentCountBytes = runPut $ putWord32le $ fromIntegral commentCount
  
  -- Encode each comment
  let encodeComment (key, value) = 
        let text = key <> "=" <> value
            textBytes = TE.encodeUtf8 text
            lenBytes = runPut $ putWord32le $ fromIntegral $ BS.length textBytes
        in lenBytes <> L.fromStrict textBytes
  
  let encodedComments = L.concat $ map encodeComment comments
  
  -- Build complete Vorbis comment data
  let vorbisData = vendorLenBytes <> L.fromStrict vendorBytes <> 
                  commentCountBytes <> encodedComments
  
  -- Create block header
  let blockLen = fromIntegral $ L.length vorbisData :: Word32
  let headerByte = if isLastBlock then 0x84 else 0x04  -- Block type 4 = Vorbis comment
  let header = runPut $ do
        putWord8 headerByte
        -- Write 24-bit length
        putWord8 $ fromIntegral $ (blockLen `shiftR` 16) .&. 0xFF
        putWord8 $ fromIntegral $ (blockLen `shiftR` 8) .&. 0xFF
        putWord8 $ fromIntegral $ blockLen .&. 0xFF
  
  return $ header <> vorbisData

-- | Generate Vorbis comments from metadata. Every field the FLAC parser
-- maps is written back, and unmapped comments in rawTags are carried over
-- so an update never drops tags it does not understand.
generateVorbisComments :: Metadata -> Writer [(Text, Text)]
generateVorbisComments metadata = return $ mappedComments ++ preservedComments
  where
    mbIds = musicBrainzIds metadata
    showT = T.pack . show

    mappedComments = catMaybes
      [ ("TITLE",) <$> title metadata
      , ("ARTIST",) <$> artist metadata
      , ("ALBUM",) <$> album metadata
      , ("ALBUMARTIST",) <$> albumArtist metadata
      , ("TRACKNUMBER",) . showT <$> trackNumber metadata
      , ("TRACKTOTAL",) . showT <$> totalTracks metadata
      , ("DISCNUMBER",) . showT <$> discNumber metadata
      , ("DISCTOTAL",) . showT <$> totalDiscs metadata
      , ("DATE",) <$> (date metadata <|> (showT <$> year metadata))
      , ("GENRE",) <$> genre metadata
      , ("COMMENT",) <$> comment metadata
      , ("PUBLISHER",) <$> publisher metadata
      , ("BARCODE",) <$> barcode metadata
      , ("CATALOGNUMBER",) <$> catalogNumber metadata
      , ("LABEL",) <$> recordLabel metadata
      , ("RELEASECOUNTRY",) <$> releaseCountry metadata
      , ("RELEASESTATUS",) <$> releaseStatus metadata
      , ("RELEASETYPE",) <$> releaseType metadata
      , ("MUSICBRAINZ_RELEASETRACKID",) <$> mbTrackId mbIds
      , ("MUSICBRAINZ_TRACKID",) <$> mbRecordingId mbIds
      , ("MUSICBRAINZ_ALBUMID",) <$> mbReleaseId mbIds
      , ("MUSICBRAINZ_RELEASEGROUPID",) <$> mbReleaseGroupId mbIds
      , ("MUSICBRAINZ_ARTISTID",) <$> mbArtistId mbIds
      , ("MUSICBRAINZ_ALBUMARTISTID",) <$> mbAlbumArtistId mbIds
      , ("MUSICBRAINZ_WORKID",) <$> mbWorkId mbIds
      , ("MUSICBRAINZ_DISCID",) <$> mbDiscId mbIds
      , ("ACOUSTID_FINGERPRINT",) <$> acoustidFingerprint metadata
      , ("ACOUSTID_ID",) <$> acoustidId metadata
      ]

    -- Keys the mapped fields own (whether or not they are set right now):
    -- stale rawTags copies of these must not be written back
    handledKeys =
      [ "TITLE", "ARTIST", "ALBUM", "ALBUMARTIST"
      , "TRACKNUMBER", "TRACKTOTAL", "DISCNUMBER", "DISCTOTAL"
      , "DATE", "YEAR", "GENRE", "COMMENT", "PUBLISHER"
      , "BARCODE", "CATALOGNUMBER", "LABEL", "RELEASECOUNTRY"
      , "RELEASESTATUS", "RELEASETYPE"
      , "MUSICBRAINZ_RELEASETRACKID", "MUSICBRAINZ_TRACKID"
      , "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ_RELEASEGROUPID"
      , "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ_ALBUMARTISTID"
      , "MUSICBRAINZ_WORKID", "MUSICBRAINZ_DISCID"
      , "ACOUSTID_FINGERPRINT", "ACOUSTID_ID"
      , "METADATA_BLOCK_PICTURE"  -- art is written as a PICTURE block instead
      ]

    preservedComments =
      [ (key, value)
      | (key, value) <- HM.toList (rawTags metadata)
      , T.toUpper key `notElem` handledKeys
      ]

-- | Generate Picture block for album art
generatePictureBlock :: AlbumArt -> Bool -> Writer L.ByteString
generatePictureBlock art isLastBlock = do
  let mimeBytes = TE.encodeUtf8 $ albumArtMimeType art
      descBytes = TE.encodeUtf8 $ albumArtDescription art
      imageData = albumArtData art

      -- Build picture data according to FLAC spec
      pictureData = runPut $ do
        putWord32be $ fromIntegral $ albumArtPictureType art  -- Picture type
        putWord32be $ fromIntegral $ BS.length mimeBytes      -- MIME type length
        putByteString mimeBytes                               -- MIME type
        putWord32be $ fromIntegral $ BS.length descBytes      -- Description length
        putByteString descBytes                               -- Description
        putWord32be 0                                         -- Width (0 = unknown)
        putWord32be 0                                         -- Height (0 = unknown)
        putWord32be 0                                         -- Color depth (0 = unknown)
        putWord32be 0                                         -- Number of colors (0 = unknown)
        putWord32be $ fromIntegral $ BS.length imageData      -- Picture data length
        putByteString imageData                               -- Picture data

      blockLen = fromIntegral $ L.length pictureData :: Word32
      headerByte = if isLastBlock then 0x86 else 0x06  -- Block type 6 = Picture

      -- Build block header
      header = runPut $ do
        putWord8 headerByte
        -- Write 24-bit length
        putWord8 $ fromIntegral $ (blockLen `shiftR` 16) .&. 0xFF
        putWord8 $ fromIntegral $ (blockLen `shiftR` 8) .&. 0xFF
        putWord8 $ fromIntegral $ blockLen .&. 0xFF

  return $ header <> pictureData