Extd cut parameter
This commit is contained in:
@@ -17,6 +17,8 @@ from ffx.constants import (
|
|||||||
DEFAULT_AC3_BANDWIDTH,
|
DEFAULT_AC3_BANDWIDTH,
|
||||||
DEFAULT_CROPDETECT_DURATION_SECONDS,
|
DEFAULT_CROPDETECT_DURATION_SECONDS,
|
||||||
DEFAULT_CROPDETECT_SEEK_SECONDS,
|
DEFAULT_CROPDETECT_SEEK_SECONDS,
|
||||||
|
DEFAULT_cut_length,
|
||||||
|
DEFAULT_cut_start,
|
||||||
DEFAULT_CONTAINER_EXTENSION,
|
DEFAULT_CONTAINER_EXTENSION,
|
||||||
DEFAULT_CONTAINER_FORMAT,
|
DEFAULT_CONTAINER_FORMAT,
|
||||||
DEFAULT_DTS_BANDWIDTH,
|
DEFAULT_DTS_BANDWIDTH,
|
||||||
@@ -45,6 +47,14 @@ CROPDETECT_DURATION_OPTION_HELP = (
|
|||||||
"Analyze this many seconds for crop detection. "
|
"Analyze this many seconds for crop detection. "
|
||||||
+ "Shorter windows are faster; longer windows are usually steadier."
|
+ "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):
|
def normalizeNicenessOption(ctx, param, value):
|
||||||
@@ -65,6 +75,48 @@ def normalizeCpuOption(ctx, param, value):
|
|||||||
raise click.BadParameter(str(ex)) from ex
|
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.group()
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@@ -582,7 +634,16 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
|||||||
show_default=True,
|
show_default=True,
|
||||||
help='When --crop auto is used, analyze this many seconds for crop detection.',
|
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='')
|
@click.option("--output-directory", type=str, default='')
|
||||||
|
|
||||||
@@ -823,13 +884,15 @@ def convert(ctx,
|
|||||||
|
|
||||||
#->
|
#->
|
||||||
# Process cut parameters
|
# Process cut parameters
|
||||||
context['perform_cut'] = (cut != 'none')
|
context['perform_cut'] = (cut is not None)
|
||||||
if context['perform_cut']:
|
if context['perform_cut']:
|
||||||
cutTokens = cut.split(',')
|
context['cut_start'], context['cut_length'] = cut
|
||||||
if cutTokens and len(cutTokens) == 2:
|
click.echo(
|
||||||
context['cut_start'] = int(cutTokens[0])
|
f"Cutting enabled: start {context['cut_start']} s, duration {context['cut_length']} s."
|
||||||
context['cut_length'] = int(cutTokens[1])
|
)
|
||||||
ctx.obj['logger'].debug(f"Cut start={context['cut_start']} length={context['cut_length']}")
|
ctx.obj['logger'].debug(
|
||||||
|
f"Cut start={context['cut_start']} length={context['cut_length']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
tc = TmdbController() if context['use_tmdb'] else None
|
tc = TmdbController() if context['use_tmdb'] else None
|
||||||
|
|||||||
64
tests/unit/test_cli_cut_option.py
Normal file
64
tests/unit/test_cli_cut_option.py
Normal file
@@ -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()
|
||||||
@@ -168,6 +168,67 @@ class CliLazyImportTests(unittest.TestCase):
|
|||||||
result["modules"],
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user