Repository: jonghwanhyeon/python-ffmpeg

Issue:
`FFmpegError(f"Non-zero exit status")` hard to debug
Right now when ffmpeg fails to execute a generic FFmpegError is raised, it would be nice if if there was some way to inspect the stderr to see what went wrong. Right now my current workaround is to build in the command using `ffmpeg._executable + " ".join(ffmpeg._options.build())` in order to debug these issues. Maybe attaching the `stderr` to the FFmpegError could work?

Discussion:

Patch:
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..bdbcd8c
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,3 @@
+include tests/assets/*
+include tests/conftest.py
+include tests/helpers.py
diff --git a/README.md b/README.md
index 587b7e9..4e99f83 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ def main():
         .option("y")
         .input("input.mp4")
         .output(
-            "ouptut.mp4",
+            "output.mp4",
             {"codec:v": "libx264"},
             vf="scale=1280:-1",
             preset="veryslow",
@@ -53,7 +53,7 @@ async def main():
         .option("y")
         .input("input.mp4")
         .output(
-            "ouptut.mp4",
+            "output.mp4",
             {"codec:v": "libx264"},
             vf="scale=1280:-1",
             preset="veryslow",
diff --git a/docs/api.md b/docs/api.md
index d869199..2db6166 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -1,12 +1,11 @@
 # API Reference
 
-
-::: ffmpeg
+## ::: ffmpeg
     options:
       members:
         - FFmpeg
 
-::: ffmpeg.asyncio
+## ::: ffmpeg.asyncio
     options:
       show_root_heading: true
       members:
@@ -16,7 +15,17 @@
     options:
       members:
         - Progress
+
+## Exceptions
+### ::: ffmpeg
+    options:
+      members:
         - FFmpegError
+        - FFmpegAlreadyExecuted
+        - FFmpegFileExists
+        - FFmpegFileNotFound
+        - FFmpegInvalidCommand
+        - FFmpegUnsupportedCodec
 
 ## Events
 ### `start`
diff --git a/docs/examples/asynchronous-listeners.md b/docs/examples/asynchronous-listeners.md
index e42a4fa..2c323f2 100644
--- a/docs/examples/asynchronous-listeners.md
+++ b/docs/examples/asynchronous-listeners.md
@@ -15,7 +15,7 @@ async def main():
         .option("y")
         .input("input.mov")
         .output(
-            "ouptut.mp4",
+            "output.mp4",
             codec="copy",
         )
     )
diff --git a/docs/examples/monitoring-status.md b/docs/examples/monitoring-status.md
index 273b622..40cae62 100644
--- a/docs/examples/monitoring-status.md
+++ b/docs/examples/monitoring-status.md
@@ -16,7 +16,7 @@ In **python-ffmpeg**, the processing status of ffmpeg can be monitored through e
             .option("y")
             .input("input.mp4")
             .output(
-                "ouptut.mp4",
+                "output.mp4",
                 {"codec:v": "libx264"},
                 vf="scale=1280:-1",
                 preset="veryslow",
@@ -68,7 +68,7 @@ In **python-ffmpeg**, the processing status of ffmpeg can be monitored through e
             .option("y")
             .input("input.mp4")
             .output(
-                "ouptut.mp4",
+                "output.mp4",
                 {"codec:v": "libx264"},
                 vf="scale=1280:-1",
                 preset="veryslow",
diff --git a/docs/examples/transcoding.md b/docs/examples/transcoding.md
index 2a8cdfb..a4bdfb5 100644
--- a/docs/examples/transcoding.md
+++ b/docs/examples/transcoding.md
@@ -16,7 +16,7 @@ For example, you can change the container of a video file without re-encoding th
             .option("y")
             .input("input.mov")
             .output(
-                "ouptut.mp4",
+                "output.mp4",
                 codec="copy",
             )
         )
@@ -47,7 +47,7 @@ For example, you can change the container of a video file without re-encoding th
             .option("y")
             .input("input.mov")
             .output(
-                "ouptut.mp4",
+                "output.mp4",
                 codec="copy",
             )
         )
@@ -77,7 +77,7 @@ You can also scale down the resolution of the video as follows.
             .option("y")
             .input("input.mov")
             .output(
-                "ouptut.mp4",
+                "output.mp4",
                 {"codec:v": "libx264", "filter:v": "scale=1280:-1"},
                 preset="veryslow",
                 crf=24,
@@ -110,7 +110,7 @@ You can also scale down the resolution of the video as follows.
             .option("y")
             .input("input.mov")
             .output(
-                "ouptut.mp4",
+                "output.mp4",
                 {"codec:v": "libx264", "filter:v": "scale=1280:-1"},
                 vf="scale=1280:-1",
                 preset="veryslow",
diff --git a/docs/index.md b/docs/index.md
index 4659872..e9c2b63 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -22,7 +22,7 @@ $ pip install python-ffmpeg
             .option("y")
             .input("input.mp4")
             .output(
-                "ouptut.mp4",
+                "output.mp4",
                 {"codec:v": "libx264"},
                 vf="scale=1280:-1",
                 preset="veryslow",
@@ -51,7 +51,7 @@ $ pip install python-ffmpeg
             .option("y")
             .input("input.mp4")
             .output(
-                "ouptut.mp4",
+                "output.mp4",
                 {"codec:v": "libx264"},
                 vf="scale=1280:-1",
                 preset="veryslow",
diff --git a/examples/asyncio/transcode.py b/examples/asyncio/transcode.py
index 3490a71..3359990 100644
--- a/examples/asyncio/transcode.py
+++ b/examples/asyncio/transcode.py
@@ -12,7 +12,7 @@ async def main():
         .option("y")
         .input("input.mp4")
         .output(
-            "ouptut.mp4",
+            "output.mp4",
             {"codec:v": "libx264", "filter:v": "scale=1280:-1"},
             preset="veryslow",
             crf=24,
diff --git a/examples/transcode.py b/examples/transcode.py
index d8525e4..840da22 100644
--- a/examples/transcode.py
+++ b/examples/transcode.py
@@ -9,7 +9,7 @@ def main():
         .option("y")
         .input("input.mp4")
         .output(
-            "ouptut.mp4",
+            "output.mp4",
             {
                 "codec:v": "libx264",
                 "filter:v": "scale=1280:-1",
diff --git a/ffmpeg/__init__.py b/ffmpeg/__init__.py
index d952b80..7912bd0 100644
--- a/ffmpeg/__init__.py
+++ b/ffmpeg/__init__.py
@@ -1,4 +1,12 @@
-from .ffmpeg import FFmpeg, FFmpegError
+from .errors import (
+    FFmpegAlreadyExecuted,
+    FFmpegError,
+    FFmpegFileExists,
+    FFmpegFileNotFound,
+    FFmpegInvalidCommand,
+    FFmpegUnsupportedCodec,
+)
+from .ffmpeg import FFmpeg
 from .progress import Progress
 
 __version__ = "2.0.4"
diff --git a/ffmpeg/asyncio/ffmpeg.py b/ffmpeg/asyncio/ffmpeg.py
index fc7fafd..e539f0d 100644
--- a/ffmpeg/asyncio/ffmpeg.py
+++ b/ffmpeg/asyncio/ffmpeg.py
@@ -12,7 +12,7 @@ from typing_extensions import Self
 
 from ffmpeg import types
 from ffmpeg.asyncio.utils import create_subprocess, ensure_stream_reader, read_stream, readlines
-from ffmpeg.ffmpeg import FFmpegError
+from ffmpeg.ffmpeg import FFmpegAlreadyExecuted, FFmpegError
 from ffmpeg.options import Options
 from ffmpeg.progress import Tracker
 from ffmpeg.utils import is_windows
@@ -98,14 +98,14 @@ class FFmpeg(AsyncIOEventEmitter):
             stream: A stream to input to the standard input. Defaults to None.
 
         Raises:
-            FFmpegError: If FFmpeg is already executed.
+            FFmpegAlreadyExecuted: If FFmpeg is already executed.
             FFmpegError: If FFmpeg process returns non-zero exit status.
 
         Returns:
             The output to the standard output.
         """
         if self._executed:
-            raise FFmpegError("FFmpeg is already executed")
+            raise FFmpegAlreadyExecuted("FFmpeg is already executed")
 
         self._executed = False
         self._terminated = False
@@ -125,7 +125,7 @@ class FFmpeg(AsyncIOEventEmitter):
 
         self._executed = True
 
-        tasks = [
+        tasks: list[asyncio.Task] = [
             asyncio.create_task(self._write_stdin(stream)),
             asyncio.create_task(self._read_stdout()),
             asyncio.create_task(self._handle_stderr()),
@@ -140,7 +140,7 @@ class FFmpeg(AsyncIOEventEmitter):
         elif self._terminated:
             self.emit("terminated")
         else:
-            raise FFmpegError(f"Non-zero exit status {self._process.returncode}")
+            raise FFmpegError.create(message=tasks[2].result(), arguments=arguments)
 
         return tasks[1].result()
 
@@ -187,11 +187,14 @@ class FFmpeg(AsyncIOEventEmitter):
 
         return bytes(buffer)
 
-    async def _handle_stderr(self):
+    async def _handle_stderr(self) -> str:
         assert self._process.stderr is not None
 
+        line = b""
         async for line in readlines(self._process.stderr):
             self.emit("stderr", line.decode())
 
+        return line.decode()
+
     def _reraise_exception(self, exception: Exception):
         raise exception
diff --git a/ffmpeg/errors.py b/ffmpeg/errors.py
new file mode 100644
index 0000000..892e3a1
--- /dev/null
+++ b/ffmpeg/errors.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+import re
+from typing import ClassVar, Optional
+
+from typing_extensions import Self
+
+
+class FFmpegError(Exception):
+    """Represents an error that has occurred during `FFmpeg` operation.
+
+    Attributes:
+        message: An error message providing details about the error.
+        arguments: Arguments passed to FFmpeg.
+    """
+
+    _patterns: ClassVar[Optional[list[str]]] = None
+
+    def __init__(self, message: str, arguments: Optional[list[str]] = None):
+        super().__init__(message)
+
+        self.message = message
+        self.arguments = arguments
+
+    @classmethod
+    def create(cls, message: str, arguments: Optional[list[str]] = None) -> Self:
+        for subclass in cls.__subclasses__():
+            if subclass._patterns is None:
+                continue
+
+            for pattern in subclass._patterns:
+                if re.search(pattern, message, flags=re.IGNORECASE) is not None:
+                    return subclass(message, arguments)
+
+        return FFmpegError(message, arguments)
+
+
+class FFmpegAlreadyExecuted(FFmpegError):
+    "FFmpeg is being executed"
+
+
+class FFmpegFileExists(FFmpegError):
+    "The output file already exists, you can overwrite it using the -y option"
+    _patterns = [
+        r"already exists",
+    ]
+
+
+class FFmpegFileNotFound(FFmpegError):
+    "An input file was not found"
+    _patterns = [
+        r"no such file",
+        r"could not open file",
+    ]
+
+
+class FFmpegInvalidCommand(FFmpegError):
+    "FFmpeg was passed invalid options or arguments"
+    _patterns = [
+        r"option .* ?not found",
+        r"unrecognized option",
+        r"trailing options were found on the commandline",
+        r"invalid encoder type",
+        r"codec not currently supported in container",
+    ]
+
+
+class FFmpegUnsupportedCodec(FFmpegError):
+    "FFmpeg attempted to use an unsupported codec"
+    _patterns = [
+        r"unknown encoder",
+        r"unknown decoder",
+    ]
diff --git a/ffmpeg/ffmpeg.py b/ffmpeg/ffmpeg.py
index ba5c6fe..6823683 100644
--- a/ffmpeg/ffmpeg.py
+++ b/ffmpeg/ffmpeg.py
@@ -11,15 +11,12 @@ from pyee import EventEmitter
 from typing_extensions import Self
 
 from ffmpeg import types
+from ffmpeg.errors import FFmpegAlreadyExecuted, FFmpegError
 from ffmpeg.options import Options
 from ffmpeg.progress import Tracker
 from ffmpeg.utils import create_subprocess, ensure_io, is_windows, read_stream, readlines
 
 
-class FFmpegError(Exception):
-    pass
-
-
 class FFmpeg(EventEmitter):
     def __init__(self, executable: str = "ffmpeg"):
         """Initialize an `FFmpeg` instance.
@@ -98,14 +95,14 @@ class FFmpeg(EventEmitter):
             stream: A stream to input to the standard input. Defaults to None.
 
         Raises:
-            FFmpegError: If FFmpeg is already executed.
+            FFmpegAlreadyExecuted: If FFmpeg is already executed.
             FFmpegError: If FFmpeg process returns non-zero exit status.
 
         Returns:
             The output to the standard output.
         """
         if self._executed:
-            raise FFmpegError("FFmpeg is already executed")
+            raise FFmpegAlreadyExecuted("FFmpeg is already executed")
 
         self._executed = False
         self._terminated = False
@@ -147,7 +144,7 @@ class FFmpeg(EventEmitter):
         elif self._terminated:
             self.emit("terminated")
         else:
-            raise FFmpegError(f"Non-zero exit status {self._process.returncode}")
+            raise FFmpegError.create(message=futures[2].result(), arguments=arguments)
 
         return futures[1].result()
 
@@ -194,10 +191,12 @@ class FFmpeg(EventEmitter):
         self._process.stdout.close()
         return bytes(buffer)
 
-    def _handle_stderr(self):
+    def _handle_stderr(self) -> str:
         assert self._process.stderr is not None
 
+        line = b""
         for line in readlines(self._process.stderr):
             self.emit("stderr", line.decode())
 
         self._process.stderr.close()
+        return line.decode()
diff --git a/mkdocs.yml b/mkdocs.yml
index 4bf46d8..cfa0b44 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -48,7 +48,7 @@ plugins:
             members_order: source
             group_by_category: false
             show_bases: true
-            show_source: false
+            show_source: true
 
 watch:
-  - ffmpeg
\ No newline at end of file
+  - ffmpeg


Test patch:
diff --git a/tests/test_asyncio_errors.py b/tests/test_asyncio_errors.py
new file mode 100644
index 0000000..ca50962
--- /dev/null
+++ b/tests/test_asyncio_errors.py
@@ -0,0 +1,173 @@
+import asyncio
+from pathlib import Path
+
+import pytest
+
+from ffmpeg import (
+    FFmpegAlreadyExecuted,
+    FFmpegFileExists,
+    FFmpegFileNotFound,
+    FFmpegInvalidCommand,
+    FFmpegUnsupportedCodec,
+)
+from ffmpeg.asyncio import FFmpeg
+
+
+@pytest.mark.asyncio
+async def test_asyncio_raises_already_executed(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    source_path = assets_path / "pier-39.ts"
+    target_path = tmp_path / "pier-39.mp4"
+
+    ffmpeg = (
+        FFmpeg()
+        .option("y")
+        .input(source_path)
+        .output(
+            target_path,
+            codec="copy",
+        )
+    )
+
+    with pytest.raises(FFmpegAlreadyExecuted):
+        ffmpeg._executed = True
+        await ffmpeg.execute()
+
+
+@pytest.mark.asyncio
+async def test_asyncio_raises_file_exists(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    source_path = assets_path / "pier-39.ts"
+    target_path = tmp_path / "pier-39.mp4"
+
+    (
+        await FFmpeg()
+        .option("y")
+        .input(source_path)
+        .output(
+            target_path,
+            codec="copy",
+        )
+        .execute()
+    )
+
+    with pytest.raises(FFmpegFileExists):
+        # Note: pytest sets stdin to a null object, so "Overwrite? [y/N]" prompt will be ignored
+        (
+            await FFmpeg()
+            .input(source_path)
+            .output(
+                target_path,
+                codec="copy",
+            )
+            .execute()
+        )
+
+
+@pytest.mark.asyncio
+async def test_asyncio_raises_file_not_found(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    invalid_source_path = assets_path / "non-existent.mp4"
+    target_path = tmp_path / "pier-39.mp4"
+
+    with pytest.raises(FFmpegFileNotFound):
+        (
+            await FFmpeg()
+            .input(invalid_source_path)
+            .output(
+                target_path,
+                codec="copy",
+            )
+            .execute()
+        )
+
+
+@pytest.mark.asyncio
+async def test_asyncio_raises_invalid_command_for_invalid_option(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    source_path = assets_path / "pier-39.ts"
+    target_path = tmp_path / "pier-39.mp4"
+
+    with pytest.raises(FFmpegInvalidCommand):
+        (
+            await FFmpeg()
+            .option("invalid")
+            .input(source_path)
+            .output(
+                target_path,
+                codec="copy",
+            )
+            .execute()
+        )
+
+
+@pytest.mark.asyncio
+async def test_asyncio_raises_invalid_command_for_invalid_file_option(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    source_path = assets_path / "pier-39.ts"
+    target_path = tmp_path / "pier-39.mp4"
+
+    with pytest.raises(FFmpegInvalidCommand):
+        (
+            await FFmpeg()
+            .option("y")
+            .input(
+                source_path,
+                pix_fmt="yuv420p",
+                preset="fast",
+            )
+            .output(target_path)
+            .execute()
+        )
+
+
+@pytest.mark.asyncio
+async def test_asyncio_raises_unsupported_codec_for_invalid_encoder(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    source_path = assets_path / "pier-39.ts"
+    target_path = tmp_path / "pier-39.mp4"
+
+    with pytest.raises(FFmpegUnsupportedCodec):
+        (
+            await FFmpeg()
+            .input(
+                source_path,
+                codec="invalid",
+            )
+            .output(
+                target_path,
+            )
+            .execute()
+        )
+
+
+@pytest.mark.asyncio
+async def test_asyncio_raises_unsupported_codec_for_invalid_decoder(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    source_path = assets_path / "pier-39.ts"
+    target_path = tmp_path / "pier-39.mp4"
+
+    with pytest.raises(FFmpegUnsupportedCodec):
+        (
+            await FFmpeg()
+            .input(source_path)
+            .output(
+                target_path,
+                codec="invalid",
+            )
+            .execute()
+        )
diff --git a/tests/test_asyncio_listeners.py b/tests/test_asyncio_listeners.py
index 3f47fad..c62477c 100644
--- a/tests/test_asyncio_listeners.py
+++ b/tests/test_asyncio_listeners.py
@@ -34,4 +34,4 @@ async def test_asyncio_exception_raising(
     except Exception as exc:
         caught_error = exc
 
-    assert caught_error == raised_error
\ No newline at end of file
+    assert caught_error == raised_error
diff --git a/tests/test_asyncio_pipe.py b/tests/test_asyncio_pipe.py
index 0f92c2c..939f06b 100644
--- a/tests/test_asyncio_pipe.py
+++ b/tests/test_asyncio_pipe.py
@@ -39,6 +39,7 @@ async def test_asyncio_input_via_stdin(
     assert source["streams"][0]["codec_name"] == target["streams"][0]["codec_name"]
     assert source["streams"][1]["codec_name"] == target["streams"][1]["codec_name"]
 
+
 @pytest.mark.asyncio
 async def test_asyncio_output_via_stdout(
     assets_path: Path,
diff --git a/tests/test_errors.py b/tests/test_errors.py
new file mode 100644
index 0000000..dc915b9
--- /dev/null
+++ b/tests/test_errors.py
@@ -0,0 +1,165 @@
+from pathlib import Path
+
+import pytest
+
+from ffmpeg import (
+    FFmpeg,
+    FFmpegAlreadyExecuted,
+    FFmpegFileExists,
+    FFmpegFileNotFound,
+    FFmpegInvalidCommand,
+    FFmpegUnsupportedCodec,
+)
+
+
+def test_raises_already_executed(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    source_path = assets_path / "pier-39.ts"
+    target_path = tmp_path / "pier-39.mp4"
+
+    ffmpeg = (
+        FFmpeg()
+        .option("y")
+        .input(source_path)
+        .output(
+            target_path,
+            codec="copy",
+        )
+    )
+
+    with pytest.raises(FFmpegAlreadyExecuted):
+        ffmpeg._executed = True
+        ffmpeg.execute()
+
+
+def test_raises_file_exists(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    source_path = assets_path / "pier-39.ts"
+    target_path = tmp_path / "pier-39.mp4"
+
+    (
+        FFmpeg()
+        .option("y")
+        .input(source_path)
+        .output(
+            target_path,
+            codec="copy",
+        )
+        .execute()
+    )
+
+    with pytest.raises(FFmpegFileExists):
+        # Note: pytest sets stdin to a null object, so "Overwrite? [y/N]" prompt will be ignored
+        (
+            FFmpeg()
+            .input(source_path)
+            .output(
+                target_path,
+                codec="copy",
+            )
+            .execute()
+        )
+
+
+def test_raises_file_not_found(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    invalid_source_path = assets_path / "non-existent.mp4"
+    target_path = tmp_path / "pier-39.mp4"
+
+    with pytest.raises(FFmpegFileNotFound):
+        (
+            FFmpeg()
+            .input(invalid_source_path)
+            .output(
+                target_path,
+                codec="copy",
+            )
+            .execute()
+        )
+
+
+def test_raises_invalid_command_for_invalid_option(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    source_path = assets_path / "pier-39.ts"
+    target_path = tmp_path / "pier-39.mp4"
+
+    with pytest.raises(FFmpegInvalidCommand):
+        (
+            FFmpeg()
+            .option("invalid")
+            .input(source_path)
+            .output(
+                target_path,
+                codec="copy",
+            )
+            .execute()
+        )
+
+
+def test_raises_invalid_command_for_invalid_file_option(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    source_path = assets_path / "pier-39.ts"
+    target_path = tmp_path / "pier-39.mp4"
+
+    with pytest.raises(FFmpegInvalidCommand):
+        (
+            FFmpeg()
+            .option("y")
+            .input(
+                source_path,
+                pix_fmt="yuv420p",
+                preset="fast",
+            )
+            .output(target_path)
+            .execute()
+        )
+
+
+def test_raises_unsupported_codec_for_invalid_decoder(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    source_path = assets_path / "pier-39.ts"
+    target_path = tmp_path / "pier-39.mp4"
+
+    with pytest.raises(FFmpegUnsupportedCodec):
+        (
+            FFmpeg()
+            .input(
+                source_path,
+                codec="invalid",
+            )
+            .output(
+                target_path,
+            )
+            .execute()
+        )
+
+
+def test_raises_unsupported_codec_for_invalid_encoders(
+    assets_path: Path,
+    tmp_path: Path,
+):
+    source_path = assets_path / "pier-39.ts"
+    target_path = tmp_path / "pier-39.mp4"
+
+    with pytest.raises(FFmpegUnsupportedCodec):
+        (
+            FFmpeg()
+            .input(source_path)
+            .output(
+                target_path,
+                codec="invalid",
+            )
+            .execute()
+        )