diff --git a/src/ffx/cli.py b/src/ffx/cli.py index 296fbfd..f5eb251 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -17,6 +17,8 @@ from ffx.constants import ( DEFAULT_AC3_BANDWIDTH, DEFAULT_CROPDETECT_DURATION_SECONDS, DEFAULT_CROPDETECT_SEEK_SECONDS, + DEFAULT_cut_length, + DEFAULT_cut_start, DEFAULT_CONTAINER_EXTENSION, DEFAULT_CONTAINER_FORMAT, DEFAULT_DTS_BANDWIDTH, @@ -45,6 +47,14 @@ CROPDETECT_DURATION_OPTION_HELP = ( "Analyze this many seconds for crop detection. " + "Shorter windows are faster; longer windows are usually steadier." ) +DEFAULT_CUT_OPTION_VALUE = f"{DEFAULT_cut_start},{DEFAULT_cut_length}" +CUT_OPTION_HELP = ( + "Cut output in seconds. " + + f"Use --cut for the default {DEFAULT_CUT_OPTION_VALUE}, " + + "--cut DURATION to cut from 0 for DURATION seconds, " + + "or --cut START,DURATION for an explicit start and duration. " + + "Omit to disable." +) def normalizeNicenessOption(ctx, param, value): @@ -65,6 +75,48 @@ def normalizeCpuOption(ctx, param, value): raise click.BadParameter(str(ex)) from ex +def parseCutOptionValue(value) -> tuple[int, int] | None: + if value is None: + return None + + cutValue = str(value).strip() + if not cutValue: + raise ValueError( + "Cut value must be DURATION or START,DURATION, or use --cut without a value." + ) + + cutTokens = [token.strip() for token in cutValue.split(',')] + + try: + if len(cutTokens) == 1: + cutStart = 0 + cutLength = int(cutTokens[0]) + elif len(cutTokens) == 2: + cutStart = int(cutTokens[0]) + cutLength = int(cutTokens[1]) + else: + raise ValueError + except ValueError as ex: + raise ValueError( + "Cut value must be DURATION or START,DURATION, or use --cut without a value." + ) from ex + + if cutStart < 0: + raise ValueError("Cut start must be 0 or greater.") + + if cutLength <= 0: + raise ValueError("Cut duration must be greater than 0.") + + return cutStart, cutLength + + +def normalizeCutOption(ctx, param, value): + try: + return parseCutOptionValue(value) + except ValueError as ex: + raise click.BadParameter(str(ex)) from ex + + @click.group() @click.pass_context @@ -582,7 +634,16 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor): show_default=True, help='When --crop auto is used, analyze this many seconds for crop detection.', ) -@click.option("--cut", is_flag=False, flag_value="default", default="none") +@click.option( + "--cut", + type=str, + metavar="DURATION|START,DURATION", + is_flag=False, + flag_value=DEFAULT_CUT_OPTION_VALUE, + default=None, + callback=normalizeCutOption, + help=CUT_OPTION_HELP, +) @click.option("--output-directory", type=str, default='') @@ -823,13 +884,15 @@ def convert(ctx, #-> # Process cut parameters - context['perform_cut'] = (cut != 'none') + context['perform_cut'] = (cut is not None) if context['perform_cut']: - cutTokens = cut.split(',') - if cutTokens and len(cutTokens) == 2: - context['cut_start'] = int(cutTokens[0]) - context['cut_length'] = int(cutTokens[1]) - ctx.obj['logger'].debug(f"Cut start={context['cut_start']} length={context['cut_length']}") + context['cut_start'], context['cut_length'] = cut + click.echo( + f"Cutting enabled: start {context['cut_start']} s, duration {context['cut_length']} s." + ) + ctx.obj['logger'].debug( + f"Cut start={context['cut_start']} length={context['cut_length']}" + ) tc = TmdbController() if context['use_tmdb'] else None diff --git a/tests/unit/test_cli_cut_option.py b/tests/unit/test_cli_cut_option.py new file mode 100644 index 0000000..11509f5 --- /dev/null +++ b/tests/unit/test_cli_cut_option.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import os +from pathlib import Path +import sys +import tempfile +import unittest + +from click.testing import CliRunner + + +SRC_ROOT = Path(__file__).resolve().parents[2] / "src" + +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + + +from ffx import cli # noqa: E402 + + +class CutOptionCliTests(unittest.TestCase): + def invoke_convert(self, *args: str): + runner = CliRunner() + + with tempfile.TemporaryDirectory() as home_dir: + result = runner.invoke( + cli.ffx, + [ + "--database-file", + os.path.join(home_dir, "ffx.db"), + "--dry-run", + "convert", + "--no-tmdb", + *args, + ], + env={**os.environ, "HOME": home_dir}, + ) + + self.assertEqual(0, result.exit_code, result.output) + return result.output + + def test_convert_without_cut_prints_no_cut_message(self): + output = self.invoke_convert() + + self.assertNotIn("Cutting enabled:", output) + + def test_convert_with_cut_flag_prints_default_cut_message(self): + output = self.invoke_convert("--cut") + + self.assertIn("Cutting enabled: start 60 s, duration 180 s.", output) + + def test_convert_with_cut_duration_prints_zero_start_message(self): + output = self.invoke_convert("--cut", "45") + + self.assertIn("Cutting enabled: start 0 s, duration 45 s.", output) + + def test_convert_with_cut_start_and_duration_prints_both_values(self): + output = self.invoke_convert("--cut", "12,34") + + self.assertIn("Cutting enabled: start 12 s, duration 34 s.", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_cli_lazy_imports.py b/tests/unit/test_cli_lazy_imports.py index c535cb7..d55d630 100644 --- a/tests/unit/test_cli_lazy_imports.py +++ b/tests/unit/test_cli_lazy_imports.py @@ -168,6 +168,67 @@ class CliLazyImportTests(unittest.TestCase): result["modules"], ) + def test_convert_cut_option_supports_flag_duration_and_start_duration_forms(self): + result = self.run_python( + textwrap.dedent( + f""" + import click + import json + import sys + + sys.path.insert(0, {str(SRC_ROOT)!r}) + + import ffx.cli + + flag_context = ffx.cli.convert.make_context( + "convert", + ["--cut"], + resilient_parsing=True, + ) + duration_context = ffx.cli.convert.make_context( + "convert", + ["--cut", "12"], + resilient_parsing=True, + ) + explicit_context = ffx.cli.convert.make_context( + "convert", + ["--cut=12,34"], + resilient_parsing=True, + ) + disabled_context = ffx.cli.convert.make_context( + "convert", + [], + resilient_parsing=True, + ) + help_output = ffx.cli.convert.get_help(click.Context(ffx.cli.convert)) + + print(json.dumps({{ + "flag_cut": flag_context.params["cut"], + "duration_cut": duration_context.params["cut"], + "explicit_cut": explicit_context.params["cut"], + "disabled_cut": disabled_context.params["cut"], + "output": help_output, + "modules": {{ + module_name: module_name in sys.modules + for module_name in {HEAVY_MODULES!r} + }}, + }})) + """ + ) + ) + + self.assertEqual([60, 180], result["flag_cut"]) + self.assertEqual([0, 12], result["duration_cut"]) + self.assertEqual([12, 34], result["explicit_cut"]) + self.assertIsNone(result["disabled_cut"]) + self.assertIn("--cut DURATION|START,DURATION", result["output"]) + self.assertIn("60,180", result["output"]) + self.assertIn("START,DURATION", result["output"]) + self.assertTrue( + all(not is_loaded for is_loaded in result["modules"].values()), + result["modules"], + ) + if __name__ == "__main__": unittest.main()