monatone 0.2.1.1 → 0.3.0.0
raw patch · 14 files changed
+728/−260 lines, 14 filesdep ~unordered-containersPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: unordered-containers
API changes (from Hackage documentation)
- Monatone.Writer: writeMetadataToFile :: Metadata -> Maybe AlbumArt -> OsPath -> Writer ()
+ Monatone.Metadata: CodecAAC :: Codec
+ Monatone.Metadata: CodecALAC :: Codec
+ Monatone.Metadata: CodecFLAC :: Codec
+ Monatone.Metadata: CodecMP3 :: Codec
+ Monatone.Metadata: CodecOpus :: Codec
+ Monatone.Metadata: CodecVorbis :: Codec
+ Monatone.Metadata: [codec] :: AudioProperties -> Maybe Codec
+ Monatone.Metadata: data Codec
+ Monatone.Metadata: instance Data.Aeson.Types.FromJSON.FromJSON Monatone.Metadata.Codec
+ Monatone.Metadata: instance Data.Aeson.Types.ToJSON.ToJSON Monatone.Metadata.Codec
+ Monatone.Metadata: instance GHC.Classes.Eq Monatone.Metadata.Codec
+ Monatone.Metadata: instance GHC.Classes.Ord Monatone.Metadata.Codec
+ Monatone.Metadata: instance GHC.Read.Read Monatone.Metadata.Codec
+ Monatone.Metadata: instance GHC.Show.Show Monatone.Metadata.Codec
- Monatone.Metadata: AudioProperties :: Maybe Int -> Maybe Int -> Maybe Int -> Maybe Int -> Maybe Int -> AudioProperties
+ Monatone.Metadata: AudioProperties :: Maybe Int -> Maybe Int -> Maybe Int -> Maybe Int -> Maybe Int -> Maybe Codec -> AudioProperties
Files
- CHANGELOG.md +29/−0
- app/Main.hs +11/−0
- monatone.cabal +7/−2
- src/Monatone/FLAC.hs +4/−2
- src/Monatone/FLAC/Writer.hs +112/−143
- src/Monatone/M4A.hs +14/−5
- src/Monatone/M4A/Writer.hs +122/−9
- src/Monatone/MP3.hs +8/−0
- src/Monatone/MP3/Writer.hs +93/−61
- src/Monatone/Metadata.hs +33/−0
- src/Monatone/OGG.hs +30/−2
- src/Monatone/Writer.hs +37/−35
- test/Test/IntegrationSpec.hs +155/−0
- test/Test/M4ASpec.hs +73/−1
CHANGELOG.md view
@@ -1,5 +1,34 @@ # Changelog for `monatone` +## [0.3.0.0] - 2026-07-20++### Fixed+- **M4A writer corrupted moov-first ("fast start") files**: stco/co64 chunk+ offsets are now adjusted when the moov atom changes size, so audio stays+ playable after a tag update+- M4A writer no longer silently discards errors from the file rewrite+- **Writes are now atomic**: metadata is written to a temporary sibling file+ which is renamed over the original, so a failed write or crash can no+ longer corrupt or truncate the audio file+- **Updates no longer drop tags the writer does not map**: MusicBrainz IDs+ and AcoustID tags (MP3), track/disc totals (MP3, FLAC), release+ status/type (M4A), unmapped ID3 text/TXXX frames, Vorbis comments, and+ iTunes text/freeform atoms are all preserved across updates+- FLAC updates preserve SEEKTABLE, APPLICATION, CUESHEET and unknown+ metadata blocks instead of discarding them+- Single date field per file: `setYear` no longer produces duplicate+ TDRC frames (MP3) or DATE comments (FLAC)+- FLAC comment values containing `=` are no longer truncated+- MP3 parser now reads disc number and track/disc totals from TRCK/TPOS+- FLAC parser now populates `rawTags`; M4A parser now reads release+ status/type+- FLAC vendor string now tracks the package version automatically++### Changed+- **Breaking**: removed `writeMetadataToFile` from `Monatone.Writer`; use+ `writeMetadata`, which is now atomic and no longer needs the `.backup`+ mechanism+ ## [0.2.1.1] - 2025-12-10 ### Fixed
app/Main.hs view
@@ -58,6 +58,7 @@ , "sampleRate" .= sampleRate props , "channels" .= channels props , "bitsPerSample" .= bitsPerSample props+ , "codec" .= codec props ] instance ToJSON MusicBrainzIds where@@ -113,6 +114,15 @@ exitFailure -- | Format track or disc info with total if available+-- | Human-readable codec name for display+codecName :: Codec -> Text+codecName CodecFLAC = "FLAC"+codecName CodecMP3 = "MP3"+codecName CodecVorbis = "Vorbis"+codecName CodecOpus = "Opus"+codecName CodecAAC = "AAC"+codecName CodecALAC = "ALAC"+ formatTrackInfo :: Maybe Int -> Maybe Int -> Text formatTrackInfo Nothing _ = "null" formatTrackInfo (Just num) Nothing = T.pack $ show num@@ -146,6 +156,7 @@ putStrLn $ T.unpack $ " Sample Rate: " <> maybe "null" (\s -> T.pack (show s) <> "Hz") (sampleRate props) putStrLn $ T.unpack $ " Channels: " <> maybe "null" (T.pack . show) (channels props) putStrLn $ T.unpack $ " Bits Per Sample: " <> maybe "null" (T.pack . show) (bitsPerSample props)+ putStrLn $ T.unpack $ " Codec: " <> maybe "null" codecName (codec props) putStrLn "\nMusicBrainz IDs:" let mbIds = musicBrainzIds metadata
monatone.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: monatone-version: 0.2.1.1+version: 0.3.0.0 synopsis: Pure Haskell library for audio metadata parsing and writing description: Monatone is a pure Haskell library for parsing and writing@@ -50,6 +50,10 @@ Monatone.OGG Monatone.Types Monatone.Writer+ other-modules:+ Paths_monatone+ autogen-modules:+ Paths_monatone hs-source-dirs: src build-depends:@@ -130,7 +134,8 @@ tasty-hunit >= 0.10 && < 0.11, tasty-quickcheck >= 0.11 && < 0.12, temporary,- text+ text,+ unordered-containers default-language: Haskell2010 ghc-options: -Wall
src/Monatone/FLAC.hs view
@@ -182,6 +182,7 @@ , bitsPerSample = Just bitsPerSample' , bitrate = Nothing -- Will be calculated later if needed , duration = duration'+ , codec = Just CodecFLAC } } where@@ -257,6 +258,7 @@ HM.lookup "acoustid_fingerprint" tagMap , acoustidId = HM.lookup "ACOUSTID_ID" tagMap <|> HM.lookup "acoustid_id" tagMap+ , rawTags = tagMap } where parseCommentList :: Int -> Get [(Text, Text)]@@ -268,9 +270,9 @@ commentBytes <- getByteString (fromIntegral commentLength) -- Parse the comment (format: "KEY=value") let comment' = case BS.split 0x3D commentBytes of -- Split on '='- (key:value:_) -> + (key:value:rest) -> let keyText = T.toUpper $ TE.decodeUtf8With TEE.lenientDecode key- valueText = TE.decodeUtf8With TEE.lenientDecode (BS.intercalate "=" (value:[]))+ valueText = TE.decodeUtf8With TEE.lenientDecode (BS.intercalate "=" (value:rest)) in Just (keyText, valueText) _ -> Nothing rest <- parseCommentList (n - 1)
src/Monatone/FLAC/Writer.hs view
@@ -1,6 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE TupleSections #-} module Monatone.FLAC.Writer ( writeFLACMetadata@@ -8,6 +9,7 @@ , Writer ) where +import Control.Applicative ((<|>)) import Control.Exception (catch, IOException) import Control.Monad.Except (ExceptT, throwError, runExceptT) import Control.Monad.IO.Class (liftIO)@@ -16,6 +18,8 @@ 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@@ -25,6 +29,8 @@ 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@@ -70,11 +76,11 @@ 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"+ -- 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+ newMetadataBlocks <- generateMetadataBlocks metadata maybeAlbumArt originalStreamInfo preservedBlocks let newMetadataSize = fromIntegral $ L.length newMetadataBlocks -- Get file size@@ -106,25 +112,34 @@ -- 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+-- | 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@@ -224,42 +239,33 @@ 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+-- | 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-- -- 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+ pictureBlocks <- case maybeAlbumArt of+ Nothing -> return []+ Just albumArt -> (: []) <$> generatePictureBlock albumArt False - -- 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+ 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 - return $ streamInfoNotLast <> vorbisBlockNotLast <> pictureBlock+ 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 = "Monatone 0.1.0.0"+ let vendor = T.pack $ "Monatone " ++ showVersion version let vendorBytes = TE.encodeUtf8 vendor let vendorLenBytes = runPut $ putWord32le $ fromIntegral $ BS.length vendorBytes @@ -293,104 +299,67 @@ return $ header <> vorbisData --- | Generate Vorbis comments from metadata+-- | 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 = 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+generateVorbisComments metadata = return $ mappedComments ++ preservedComments+ where+ mbIds = musicBrainzIds metadata+ showT = T.pack . show - let comments21 = case releaseStatus metadata of- Just rs -> ("RELEASESTATUS", rs) : comments20- Nothing -> comments20+ 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+ ] - let comments22 = case releaseType metadata of- Just rt -> ("RELEASETYPE", rt) : comments21- Nothing -> comments21+ -- 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+ ] - return comments22+ 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
src/Monatone/M4A.hs view
@@ -317,6 +317,8 @@ , comment = HM.lookup "\169cmt" tags , publisher = HM.lookup "\169pub" tags , releaseCountry = lookupFreeform "MusicBrainz Album Release Country" tags+ , releaseStatus = lookupFreeform "MusicBrainz Album Status" tags+ , releaseType = lookupFreeform "MusicBrainz Album Type" tags , recordLabel = lookupFreeform "LABEL" tags , catalogNumber = lookupFreeform "CATALOGNUMBER" tags , barcode = lookupFreeform "BARCODE" tags@@ -470,16 +472,16 @@ entryData <- BS.hGet handle 28 -- AudioSampleEntry header if BS.length entryData < 28- then return emptyAudioProperties+ then return emptyAudioProperties { codec = codecFromName (atomName entry) } else do let entryChannels = runGet getWord16be (L.fromStrict $ BS.take 2 $ BS.drop 16 entryData) entrySampleSize = runGet getWord16be (L.fromStrict $ BS.take 2 $ BS.drop 18 entryData) entrySampleRate = (runGet getWord32be (L.fromStrict $ BS.take 4 $ BS.drop 24 entryData)) `div` 65536 - codec = atomName entry+ codecName = atomName entry - -- Parse extension atoms for more details- case atomChildren entry of+ -- Parse extension atoms for more details, then stamp the codec on the result+ props <- case atomChildren entry of Nothing -> return $ emptyAudioProperties { channels = Just $ fromIntegral entryChannels , bitsPerSample = Just $ fromIntegral entrySampleSize@@ -490,7 +492,7 @@ let esdsAtom = listToMaybe $ filter (\a -> atomName a == "esds") exts let alacAtom = listToMaybe $ filter (\a -> atomName a == "alac") exts - case (codec, esdsAtom, alacAtom) of+ case (codecName, esdsAtom, alacAtom) of ("mp4a", Just esds, _) -> parseEsdsAtom handle esds entryChannels entrySampleSize entrySampleRate ("alac", _, Just alac) -> parseAlacAtom handle alac _ -> return $ emptyAudioProperties@@ -498,6 +500,13 @@ , bitsPerSample = Just $ fromIntegral entrySampleSize , sampleRate = Just $ fromIntegral entrySampleRate }+ return props { codec = codecFromName codecName }++-- | Map an M4A sample-entry atom name to a codec+codecFromName :: BS.ByteString -> Maybe Codec+codecFromName "mp4a" = Just CodecAAC+codecFromName "alac" = Just CodecALAC+codecFromName _ = Nothing -- | Parse ESDS atom for AAC info parseEsdsAtom :: Handle -> Atom -> Word16 -> Word16 -> Word32 -> IO AudioProperties
src/Monatone/M4A/Writer.hs view
@@ -8,6 +8,7 @@ , Writer ) where +import Control.Applicative ((<|>)) import Control.Exception (catch, IOException) import Control.Monad.Except (ExceptT, throwError, runExceptT) import Control.Monad.IO.Class (liftIO)@@ -16,6 +17,7 @@ import Data.ByteString (ByteString) import qualified Data.ByteString as BS import qualified Data.ByteString.Lazy as L+import qualified Data.HashMap.Strict as HM import Data.Maybe (fromMaybe, maybeToList) import Data.Text (Text) import qualified Data.Text as T@@ -91,11 +93,11 @@ ilstData <- generateIlstData metadata maybeAlbumArt -- Write to destination- _ <- liftIO $ withBinaryFile srcPath ReadMode $ \srcHandle -> do+ writeResult <- liftIO $ withBinaryFile srcPath ReadMode $ \srcHandle -> do withBinaryFile dstPath WriteMode $ \dstHandle -> do runExceptT $ rewriteM4AFile srcHandle dstHandle atoms moovOffset moovSize ilstData - return ()+ either throwError return writeResult -- Simple atom info for tracking during parse data AtomInfo = AtomInfo@@ -155,9 +157,13 @@ hSeek srcHandle AbsoluteSeek moovOffset moovData <- BS.hGet srcHandle (fromIntegral moovSize) - -- Rebuild moov with new ilst- let newMoovData = rebuildMoovAtom moovData newIlstData- L.hPut dstHandle newMoovData+ -- Rebuild moov with new ilst, then fix up chunk offsets: stco/co64+ -- entries are absolute file positions, and resizing moov moves every+ -- byte after moov's old end by the size delta.+ let newMoovData = L.toStrict $ rebuildMoovAtom moovData newIlstData+ delta = toInteger (BS.length newMoovData) - toInteger moovSize+ oldMoovEnd = moovOffset + fromIntegral moovSize+ BS.hPut dstHandle $ adjustChunkOffsets delta oldMoovEnd newMoovData -- Copy all atoms after moov liftIO $ copyAfterMoov srcHandle dstHandle moovOffset moovSize@@ -218,6 +224,62 @@ , [0,0,0,0, 0,0,0,0, 0] -- reserved ] +-- | Shift absolute chunk offsets in every stco/co64 table inside a moov atom.+-- Only offsets pointing at or beyond moov's old end move: data before moov+-- stays put, so those offsets stay valid regardless of the size delta.+adjustChunkOffsets :: Integer -> Integer -> ByteString -> ByteString+adjustChunkOffsets delta oldMoovEnd = goAtoms+ where+ containerNames = ["moov", "trak", "mdia", "minf", "stbl"] :: [ByteString]++ goAtoms bs+ | BS.length bs < 8 = bs+ | otherwise =+ let size32 = readWord32BE $ BS.take 4 bs+ name = BS.take 4 $ BS.drop 4 bs+ (headerLen, atomSize)+ | size32 == 0 = (8, BS.length bs) -- atom extends to end+ | size32 == 1 && BS.length bs >= 16 =+ (16, fromIntegral $ readWord64BE $ BS.take 8 $ BS.drop 8 bs)+ | otherwise = (8, fromIntegral size32)+ in if atomSize < headerLen || atomSize > BS.length bs+ then bs -- corrupt size; leave the remainder untouched+ else+ let (atom, rest) = BS.splitAt atomSize bs+ (header, content) = BS.splitAt headerLen atom+ atom'+ | name `elem` containerNames = header <> goAtoms content+ | name == "stco" = header <> patchTable 4 content+ | name == "co64" = header <> patchTable 8 content+ | otherwise = atom+ in atom' <> goAtoms rest++ -- Table layout: version/flags (4) + entry count (4) + count offsets+ patchTable entryWidth content+ | BS.length content < 8 + tableLen = content+ | otherwise = prefix <> BS.concat (map patchEntry entries) <> trailer+ where+ count = if BS.length content >= 8+ then fromIntegral $ readWord32BE $ BS.take 4 $ BS.drop 4 content+ else 0+ tableLen = count * entryWidth+ (prefix, tableAndTrailer) = BS.splitAt 8 content+ (table, trailer) = BS.splitAt tableLen tableAndTrailer+ entries = [ BS.take entryWidth $ BS.drop (i * entryWidth) table+ | i <- [0 .. count - 1] ]++ patchEntry entry =+ let wide = BS.length entry == 8+ old = if wide+ then toInteger $ readWord64BE entry+ else toInteger $ readWord32BE entry+ new = if old >= oldMoovEnd then old + delta else old+ in if new == old+ then entry+ else L.toStrict $ runPut $ if wide+ then putWord64be (fromIntegral new)+ else putWord32be (fromIntegral new)+ -- | Filter out udta atom from a sequence of atoms filterOutUdta :: ByteString -> L.ByteString filterOutUdta bs = go bs L.empty@@ -240,6 +302,9 @@ else go nextRemaining (acc <> atomData) -- Keep other atoms -- | Generate ilst atom data with all tags+-- | Generate ilst atom data with all tags. Every field the M4A parser maps+-- is written back, and unmapped atoms in rawTags are carried over so an+-- update never drops tags it does not understand. generateIlstData :: Metadata -> Maybe AlbumArt -> Writer L.ByteString generateIlstData metadata maybeAlbumArt = do let tags = concat@@ -247,7 +312,7 @@ , renderTextTag "\169ART" <$> maybeToList (artist metadata) , renderTextTag "\169alb" <$> maybeToList (album metadata) , renderTextTag "aART" <$> maybeToList (albumArtist metadata)- , renderTextTag "\169day" <$> maybeToList (date metadata)+ , renderTextTag "\169day" <$> maybeToList (date metadata <|> (T.pack . show <$> year metadata)) , renderTextTag "\169gen" <$> maybeToList (genre metadata) , renderTextTag "\169cmt" <$> maybeToList (comment metadata) , renderTextTag "\169pub" <$> maybeToList (publisher metadata)@@ -259,11 +324,18 @@ , renderFreeformTag "CATALOGNUMBER" <$> maybeToList (catalogNumber metadata) , renderFreeformTag "BARCODE" <$> maybeToList (barcode metadata) , renderFreeformTag "MusicBrainz Album Release Country" <$> maybeToList (releaseCountry metadata)+ , renderFreeformTag "MusicBrainz Album Status" <$> maybeToList (releaseStatus metadata)+ , renderFreeformTag "MusicBrainz Album Type" <$> maybeToList (releaseType metadata) -- MusicBrainz IDs , renderMusicBrainzIds (musicBrainzIds metadata) -- Acoustid , renderFreeformTag "Acoustid Fingerprint" <$> maybeToList (acoustidFingerprint metadata) , renderFreeformTag "Acoustid Id" <$> maybeToList (acoustidId metadata)+ -- Unmapped tags carried over from the source file+ , concat+ [ renderPreservedTag key value+ | (key, value) <- HM.toList (rawTags metadata)+ ] ] return $ mconcat tags@@ -279,6 +351,44 @@ , renderFreeformTag "MusicBrainz Disc Id" <$> maybeToList (mbDiscId mbids) ] + -- Atoms the mapped fields own (whether or not they are set right now):+ -- stale rawTags copies of these must not be written back+ handledAtoms =+ [ "\169nam", "\169ART", "\169alb", "aART", "\169day", "\169gen"+ , "\169cmt", "\169pub"+ ] :: [Text]++ handledFreeform = map ("----:com.apple.iTunes:" <>)+ [ "LABEL", "CATALOGNUMBER", "BARCODE"+ , "MusicBrainz Album Release Country", "MusicBrainz Album Status"+ , "MusicBrainz Album Type"+ , "MusicBrainz Release Track Id", "MusicBrainz Track Id"+ , "MusicBrainz Album Id", "MusicBrainz Release Group Id"+ , "MusicBrainz Artist Id", "MusicBrainz Album Artist Id"+ , "MusicBrainz Work Id", "MusicBrainz Disc Id"+ , "Acoustid Fingerprint", "Acoustid Id"+ ] :: [Text]++ -- rawTags only holds text-decoded values, so preservation is limited to+ -- tags that can be reproduced faithfully from text: \169-prefixed text+ -- atoms and freeform (----) tags. Typed atoms (trkn, disk, covr, tmpo,+ -- ...) are either regenerated from mapped fields or skipped+ renderPreservedTag :: Text -> Text -> [L.ByteString]+ renderPreservedTag key value+ | key `elem` handledAtoms || key `elem` handledFreeform = []+ | Just rest <- T.stripPrefix "----:" key+ , (mean, nameWithColon) <- T.breakOn ":" rest+ , name <- T.drop 1 nameWithColon+ , not (T.null mean) && not (T.null name)+ = [renderFreeformTagWith (TE.encodeUtf8 mean) (TE.encodeUtf8 name) value]+ | T.isPrefixOf "\169" key+ , T.length key == 4+ , all ((< 256) . fromEnum) (T.unpack key)+ = [renderTextTag (latin1Bytes key) value]+ | otherwise = []++ latin1Bytes = BS.pack . map (fromIntegral . fromEnum) . T.unpack+ -- | Render a text tag atom renderTextTag :: ByteString -> Text -> L.ByteString renderTextTag name value =@@ -312,9 +422,12 @@ -- | Render freeform tag (----:com.apple.iTunes:NAME) renderFreeformTag :: ByteString -> Text -> L.ByteString-renderFreeformTag name value =- let mean = "com.apple.iTunes"- meanAtom = renderAtom "mean" (runPut (putWord32be 0) <> L.fromStrict mean)+renderFreeformTag = renderFreeformTagWith "com.apple.iTunes"++-- | Render freeform tag (----:MEAN:NAME) with an explicit mean+renderFreeformTagWith :: ByteString -> ByteString -> Text -> L.ByteString+renderFreeformTagWith mean name value =+ let meanAtom = renderAtom "mean" (runPut (putWord32be 0) <> L.fromStrict mean) nameAtom = renderAtom "name" (runPut (putWord32be 0) <> L.fromStrict name) textData = TE.encodeUtf8 value dataAtom = renderDataAtom 1 textData -- Type 1 = UTF-8
src/Monatone/MP3.hs view
@@ -93,6 +93,9 @@ , album = HM.lookup "TALB" tagMap <|> HM.lookup "TAL" tagMap , albumArtist = HM.lookup "TPE2" tagMap <|> HM.lookup "TP2" tagMap , trackNumber = (HM.lookup "TRCK" tagMap <|> HM.lookup "TRK" tagMap) >>= parseTrackNumber+ , totalTracks = (HM.lookup "TRCK" tagMap <|> HM.lookup "TRK" tagMap) >>= parseTotal+ , discNumber = (HM.lookup "TPOS" tagMap <|> HM.lookup "TPA" tagMap) >>= parseTrackNumber+ , totalDiscs = (HM.lookup "TPOS" tagMap <|> HM.lookup "TPA" tagMap) >>= parseTotal , year = ((HM.lookup "TYER" tagMap <|> HM.lookup "TYE" tagMap) >>= readInt) <|> (HM.lookup "TDRC" tagMap >>= extractYearFromDate) , date = HM.lookup "TDRC" tagMap@@ -115,8 +118,12 @@ parseTrackNumber t = case T.split (== '/') t of (n:_) -> readInt n _ -> Nothing+ parseTotal t = case T.split (== '/') t of+ (_:total:_) -> readInt total+ _ -> Nothing extractMusicBrainzIds tags = MusicBrainzIds { mbTrackId = HM.lookup "UFID:http://musicbrainz.org" tags+ <|> HM.lookup "TXXX:MusicBrainz Release Track Id" tags , mbRecordingId = HM.lookup "TXXX:MusicBrainz Recording Id" tags , mbReleaseId = HM.lookup "TXXX:MusicBrainz Album Id" tags , mbReleaseGroupId = HM.lookup "TXXX:MusicBrainz Release Group Id" tags@@ -531,6 +538,7 @@ { sampleRate = Just sampleRate' , channels = Just channels' , bitrate = if bitrate' > 0 then Just bitrate' else Nothing+ , codec = Just CodecMP3 } -- | Parse VBR headers (Xing/Info or VBRI)
src/Monatone/MP3/Writer.hs view
@@ -2,6 +2,7 @@ {-# LANGUAGE BangPatterns #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE TupleSections #-} module Monatone.MP3.Writer ( writeMP3Metadata@@ -9,6 +10,7 @@ , Writer ) where +import Control.Applicative ((<|>)) import Control.Exception (catch, IOException) import Control.Monad.Except (ExceptT, throwError, runExceptT) import Control.Monad.IO.Class (liftIO)@@ -17,6 +19,8 @@ import Data.ByteString (ByteString) 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@@ -225,75 +229,103 @@ return $ header <> framesData --- | Generate all ID3v2.4 frames for the metadata+-- | Generate all ID3v2.4 frames for the metadata. Every field the MP3+-- parser maps is written back, and unmapped frames in rawTags are carried+-- over so an update never drops tags it does not understand. generateFrames :: Metadata -> Maybe AlbumArt -> Writer [L.ByteString] generateFrames metadata maybeAlbumArt = do- -- Start with empty list- frames0 <- return []- - -- Add text frames- frames1 <- addTextFrame frames0 "TIT2" (title metadata)- frames2 <- addTextFrame frames1 "TPE1" (artist metadata) - frames3 <- addTextFrame frames2 "TALB" (album metadata)- frames4 <- addTextFrame frames3 "TPE2" (albumArtist metadata)- frames5 <- addTextFrame frames4 "TCON" (genre metadata)- frames6 <- addTextFrame frames5 "TPUB" (publisher metadata)- - -- Add comment frame (COMM has special structure)- frames7 <- case comment metadata of- Nothing -> return frames6- Just c -> do- commFrame <- generateCOMMFrame c- return $ frames6 ++ [commFrame]- - -- Add numeric frames- frames8 <- addNumericFrame frames7 "TRCK" (trackNumber metadata)- frames9 <- addNumericFrame frames8 "TPOS" (discNumber metadata)- frames10 <- addNumericFrame frames9 "TDRC" (year metadata) -- TDRC for recording date in ID3v2.4+ mapped <- sequence $+ [ generateTextFrame frameId value | (frameId, value) <- textFrames ] +++ [ generateTXXXFrame description value | (description, value) <- txxxFrames ] - -- Add additional metadata fields using TXXX frames- frames11 <- addTXXXFrame frames10 "BARCODE" (barcode metadata)- frames12 <- addTXXXFrame frames11 "CATALOGNUMBER" (catalogNumber metadata)- frames13 <- addTXXXFrame frames12 "LABEL" (recordLabel metadata)- frames14 <- addTXXXFrame frames13 "MusicBrainz Album Release Country" (releaseCountry metadata)- frames15 <- addTXXXFrame frames14 "MusicBrainz Album Status" (releaseStatus metadata)- frames16 <- addTXXXFrame frames15 "MusicBrainz Album Type" (releaseType metadata)+ commFrames <- case comment metadata of+ Nothing -> return []+ Just c -> (: []) <$> generateCOMMFrame c - -- Add date field (separate from year) if present- frames17 <- addTextFrame frames16 "TDRC" (date metadata)+ preserved <- generatePreservedFrames metadata - -- Add album art frame if provided- finalFrames <- case maybeAlbumArt of- Nothing -> return frames17- Just artData -> do- apicFrame <- generateAPICFrame artData- return $ apicFrame : frames17+ apicFrames <- case maybeAlbumArt of+ Nothing -> return []+ Just art -> (: []) <$> generateAPICFrame art - return finalFrames+ return $ mapped ++ commFrames ++ preserved ++ apicFrames where- -- Helper to add text frame if value is present- addTextFrame :: [L.ByteString] -> ByteString -> Maybe Text -> Writer [L.ByteString]- addTextFrame frameList frameId maybeText = case maybeText of- Nothing -> return frameList- Just text -> do- frame <- generateTextFrame frameId text- return $ frame : frameList- - -- Helper to add numeric frame if value is present- addNumericFrame :: [L.ByteString] -> ByteString -> Maybe Int -> Writer [L.ByteString]- addNumericFrame frameList frameId maybeNum = case maybeNum of- Nothing -> return frameList- Just num -> do- frame <- generateTextFrame frameId (T.pack $ show num)- return $ frame : frameList+ mbIds = musicBrainzIds metadata+ showT = T.pack . show - -- Helper to add TXXX frame if value is present- addTXXXFrame :: [L.ByteString] -> Text -> Maybe Text -> Writer [L.ByteString]- addTXXXFrame frameList description maybeText = case maybeText of- Nothing -> return frameList- Just text -> do- frame <- generateTXXXFrame description text- return $ frame : frameList+ -- "7" alone, or "7/12" when the total is known+ numWithTotal num total = renderNum <$> num+ where renderNum n = maybe (showT n) (\t -> showT n <> "/" <> showT t) total++ textFrames = catMaybes+ [ ("TIT2",) <$> title metadata+ , ("TPE1",) <$> artist metadata+ , ("TALB",) <$> album metadata+ , ("TPE2",) <$> albumArtist metadata+ , ("TCON",) <$> genre metadata+ , ("TPUB",) <$> publisher metadata+ , ("TRCK",) <$> numWithTotal (trackNumber metadata) (totalTracks metadata)+ , ("TPOS",) <$> numWithTotal (discNumber metadata) (totalDiscs metadata)+ , ("TDRC",) <$> (date metadata <|> (showT <$> year metadata))+ ]++ txxxFrames = catMaybes+ [ ("BARCODE",) <$> barcode metadata+ , ("CATALOGNUMBER",) <$> catalogNumber metadata+ , ("LABEL",) <$> recordLabel metadata+ , ("MusicBrainz Album Release Country",) <$> releaseCountry metadata+ , ("MusicBrainz Album Status",) <$> releaseStatus metadata+ , ("MusicBrainz Album Type",) <$> releaseType metadata+ , ("MusicBrainz Release Track Id",) <$> mbTrackId mbIds+ , ("MusicBrainz Recording Id",) <$> mbRecordingId mbIds+ , ("MusicBrainz Album Id",) <$> mbReleaseId mbIds+ , ("MusicBrainz Release Group Id",) <$> mbReleaseGroupId mbIds+ , ("MusicBrainz Artist Id",) <$> mbArtistId mbIds+ , ("MusicBrainz Album Artist Id",) <$> mbAlbumArtistId mbIds+ , ("MusicBrainz Work Id",) <$> mbWorkId mbIds+ , ("MusicBrainz Disc Id",) <$> mbDiscId mbIds+ , ("Acoustid Fingerprint",) <$> acoustidFingerprint metadata+ , ("Acoustid Id",) <$> acoustidId metadata+ ]++-- | Re-emit raw frames the mapped fields do not own. rawTags only holds+-- text-decoded values, so preservation is limited to frames that can be+-- reproduced faithfully from text: T-frames and TXXX+generatePreservedFrames :: Metadata -> Writer [L.ByteString]+generatePreservedFrames metadata = mapM emit preservable+ where+ preservable =+ [ kv | kv@(key, _) <- HM.toList (rawTags metadata), isPreservable key ]++ isPreservable key+ | Just description <- T.stripPrefix "TXXX:" key =+ T.toLower description `notElem` handledDescriptions+ | otherwise =+ T.length key == 4 && T.isPrefixOf "T" key && key `notElem` handledTextFrames++ emit (key, value) = case T.stripPrefix "TXXX:" key of+ Just description -> generateTXXXFrame description value+ Nothing -> generateTextFrame (TE.encodeUtf8 key) value++ -- Frames the mapped fields own (whether or not they are set right now):+ -- stale rawTags copies of these must not be written back+ handledTextFrames =+ [ "TIT2", "TPE1", "TALB", "TPE2", "TCON", "TPUB", "TRCK", "TPOS"+ , "TDRC", "TXXX"+ -- legacy v2.3 date frames, superseded by the TDRC we write+ , "TYER", "TDAT", "TIME", "TRDA"+ ]++ handledDescriptions = map T.toLower+ [ "BARCODE", "CATALOGNUMBER", "LABEL", "comment"+ , "MusicBrainz Album Release Country", "MusicBrainz Album Status"+ , "MusicBrainz Album Type"+ , "MusicBrainz Release Track Id", "MusicBrainz Recording Id"+ , "MusicBrainz Album Id", "MusicBrainz Release Group Id"+ , "MusicBrainz Artist Id", "MusicBrainz Album Artist Id"+ , "MusicBrainz Work Id", "MusicBrainz Disc Id"+ , "Acoustid Fingerprint", "Acoustid Id"+ ] -- | Generate a text frame (TIT2, TPE1, TALB, etc.) generateTextFrame :: ByteString -> Text -> Writer L.ByteString
src/Monatone/Metadata.hs view
@@ -6,6 +6,7 @@ -- audio metadata across different formats (FLAC, MP3, OGG/Vorbis, Opus). module Monatone.Metadata ( AudioFormat(..)+ , Codec(..) , Metadata(..) , AudioProperties(..) , MusicBrainzIds(..)@@ -48,6 +49,36 @@ "m4a" -> return M4A _ -> fail $ "Unknown audio format: " ++ show t +-- | Audio codec — the actual compression scheme, distinct from the container+-- format ('AudioFormat'). A single container can carry different codecs+-- (e.g. M4A holds either AAC or ALAC).+data Codec+ = CodecFLAC -- ^ FLAC lossless+ | CodecMP3 -- ^ MPEG-1/2 Audio Layer III (lossy)+ | CodecVorbis -- ^ Ogg Vorbis (lossy)+ | CodecOpus -- ^ Opus (lossy)+ | CodecAAC -- ^ Advanced Audio Coding (lossy)+ | CodecALAC -- ^ Apple Lossless+ deriving (Show, Eq, Ord, Read)++instance ToJSON Codec where+ toJSON CodecFLAC = "flac"+ toJSON CodecMP3 = "mp3"+ toJSON CodecVorbis = "vorbis"+ toJSON CodecOpus = "opus"+ toJSON CodecAAC = "aac"+ toJSON CodecALAC = "alac"++instance FromJSON Codec where+ parseJSON = withText "Codec" $ \t -> case t of+ "flac" -> return CodecFLAC+ "mp3" -> return CodecMP3+ "vorbis" -> return CodecVorbis+ "opus" -> return CodecOpus+ "aac" -> return CodecAAC+ "alac" -> return CodecALAC+ _ -> fail $ "Unknown codec: " ++ show t+ -- | Audio file properties data AudioProperties = AudioProperties { duration :: Maybe Int -- Duration in milliseconds@@ -55,6 +86,7 @@ , sampleRate :: Maybe Int -- Sample rate in Hz , channels :: Maybe Int -- Number of channels , bitsPerSample :: Maybe Int -- Bits per sample (bit depth)+ , codec :: Maybe Codec -- Audio codec (e.g. AAC vs ALAC within M4A) } deriving (Show, Eq) -- | MusicBrainz identifiers@@ -154,6 +186,7 @@ , sampleRate = Nothing , channels = Nothing , bitsPerSample = Nothing+ , codec = Nothing } -- | Empty MusicBrainz IDs
src/Monatone/OGG.hs view
@@ -79,10 +79,12 @@ pageData <- BS.hGet handle pageDataSize -- Check packet type- let (newMetadata, newFoundIdent, newFoundComment) = + let (newMetadata, newFoundIdent, newFoundComment) = if "\x01vorbis" `BS.isPrefixOf` pageData && not foundIdent then (parseVorbisInfo pageData metadata, True, foundComment)- else if "\x03vorbis" `BS.isPrefixOf` pageData && not foundComment + else if "OpusHead" `BS.isPrefixOf` pageData && not foundIdent+ then (parseOpusInfo pageData metadata, True, foundComment)+ else if "\x03vorbis" `BS.isPrefixOf` pageData && not foundComment then (parseVorbisComment pageData metadata, foundIdent, True) else (metadata, foundIdent, foundComment) @@ -127,6 +129,32 @@ , 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+ inputSampleRate <- getWord32le -- original sample rate before Opus resampled to 48k+ return $ metadata+ { audioProperties = emptyAudioProperties+ { sampleRate = Just $ fromIntegral inputSampleRate+ , channels = Just $ fromIntegral opusChannels+ , codec = Just CodecOpus } }
src/Monatone/Writer.hs view
@@ -35,7 +35,6 @@ , removeAlbumArt -- * Writing operations , writeMetadata- , writeMetadataToFile , updateMetadata ) where @@ -44,8 +43,7 @@ import Data.Text (Text) import qualified Data.Text as T import System.OsPath-import System.Directory.OsPath (renameFile, removeFile)-import System.File.OsPath (readFile', writeFile')+import System.Directory.OsPath (copyFile, renameFile, removeFile) import Control.Exception (try, IOException, evaluate) import Monatone.Metadata@@ -246,9 +244,43 @@ applyMaybeUpdate Nothing current = current -- No change applyMaybeUpdate (Just newValue) _ = newValue -- Apply change (including clearing) --- | Write complete metadata to a new file+-- | Write complete metadata to a file, atomically.+--+-- The file is copied to a temporary sibling, the format writer modifies the+-- copy, and the copy is renamed over the original. A crash or failed write+-- leaves the original untouched (at worst a stray @.monatone.tmp@ file). writeMetadata :: Metadata -> Maybe AlbumArt -> OsPath -> Writer () writeMetadata metadata maybeAlbumArt filePath = do+ -- The temp file must be a sibling of the target: rename is only atomic+ -- within a filesystem+ let tmpPath = filePath <> [osp|.monatone.tmp|]++ copyResult <- liftIO $ try $ copyFile filePath tmpPath+ case copyResult of+ Left (ioErr :: IOException) ->+ throwError $ WriteIOError $ "Failed to create temporary copy: " <> T.pack (show ioErr)+ Right () -> do+ writeResult <- liftIO $ runExceptT $ dispatchWrite metadata maybeAlbumArt tmpPath+ case writeResult of+ Left err -> do+ discardTemp tmpPath+ throwError err+ Right () -> do+ renameResult <- liftIO $ try $ renameFile tmpPath filePath+ case renameResult of+ Left (ioErr :: IOException) -> do+ discardTemp tmpPath+ throwError $ WriteIOError $ "Failed to replace file: " <> T.pack (show ioErr)+ Right () -> return ()+ where+ discardTemp :: OsPath -> Writer ()+ discardTemp path = do+ _ <- liftIO $ (try :: IO () -> IO (Either IOException ())) $ removeFile path+ return ()++-- | Dispatch to the format-specific writer, which modifies the file in place+dispatchWrite :: Metadata -> Maybe AlbumArt -> OsPath -> Writer ()+dispatchWrite metadata maybeAlbumArt filePath = do let audioFormat = format metadata case audioFormat of MP3 -> writeMP3Metadata metadata maybeAlbumArt filePath@@ -256,36 +288,6 @@ M4A -> writeM4AMetadata metadata maybeAlbumArt filePath _ -> throwError $ UnsupportedWriteFormat audioFormat --- | Write metadata to the same file (with backup)-writeMetadataToFile :: Metadata -> Maybe AlbumArt -> OsPath -> Writer ()-writeMetadataToFile metadata maybeAlbumArt filePath = do- -- Create backup path by appending .backup to filename- let backupPath = filePath <> [osp|.backup|]-- -- Create backup by copying (not renaming) so original stays available for writers- backupResult <- liftIO $ try $ do- content <- readFile' filePath- writeFile' backupPath content- case backupResult of- Left (ioErr :: IOException) -> throwError $ WriteIOError $ "Failed to create backup: " <> T.pack (show ioErr)- Right _ -> do- -- Try to write new file- writeResult <- liftIO $ runExceptT $ writeMetadata metadata maybeAlbumArt filePath- case writeResult of- Left err -> do- -- Restore backup on failure- restoreResult <- liftIO $ try $ renameFile backupPath filePath- case restoreResult of- Left (restoreErr :: IOException) ->- throwError $ WriteIOError $ "Write failed and backup restore failed: " <> T.pack (show restoreErr)- Right _ -> throwError err- Right _ -> do- -- Success - clean up backup- cleanupResult <- liftIO $ (try :: IO () -> IO (Either IOException ())) $ removeFile backupPath- case cleanupResult of- Left _ -> return () -- Ignore cleanup errors- Right _ -> return ()- -- | Update existing file with metadata changes updateMetadata :: OsPath -> MetadataUpdate -> Writer () updateMetadata filePath update = do@@ -315,7 +317,7 @@ Right art -> return art -- Write back- writeMetadataToFile updatedMetadata maybeArt filePath+ writeMetadata updatedMetadata maybeArt filePath -- | Write MP3 metadata using the MP3Writer module writeMP3Metadata :: Metadata -> Maybe AlbumArt -> OsPath -> Writer ()
test/Test/IntegrationSpec.hs view
@@ -13,7 +13,12 @@ import Control.Exception (catch, SomeException) import Control.Monad (unless) import System.OsPath hiding ((</>))+import Data.Bits ((.&.), (.|.), shiftL)+import qualified Data.ByteString as BS+import qualified Data.HashMap.Strict as HM+import Data.Text (Text) import qualified Data.Text as T+import Data.Word (Word8) import Monatone.Common (parseMetadata) import Monatone.Metadata@@ -33,7 +38,157 @@ , testFLACRoundTrip , testM4ARoundTrip ]+ , testGroup "Write Safety"+ [ testFailedWriteLeavesOriginalIntact+ ]+ , testGroup "Tag Preservation"+ [ testTagPreservation "MP3" "minimal.mp3"+ [("TXXX:MyCustomTag", "custom-value"), ("TMOO", "Chill")]+ , testTagPreservation "FLAC" "minimal.flac"+ [("MYCUSTOMTAG", "custom-value")]+ , testTagPreservation "M4A" "minimal.m4a"+ [("----:com.example.test:CustomField", "custom-value"), ("\169wrt", "A Composer")]+ , testFLACBlockPreservation+ ] ]++-- | Updating one field must not drop MusicBrainz/AcoustID tags, track and+-- disc totals, release status/type, or tags the writer does not map at all+testTagPreservation :: String -> FilePath -> [(Text, Text)] -> TestTree+testTagPreservation label fixtureName customTags =+ testCase (label ++ " update preserves unrelated tags") $ do+ tmpDir <- getTemporaryDirectory+ let origPath = fixturesDir </> fixtureName+ tmpPath = tmpDir </> "monatone-test-preserve-" ++ fixtureName+ origExists <- doesFileExist origPath+ unless origExists $ assertFailure "Test skipped: fixture not available (run with ffmpeg to generate)"+ copyFile origPath tmpPath+ osTmpPath <- toOsPath tmpPath++ -- Enrich the file with everything an update must not lose+ parsed <- parseMetadata osTmpPath >>= either (assertFailure . show) return+ let enriched = parsed+ { musicBrainzIds = MusicBrainzIds+ { mbTrackId = Just "mb-track"+ , mbRecordingId = Just "mb-recording"+ , mbReleaseId = Just "mb-release"+ , mbReleaseGroupId = Just "mb-release-group"+ , mbArtistId = Just "mb-artist"+ , mbAlbumArtistId = Just "mb-album-artist"+ , mbWorkId = Just "mb-work"+ , mbDiscId = Just "mb-disc"+ }+ , acoustidFingerprint = Just "fp-12345"+ , acoustidId = Just "acoustid-67890"+ -- MP3/M4A can only carry totals alongside a number ("n/total"),+ -- so the numbers must be present too+ , trackNumber = Just 7+ , totalTracks = Just 12+ , discNumber = Just 1+ , totalDiscs = Just 2+ , releaseStatus = Just "official"+ , releaseType = Just "album"+ , rawTags = foldr (uncurry HM.insert) (rawTags parsed) customTags+ }+ runExceptT (writeMetadata enriched Nothing osTmpPath)+ >>= either (assertFailure . show) return++ -- The regression under test: an unrelated update must keep all of it+ runExceptT (updateMetadata osTmpPath (setTitle "Preserved Title Test" emptyUpdate))+ >>= either (assertFailure . show) return++ final <- parseMetadata osTmpPath >>= either (assertFailure . show) return+ assertEqual "title updated" (Just "Preserved Title Test") (title final)+ let mbIds = musicBrainzIds final+ assertEqual "mbTrackId" (Just "mb-track") (mbTrackId mbIds)+ assertEqual "mbRecordingId" (Just "mb-recording") (mbRecordingId mbIds)+ assertEqual "mbReleaseId" (Just "mb-release") (mbReleaseId mbIds)+ assertEqual "mbReleaseGroupId" (Just "mb-release-group") (mbReleaseGroupId mbIds)+ assertEqual "mbArtistId" (Just "mb-artist") (mbArtistId mbIds)+ assertEqual "mbAlbumArtistId" (Just "mb-album-artist") (mbAlbumArtistId mbIds)+ assertEqual "mbWorkId" (Just "mb-work") (mbWorkId mbIds)+ assertEqual "mbDiscId" (Just "mb-disc") (mbDiscId mbIds)+ assertEqual "acoustidFingerprint" (Just "fp-12345") (acoustidFingerprint final)+ assertEqual "acoustidId" (Just "acoustid-67890") (acoustidId final)+ assertEqual "totalTracks" (Just 12) (totalTracks final)+ assertEqual "totalDiscs" (Just 2) (totalDiscs final)+ assertEqual "releaseStatus" (Just "official") (releaseStatus final)+ assertEqual "releaseType" (Just "album") (releaseType final)+ mapM_ (\(key, value) ->+ assertEqual ("custom tag " ++ T.unpack key) (Just value) (HM.lookup key (rawTags final)))+ customTags+ removeFile tmpPath++-- | FLAC updates must carry over metadata blocks they do not regenerate+-- (SEEKTABLE, APPLICATION, CUESHEET)+testFLACBlockPreservation :: TestTree+testFLACBlockPreservation = testCase "FLAC update preserves SEEKTABLE block" $ do+ tmpDir <- getTemporaryDirectory+ let origPath = fixturesDir </> "minimal.flac"+ tmpPath = tmpDir </> "monatone-test-seektable.flac"+ origExists <- doesFileExist origPath+ unless origExists $ assertFailure "Test skipped: fixture not available (run with ffmpeg to generate)"++ -- Insert a synthetic SEEKTABLE (type 3, one placeholder seekpoint) after+ -- STREAMINFO: 4-byte signature + 38-byte STREAMINFO block, then the rest+ orig <- BS.readFile origPath+ let (prefix, rest) = BS.splitAt 42 orig+ seekPoint = BS.replicate 18 0xFF -- placeholder seekpoint per spec+ seekTable = BS.pack [3, 0, 0, 18] <> seekPoint+ BS.writeFile tmpPath (prefix <> seekTable <> rest)++ osTmpPath <- toOsPath tmpPath+ runExceptT (updateMetadata osTmpPath (setTitle "Block Preservation" emptyUpdate))+ >>= either (assertFailure . show) return++ final <- BS.readFile tmpPath+ let blocks = flacBlocks final+ assertBool "SEEKTABLE block survived the update" $+ (3, seekTable) `elem` [(t, BS.pack [3, 0, 0, 18] <> c) | (t, c) <- blocks, t == 3]+ removeFile tmpPath+ where+ -- Walk the metadata blocks of a FLAC file: (type, content) pairs+ flacBlocks :: BS.ByteString -> [(Word8, BS.ByteString)]+ flacBlocks bs = go 4+ where+ go pos+ | pos + 4 > BS.length bs = []+ | otherwise =+ let hdr = BS.index bs pos+ btype = hdr .&. 0x7F+ len = (fromIntegral (BS.index bs (pos + 1)) `shiftL` 16) .|.+ (fromIntegral (BS.index bs (pos + 2)) `shiftL` 8) .|.+ fromIntegral (BS.index bs (pos + 3))+ content = BS.take len (BS.drop (pos + 4) bs)+ next = if hdr .&. 0x80 /= 0 then [] else go (pos + 4 + len)+ in (btype, content) : next++-- | Writes go through a temp copy + atomic rename, so a failed write must+-- leave the original file byte-identical and clean up its temp file.+testFailedWriteLeavesOriginalIntact :: TestTree+testFailedWriteLeavesOriginalIntact = testCase "Failed write leaves original untouched" $ do+ tmpDir <- getTemporaryDirectory+ let origPath = fixturesDir </> "minimal.flac"+ tmpPath = tmpDir </> "monatone-test-atomic.flac"++ origExists <- doesFileExist origPath+ unless origExists $ assertFailure "Test skipped: fixture not available (run with ffmpeg to generate)"+ copyFile origPath tmpPath+ before <- BS.readFile tmpPath++ -- Force a failure: metadata claims M4A, but the file is FLAC, so the+ -- M4A writer errors out partway through+ osTmpPath <- toOsPath tmpPath+ result <- runExceptT $ writeMetadata (emptyMetadata M4A) Nothing osTmpPath+ case result of+ Left _ -> return ()+ Right () -> assertFailure "Expected write to fail on mismatched format"++ after <- BS.readFile tmpPath+ assertEqual "original bytes unchanged after failed write" before after+ leftover <- doesFileExist (tmpPath ++ ".monatone.tmp")+ assertBool "no temp file left behind" (not leftover)+ removeFile tmpPath -- | Ensure test fixtures exist, generate them if missing ensureFixtures :: IO ()
test/Test/M4ASpec.hs view
@@ -8,10 +8,15 @@ import Test.Tasty.HUnit import Control.Monad.Except (runExceptT) import Control.Exception (try, IOException)-import System.OsPath+import Data.Bits (shiftR)+import qualified Data.ByteString as BS+import System.Directory (getTemporaryDirectory, removeFile)+import System.FilePath ((</>))+import System.OsPath hiding ((</>)) import Data.Text (Text) import Monatone.M4A (parseM4A, loadAlbumArtM4A)+import Monatone.M4A.Writer (writeM4AMetadata) import Monatone.Metadata tests :: TestTree@@ -23,6 +28,9 @@ , testGroup "Album art" [ testLoadAlbumArt ]+ , testGroup "Writer chunk offsets"+ [ testChunkOffsetAdjustment+ ] ] testParseM4AErrors :: TestTree@@ -64,3 +72,67 @@ -- Would test with actual fixture return () ]++-- | Rewriting metadata resizes moov; in moov-first files the absolute chunk+-- offsets in stco/co64 must shift with the mdat data or the audio is lost.+testChunkOffsetAdjustment :: TestTree+testChunkOffsetAdjustment = testGroup "stco/co64 adjustment on moov resize"+ [ testCase "stco offsets still point at chunk data after write" $+ checkChunkOffsets "stco" "stco" w32+ , testCase "co64 offsets still point at chunk data after write" $+ checkChunkOffsets "co64" "co64" w64+ ]+ where+ chunk1 = "CHUNKONEDATA" :: BS.ByteString+ chunk2 = "CHUNKTWODATA" :: BS.ByteString++ checkChunkOffsets label tableName putOffset = do+ tmpDir <- getTemporaryDirectory+ let tmpPath = tmpDir </> "monatone-test-" ++ label ++ ".m4a"+ BS.writeFile tmpPath (buildMoovFirstFile tableName putOffset)++ osTmpPath <- encodeFS tmpPath+ let meta = (emptyMetadata M4A) { title = Just "A considerably longer replacement title" }+ result <- runExceptT $ writeM4AMetadata meta Nothing osTmpPath+ case result of+ Left err -> assertFailure $ "Write failed: " ++ show err+ Right () -> return ()++ output <- BS.readFile tmpPath+ removeFile tmpPath++ let atTable = snd $ BS.breakSubstring tableName output+ assertBool "output contains offset table" (not (BS.null atTable))+ assertBool "offset table is unique" $+ BS.null $ snd $ BS.breakSubstring tableName (BS.drop 4 atTable)+ let entryWidth = if tableName == ("co64" :: BS.ByteString) then 8 else 4+ tableBody = BS.drop 12 atTable -- name (4) + version/flags (4) + count (4)+ readOffset i = readBE $ BS.take entryWidth $ BS.drop (i * entryWidth) tableBody+ off1 = readOffset 0+ off2 = readOffset 1+ assertEqual "chunk 1 readable at patched offset"+ chunk1 (BS.take (BS.length chunk1) (BS.drop off1 output))+ assertEqual "chunk 2 readable at patched offset"+ chunk2 (BS.take (BS.length chunk2) (BS.drop off2 output))++ -- Layout: ftyp | moov(trak(mdia(minf(stbl(stco|co64))))) | mdat(chunk1 chunk2)+ buildMoovFirstFile tableName putOffset =+ let moovFor off1 off2 =+ atomBS "moov" $ atomBS "trak" $ atomBS "mdia" $ atomBS "minf" $+ atomBS "stbl" $ atomBS tableName $+ BS.concat [w32 0, w32 2, putOffset off1, putOffset off2]+ ftyp = atomBS "ftyp" ("M4A " <> w32 0)+ moovLen = BS.length (moovFor 0 0) -- offsets are fixed-width+ mdatStart = BS.length ftyp + moovLen+ off1 = mdatStart + 8+ off2 = off1 + BS.length chunk1+ in ftyp <> moovFor off1 off2 <> atomBS "mdat" (chunk1 <> chunk2)++ atomBS name content =+ w32 (8 + BS.length content) <> name <> content++ w32, w64 :: Int -> BS.ByteString+ w32 n = BS.pack [fromIntegral (n `shiftR` s) | s <- [24, 16, 8, 0]]+ w64 n = BS.pack [fromIntegral (n `shiftR` s) | s <- [56, 48, 40, 32, 24, 16, 8, 0]]++ readBE = BS.foldl' (\acc b -> acc * 256 + fromIntegral b) 0