packages feed

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

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

module Monatone.FLAC.Writer
  ( writeFLACMetadata
  , WriteError(..)
  , Writer
    -- * Shared with the OGG writer (Vorbis comments are common to both)
  , generateVorbisComments
  , renderPictureData
  ) 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 (fromMaybe, listToMaybe)
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
writeFLACMetadata :: Metadata -> AlbumArtUpdate -> OsPath -> Writer ()
writeFLACMetadata metadata artUpdate filePath = do
  -- Open file in read/write mode
  result <- liftIO $ tryIO $ withBinaryFile filePath ReadWriteMode $ \handle -> do
    runExceptT $ writeFLACHandleIncremental metadata artUpdate 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 -> AlbumArtUpdate -> Handle -> Writer ()
writeFLACHandleIncremental metadata artUpdate 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
  if BS.length streamInfoHeader < 4 || BS.length streamInfoData < 34
    then throwError $ CorruptedWrite "File too small for STREAMINFO block"
    else pure ()
  let originalStreamInfo = L.fromStrict $ BS.append streamInfoHeader streamInfoData

  -- Find where the audio data starts, collecting blocks we must keep
  -- verbatim; existing PICTURE blocks are kept when the art is unchanged
  let keepPictures = artUpdate == KeepExistingArt
  (audioDataOffset, preservedBlocks, originalVendor) <- scanMetadataBlocks handle 4 keepPictures

  -- Generate new metadata blocks with preserved STREAMINFO
  newMetadataBlocks <- generateMetadataBlocks metadata artUpdate originalStreamInfo preservedBlocks originalVendor
  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, the raw bytes
-- of every block we must carry over verbatim (SEEKTABLE, APPLICATION,
-- CUESHEET, unknown types - and PICTURE blocks when keepPictures is set),
-- and the original Vorbis vendor string. STREAMINFO and VORBIS_COMMENT
-- are regenerated; PADDING is dropped.
scanMetadataBlocks :: Handle -> Int -> Bool -> Writer (Int, [L.ByteString], Maybe BS.ByteString)
scanMetadataBlocks handle startOffset keepPictures = go startOffset [] Nothing
  where
    -- STREAMINFO, PADDING, VORBIS_COMMENT, and PICTURE unless kept
    regenerated = [0, 1, 4] ++ [6 | not keepPictures]

    go :: Int -> [L.ByteString] -> Maybe BS.ByteString -> Writer (Int, [L.ByteString], Maybe BS.ByteString)
    go currentOffset acc vendor = do
      liftIO $ hSeek handle AbsoluteSeek (fromIntegral currentOffset)
      headerBytes <- liftIO $ BS.hGet handle 4
      if BS.length headerBytes < 4
        then return (currentOffset, reverse acc, vendor)
        else do
          let header = runGet parseBlockHeader (L.fromStrict headerBytes)
              blockSize = fromIntegral (blockLength header)
              nextOffset = currentOffset + 4 + blockSize
          (acc', vendor') <- if blockType header `elem` regenerated
            then do
              -- Remember the original encoder's vendor string so a tag
              -- update does not claim the file was encoded by us
              newVendor <- if blockType header == 4 && vendor == Nothing
                then do
                  blockData <- liftIO $ BS.hGet handle blockSize
                  return $ extractVendor blockData
                else return vendor
              return (acc, newVendor)
            else do
              blockData <- liftIO $ BS.hGet handle blockSize
              if BS.length blockData < blockSize
                then throwError $ CorruptedWrite "Truncated metadata block"
                else pure ()
              -- Last-block flag is recomputed when the blocks are reassembled
              return (L.fromStrict (headerBytes <> blockData) : acc, vendor)
          if isLast header
            then return (nextOffset, reverse acc', vendor')
            else go nextOffset acc' vendor'

    extractVendor blockData
      | BS.length blockData < 4 = Nothing
      | otherwise =
          let vendorLen = fromIntegral $ runGet getWord32le (L.fromStrict (BS.take 4 blockData))
              vendorBytes = BS.take vendorLen (BS.drop 4 blockData)
          in if BS.length vendorBytes == vendorLen then Just vendorBytes else Nothing

-- | 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

-- | 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. Kept pictures travel in preservedBlocks.
generateMetadataBlocks :: Metadata -> AlbumArtUpdate -> L.ByteString -> [L.ByteString] -> Maybe BS.ByteString -> Writer L.ByteString
generateMetadataBlocks metadata artUpdate originalStreamInfo preservedBlocks originalVendor = do
  vorbisBlock <- generateVorbisCommentBlock metadata originalVendor False
  pictureBlocks <- case artUpdate of
    SetAlbumArt albumArt -> (: []) <$> generatePictureBlock albumArt False
    _ -> return []

  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, keeping the original vendor string
-- when there is one
generateVorbisCommentBlock :: Metadata -> Maybe BS.ByteString -> Bool -> Writer L.ByteString
generateVorbisCommentBlock metadata originalVendor isLastBlock = do
  let defaultVendor = TE.encodeUtf8 $ T.pack $ "Monatone " ++ showVersion version
  let vendorBytes = fromMaybe defaultVendor originalVendor
  let vendorLenBytes = runPut $ putWord32le $ fromIntegral $ BS.length vendorBytes
  
  -- Create comment list
  let 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 headerByte = if isLastBlock then 0x84 else 0x04  -- Block type 4 = Vorbis comment
  header <- blockHeader headerByte vorbisData
  return $ header <> vorbisData

-- | Generate Vorbis comments from metadata. Every field the parser maps
-- is written back, and unmapped comments in rawTags are carried over so
-- an update never drops tags it does not understand. (Album art is not
-- included: FLAC stores it in a PICTURE block, OGG appends its own
-- METADATA_BLOCK_PICTURE comment.)
generateVorbisComments :: Metadata -> [(Text, Text)]
generateVorbisComments metadata = mappedComments ++ preservedComments
  where
    mbIds = musicBrainzIds metadata
    showT = T.pack . show

    -- A scalar field is the first value of a possibly multi-valued key.
    -- If the field still matches what was parsed, write every stored
    -- value back (several ARTIST comments survive an unrelated update);
    -- if it was edited, the edit replaces the whole set.
    multi key field = case field of
      Nothing -> []
      Just v ->
        let stored = HM.lookupDefault [] key (rawTags metadata)
        in if listToMaybe stored == Just v
             then [(key, s) | s <- stored]
             else [(key, v)]

    single key = maybe [] (\v -> [(key, v)])

    mappedComments = concat
      [ multi "TITLE" (title metadata)
      , multi "ARTIST" (artist metadata)
      , multi "ALBUM" (album metadata)
      , multi "ALBUMARTIST" (albumArtist metadata)
      , single "TRACKNUMBER" (showT <$> trackNumber metadata)
      , single "TRACKTOTAL" (showT <$> totalTracks metadata)
      , single "DISCNUMBER" (showT <$> discNumber metadata)
      , single "DISCTOTAL" (showT <$> totalDiscs metadata)
      , single "DATE" (date metadata <|> (showT <$> year metadata))
      , multi "GENRE" (genre metadata)
      , multi "COMMENT" (comment metadata)
      , multi "PUBLISHER" (publisher metadata)
      , single "BARCODE" (barcode metadata)
      , single "CATALOGNUMBER" (catalogNumber metadata)
      , multi "LABEL" (recordLabel metadata)
      , single "RELEASECOUNTRY" (releaseCountry metadata)
      , single "RELEASESTATUS" (releaseStatus metadata)
      , single "RELEASETYPE" (releaseType metadata)
      , single "MUSICBRAINZ_RELEASETRACKID" (mbTrackId mbIds)
      , single "MUSICBRAINZ_TRACKID" (mbRecordingId mbIds)
      , single "MUSICBRAINZ_ALBUMID" (mbReleaseId mbIds)
      , single "MUSICBRAINZ_RELEASEGROUPID" (mbReleaseGroupId mbIds)
      , multi "MUSICBRAINZ_ARTISTID" (mbArtistId mbIds)
      , multi "MUSICBRAINZ_ALBUMARTISTID" (mbAlbumArtistId mbIds)
      , single "MUSICBRAINZ_WORKID" (mbWorkId mbIds)
      , single "MUSICBRAINZ_DISCID" (mbDiscId mbIds)
      , single "ACOUSTID_FINGERPRINT" (acoustidFingerprint metadata)
      , single "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, values) <- HM.toList (rawTags metadata)
      , T.toUpper key `notElem` handledKeys
      , value <- values
      ]

-- | Render the body of a FLAC PICTURE block (also the payload OGG encodes
-- as a base64 METADATA_BLOCK_PICTURE comment)
renderPictureData :: AlbumArt -> L.ByteString
renderPictureData art =
  let mimeBytes = TE.encodeUtf8 $ albumArtMimeType art
      descBytes = TE.encodeUtf8 $ albumArtDescription art
      imageData = albumArtData art
  in 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

-- | Generate Picture block for album art
generatePictureBlock :: AlbumArt -> Bool -> Writer L.ByteString
generatePictureBlock art isLastBlock = do
  let pictureData = renderPictureData art
      headerByte = if isLastBlock then 0x86 else 0x06  -- Block type 6 = Picture

  header <- blockHeader headerByte pictureData
  return $ header <> pictureData

-- | Build a metadata block header, rejecting content that does not fit the
-- 24-bit length field (silently truncating it would corrupt the stream)
blockHeader :: Word8 -> L.ByteString -> Writer L.ByteString
blockHeader headerByte content = do
  let len = L.length content
  if len > 0xFFFFFF
    then throwError $ InvalidMetadata "Metadata block exceeds FLAC's 16MiB block size limit"
    else return $ runPut $ do
      putWord8 headerByte
      -- Write 24-bit length
      putWord8 $ fromIntegral $ (len `shiftR` 16) .&. 0xFF
      putWord8 $ fromIntegral $ (len `shiftR` 8) .&. 0xFF
      putWord8 $ fromIntegral $ len .&. 0xFF