diff --git a/ffmpeg4discord/__main__.py b/ffmpeg4discord/__main__.py index 6fc56a1..cccaef6 100644 --- a/ffmpeg4discord/__main__.py +++ b/ffmpeg4discord/__main__.py @@ -1,21 +1,4 @@ -""" -This module provides the main entry point for the ffmpeg4discord application. - -It supports both command-line and web-based interfaces for performing two-pass -video encoding using FFmpeg. The script allows users to specify various encoding -parameters and target file sizes, and it will iteratively adjust the encoding -settings to meet the desired output file size. - -Functions: -- twopass_loop: Executes the two-pass encoding process in a loop until the target file size is achieved. -- open_browser: Opens the default web browser to the specified port. -- format_commands: Lists the ffmpeg commands for an encode with a short explanation. -- success_message: The message shown when an encode finishes. -- source_facts: Facts about the input video that the web page checks the form against. -- form_values: The values the web UI's form shows. -- parse_encode_form: Checks the web UI's form and turns it into EncodeSettings. -- main: The main function that parses command-line arguments and starts the encoding process or web server. -""" +"""The ff4d command: runs an encode from the command line, or starts the web UI.""" import atexit import logging @@ -34,7 +17,7 @@ from flask import Flask, redirect, render_template, request, send_file, url_for from ffmpeg4discord import arguments -from ffmpeg4discord.twopass import EncodeResult, EncodeSettings, TwoPass, available_codecs +from ffmpeg4discord.twopass import EncodeResult, EncodeSettings, TwoPass, available_codecs, red from ffmpeg4discord.versioning import check_for_update # smallest fraction the target size is cut by on each retry @@ -73,14 +56,12 @@ def twopass_loop( break print( - "\033[31m" - + dedent( + red( f""" The output file size ({round(result.output_filesize, 2)}MB) is still above the target of {target_filesize}MB. Restarting... """ - ).strip() - + "\033[0m" + ) ) Path(result.output_filename).unlink() @@ -412,7 +393,7 @@ def parse_encode_form(form, twopass: TwoPass, codecs: list[str]) -> tuple[Encode @contextmanager def exit_on_setting_error(): - """TwoPass raises ValueError for settings that can't work. Log its message, which says what to change, and exit.""" + """TwoPass and get_args raise ValueError for bad settings. Log the message, which says what to change, and exit.""" try: yield except ValueError as exc: @@ -424,8 +405,10 @@ def main() -> None: """ The main function that parses command-line arguments and starts the encoding process or web server. """ - # Best-effort version check (never blocks startup) - version_info = check_for_update(timeout_s=1.5) + logging.getLogger().setLevel(logging.INFO) + + # If PyPI is slow or unreachable, this gives up after a short timeout and skips the update notice + version_info = check_for_update() # CLI: show version + update notice early (for both CLI mode and web-launch mode) if version_info.current_version: @@ -446,7 +429,8 @@ def main() -> None: ) # get args from the command line - args = arguments.get_args() + with exit_on_setting_error(): + args = arguments.get_args() web = args.pop("web") approx = args.pop("approx") dry_run = args.pop("dry_run") diff --git a/ffmpeg4discord/arguments.py b/ffmpeg4discord/arguments.py index 87389eb..d14e2d8 100644 --- a/ffmpeg4discord/arguments.py +++ b/ffmpeg4discord/arguments.py @@ -1,12 +1,4 @@ -""" -This module provides functions for parsing command-line arguments and -loading configuration settings for the ffmpeg4discord application. - -Functions: -- is_port_in_use: Checks if a given port is currently in use. -- load_config: Loads configuration settings from a JSON file. -- get_args: Parses command-line arguments and returns them as a dictionary. -""" +"""Reads ff4d's command-line flags and config files.""" import json import logging @@ -15,11 +7,10 @@ from argparse import ArgumentParser, BooleanOptionalAction from pathlib import Path from random import randint -from textwrap import dedent import platformdirs -from ffmpeg4discord.twopass import available_codecs +from ffmpeg4discord.twopass import FALLBACK_AUDIO_BR, available_codecs def is_port_in_use(port: int) -> bool: @@ -99,7 +90,9 @@ def build_parser() -> ArgumentParser: type=float, help="The output file size in MB.", ) - parser.add_argument("-a", "--audio-br", type=float, default=96, help="Audio bitrate in kbps.") + parser.add_argument( + "-a", "--audio-br", type=float, default=FALLBACK_AUDIO_BR // 1000, help="Audio bitrate in kbps." + ) parser.add_argument( "-c", "--codec", @@ -167,15 +160,6 @@ def build_parser() -> ArgumentParser: return parser -def _normalize_amix_args(args: dict) -> dict: - """Make --amix-normalize imply --amix, unless amix was explicitly set false.""" - # If user/config turned on normalize but never explicitly disabled amix, - # treat that as "we are mixing, with normalization". - if args.get("amix_normalize") and not args.get("amix"): - args["amix"] = True - return args - - def _search_for_default_config(args: dict) -> dict: """ Search for config files in platform-specific default locations and include them @@ -249,58 +233,20 @@ def _extract_times(args: dict) -> dict: def _parse_astreams(args: dict) -> dict: - """Parse --astreams from comma-separated string (or list) into a list[int] or None.""" - + """Turn --astreams, e.g. "0,2", into a list of track numbers. A config file can also give a list, e.g. [0, 2].""" raw = args.get("astreams") - if raw is None: + if raw is None or raw == "": + args["astreams"] = None # use every audio track return args - # Allow config/json to provide a list already. - if isinstance(raw, list): - try: - parsed = [int(x) for x in raw] - except (TypeError, ValueError): - logging.error("Invalid astreams list. Expected list of ints.") - args["astreams"] = None - return args - else: - raw_str = str(raw).strip() - if raw_str == "": - args["astreams"] = None - return args - - parts = [p.strip() for p in raw_str.split(",") if p.strip() != ""] - try: - parsed = [int(p) for p in parts] - except ValueError: - logging.error( - "\033[31m" - + dedent( - """ - Invalid --astreams format. + parts = [str(part).strip() for part in (raw if isinstance(raw, list) else str(raw).split(","))] + # isdecimal() is False for negative numbers, decimals, and blanks + if not all(part.isdecimal() for part in parts): + raise ValueError( + f"Invalid --astreams value {raw!r}. Use audio track numbers starting at 0, separated by commas, e.g. 0,2." + ) - Provide a comma-separated list of integers, e.g. --astreams "0,1,2". - Ignoring --astreams. - """ - ).strip() - + "\033[0m" - ) - args["astreams"] = None - return args - - # Normalize: remove duplicates, preserve order. - seen: set[int] = set() - normalized: list[int] = [] - for idx in parsed: - if idx < 0: - logging.warning(f"Ignoring negative audio stream index in --astreams: {idx}") - continue - if idx in seen: - continue - seen.add(idx) - normalized.append(idx) - - args["astreams"] = normalized + args["astreams"] = list(dict.fromkeys(int(part) for part in parts)) # removes duplicates, keeps the order return args @@ -318,5 +264,7 @@ def get_args() -> dict: args = _assign_port(args) args = _extract_times(args) args = _parse_astreams(args) - args = _normalize_amix_args(args) + # Normalizing only applies to mixed audio, so --amix-normalize turns on --amix + if args.get("amix_normalize"): + args["amix"] = True return args diff --git a/ffmpeg4discord/twopass.py b/ffmpeg4discord/twopass.py index b13e33d..e1c2b76 100644 --- a/ffmpeg4discord/twopass.py +++ b/ffmpeg4discord/twopass.py @@ -1,26 +1,4 @@ -""" -This module provides the TwoPass class and utility functions for performing two-pass video encoding using FFmpeg. - -The TwoPass class allows users to compress video files to a specified target file size while maintaining -optimal video quality. It supports various encoding parameters such as codec, resolution, and audio bitrate. -The module also includes helper functions for timestamp conversion. - -Classes: -- EncodeSettings: What the user asked for, like the target size, trim times, and codec. -- EncodeResult: The output file, its size, and the ffmpeg commands from one encode. -- TwoPass: Encodes and resizes video files using FFmpeg's two-pass encoding to meet a specified target file size. - -Functions: -- available_codecs: Returns the codec choices for this operating system. -- command_for_display: Turns one encoding pass into a command the user can paste into a terminal. -- ffmpeg_major_version: Returns the installed ffmpeg's major version from the probe data. -- fps_mode_flag: Returns the frame rate flag name supported by the installed ffmpeg. -- run_pass: Runs one encoding pass and reports ffmpeg failures with a readable message. -- seconds_from_ts_string: Converts a timestamp string into seconds. -- seconds_to_timestamp: Converts seconds into a timestamp string. -- timestamp_from_percentage: Converts a percentage of the video's duration into a timestamp string. -- tonemap_filters: Returns the HDR-to-SDR filter chain this system's ffmpeg can run. -""" +"""Compresses a video to a target file size with FFmpeg two-pass encoding.""" import logging import math @@ -38,14 +16,8 @@ import ffmpeg -logging.getLogger().setLevel(logging.INFO) - - -# Note: FILE_SIZE_MULT converts bytes to mebibytes (MiB, 1 MiB = 1,048,576 bytes). -# macOS Finder reports file sizes in megabytes (MB, 1 MB = 1,000,000 bytes), -# while Windows File Explorer reports in mebibytes (but labels as MB). -# This can cause discrepancies in reported file sizes between the two systems. -FILE_SIZE_MULT = 0.00000095367432 +# Converts bytes to mebibytes (MiB). macOS Finder shows megabytes (MB), so its sizes look a little larger. +FILE_SIZE_MULT = 1 / 1024**2 # ff4d codec names mapped to ffmpeg encoder names, ordered from most to least compatible. @@ -73,6 +45,11 @@ HDR_TRANSFERS = {"smpte2084", "arib-std-b67"} +def red(text: str) -> str: + """Remove the source-code indentation from a multi-line message and color it red for the terminal.""" + return "\033[31m" + dedent(text).strip() + "\033[0m" + + def available_codecs() -> list[str]: """Return the codecs from `CODEC_ENCODERS` that this operating system can use. @@ -192,11 +169,9 @@ def run_pass(ffoutput, pass_name: str, hint: str = "", **run_kwargs): try: return ffoutput.run(**run_kwargs) except ffmpeg.Error as exc: - # compile() can contain Path objects, so stringify each argument before joining. - command = " ".join(str(arg) for arg in ffoutput.compile()) + # The hint is one line, so it can sit in the template without breaking dedent. strip() drops it when empty. raise RuntimeError( - "\033[31m" - + dedent( + red( f""" FFmpeg failed during the {pass_name} pass. @@ -204,30 +179,24 @@ def run_pass(ffoutput, pass_name: str, hint: str = "", **run_kwargs): problem (an unsupported option, a bad filter, a missing encoder, etc.). The command that failed was: - {command} + {command_for_display(ffoutput)} + + {hint} """ - ).strip() - + (f"\n\n{hint}" if hint else "") - + "\033[0m" + ) ) from exc -# Per-codec tweaks for `_generate_params()`. -# Kept module-level (instead of inside the method) so tests can patch it and -# exercise the `overrides.get("both", {})` branch. +# Second-pass options for each codec, added by `_generate_params()`. CODEC_OVERRIDES = { - "x264": {"pass2": {"c:a": "aac"}}, - "x265": {"pass2": {"tag:v": "hvc1", "c:a": "aac"}}, - "h264_nvenc": {"pass2": {"c:a": "aac"}}, - "hevc_nvenc": {"pass2": {"tag:v": "hvc1", "c:a": "aac"}}, - "h264_videotoolbox": {"pass2": {"c:a": "aac"}}, - "hevc_videotoolbox": {"pass2": {"tag:v": "hvc1", "c:a": "aac"}}, - "vp9": { - "pass2": {"c:a": "libopus", "row-mt": 1, "cpu-used": 5, "deadline": "good"}, - }, - "av1": { - "pass2": {"tag:v": "av01", "c:a": "libopus", "row-mt": 1, "cpu-used": 8}, - }, + "x264": {"c:a": "aac"}, + "x265": {"tag:v": "hvc1", "c:a": "aac"}, + "h264_nvenc": {"c:a": "aac"}, + "hevc_nvenc": {"tag:v": "hvc1", "c:a": "aac"}, + "h264_videotoolbox": {"c:a": "aac"}, + "hevc_videotoolbox": {"tag:v": "hvc1", "c:a": "aac"}, + "vp9": {"c:a": "libopus", "row-mt": 1, "cpu-used": 5, "deadline": "good"}, + "av1": {"tag:v": "av01", "c:a": "libopus", "row-mt": 1, "cpu-used": 8}, } @@ -366,14 +335,12 @@ def _check_hdr(self): self.tonemap = tonemap_filters(self.probe) if not self.tonemap: logging.warning( - "\033[31m" - + dedent( + red( """ This video is HDR, but your ffmpeg can't convert it to SDR. It needs ffmpeg 8.0 or newer, or a build with zscale (libzimg). The output may look washed out. """ - ).strip() - + "\033[0m" + ) ) def clip_times(self, settings: EncodeSettings) -> tuple[float, float]: @@ -386,16 +353,12 @@ def clip_times(self, settings: EncodeSettings) -> tuple[float, float]: if settings.filename_times: start, end = self._times_from_file_name() else: - # Accept any supported format (timestamp, seconds, or percentage). - start = seconds_from_ts_string(timestamp_from_percentage(settings.start or "0", self.duration)) - end = self.duration - if settings.end: - end = seconds_from_ts_string(timestamp_from_percentage(settings.end, self.duration)) + start = parse_time(settings.start or "0", self.duration) + end = parse_time(settings.end, self.duration) if settings.end else self.duration if end <= start: raise ValueError( - "\033[31m" - + dedent( + red( f""" Time Paradox? @@ -405,8 +368,7 @@ def clip_times(self, settings: EncodeSettings) -> tuple[float, float]: - Your video is {seconds_to_timestamp(self.duration)} long - Your clipping times are {seconds_to_timestamp(start)} to {seconds_to_timestamp(end)} """ - ).strip() - + "\033[0m" + ) ) return start, end @@ -474,14 +436,12 @@ def _generate_params(self, settings: EncodeSettings, bitrate_targets: dict) -> d params["pass2"]["r"] = settings.framerate else: logging.warning( - "\033[31m" - + dedent( + red( f""" Desired framerate ({settings.framerate}) is more than original framerate ({self.init_framerate}). Keeping the original framerate... """ - ).strip() - + "\033[0m" + ) ) # add in configurations specific to the codec @@ -496,16 +456,7 @@ def _generate_params(self, settings: EncodeSettings, bitrate_targets: dict) -> d _ = params["pass1"].pop("pass") _ = params["pass2"].pop("pass") - overrides = CODEC_OVERRIDES.get(codec) - if overrides: - # Apply shared params to both passes - for key, value in overrides.get("both", {}).items(): - params["pass1"][key] = value - params["pass2"][key] = value - - # Apply pass-specific params - params["pass1"].update(overrides.get("pass1", {})) - params["pass2"].update(overrides.get("pass2", {})) + params["pass2"].update(CODEC_OVERRIDES.get(codec, {})) # Apply bitrate targets to both passes for pass_params in params.values(): @@ -607,14 +558,12 @@ def _output_filename(self, settings: EncodeSettings) -> str: if output.suffix != ext: logging.warning( - "\033[31m" - + dedent( + red( f""" You specified {settings.codec}, but your output file name ends with {output.suffix}. I've corrected this. """ - ).strip() - + "\033[0m" + ) ) output = output.with_suffix(ext) @@ -642,14 +591,12 @@ def _apply_video_filters(self, video, settings: EncodeSettings): if ratio != outputratio: logging.warning( - "\033[31m" - + dedent( + red( """ Your output resolution's aspect ratio does not match the input resolution's or your cropped resolution's aspect ratio. """ - ).strip() - + "\033[0m" + ) ) # Tone map last, so a smaller --resolution means fewer pixels to process. @@ -796,13 +743,13 @@ def seconds_from_ts_string(ts_string: str) -> float: return seconds -def timestamp_from_percentage(value: str, duration: float) -> str: +def parse_time(value: str, duration: float) -> float: """ - Convert a percentage like "75%" into a timestamp string for a video of `duration` seconds. - Values without a "%" are returned unchanged. + Convert a timestamp, seconds, or a percentage of a `duration`-second video into seconds, + e.g. "00:01:30", "90", or "75%". """ if not value.endswith("%"): - return value + return seconds_from_ts_string(value) try: percent = float(value[:-1]) @@ -812,7 +759,8 @@ def timestamp_from_percentage(value: str, duration: float) -> str: if not 0 <= percent <= 100: raise ValueError(f"Invalid percentage '{value}'. It must be between 0% and 100%.") - return seconds_to_timestamp(duration * percent / 100) + # Round to whole milliseconds, like the timestamps ffmpeg is given. + return round(duration * percent / 100, 3) def seconds_to_timestamp(seconds: float) -> str: diff --git a/ffmpeg4discord/versioning.py b/ffmpeg4discord/versioning.py index aa19e57..6ba0d2d 100644 --- a/ffmpeg4discord/versioning.py +++ b/ffmpeg4discord/versioning.py @@ -1,29 +1,28 @@ -"""ffmpeg4discord version helpers. - -Goals: -- Determine the currently running version of ffmpeg4discord. -- Query PyPI to determine the latest published version. -- Compare versions safely without introducing extra dependencies. - -Network access is best-effort: failures should never prevent the app from -starting. -""" - -from __future__ import annotations +"""Checks PyPI for a newer ffmpeg4discord. Network failures never stop the app from starting.""" import json import re import urllib.request from dataclasses import dataclass from importlib import metadata -from pathlib import Path from typing import Optional PACKAGE_NAME = "ffmpeg4discord" +# Seconds to wait for PyPI before giving up on the update notice +PYPI_TIMEOUT = 1.5 + + +def _version_numbers(version: str) -> tuple[int, ...]: + """The leading numbers of a version, e.g. (0, 4, 1) for "0.4.1" or "0.4.1rc1", so versions compare correctly.""" + match = re.match(r"\d+(\.\d+)*", version) + return tuple(int(n) for n in match.group().split(".")) if match else () + @dataclass(frozen=True) class VersionInfo: + """The installed version and the newest one on PyPI. Either is None when it couldn't be read.""" + current_version: Optional[str] latest_version: Optional[str] @@ -31,85 +30,23 @@ class VersionInfo: def update_available(self) -> bool: if not self.current_version or not self.latest_version: return False - return _is_newer_version(self.latest_version, self.current_version) - + return _version_numbers(self.latest_version) > _version_numbers(self.current_version) -def get_current_version(package_name: str = PACKAGE_NAME) -> Optional[str]: - """Return the currently installed version. - - Primary source is `importlib.metadata` (works for normal and editable - installs). As a dev convenience (running from a source checkout without an - installed dist), falls back to reading `pyproject.toml`. - """ +def get_latest_pypi_version() -> Optional[str]: + """Return the newest version on PyPI, or None if PyPI can't be reached.""" try: - return metadata.version(package_name) - except metadata.PackageNotFoundError: - return _get_version_from_pyproject() + with urllib.request.urlopen(f"https://pypi.org/pypi/{PACKAGE_NAME}/json", timeout=PYPI_TIMEOUT) as resp: + return json.load(resp)["info"]["version"] except Exception: + # Offline, slow, or an unexpected response. The update notice is optional, so skip it. return None -def get_latest_pypi_version(package_name: str = PACKAGE_NAME, timeout_s: float = 1.5) -> Optional[str]: - """Return the latest published version on PyPI (best-effort).""" - - url = f"https://pypi.org/pypi/{package_name}/json" +def check_for_update() -> VersionInfo: + """Compare the installed version with the newest one on PyPI.""" try: - with urllib.request.urlopen(url, timeout=timeout_s) as resp: - data = json.loads(resp.read().decode("utf-8")) - return data.get("info", {}).get("version") - except Exception: - return None - - -def check_for_update(package_name: str = PACKAGE_NAME, timeout_s: float = 1.5) -> VersionInfo: - """Compute current vs latest version info.""" - - current = get_current_version(package_name=package_name) - latest = get_latest_pypi_version(package_name=package_name, timeout_s=timeout_s) - return VersionInfo(current_version=current, latest_version=latest) - - -def _get_version_from_pyproject() -> Optional[str]: - """Best-effort parsing of version from a source checkout.""" - - try: - repo_root = Path(__file__).resolve().parents[1] - pyproject = repo_root / "pyproject.toml" - if not pyproject.exists(): - return None - - text = pyproject.read_text(encoding="utf-8") - # simple pattern: version = "0.1.9" - match = re.search(r"^version\s*=\s*\"([^\"]+)\"\s*$", text, flags=re.MULTILINE) - return match.group(1) if match else None - except Exception: - return None - - -def _is_newer_version(candidate: str, current: str) -> bool: - """Return True if `candidate` represents a newer version than `current`. - - This is intentionally minimal (no external deps). It correctly handles the - project's current numeric versions like 0.1.9. - """ - - return _version_key(candidate) > _version_key(current) - - -def _version_key(v: str) -> tuple: - """Turn a version string into a comparable tuple. - - For versions we don't understand, falls back to the raw string. - """ - - # Accept things like "1", "1.2", "1.2.3" and ignore trailing metadata. - match = re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?", v) - if not match: - return (0, 0, 0, v) - - major = int(match.group(1) or 0) - minor = int(match.group(2) or 0) - patch = int(match.group(3) or 0) - # Keep original for tie-breaker stability - return (major, minor, patch, v) + current = metadata.version(PACKAGE_NAME) + except metadata.PackageNotFoundError: # running from source without installing + current = None + return VersionInfo(current_version=current, latest_version=get_latest_pypi_version()) diff --git a/ffmpeg4discord/windows.py b/ffmpeg4discord/windows.py index 21b3ce0..d62ebf0 100644 --- a/ffmpeg4discord/windows.py +++ b/ffmpeg4discord/windows.py @@ -1,15 +1,4 @@ -""" -This module provides functions for downloading and installing FFmpeg on Windows systems. -This file may not be well-maintained. Use with caution. - -It includes functions to check the Python installation, download FFmpeg with a progress indicator, -unzip the downloaded files, and copy the necessary files to the appropriate directory. - -Functions: -- copy_directory_contents: Copies the contents of one directory to another. -- download_with_progress: Downloads a file from a URL with a progress indicator. -- install: Downloads, unzips, and installs FFmpeg on a Windows system. -""" +"""Downloads FFmpeg and copies it next to Python on Windows. This file may not be well-maintained.""" import logging import os diff --git a/tests/test_arguments.py b/tests/test_arguments.py index f44682b..4723c1e 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -10,7 +10,6 @@ _assign_port, _extract_times, _merge_config_args, - _normalize_amix_args, _parse_astreams, _search_for_default_config, build_parser, @@ -281,29 +280,19 @@ def test_extract_times(args, start, end): [ ("0, 2,2", [0, 2]), ("", None), + (None, None), (["0", 2, "2"], [0, 2]), # a config file can give a list; duplicates are still removed + ([], []), ], ) def test_parse_astreams(raw, parsed): assert _parse_astreams({"astreams": raw})["astreams"] == parsed -@pytest.mark.parametrize( - "raw, message", - [("0, no", "invalid --astreams format"), (["nope"], "invalid astreams list")], -) -def test_parse_astreams_invalid_logs_error(caplog, raw, message): - assert _parse_astreams({"astreams": raw})["astreams"] is None - assert message in caplog.text.lower() - - -def test_parse_astreams_ignores_negative_index(caplog): - assert _parse_astreams({"astreams": "0,-1,2"})["astreams"] == [0, 2] - assert "ignoring negative audio stream index" in caplog.text.lower() - - -def test_amix_normalize_implies_amix(): - assert _normalize_amix_args({"amix": False, "amix_normalize": True})["amix"] is True +@pytest.mark.parametrize("raw", ["0, no", "0,-1", "0,,1", "1.5", ["nope"], [-1]]) +def test_parse_astreams_rejects_bad_values(raw): + with pytest.raises(ValueError, match="Invalid --astreams value"): + _parse_astreams({"astreams": raw}) # --- get_args end to end --- diff --git a/tests/test_main.py b/tests/test_main.py index 8d693fb..55f8f24 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,6 @@ import json import re +import sys from pathlib import Path from types import SimpleNamespace from unittest.mock import ANY, MagicMock @@ -141,6 +142,15 @@ def test_main_cli_shows_setting_errors_without_a_traceback(run_main, monkeypatch assert caplog.messages == ["Audio track 7 doesn't exist."] +def test_main_shows_bad_flags_without_a_traceback(monkeypatch, caplog): + monkeypatch.setattr(mainmod, "check_for_update", lambda **_: VersionInfo("0.1.9", "0.1.9")) + monkeypatch.setattr(sys, "argv", ["ff4d", "file.mp4", "--astreams", "0,x", "--no-config"]) + with pytest.raises(SystemExit) as exc_info: + mainmod.main() + assert exc_info.value.code == 1 + assert "Invalid --astreams value '0,x'" in caplog.text + + def test_main_web_ignores_dry_run(run_main, monkeypatch, caplog): app = MagicMock() monkeypatch.setattr(mainmod, "Flask", MagicMock(return_value=app)) diff --git a/tests/test_twopass.py b/tests/test_twopass.py index 41e4bc3..34cffd4 100644 --- a/tests/test_twopass.py +++ b/tests/test_twopass.py @@ -11,16 +11,15 @@ from ffmpeg4discord.__main__ import twopass_loop from ffmpeg4discord.twopass import ( CODEC_ENCODERS, - CODEC_OVERRIDES, EncodeSettings, TwoPass, available_codecs, command_for_display, fps_mode_flag, + parse_time, run_pass, seconds_from_ts_string, seconds_to_timestamp, - timestamp_from_percentage, tonemap_filters, ) @@ -98,21 +97,23 @@ def test_seconds_to_timestamp(timestamp, seconds): @pytest.mark.parametrize( "value, duration, expected", [ - ("75%", 60, "00:00:45"), - ("0%", 60, "00:00:00"), - ("100%", 60, "00:01:00"), - ("33.3%", 100, "00:00:33.300"), - ("00:00:10", 60, "00:00:10"), # timestamps pass through unchanged + ("75%", 60, 45), + ("0%", 60, 0), + ("100%", 60, 60), + ("33.3%", 100, 33.3), + ("12.34567%", 100, 12.346), # rounded to milliseconds + ("00:00:10", 60, 10), + ("90", 60, 90), ], ) -def test_timestamp_from_percentage(value, duration, expected): - assert timestamp_from_percentage(value, duration) == expected +def test_parse_time(value, duration, expected): + assert parse_time(value, duration) == expected -@pytest.mark.parametrize("value", ["abc%", "150%", "-5%"]) -def test_timestamp_from_percentage_invalid(value): +@pytest.mark.parametrize("value", ["abc%", "150%", "-5%", "abc"]) +def test_parse_time_invalid(value): with pytest.raises(ValueError): - timestamp_from_percentage(value, 60) + parse_time(value, 60) # --- reading the probe and trim times --- @@ -281,14 +282,6 @@ def test_generate_params_hardware_codec_has_no_pass_flags(make_twopass): assert params["pass2"]["c:a"] == "aac" -def test_generate_params_applies_shared_overrides_to_both_passes(make_twopass, monkeypatch): - # No built-in codec uses a "both" section yet, so add one for this test. - monkeypatch.setitem(CODEC_OVERRIDES, "x264", {"both": {"preset": "slow"}}) - params = make_twopass()._generate_params(make_settings(), BITRATE) - assert params["pass1"]["preset"] == "slow" - assert params["pass2"]["preset"] == "slow" - - @pytest.mark.parametrize("codec", CODEC_ENCODERS) def test_generate_params_forces_yuv420p(make_twopass, codec): """10-bit and 4:4:4 sources must be converted to 8-bit 4:2:0 so the output plays everywhere.""" diff --git a/tests/test_versioning.py b/tests/test_versioning.py index 0d83579..e8bc2d4 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -4,15 +4,7 @@ import pytest from ffmpeg4discord import versioning -from ffmpeg4discord.versioning import ( - VersionInfo, - _get_version_from_pyproject, - _is_newer_version, - _version_key, - check_for_update, - get_current_version, - get_latest_pypi_version, -) +from ffmpeg4discord.versioning import VersionInfo, check_for_update, get_latest_pypi_version def raise_(exc): @@ -24,75 +16,43 @@ def _raise(*args, **kwargs): return _raise -def test_version_key_non_numeric_falls_back(): - assert _version_key("dev") == (0, 0, 0, "dev") - - -@pytest.mark.parametrize( - "candidate, current, newer", - [("0.2.0", "0.1.9", True), ("0.1.9", "0.1.9", False)], -) -def test_is_newer_version(candidate, current, newer): - assert _is_newer_version(candidate, current) is newer - - @pytest.mark.parametrize( "current, latest, available", - [("0.1.9", "0.2.0", True), ("0.1.9", "0.1.9", False)], + [ + ("0.1.9", "0.2.0", True), + ("0.1.9", "0.1.9", False), + ("0.4.9", "0.4.10", True), # compared as numbers, not text + ("0.5.0", "0.4.1", False), # a newer local build + ("dev", "0.4.1", True), # an unreadable version counts as oldest + ("0.4.1", None, False), # PyPI couldn't be reached + (None, "0.4.1", False), + ], ) def test_update_available(current, latest, available): assert VersionInfo(current_version=current, latest_version=latest).update_available is available -def test_get_current_version_falls_back_to_pyproject(monkeypatch): - monkeypatch.setattr(versioning.metadata, "version", raise_(metadata.PackageNotFoundError)) - monkeypatch.setattr(versioning, "_get_version_from_pyproject", lambda: "1.2.3") - assert get_current_version() == "1.2.3" - - -def test_get_current_version_unexpected_error_returns_none(monkeypatch): - monkeypatch.setattr(versioning.metadata, "version", raise_(Exception("boom"))) - assert get_current_version() is None - - def test_get_latest_pypi_version(monkeypatch): # urlopen is used as a context manager, so the response comes from __enter__ response = MagicMock() response.__enter__.return_value.read.return_value = b'{"info": {"version": "9.9.9"}}' monkeypatch.setattr(versioning.urllib.request, "urlopen", lambda *args, **kwargs: response) - assert get_latest_pypi_version(timeout_s=0.01) == "9.9.9" + assert get_latest_pypi_version() == "9.9.9" def test_get_latest_pypi_version_network_error_returns_none(monkeypatch): - monkeypatch.setattr(versioning.urllib.request, "urlopen", raise_(Exception("no net"))) - assert get_latest_pypi_version(timeout_s=0.01) is None - - -def test_get_version_from_pyproject_missing_file(monkeypatch): - monkeypatch.setattr(versioning.Path, "exists", lambda self: False) - assert _get_version_from_pyproject() is None - + monkeypatch.setattr(versioning.urllib.request, "urlopen", raise_(OSError("no net"))) + assert get_latest_pypi_version() is None -def test_get_version_from_pyproject(monkeypatch): - monkeypatch.setattr(versioning.Path, "exists", lambda self: True) - monkeypatch.setattr(versioning.Path, "read_text", lambda self, **kwargs: '[project]\nversion = "1.2.3"\n') - assert _get_version_from_pyproject() == "1.2.3" +def test_check_for_update(monkeypatch): + monkeypatch.setattr(versioning, "get_latest_pypi_version", lambda: "9.9.9") + info = check_for_update() + assert info.current_version == metadata.version("ffmpeg4discord") + assert info.update_available -def test_get_version_from_pyproject_without_version(monkeypatch): - monkeypatch.setattr(versioning.Path, "exists", lambda self: True) - monkeypatch.setattr(versioning.Path, "read_text", lambda self, **kwargs: "[project]\nname='ffmpeg4discord'\n") - assert _get_version_from_pyproject() is None - -def test_get_version_from_pyproject_unexpected_error_returns_none(monkeypatch): - monkeypatch.setattr(versioning.Path, "resolve", raise_(Exception("boom"))) - assert _get_version_from_pyproject() is None - - -def test_check_for_update_survives_network_failure(monkeypatch): - monkeypatch.setattr(versioning, "get_latest_pypi_version", lambda **kwargs: None) - info = check_for_update(timeout_s=0.01) - assert info.current_version is not None - assert info.latest_version is None - assert not info.update_available +def test_check_for_update_without_installed_package(monkeypatch): + monkeypatch.setattr(versioning.metadata, "version", raise_(metadata.PackageNotFoundError)) + monkeypatch.setattr(versioning, "get_latest_pypi_version", lambda: "9.9.9") + assert check_for_update() == VersionInfo(current_version=None, latest_version="9.9.9")