monatone-0.1.0.0: src/Monatone/FLAC/Writer.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE QuasiQuotes #-}
module Monatone.FLAC.Writer
( writeFLACMetadata
, WriteError(..)
, Writer
) where
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 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
-- 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
audioDataOffset <- findAudioDataOffsetHandle handle 4 -- Start after "fLaC"
-- Generate new metadata blocks with preserved STREAMINFO
newMetadataBlocks <- generateMetadataBlocks metadata maybeAlbumArt originalStreamInfo
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)
-- | Find where audio data starts by parsing metadata blocks
findAudioDataOffsetHandle :: Handle -> Int -> Writer Int
findAudioDataOffsetHandle handle currentOffset = do
-- Seek to current position
liftIO $ hSeek handle AbsoluteSeek (fromIntegral currentOffset)
-- Read block header (4 bytes)
headerBytes <- liftIO $ BS.hGet handle 4
if BS.length headerBytes < 4 then
return currentOffset
else do
let header = runGet parseBlockHeader (L.fromStrict headerBytes)
let blockSize = fromIntegral (blockLength header)
let nextOffset = currentOffset + 4 + blockSize
if isLast header
then return nextOffset -- This was the last metadata block
else findAudioDataOffsetHandle handle nextOffset
-- | 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
generateMetadataBlocks :: Metadata -> Maybe AlbumArt -> L.ByteString -> Writer L.ByteString
generateMetadataBlocks metadata maybeAlbumArt originalStreamInfo = do
-- Generate Vorbis comment block with metadata
vorbisBlock <- generateVorbisCommentBlock metadata False
-- Mark STREAMINFO as not-last (clear the last-block flag)
let streamInfoNotLast = case L.unpack originalStreamInfo of
(firstByte:rest) -> L.pack $ (firstByte .&. 0x7F) : rest -- Clear the 0x80 bit
_ -> originalStreamInfo
-- Generate Picture block if album art is provided
case maybeAlbumArt of
Nothing -> do
-- Mark Vorbis comment as last block
let vorbisBlockLast = case L.unpack vorbisBlock of
(firstByte:rest) -> L.pack $ (firstByte .|. 0x80) : rest -- Set the 0x80 bit
_ -> vorbisBlock
return $ streamInfoNotLast <> vorbisBlockLast
Just albumArt -> do
-- Generate Picture block
pictureBlock <- generatePictureBlock albumArt True
-- Mark Vorbis comment as not-last
let vorbisBlockNotLast = case L.unpack vorbisBlock of
(firstByte:rest) -> L.pack $ (firstByte .&. 0x7F) : rest -- Clear the 0x80 bit
_ -> vorbisBlock
return $ streamInfoNotLast <> vorbisBlockNotLast <> pictureBlock
-- | Generate Vorbis comment block
generateVorbisCommentBlock :: Metadata -> Bool -> Writer L.ByteString
generateVorbisCommentBlock metadata isLastBlock = do
-- Create vendor string
let vendor = "Monatone 0.1.0.0"
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
generateVorbisComments :: Metadata -> Writer [(Text, Text)]
generateVorbisComments metadata = do
let comments = []
-- Add standard tags
let comments1 = case title metadata of
Just t -> ("TITLE", t) : comments
Nothing -> comments
let comments2 = case artist metadata of
Just a -> ("ARTIST", a) : comments1
Nothing -> comments1
let comments3 = case album metadata of
Just a -> ("ALBUM", a) : comments2
Nothing -> comments2
let comments4 = case albumArtist metadata of
Just aa -> ("ALBUMARTIST", aa) : comments3
Nothing -> comments3
let comments5 = case trackNumber metadata of
Just n -> ("TRACKNUMBER", T.pack $ show n) : comments4
Nothing -> comments4
let comments6 = case discNumber metadata of
Just n -> ("DISCNUMBER", T.pack $ show n) : comments5
Nothing -> comments5
let comments7 = case year metadata of
Just y -> ("DATE", T.pack $ show y) : comments6
Nothing -> comments6
let comments8 = case genre metadata of
Just g -> ("GENRE", g) : comments7
Nothing -> comments7
let comments9 = case comment metadata of
Just c -> ("COMMENT", c) : comments8
Nothing -> comments8
let comments10 = case publisher metadata of
Just p -> ("PUBLISHER", p) : comments9
Nothing -> comments9
-- Add MusicBrainz IDs
let mbIds = musicBrainzIds metadata
let comments11 = case mbRecordingId mbIds of
Just mbId -> ("MUSICBRAINZ_TRACKID", mbId) : comments10
Nothing -> comments10
let comments12 = case mbReleaseId mbIds of
Just mbId -> ("MUSICBRAINZ_ALBUMID", mbId) : comments11
Nothing -> comments11
let comments13 = case mbArtistId mbIds of
Just mbId -> ("MUSICBRAINZ_ARTISTID", mbId) : comments12
Nothing -> comments12
let comments14 = case mbAlbumArtistId mbIds of
Just mbId -> ("MUSICBRAINZ_ALBUMARTISTID", mbId) : comments13
Nothing -> comments13
let comments15 = case mbReleaseGroupId mbIds of
Just mbId -> ("MUSICBRAINZ_RELEASEGROUPID", mbId) : comments14
Nothing -> comments14
-- Add additional metadata fields
let comments16 = case date metadata of
Just d -> ("DATE", d) : comments15
Nothing -> comments15
let comments17 = case barcode metadata of
Just b -> ("BARCODE", b) : comments16
Nothing -> comments16
let comments18 = case catalogNumber metadata of
Just cn -> ("CATALOGNUMBER", cn) : comments17
Nothing -> comments17
let comments19 = case recordLabel metadata of
Just rl -> ("LABEL", rl) : comments18
Nothing -> comments18
let comments20 = case releaseCountry metadata of
Just rc -> ("RELEASECOUNTRY", rc) : comments19
Nothing -> comments19
let comments21 = case releaseStatus metadata of
Just rs -> ("RELEASESTATUS", rs) : comments20
Nothing -> comments20
let comments22 = case releaseType metadata of
Just rt -> ("RELEASETYPE", rt) : comments21
Nothing -> comments21
return comments22
-- | 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