"""Tests for audio helpers: speed parameters, chunk cleanup, encoding, command construction, and duration verification.""" import tempfile import unittest from pathlib import Path from converter import audio from converter import config from converter.audio import ( _collect_chunk_files, _encode_args, build_concat_command, build_ffmetadata, build_m4b_chapters_command, cleanup_chunks, speed_export_params, verify_output_duration, ) class SpeedExportParamsTests(unittest.TestCase): def test_normal_speed_no_filter(self): self.assertEqual(speed_export_params(1.0), []) def test_simple_speedup(self): self.assertEqual(speed_export_params(1.5), ["-filter:a", "atempo=1.5"]) def test_simple_slowdown(self): self.assertEqual(speed_export_params(0.75), ["-filter:a", "atempo=0.75"]) def test_chained_speedup_beyond_2x(self): self.assertEqual(speed_export_params(3.0), ["-filter:a", "atempo=2.0,atempo=1.5"]) def test_chained_slowdown_below_half(self): self.assertEqual(speed_export_params(0.25), ["-filter:a", "atempo=0.5,atempo=0.5"]) def test_zero_speed_rejected(self): with self.assertRaises(ValueError): speed_export_params(0) def test_negative_speed_rejected(self): with self.assertRaises(ValueError): speed_export_params(-1.5) class CleanupChunksTests(unittest.TestCase): def test_removes_only_chunk_files(self): with tempfile.TemporaryDirectory() as tmp: chunks_dir = Path(tmp) (chunks_dir / "chunk_0001.wav").write_bytes(b"stale") (chunks_dir / "chunk_0002.wav").write_bytes(b"stale") (chunks_dir / "keep.txt").write_bytes(b"keep") original = config.CHUNKS_FOLDER config.CHUNKS_FOLDER = chunks_dir try: cleanup_chunks() finally: config.CHUNKS_FOLDER = original self.assertFalse((chunks_dir / "chunk_0001.wav").exists()) self.assertFalse((chunks_dir / "chunk_0002.wav").exists()) self.assertTrue((chunks_dir / "keep.txt").exists()) def test_removes_chapter_files(self): with tempfile.TemporaryDirectory() as tmp: chunks_dir = Path(tmp) (chunks_dir / "chapter_0001.m4b").write_bytes(b"stale") (chunks_dir / "chunk_0001.wav").write_bytes(b"stale") original = config.CHUNKS_FOLDER config.CHUNKS_FOLDER = chunks_dir try: cleanup_chunks() finally: config.CHUNKS_FOLDER = original self.assertFalse((chunks_dir / "chapter_0001.m4b").exists()) self.assertFalse((chunks_dir / "chunk_0001.wav").exists()) class EncodeArgsTests(unittest.TestCase): def test_mp3_uses_bitrate_only(self): self.assertEqual(_encode_args("mp3"), ["-b:a", config.AUDIO_BITRATE]) def test_m4b_uses_aac(self): self.assertEqual(_encode_args("m4b"), ["-c:a", "aac", "-b:a", config.AUDIO_BITRATE]) def test_wav_is_lossless_pcm(self): self.assertEqual(_encode_args("wav"), ["-c:a", "pcm_s16le"]) def test_ogg_uses_libvorbis(self): self.assertEqual(_encode_args("ogg"), ["-c:a", "libvorbis", "-b:a", config.AUDIO_BITRATE]) def test_flac_is_lossless(self): self.assertEqual(_encode_args("flac"), ["-c:a", "flac"]) class M4bContainerArgsTests(unittest.TestCase): def setUp(self): self._original = audio._brand_supported audio._brand_supported = True def tearDown(self): audio._brand_supported = self._original def test_includes_faststart_and_brand(self): args = audio._m4b_container_args() self.assertIn("+faststart", args) self.assertIn("M4B ", args) def test_brand_omitted_when_unsupported(self): audio._brand_supported = False self.assertEqual(audio._m4b_container_args(), ["-movflags", "+faststart"]) class BuildConcatCommandTests(unittest.TestCase): def setUp(self): self._original = audio._brand_supported audio._brand_supported = True def tearDown(self): audio._brand_supported = self._original def test_mp3_has_no_container_flags(self): cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3") self.assertEqual(cmd[:6], ["ffmpeg", "-y", "-f", "concat", "-safe", "0"]) self.assertNotIn("-movflags", cmd) self.assertEqual(cmd[-1], "out.mp3") def test_m4b_gets_faststart_and_brand(self): cmd = build_concat_command(Path("list.txt"), Path("out.m4b"), "m4b") self.assertIn("+faststart", cmd) self.assertIn("M4B ", cmd) self.assertEqual(cmd[-1], "out.m4b") def test_speed_copy_writes_two_outputs(self): cmd = build_concat_command(Path("list.txt"), Path("out.m4b"), "m4b", speed=1.5, speed_path=Path("out_1.5.m4b")) self.assertIn("out.m4b", cmd) self.assertIn("out_1.5.m4b", cmd) # faststart must apply to both outputs self.assertEqual(cmd.count("+faststart"), 2) self.assertTrue(any("atempo=1.5" in arg for arg in cmd)) def test_wav_intermediate(self): cmd = build_concat_command(Path("list.txt"), Path("chapter.wav"), "wav") self.assertIn("pcm_s16le", cmd) self.assertNotIn("-movflags", cmd) def test_speed_without_speed_path_rejected(self): with self.assertRaises(ValueError): build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3", speed=1.5) class CollectChunkFilesTests(unittest.TestCase): def test_uses_recorded_paths_exactly(self): with tempfile.TemporaryDirectory() as tmp: present = Path(tmp) / "chunk_0001.wav" present.write_bytes(b"audio") chunk_results = { 1: present, 2: None, # failed chunk 3: Path(tmp) / "chunk_0003.wav", # recorded but deleted } files, missing = _collect_chunk_files(3, chunk_results) self.assertEqual(files, [present]) self.assertEqual(missing, [2, 3]) def test_glob_fallback_without_results(self): with tempfile.TemporaryDirectory() as tmp: chunks_dir = Path(tmp) (chunks_dir / "chunk_0002.wav").write_bytes(b"audio") (chunks_dir / "chunk_0001.wav").write_bytes(b"audio") original = config.CHUNKS_FOLDER config.CHUNKS_FOLDER = chunks_dir try: files, missing = _collect_chunk_files(3) finally: config.CHUNKS_FOLDER = original self.assertEqual(files, [chunks_dir / "chunk_0001.wav", chunks_dir / "chunk_0002.wav"]) self.assertEqual(missing, [3]) class BuildM4bChaptersCommandTests(unittest.TestCase): def setUp(self): self._original = audio._brand_supported audio._brand_supported = True def tearDown(self): audio._brand_supported = self._original def test_base_output_maps_metadata_and_chapters(self): cmd = build_m4b_chapters_command(Path("list.txt"), Path("meta.txt"), Path("out.m4b")) self.assertIn("-map_metadata", cmd) self.assertIn("-map_chapters", cmd) self.assertIn("out.m4b", cmd) self.assertIn("+faststart", cmd) self.assertNotIn("filter_complex", cmd) def test_speed_outputs_get_their_own_chapter_metadata(self): cmd = build_m4b_chapters_command( Path("list.txt"), Path("meta.txt"), Path("out.m4b"), speed=2.0, speed_path=Path("out_2.m4b"), speed_metadata_file=Path("meta2.txt"), ) self.assertEqual(cmd.count("+faststart"), 2) self.assertEqual(cmd.count("-map_chapters"), 2) self.assertTrue(any("atempo=2" in arg for arg in cmd)) # base output chapters come from metadata input 1, speed copy from 2 chapter_flags = [i for i, v in enumerate(cmd) if v == "-map_chapters"] self.assertEqual(cmd[chapter_flags[0] + 1], "1") self.assertEqual(cmd[chapter_flags[1] + 1], "2") base_idx, speed_idx = cmd.index("out.m4b"), cmd.index("out_2.m4b") self.assertLess(chapter_flags[0], base_idx) self.assertGreater(chapter_flags[1], base_idx) self.assertLess(chapter_flags[1], speed_idx) class VerifyOutputDurationTests(unittest.TestCase): def _patch_probe(self, ms): audio.probe_duration_ms = lambda path: ms def setUp(self): self._original_probe = audio.probe_duration_ms def tearDown(self): audio.probe_duration_ms = self._original_probe def test_close_duration_passes(self): self._patch_probe(100_000) self.assertTrue(verify_output_duration(Path("x.m4b"), 101_000)) def test_unverifiable_duration_passes(self): self._patch_probe(0) self.assertTrue(verify_output_duration(Path("x.m4b"), 100_000)) def test_zero_expected_passes(self): self._patch_probe(50_000) self.assertTrue(verify_output_duration(Path("x.m4b"), 0)) def test_large_drift_fails(self): self._patch_probe(3_600_000) # bogus "1 hour" for a 1 minute book with self.assertLogs(level="ERROR"): self.assertFalse(verify_output_duration(Path("x.m4b"), 60_000)) class BuildFFMetadataTests(unittest.TestCase): def test_writes_chapters(self): chapters = [(0, 1200, "One"), (1200, 2500, "Two")] with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "meta.txt" build_ffmetadata(chapters, path) content = path.read_text(encoding="utf-8") self.assertTrue(content.startswith(";FFMETADATA1\n")) self.assertIn("[CHAPTER]", content) self.assertIn("TIMEBASE=1/1000", content) self.assertIn("START=0", content) self.assertIn("END=1200", content) self.assertIn("title=One", content) self.assertIn("START=1200", content) self.assertIn("title=Two", content) def test_escapes_special_characters(self): # ffmpeg's FFMETADATA format treats = ; # and \ as structural. chapters = [(0, 1000, "A = B; C# D\\E")] with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "meta.txt" build_ffmetadata(chapters, path) content = path.read_text(encoding="utf-8") self.assertIn(r"title=A \= B\; C\# D\\E", content) def test_collapses_newlines_in_titles(self): chapters = [(0, 1000, "Two\nLines")] with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "meta.txt" build_ffmetadata(chapters, path) content = path.read_text(encoding="utf-8") self.assertIn("title=Two Lines\n", content) self.assertNotIn("title=Two\n", content) if __name__ == "__main__": unittest.main()