Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The package provides a convenient interface for FFmpeg, including a 2-pass encod

## Installation

This package requires Python >= 3.9. For Windows users, ensure that Python is added to your system's PATH. I highly recommend that you use the [python.org](https://python.org) Python installation.
This package requires Python >= 3.10. For Windows users, ensure that Python is added to your system's PATH. I highly recommend that you use the [python.org](https://python.org) Python installation.

Install this package with the following command.

Expand Down Expand Up @@ -75,7 +75,7 @@ I've had a good time using this command with a Batch file on Windows. Refer to t
| `--astreams` | all | `--astreams "0,1"` | Comma-separated list of **0-based audio stream positions** to include. When used with `--amix`, those streams will be mixed; otherwise, only the first selected stream is kept. |
| `-v`<br>`--verbose` | false | `--verbose` | Enable verbose FFmpeg logging (useful for debugging). |
| `--config` | off | `--config config.json` | Path to a JSON file containing a saved configuration. When a setting is provided by both JSON and CLI flags, the CLI flag wins. See [JSON Configuration](#json-configuration). |
| `--no-config` | off | `no---config` | Disable loading any default configuration files. See [JSON Configuration](#json-configuration). |
| `--no-config` | off | `--no-config` | Disable loading any default configuration files. See [JSON Configuration](#json-configuration). |
| `--web` | false | `--web` | Launch the Web UI for this job. See [Web UI](#web-ui). |
| `-p`<br>`--port` | random (when `--web`) | `-p 5333` | Run the Web UI on a specific port. If omitted, a random free port between 5000–6000 is chosen. |

Expand Down Expand Up @@ -115,7 +115,7 @@ Create a new plain text JSON file (`my-config.json`) structured like this:
{
"target_filesize": 10.0,
"resolution": "1280x720",
"codec": "libvpx-vp9",
"codec": "vp9",
"to": "00:00:40"
}
```
Expand Down
9 changes: 8 additions & 1 deletion ffmpeg4discord/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from textwrap import dedent
from typing import Callable, Optional

import ffmpeg
from flask import Flask, redirect, render_template, request, send_file, url_for

from ffmpeg4discord import arguments
Expand Down Expand Up @@ -445,7 +446,13 @@ def main() -> None:
args["output"] = Path(args["output"])
# the rest of the args are the encode settings
settings = EncodeSettings(**args)
twopass = TwoPass(path)
try:
twopass = TwoPass(path)
except ffmpeg.Error as exc:
# ffprobe's last stderr line names the file and the problem, e.g. "clip.mp4: No such file or directory"
reason = exc.stderr.decode(errors="replace").strip().rsplit("\n", 1)[-1]
logging.error(reason or f"ffprobe couldn't read {path}")
raise SystemExit(1) from None

if web:
# --from and --to fill in the form's starting trim, so check them before starting the server
Expand Down
20 changes: 20 additions & 0 deletions ffmpeg4discord/arguments.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Reads ff4d's command-line flags and config files."""

import difflib
import json
import logging
import socket
Expand Down Expand Up @@ -185,6 +186,24 @@ def _search_for_default_config(args: dict) -> dict:
return args


def _check_config(config: dict, parser: ArgumentParser, file_path: Path) -> None:
"""Reject config keys that match no flag and values outside a flag's choices, like argparse does on the command line."""
actions = {action.dest: action for action in parser._actions if action.dest != "help"}
for key, value in config.items():
if key not in actions:
message = f"{file_path} has an unknown setting {key!r}."
match = difflib.get_close_matches(key, actions, n=1)
if match:
message += f" Did you mean {match[0]!r}?"
parser.error(message)
choices = actions[key].choices
if choices is not None and value not in choices:
flags = "/".join(actions[key].option_strings)
parser.error(
f"{file_path}: argument {flags}: invalid choice: {value!r} (choose from {', '.join(map(repr, choices))})"
)


def _merge_config_args(args: dict, parser: ArgumentParser, argv: list[str] | None = None) -> dict:
"""
Make the config file's values the parser's defaults, then parse the command line again.
Expand All @@ -198,6 +217,7 @@ def _merge_config_args(args: dict, parser: ArgumentParser, argv: list[str] | Non
if file_path not in configs:
configs.add(file_path)
config = load_config(file_path)
_check_config(config, parser, file_path)
args["config"] = config.get("config")
defaults = {**config, **defaults} # a config file beats the one it points to
if defaults:
Expand Down
30 changes: 25 additions & 5 deletions tests/test_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ def test_merge_config_args_applies_config_values(parser, write_config):
config = {
"output": "mydir",
"audio_br": 128,
"codec": "vp9-speed",
"codec": "vp9",
"filename_times": True,
"from": "00:00:10",
"framerate": 60,
Expand Down Expand Up @@ -205,10 +205,30 @@ def test_merge_config_args_command_line_beats_config(parser, write_config, confi
assert result[key] == expected


def test_merge_config_args_keeps_keys_that_are_not_flags(parser, write_config):
argv = ["file.mp4", "--config", str(write_config({"something_else": 1}))]
result = _merge_config_args(vars(parser.parse_args(argv)), parser, argv)
assert result["something_else"] == 1
def test_merge_config_args_rejects_unknown_key(parser, write_config, capsys):
path = write_config({"target-filesize": 20})
argv = ["file.mp4", "--config", str(path)]
with pytest.raises(SystemExit):
_merge_config_args(vars(parser.parse_args(argv)), parser, argv)
err = capsys.readouterr().err
assert f"{path.resolve()} has an unknown setting 'target-filesize'. Did you mean 'target_filesize'?" in err


def test_merge_config_args_rejects_invalid_codec(parser, write_config, capsys):
path = write_config({"codec": "libvpx-vp9"})
argv = ["file.mp4", "--config", str(path)]
with pytest.raises(SystemExit):
_merge_config_args(vars(parser.parse_args(argv)), parser, argv)
err = capsys.readouterr().err
assert f"{path.resolve()}: argument -c/--codec: invalid choice: 'libvpx-vp9'" in err


def test_merge_config_args_names_the_nested_file_with_the_bad_value(parser, write_config, capsys):
inner = write_config({"codec": "bogus"}, name="inner.json")
argv = ["file.mp4", "--config", str(write_config({"config": str(inner)}))]
with pytest.raises(SystemExit):
_merge_config_args(vars(parser.parse_args(argv)), parser, argv)
assert f"{inner.resolve()}: argument -c/--codec" in capsys.readouterr().err


def test_merge_config_args_follows_config_key(parser, write_config):
Expand Down
21 changes: 21 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from types import SimpleNamespace
from unittest.mock import ANY, MagicMock

import ffmpeg
import pytest
from flask import Flask

Expand Down Expand Up @@ -151,6 +152,26 @@ def test_main_shows_bad_flags_without_a_traceback(monkeypatch, caplog):
assert "Invalid --astreams value '0,x'" in caplog.text


@pytest.mark.parametrize(
("stderr", "message"),
[
(
b"ffprobe version 8.0\n/clips/nope.mp4: No such file or directory\n",
"/clips/nope.mp4: No such file or directory",
),
(b"", "ffprobe couldn't read"),
],
)
def test_main_shows_unreadable_input_without_a_traceback(monkeypatch, caplog, stderr, message):
monkeypatch.setattr(mainmod, "check_for_update", lambda **_: VersionInfo("0.1.9", "0.1.9"))
monkeypatch.setattr(sys, "argv", ["ff4d", "nope.mp4", "--no-config"])
monkeypatch.setattr(mainmod, "TwoPass", MagicMock(side_effect=ffmpeg.Error("ffprobe", b"", stderr)))
with pytest.raises(SystemExit) as exc_info:
mainmod.main()
assert exc_info.value.code == 1
assert message in caplog.text


def test_main_web_ignores_dry_run(run_main, monkeypatch, caplog):
app = MagicMock()
monkeypatch.setattr(mainmod, "Flask", MagicMock(return_value=app))
Expand Down
Loading