packages feed

monatone-0.4.0.0: test/Test/M4ASpec.hs

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

module Test.M4ASpec (tests) where

import Test.Tasty
import Test.Tasty.HUnit
import Control.Monad.Except (runExceptT)
import Control.Exception (try, IOException)
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
tests = testGroup "M4A Parser"
  [ testGroup "File parsing"
      [ testParseM4AErrors
      , testParseM4AMetadata
      ]
  , testGroup "Album art"
      [ testLoadAlbumArt
      ]
  , testGroup "Writer chunk offsets"
      [ testChunkOffsetAdjustment
      ]
  ]

testParseM4AErrors :: TestTree
testParseM4AErrors = testGroup "parseM4A error handling"
  [ testCase "handles non-existent file" $ do
      result <- try $ runExceptT $ parseM4A [osp|/nonexistent/file.m4a|]
      case result of
        Left (_ :: IOException) -> return ()  -- Expected IO exception
        Right (Left _) -> return ()  -- Also acceptable - parser error
        Right (Right _) -> assertFailure "Expected error for non-existent file"
  ]

testParseM4AMetadata :: TestTree
testParseM4AMetadata = testGroup "parseM4A metadata extraction"
  [ testCase "parses basic M4A metadata" $ do
      -- This test would run if we had test fixtures
      -- For now, just verify the test structure works
      return ()

  , testCase "handles AAC files" $ do
      -- AAC-specific test
      return ()

  , testCase "handles ALAC files" $ do
      -- Apple Lossless specific test
      return ()
  ]

testLoadAlbumArt :: TestTree
testLoadAlbumArt = testGroup "loadAlbumArtM4A"
  [ testCase "handles non-existent file" $ do
      result <- try $ runExceptT $ loadAlbumArtM4A [osp|/nonexistent/file.m4a|]
      case result of
        Left (_ :: IOException) -> return ()
        Right (Left _) -> return ()
        Right (Right _) -> assertFailure "Expected error for non-existent file"

  , testCase "returns Nothing for file without album art" $ do
      -- 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 KeepExistingArt 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