monatone-0.4.0.0: src/Monatone/OGG/Writer.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | Write Vorbis/Opus tags into an OGG container.
--
-- The comment packet is rebuilt and repaginated, which can change the
-- number of header pages; every following page therefore gets its
-- sequence number rewritten and its CRC recomputed. Audio data itself is
-- copied verbatim.
module Monatone.OGG.Writer
( writeOGGMetadata
, WriteError(..)
, Writer
) where
import Control.Exception (catch, IOException)
import Control.Monad (unless)
import Control.Monad.Except (ExceptT, throwError, runExceptT)
import Control.Monad.IO.Class (liftIO)
import Data.Binary.Put
import Data.Bits (shiftL, shiftR, testBit, xor, (.&.), (.|.))
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64 as B64
import qualified Data.ByteString.Lazy as L
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 Data.Word
import System.IO hiding (withBinaryFile)
import System.OsPath
import System.File.OsPath (withBinaryFile)
import System.IO.Temp (withSystemTempFile)
import Monatone.Metadata
import Monatone.FLAC.Writer (generateVorbisComments, renderPictureData)
-- 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
-- | Write metadata to an OGG (Vorbis or Opus) file
writeOGGMetadata :: Metadata -> AlbumArtUpdate -> OsPath -> Writer ()
writeOGGMetadata metadata artUpdate filePath = do
result <- liftIO $ tryIO $ do
withSystemTempFile "monatone-ogg.tmp" $ \tmpPath tmpHandle -> do
hClose tmpHandle
tmpOsPath <- encodeFS tmpPath
rewriteResult <- withBinaryFile filePath ReadMode $ \srcHandle ->
withBinaryFile tmpOsPath WriteMode $ \dstHandle ->
runExceptT $ rewriteOGGFile srcHandle dstHandle metadata artUpdate
case rewriteResult of
Left err -> return $ Left err
Right () -> do
copyFileContents tmpOsPath filePath
return $ Right ()
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)
-- | Copy file contents
copyFileContents :: OsPath -> OsPath -> IO ()
copyFileContents src dst =
withBinaryFile src ReadMode $ \srcHandle ->
withBinaryFile dst WriteMode $ \dstHandle ->
let loop = do
chunk <- BS.hGet srcHandle 65536
unless (BS.null chunk) $ do
BS.hPut dstHandle chunk
loop
in loop
-- | One raw OGG page, split into its parts
data RawPage = RawPage
{ rpHeader :: ByteString -- 27 bytes
, rpSegTable :: ByteString
, rpData :: ByteString
}
rpContinued :: RawPage -> Bool
rpContinued page = BS.index (rpHeader page) 5 .&. 0x01 /= 0
rpSerial :: RawPage -> ByteString
rpSerial page = BS.take 4 $ BS.drop 14 $ rpHeader page
-- | Segments with an ends-packet marker (lacing value < 255)
rpSegments :: RawPage -> [(ByteString, Bool)]
rpSegments page = go (map fromIntegral (BS.unpack (rpSegTable page))) (rpData page)
where
go [] _ = []
go (lacing:rest) bs =
let (segment, unread) = BS.splitAt lacing bs
in (segment, lacing < 255) : go rest unread
readRawPage :: Handle -> IO (Maybe RawPage)
readRawPage handle = do
header <- BS.hGet handle 27
if BS.length header < 27 || BS.take 4 header /= "OggS"
then return Nothing
else do
let numSegments = fromIntegral $ BS.index header 26
segTable <- BS.hGet handle numSegments
let dataSize = sum $ map fromIntegral $ BS.unpack segTable
pageData <- BS.hGet handle dataSize
if BS.length segTable < numSegments || BS.length pageData < dataSize
then return Nothing
else return $ Just $ RawPage header segTable pageData
-- | Rewrite the OGG stream with a new comment packet
rewriteOGGFile :: Handle -> Handle -> Metadata -> AlbumArtUpdate -> Writer ()
rewriteOGGFile srcHandle dstHandle metadata artUpdate = do
-- Page 0 carries the identification packet alone; copy it verbatim
maybeFirst <- liftIO $ readRawPage srcHandle
firstPage <- case maybeFirst of
Nothing -> throwError $ CorruptedWrite "Not an OGG file"
Just page -> return page
let identPacket = BS.concat (map fst (rpSegments firstPage))
-- Vorbis has a setup packet after the comment; Opus does not
(commentMagic, trailingHeaderPackets) <-
if "\x01vorbis" `BS.isPrefixOf` identPacket then return ("\x03vorbis", 1)
else if "OpusHead" `BS.isPrefixOf` identPacket then return ("OpusTags", 0)
else throwError $ UnsupportedWriteFormat OGG
-- Recompute the CRC rather than trusting the source's
let firstRaw = rpHeader firstPage <> rpSegTable firstPage <> rpData firstPage
liftIO $ BS.hPut dstHandle $ patchCrc $
BS.take 22 firstRaw <> BS.replicate 4 0 <> BS.drop 26 firstRaw
-- Collect the remaining header packets (comment [+ setup])
headerPackets <- collectHeaderPackets srcHandle (1 + trailingHeaderPackets)
(oldComment, setupPackets) <- case headerPackets of
(c:rest) -> return (c, rest)
[] -> throwError $ CorruptedWrite "Missing comment header packet"
-- Build the new comment packet, keeping the original vendor string
let newComment = buildCommentPacket commentMagic (extractVendor commentMagic oldComment)
metadata artUpdate
-- Repaginate the header packets, then stream the audio pages through
-- with renumbered sequence numbers and recomputed CRCs
nextSeqno <- liftIO $ writeHeaderPages dstHandle (rpSerial firstPage) (newComment : setupPackets)
liftIO $ rewriteAudioPages srcHandle dstHandle nextSeqno
-- | Read pages after the first, reassembling exactly n packets. The
-- header packets must end on a page boundary (they do in spec-conformant
-- streams); anything else would interleave audio into the header pages.
collectHeaderPackets :: Handle -> Int -> Writer [ByteString]
collectHeaderPackets handle n = go [] []
where
go :: [ByteString] -> [ByteString] -> Writer [ByteString]
go completed partial
| length completed >= n =
if null partial && length completed == n
then return $ reverse completed
else throwError $ CorruptedWrite "Header packets do not end on a page boundary"
| otherwise = do
maybePage <- liftIO $ readRawPage handle
case maybePage of
Nothing -> throwError $ CorruptedWrite "Truncated OGG header pages"
Just page -> do
let partial0 = if rpContinued page then partial else []
(completed', partial') = foldl step (completed, partial0) (rpSegments page)
go completed' partial'
step (completed, partial) (segmentData, endsPacket)
| endsPacket = (BS.concat (reverse (segmentData : partial)) : completed, [])
| otherwise = (completed, segmentData : partial)
-- | The vendor string sits right after the packet magic
extractVendor :: ByteString -> ByteString -> ByteString
extractVendor magic packet =
let body = BS.drop (BS.length magic) packet
vendorLen = word32leAt body
vendor = BS.take vendorLen (BS.drop 4 body)
in if BS.length body >= 4 && BS.length vendor == vendorLen then vendor else ""
where
word32leAt bs
| BS.length bs < 4 = 0
| otherwise = sum [fromIntegral (BS.index bs i) `shiftL` (8 * i) | i <- [0 .. 3]]
-- | Build the comment packet: magic, vendor, comments (art included as a
-- base64 METADATA_BLOCK_PICTURE comment), and Vorbis's framing bit
buildCommentPacket :: ByteString -> ByteString -> Metadata -> AlbumArtUpdate -> ByteString
buildCommentPacket magic vendor metadata artUpdate =
let comments = generateVorbisComments metadata ++ artComments
renderComment (key, value) =
let bytes = TE.encodeUtf8 (key <> "=" <> value)
in w32le (BS.length bytes) <> bytes
framing = if magic == "\x03vorbis" then BS.singleton 0x01 else BS.empty
in BS.concat $
[magic, w32le (BS.length vendor), vendor, w32le (length comments)]
++ map renderComment comments
++ [framing]
where
artComments :: [(Text, Text)]
artComments = case artUpdate of
KeepExistingArt ->
[ ("METADATA_BLOCK_PICTURE", value)
| value <- HM.lookupDefault [] "METADATA_BLOCK_PICTURE" (rawTags metadata)
]
SetAlbumArt art ->
[("METADATA_BLOCK_PICTURE", TE.decodeUtf8 (B64.encode (L.toStrict (renderPictureData art))))]
RemoveAlbumArt -> []
-- | Paginate packets into header pages (granule 0) starting at sequence
-- number 1; returns the next free sequence number
writeHeaderPages :: Handle -> ByteString -> [ByteString] -> IO Word32
writeHeaderPages handle serial packets = go 1 False (pagesOf (concatMap packetSegments packets))
where
go seqno _ [] = return seqno
go seqno continued (pageSegments : rest) = do
BS.hPut handle $ renderPage continued seqno pageSegments
let nextContinued = case reverse pageSegments of
(lastSegment:_) -> BS.length lastSegment == 255
[] -> False
go (seqno + 1) nextContinued rest
-- Segments of one packet: 255-byte chunks with a final short one
-- (empty when the length is a multiple of 255)
packetSegments packet =
let (chunk, rest) = BS.splitAt 255 packet
in if BS.length chunk == 255
then chunk : packetSegments rest
else [chunk]
pagesOf [] = []
pagesOf segments = let (page, rest) = splitAt 255 segments in page : pagesOf rest
renderPage continued seqno segments =
let flags = if continued then 0x01 else 0x00
headerNoCrc = BS.concat
[ "OggS"
, BS.singleton 0 -- version
, BS.singleton flags
, BS.replicate 8 0 -- granule position
, serial
, w32le (fromIntegral seqno)
, BS.replicate 4 0 -- CRC placeholder
, BS.singleton (fromIntegral (length segments))
, BS.pack (map (fromIntegral . BS.length) segments)
]
page = headerNoCrc <> BS.concat segments
in patchCrc page
-- | Copy the remaining pages, renumbering sequence numbers (the header
-- page count may have changed) and recomputing CRCs. Trailing bytes that
-- do not parse as a page are copied verbatim.
rewriteAudioPages :: Handle -> Handle -> Word32 -> IO ()
rewriteAudioPages srcHandle dstHandle seqno = do
pos <- hTell srcHandle
maybePage <- readRawPage srcHandle
case maybePage of
Just page -> do
let raw = rpHeader page <> rpSegTable page <> rpData page
renumbered = BS.take 18 raw <> w32le (fromIntegral seqno)
<> BS.replicate 4 0 <> BS.drop 26 raw
BS.hPut dstHandle $ patchCrc renumbered
rewriteAudioPages srcHandle dstHandle (seqno + 1)
Nothing -> do
-- copy whatever is left verbatim
hSeek srcHandle AbsoluteSeek pos
let loop = do
chunk <- BS.hGet srcHandle 65536
unless (BS.null chunk) $ do
BS.hPut dstHandle chunk
loop
loop
-- | Compute the page CRC (bytes 22-25 must be zero) and patch it in
patchCrc :: ByteString -> ByteString
patchCrc page =
BS.take 22 page <> w32le (fromIntegral (oggCrc page)) <> BS.drop 26 page
-- | The OGG page checksum: CRC-32 with polynomial 0x04c11db7, no
-- reflection, zero initial value and no final xor
oggCrc :: ByteString -> Word32
oggCrc = BS.foldl' step 0
where
step crc byte = go (8 :: Int) (crc `xor` (fromIntegral byte `shiftL` 24))
go 0 crc = crc
go n crc = go (n - 1) $
if testBit crc 31 then (crc `shiftL` 1) `xor` 0x04c11db7 else crc `shiftL` 1
w32le :: Int -> ByteString
w32le n = BS.pack [fromIntegral (n `shiftR` s) | s <- [0, 8, 16, 24]]