From 528915a235b200c1727de7d96a87959a5c9f7568 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Sat, 11 Apr 2026 21:17:21 +0200 Subject: [PATCH] Adds subtitle default dir --- src/ffx/cli.py | 61 ++++++++++++-- src/ffx/configuration_controller.py | 9 +- .../subtrack_mapping/test_cli_bundle.py | 77 +++++++++++++++++ tests/unit/test_cli_subtitle_directory.py | 84 +++++++++++++++++++ tools/configure_workstation.sh | 24 ++++++ 5 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_cli_subtitle_directory.py diff --git a/src/ffx/cli.py b/src/ffx/cli.py index f5eb251..4be3451 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -39,6 +39,14 @@ CPU_OPTION_HELP = ( + "(about 2 cores), or use a percentage such as 25% for a share of present cores. " + "Omit to disable; 0 also disables." ) +SUBTITLE_DIRECTORY_OPTION_HELP = ( + "Load subtitles from here. When omitted and --subtitle-prefix is set, " + + "FFX uses the configured subtitlesDirectory base path plus the prefix as a subdirectory." +) +SUBTITLE_PREFIX_OPTION_HELP = ( + "Subtitle filename prefix. Requires --subtitle-directory, or a configured " + + "subtitlesDirectory base path that contains a matching / subdirectory." +) CROPDETECT_SEEK_OPTION_HELP = ( "Start crop detection this many seconds into the input. " + "Useful for skipping logos, intros, or black frames." @@ -117,6 +125,41 @@ def normalizeCutOption(ctx, param, value): raise click.BadParameter(str(ex)) from ex +def resolveSubtitleImportOptions(context, subtitleDirectory, subtitlePrefix): + resolvedSubtitlePrefix = str(subtitlePrefix).strip() + resolvedSubtitleDirectory = ( + os.path.expanduser(str(subtitleDirectory).strip()) + if subtitleDirectory + else '' + ) + + if not resolvedSubtitlePrefix: + return False, resolvedSubtitleDirectory, resolvedSubtitlePrefix + + if resolvedSubtitleDirectory: + return True, resolvedSubtitleDirectory, resolvedSubtitlePrefix + + configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath() + if not configuredSubtitlesBaseDirectory: + raise click.ClickException( + "Subtitle prefix was set but no --subtitle-directory was provided and " + + "no subtitlesDirectory default is configured in ffx.json." + ) + + resolvedSubtitleDirectory = os.path.join( + configuredSubtitlesBaseDirectory, + resolvedSubtitlePrefix, + ) + + if not os.path.isdir(resolvedSubtitleDirectory): + raise click.ClickException( + "Subtitle prefix was set but the resolved subtitle directory does not exist: " + + resolvedSubtitleDirectory + ) + + return True, resolvedSubtitleDirectory, resolvedSubtitlePrefix + + @click.group() @click.pass_context @@ -604,8 +647,8 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor): @click.option('--ac3', type=int, default=DEFAULT_AC3_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 5.1 audio streams", show_default=True) @click.option('--dts', type=int, default=DEFAULT_DTS_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 6.1 audio streams", show_default=True) -@click.option('--subtitle-directory', type=str, default='', help='Load subtitles from here') -@click.option('--subtitle-prefix', type=str, default='', help='Subtitle filename prefix') +@click.option('--subtitle-directory', type=str, default='', help=SUBTITLE_DIRECTORY_OPTION_HELP) +@click.option('--subtitle-prefix', type=str, default='', help=SUBTITLE_PREFIX_OPTION_HELP) @click.option('--language', type=str, multiple=True, help='Set stream language. Use format :<3 letter iso code>') @click.option('--title', type=str, multiple=True, help='Set stream title. Use format :') @@ -797,10 +840,18 @@ def convert(ctx, } - context['import_subtitles'] = (subtitle_directory and subtitle_prefix) + ( + context['import_subtitles'], + resolvedSubtitleDirectory, + resolvedSubtitlePrefix, + ) = resolveSubtitleImportOptions( + context, + subtitle_directory, + subtitle_prefix, + ) if context['import_subtitles']: - context['subtitle_directory'] = subtitle_directory - context['subtitle_prefix'] = subtitle_prefix + context['subtitle_directory'] = resolvedSubtitleDirectory + context['subtitle_prefix'] = resolvedSubtitlePrefix existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in SUPPORTED_INPUT_FILE_EXTENSIONS] diff --git a/src/ffx/configuration_controller.py b/src/ffx/configuration_controller.py index 74cc960..8f907a3 100644 --- a/src/ffx/configuration_controller.py +++ b/src/ffx/configuration_controller.py @@ -8,6 +8,7 @@ class ConfigurationController(): DATABASE_PATH_CONFIG_KEY = 'databasePath' LOG_DIRECTORY_CONFIG_KEY = 'logDirectory' + SUBTITLES_DIRECTORY_CONFIG_KEY = 'subtitlesDirectory' OUTPUT_FILENAME_TEMPLATE_KEY = 'outputFilenameTemplate' @@ -49,6 +50,12 @@ class ConfigurationController(): def getDatabaseFilePath(self): return self.__databaseFilePath + def getSubtitlesDirectoryPath(self): + subtitlesDirectory = self.__configurationData.get( + ConfigurationController.SUBTITLES_DIRECTORY_CONFIG_KEY, + '', + ) + return os.path.expanduser(str(subtitlesDirectory)) if subtitlesDirectory else '' def getData(self): return self.__configurationData @@ -139,4 +146,4 @@ class ConfigurationController(): # raise click.ClickException(f"PatternController.getPattern(): {repr(ex)}") # finally: # s.close() -# \ No newline at end of file +# diff --git a/tests/integration/subtrack_mapping/test_cli_bundle.py b/tests/integration/subtrack_mapping/test_cli_bundle.py index 874062f..11e0a30 100644 --- a/tests/integration/subtrack_mapping/test_cli_bundle.py +++ b/tests/integration/subtrack_mapping/test_cli_bundle.py @@ -354,6 +354,83 @@ class SubtrackMappingBundleTests(unittest.TestCase): self.assertIn("external subtitle payload", extracted_subtitle) self.assertNotIn("embedded subtitle payload", extracted_subtitle) + def test_subtitle_prefix_uses_configured_base_directory_when_directory_is_omitted(self): + source_filename = "substitute_default_s01e01.mkv" + subtitle_prefix = "substitute_default" + subtitles_base_dir = self.home_dir / ".local" / "var" / "sync" / "subtitles" + resolved_subtitle_dir = subtitles_base_dir / subtitle_prefix + resolved_subtitle_dir.mkdir(parents=True, exist_ok=True) + self.write_config( + { + "subtitlesDirectory": "~/.local/var/sync/subtitles", + "metadata": { + "streams": { + "remove": ["BPS"], + } + } + } + ) + source_path = create_source_fixture( + self.workdir, + source_filename, + [ + SourceTrackSpec(TrackType.VIDEO, identity="video-0"), + SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng", title="Main Audio"), + SourceTrackSpec( + TrackType.SUBTITLE, + identity="embedded-subtitle", + language="eng", + title="Embedded Title", + extra_tags={"BPS": "remove-me", "EXTERNAL_KEEP": "keep-me"}, + subtitle_lines=("embedded subtitle payload",), + ), + ], + ) + + write_vtt( + resolved_subtitle_dir / f"{subtitle_prefix}_s01e01_2_deu.vtt", + ("external subtitle payload",), + ) + + prepare_pattern_database( + self.database_path, + r"^substitute_default_(s[0-9]+e[0-9]+)\.mkv$", + [ + PatternTrackSpec(index=0, source_index=0, track_type=TrackType.VIDEO), + PatternTrackSpec(index=1, source_index=1, track_type=TrackType.AUDIO), + PatternTrackSpec(index=2, source_index=2, track_type=TrackType.SUBTITLE), + ], + ) + + completed = run_ffx_convert( + self.workdir, + self.home_dir, + self.database_path, + "--video-encoder", + "copy", + "--no-tmdb", + "--no-prompt", + "--no-signature", + "--subtitle-prefix", + subtitle_prefix, + str(source_path), + ) + self.assertCompleted(completed) + + output_path = expected_output_path(self.workdir, source_filename) + streams = ffprobe_json(output_path)["streams"] + subtitle_stream = [stream for stream in streams if stream["codec_type"] == "subtitle"][0] + + self.assertEqual(get_tag(subtitle_stream, "language"), "deu") + self.assertEqual(get_tag(subtitle_stream, "title"), "Embedded Title") + self.assertEqual(get_tag(subtitle_stream, "THIS_IS"), "embedded-subtitle") + self.assertEqual(get_tag(subtitle_stream, "EXTERNAL_KEEP"), "keep-me") + self.assertIsNone(get_tag(subtitle_stream, "BPS")) + + extracted_subtitle = extract_first_subtitle_text(self.workdir, output_path) + self.assertIn("external subtitle payload", extracted_subtitle) + self.assertNotIn("embedded subtitle payload", extracted_subtitle) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_cli_subtitle_directory.py b/tests/unit/test_cli_subtitle_directory.py new file mode 100644 index 0000000..d0ef29c --- /dev/null +++ b/tests/unit/test_cli_subtitle_directory.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +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 SubtitleDirectoryCliTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home_dir = Path(self.tempdir.name) / "home" + self.home_dir.mkdir() + self.database_path = Path(self.tempdir.name) / "test.db" + + def tearDown(self): + self.tempdir.cleanup() + + def write_config(self, data: dict) -> None: + config_dir = self.home_dir / ".local" / "etc" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "ffx.json").write_text(json.dumps(data), encoding="utf-8") + + def invoke_convert(self, *args: str): + runner = CliRunner() + return runner.invoke( + cli.ffx, + [ + "--database-file", + str(self.database_path), + "convert", + "--no-tmdb", + *args, + ], + env={**os.environ, "HOME": str(self.home_dir)}, + ) + + def test_subtitle_prefix_without_directory_or_default_fails(self): + result = self.invoke_convert("--subtitle-prefix", "dball") + + self.assertNotEqual(0, result.exit_code) + self.assertIn("no --subtitle-directory was provided", result.output) + self.assertIn("no subtitlesDirectory default is configured", result.output) + + def test_subtitle_prefix_without_directory_fails_when_configured_subdir_is_missing(self): + subtitles_base_dir = self.home_dir / ".local" / "var" / "sync" / "subtitles" + subtitles_base_dir.mkdir(parents=True, exist_ok=True) + self.write_config({"subtitlesDirectory": "~/.local/var/sync/subtitles"}) + + result = self.invoke_convert("--subtitle-prefix", "dball") + + self.assertNotEqual(0, result.exit_code) + self.assertIn("resolved subtitle directory does not exist", result.output) + self.assertIn(str(subtitles_base_dir / "dball"), result.output) + + def test_explicit_subtitle_directory_wins_over_missing_default(self): + explicit_subtitle_directory = self.home_dir / "manual-subtitles" + explicit_subtitle_directory.mkdir(parents=True, exist_ok=True) + + result = self.invoke_convert( + "--subtitle-directory", + str(explicit_subtitle_directory), + "--subtitle-prefix", + "dball", + ) + + self.assertEqual(0, result.exit_code, result.output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/configure_workstation.sh b/tools/configure_workstation.sh index 5f86948..5c2112f 100755 --- a/tools/configure_workstation.sh +++ b/tools/configure_workstation.sh @@ -7,6 +7,7 @@ CONFIG_FILE="${FFX_CONFIG_FILE:-${CONFIG_DIR}/ffx.json}" VAR_DIR="${FFX_VAR_DIR:-${HOME}/.local/var/ffx}" LOG_DIR="${FFX_LOG_DIR:-${HOME}/.local/var/log}" DATABASE_FILE="${FFX_DATABASE_FILE:-${VAR_DIR}/ffx.db}" +SUBTITLES_BASE_DIR="${FFX_SUBTITLES_BASE_DIR:-${HOME}/.local/var/sync/subtitles}" CHECK_ONLY=0 WITH_TESTS=0 @@ -47,6 +48,7 @@ Environment overrides: FFX_VAR_DIR Override the default data directory. FFX_LOG_DIR Override the default log directory. FFX_DATABASE_FILE Override the database path written into a newly seeded config. + FFX_SUBTITLES_BASE_DIR Override the default subtitles base directory written into a newly seeded config. Notes: - tools/setup.sh is the first installation step and owns bundle venv setup. @@ -142,6 +144,13 @@ component_detail() { printf 'missing; prep can create it' fi ;; + subtitles-base-dir) + if check_seeded_dir "${SUBTITLES_BASE_DIR}"; then + printf '%s' "${SUBTITLES_BASE_DIR}" + else + printf 'missing; prep can create it' + fi + ;; ffx-config) if check_seeded_file "${CONFIG_FILE}"; then printf '%s' "${CONFIG_FILE}" @@ -195,6 +204,9 @@ report_seeded_component() { log-dir) check_seeded_dir "${LOG_DIR}" || ok=0 ;; + subtitles-base-dir) + check_seeded_dir "${SUBTITLES_BASE_DIR}" || ok=0 + ;; ffx-config) check_seeded_file "${CONFIG_FILE}" || ok=0 ;; @@ -231,6 +243,7 @@ print_seeded_file_status() { report_seeded_component "Config dir" "config-dir" "optional" report_seeded_component "Var dir" "var-dir" "optional" report_seeded_component "Log dir" "log-dir" "optional" + report_seeded_component "Subtitles base dir" "subtitles-base-dir" "optional" report_seeded_component "ffx config" "ffx-config" "optional" } @@ -340,12 +353,23 @@ seed_default_config() { created_any=1 fi + if [ ! -d "${SUBTITLES_BASE_DIR}" ]; then + printf 'Creating subtitles base dir at %s...\n' "${SUBTITLES_BASE_DIR}" + if ! mkdir -p "${SUBTITLES_BASE_DIR}"; then + printf 'Failed to create subtitles base dir at %s.\n' "${SUBTITLES_BASE_DIR}" >&2 + INSTALL_FAILURES=$((INSTALL_FAILURES + 1)) + return 1 + fi + created_any=1 + fi + if [ ! -f "${CONFIG_FILE}" ]; then printf 'Seeding ffx config at %s...\n' "${CONFIG_FILE}" if ! cat >"${CONFIG_FILE}" <<EOF { "databasePath": "${DATABASE_FILE}", "logDirectory": "${LOG_DIR}", + "subtitlesDirectory": "${SUBTITLES_BASE_DIR}", "metadata": { "signature": { "RECODED_WITH": "FFX"