import contextlib import io import os import shutil import subprocess import tempfile import unittest import convertaudio HAS_FFMPEG = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None def run_main(*arguments): output = io.StringIO() with contextlib.redirect_stdout(output): code = convertaudio.main(list(arguments)) return code, output.getvalue() @unittest.skipUnless(HAS_FFMPEG, "needs ffmpeg and ffprobe") class ConvertAudioTests(unittest.TestCase): def setUp(self): self.directory = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, self.directory, True) def path(self, *parts): return os.path.join(self.directory, *parts) def make(self, name, source="sine=frequency=441:duration=0.1:sample_rate=44100", channels=1, codec_arguments=()): path = self.path(name) os.makedirs(os.path.dirname(path), exist_ok=True) subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", source, "-ac", str(channels), *codec_arguments, path], check=True) return path def info(self, path): return convertaudio.probe(path, shutil.which("ffprobe")) def read(self, path): with open(path, "rb") as file: return file.read() def test_ALosslessInput_BecomesVorbisBesideIt(self): self.make("tone.wav") code, output = run_main(self.path("tone.wav")) self.assertEqual(0, code) converted = self.info(self.path("tone.ogg")) self.assertEqual("vorbis", converted.codec) self.assertEqual(1, converted.channels) self.assertEqual(44100, converted.sample_rate) self.assertIn("encoded at quality 5", output) self.assertNotIn("WARNING", output) def test_StereoAndTheSampleRatePassThrough(self): self.make("wide.wav", source="sine=frequency=441:duration=0.1:sample_rate=22050", channels=2) run_main(self.path("wide.wav")) converted = self.info(self.path("wide.ogg")) self.assertEqual(2, converted.channels) self.assertEqual(22050, converted.sample_rate) def test_AnOggVorbisInput_IsCopiedByteForByte(self): self.make("tone.ogg", codec_arguments=("-c:a", "libvorbis")) os.rename(self.path("tone.ogg"), self.path("original.oga")) code, output = run_main(self.path("original.oga"), "--out", self.path("out")) self.assertEqual(0, code) self.assertEqual(self.read(self.path("original.oga")), self.read(self.path("out", "original.ogg"))) self.assertIn("copied", output) def test_AnOggVorbisInputThatIsItsOwnResult_IsLeftAlone(self): original = self.make("tone.ogg", codec_arguments=("-c:a", "libvorbis")) before = self.read(original) code, output = run_main(original, "--force") self.assertEqual(0, code) self.assertEqual(before, self.read(original)) self.assertIn("already Ogg Vorbis", output) def test_ALossyInput_ConvertsWithAWarning(self): self.make("tone.m4a", codec_arguments=("-c:a", "aac")) code, output = run_main(self.path("tone.m4a")) self.assertEqual(0, code) self.assertIn("WARNING", output) self.assertNotIn("gap", output) self.assertEqual("vorbis", self.info(self.path("tone.ogg")).codec) def test_AnMp3_WarnsThatItMayNotLoopWithoutAGap(self): self.make("tone.mp3", codec_arguments=("-c:a", "libmp3lame")) _, output = run_main(self.path("tone.mp3")) self.assertIn("gap", output) def test_MoreThanTwoChannels_Fails(self): self.make("surround.wav", channels=3) code, output = run_main(self.path("surround.wav")) self.assertEqual(1, code) self.assertIn("3 channels", output) self.assertFalse(os.path.exists(self.path("surround.ogg"))) def test_AFailureDoesNotStopTheOtherFiles(self): self.make("a_surround.wav", channels=3) self.make("b_tone.wav") code, _ = run_main(self.directory) self.assertEqual(1, code) self.assertTrue(os.path.exists(self.path("b_tone.ogg"))) def test_AFileThatIsNotAudio_FailsWithoutLeavingAResult(self): with open(self.path("notes.wav"), "w") as file: file.write("not audio") code, output = run_main(self.path("notes.wav")) self.assertEqual(1, code) self.assertIn("FAILED", output) self.assertEqual([], [name for name in os.listdir(self.directory) if name.endswith(".ogg")]) def test_ANewerResult_IsSkippedUntilForced(self): source = self.make("tone.wav") run_main(source) converted = self.path("tone.ogg") with open(converted, "ab") as file: file.write(b"marker") os.utime(converted, (os.path.getmtime(source) + 10, os.path.getmtime(source) + 10)) _, skipped = run_main(source) marked = self.read(converted).endswith(b"marker") run_main(source, "--force") forced = self.read(converted).endswith(b"marker") self.assertIn("up to date", skipped) self.assertTrue(marked) self.assertFalse(forced) def test_RunningTwice_GivesTheSameBytes(self): source = self.make("tone.wav") run_main(source) first = self.read(self.path("tone.ogg")) run_main(source, "--force") self.assertEqual(first, self.read(self.path("tone.ogg"))) def test_AFolder_IsSearchedRecursivelyAndKeepsItsStructureUnderOut(self): self.make(os.path.join("in", "top.wav")) self.make(os.path.join("in", "sub", "deep.wav")) self.make(os.path.join("in", "readme.wav"), source="sine=duration=0.05") with open(self.path("in", "skipped.txt"), "w") as file: file.write("text") code, _ = run_main(self.path("in"), "--out", self.path("out")) self.assertEqual(0, code) self.assertTrue(os.path.exists(self.path("out", "top.ogg"))) self.assertTrue(os.path.exists(self.path("out", "sub", "deep.ogg"))) self.assertFalse(os.path.exists(self.path("out", "skipped.ogg"))) def test_TwoInputsWithTheSameResult_Fail(self): self.make("tone.wav") self.make("tone.flac") code, output = run_main(self.directory) self.assertEqual(2, code) self.assertIn("would both become", output) self.assertEqual([], [name for name in os.listdir(self.directory) if name.endswith(".ogg")]) def test_TheQualityOptionChangesTheSize(self): source = self.make("noise.wav", source="anoisesrc=duration=1:sample_rate=44100") run_main(source, "--quality", "0", "--out", self.path("low")) run_main(source, "--quality", "9", "--out", self.path("high")) self.assertLess(os.path.getsize(self.path("low", "noise.ogg")), os.path.getsize(self.path("high", "noise.ogg"))) def test_NoAudioFilesFound_Fails(self): code, output = run_main(self.directory) self.assertEqual(2, code) self.assertIn("No audio files", output) def test_AMissingInput_Fails(self): code, output = run_main(self.path("nothing.wav")) self.assertEqual(2, code) self.assertIn("isn't a file or a folder", output) class ClassifyTests(unittest.TestCase): def test_VorbisInOgg_IsCopied(self): self.assertEqual("copy", convertaudio.classify(convertaudio.AudioInfo("vorbis", "ogg", 2, 44100))) def test_VorbisInAnotherContainer_IsLossy(self): self.assertEqual("lossy", convertaudio.classify(convertaudio.AudioInfo("vorbis", "matroska,webm", 2, 44100))) def test_OpusInOgg_IsLossy(self): self.assertEqual("lossy", convertaudio.classify(convertaudio.AudioInfo("opus", "ogg", 2, 48000))) def test_PcmAndFlac_AreLossless(self): self.assertEqual("lossless", convertaudio.classify(convertaudio.AudioInfo("pcm_s16le", "wav", 2, 44100))) self.assertEqual("lossless", convertaudio.classify(convertaudio.AudioInfo("pcm_f32le", "wav", 2, 44100))) self.assertEqual("lossless", convertaudio.classify(convertaudio.AudioInfo("flac", "flac", 2, 44100))) def test_Mp3AndAac_AreLossy(self): self.assertEqual("lossy", convertaudio.classify(convertaudio.AudioInfo("mp3", "mp3", 2, 44100))) self.assertEqual("lossy", convertaudio.classify(convertaudio.AudioInfo("aac", "mov,mp4,m4a", 2, 44100))) class FindToolTests(unittest.TestCase): def test_AMissingTool_NamesTheInstallCommand(self): with self.assertRaises(convertaudio.ConvertError) as caught: convertaudio.find_tool("no-such-audio-tool-anywhere") self.assertIn(convertaudio.INSTALL_HINT, str(caught.exception)) class ParseArgumentsTests(unittest.TestCase): def test_TheDefaultsAreQualityFiveBesideTheInput(self): arguments = convertaudio.parse_arguments(["a.wav"]) self.assertEqual(5, arguments.quality) self.assertIsNone(arguments.out) self.assertFalse(arguments.force) def test_AQualityOutOfRange_Exits(self): with contextlib.redirect_stderr(io.StringIO()): with self.assertRaises(SystemExit): convertaudio.parse_arguments(["a.wav", "--quality", "11"]) if __name__ == "__main__": unittest.main()