Adds subtitle default dir
This commit is contained in:
@@ -39,6 +39,14 @@ CPU_OPTION_HELP = (
|
|||||||
+ "(about 2 cores), or use a percentage such as 25% for a share of present cores. "
|
+ "(about 2 cores), or use a percentage such as 25% for a share of present cores. "
|
||||||
+ "Omit to disable; 0 also disables."
|
+ "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 <prefix>/ subdirectory."
|
||||||
|
)
|
||||||
CROPDETECT_SEEK_OPTION_HELP = (
|
CROPDETECT_SEEK_OPTION_HELP = (
|
||||||
"Start crop detection this many seconds into the input. "
|
"Start crop detection this many seconds into the input. "
|
||||||
+ "Useful for skipping logos, intros, or black frames."
|
+ "Useful for skipping logos, intros, or black frames."
|
||||||
@@ -117,6 +125,41 @@ def normalizeCutOption(ctx, param, value):
|
|||||||
raise click.BadParameter(str(ex)) from ex
|
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.group()
|
||||||
@click.pass_context
|
@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('--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('--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-directory', type=str, default='', help=SUBTITLE_DIRECTORY_OPTION_HELP)
|
||||||
@click.option('--subtitle-prefix', type=str, default='', help='Subtitle filename prefix')
|
@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 <stream index>:<3 letter iso code>')
|
@click.option('--language', type=str, multiple=True, help='Set stream language. Use format <stream index>:<3 letter iso code>')
|
||||||
@click.option('--title', type=str, multiple=True, help='Set stream title. Use format <stream index>:<title>')
|
@click.option('--title', type=str, multiple=True, help='Set stream title. Use format <stream index>:<title>')
|
||||||
@@ -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']:
|
if context['import_subtitles']:
|
||||||
context['subtitle_directory'] = subtitle_directory
|
context['subtitle_directory'] = resolvedSubtitleDirectory
|
||||||
context['subtitle_prefix'] = subtitle_prefix
|
context['subtitle_prefix'] = resolvedSubtitlePrefix
|
||||||
|
|
||||||
|
|
||||||
existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in SUPPORTED_INPUT_FILE_EXTENSIONS]
|
existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in SUPPORTED_INPUT_FILE_EXTENSIONS]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class ConfigurationController():
|
|||||||
|
|
||||||
DATABASE_PATH_CONFIG_KEY = 'databasePath'
|
DATABASE_PATH_CONFIG_KEY = 'databasePath'
|
||||||
LOG_DIRECTORY_CONFIG_KEY = 'logDirectory'
|
LOG_DIRECTORY_CONFIG_KEY = 'logDirectory'
|
||||||
|
SUBTITLES_DIRECTORY_CONFIG_KEY = 'subtitlesDirectory'
|
||||||
OUTPUT_FILENAME_TEMPLATE_KEY = 'outputFilenameTemplate'
|
OUTPUT_FILENAME_TEMPLATE_KEY = 'outputFilenameTemplate'
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +50,12 @@ class ConfigurationController():
|
|||||||
def getDatabaseFilePath(self):
|
def getDatabaseFilePath(self):
|
||||||
return self.__databaseFilePath
|
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):
|
def getData(self):
|
||||||
return self.__configurationData
|
return self.__configurationData
|
||||||
|
|||||||
@@ -354,6 +354,83 @@ class SubtrackMappingBundleTests(unittest.TestCase):
|
|||||||
self.assertIn("external subtitle payload", extracted_subtitle)
|
self.assertIn("external subtitle payload", extracted_subtitle)
|
||||||
self.assertNotIn("embedded 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
84
tests/unit/test_cli_subtitle_directory.py
Normal file
84
tests/unit/test_cli_subtitle_directory.py
Normal file
@@ -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()
|
||||||
@@ -7,6 +7,7 @@ CONFIG_FILE="${FFX_CONFIG_FILE:-${CONFIG_DIR}/ffx.json}"
|
|||||||
VAR_DIR="${FFX_VAR_DIR:-${HOME}/.local/var/ffx}"
|
VAR_DIR="${FFX_VAR_DIR:-${HOME}/.local/var/ffx}"
|
||||||
LOG_DIR="${FFX_LOG_DIR:-${HOME}/.local/var/log}"
|
LOG_DIR="${FFX_LOG_DIR:-${HOME}/.local/var/log}"
|
||||||
DATABASE_FILE="${FFX_DATABASE_FILE:-${VAR_DIR}/ffx.db}"
|
DATABASE_FILE="${FFX_DATABASE_FILE:-${VAR_DIR}/ffx.db}"
|
||||||
|
SUBTITLES_BASE_DIR="${FFX_SUBTITLES_BASE_DIR:-${HOME}/.local/var/sync/subtitles}"
|
||||||
|
|
||||||
CHECK_ONLY=0
|
CHECK_ONLY=0
|
||||||
WITH_TESTS=0
|
WITH_TESTS=0
|
||||||
@@ -47,6 +48,7 @@ Environment overrides:
|
|||||||
FFX_VAR_DIR Override the default data directory.
|
FFX_VAR_DIR Override the default data directory.
|
||||||
FFX_LOG_DIR Override the default log directory.
|
FFX_LOG_DIR Override the default log directory.
|
||||||
FFX_DATABASE_FILE Override the database path written into a newly seeded config.
|
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:
|
Notes:
|
||||||
- tools/setup.sh is the first installation step and owns bundle venv setup.
|
- 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'
|
printf 'missing; prep can create it'
|
||||||
fi
|
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)
|
ffx-config)
|
||||||
if check_seeded_file "${CONFIG_FILE}"; then
|
if check_seeded_file "${CONFIG_FILE}"; then
|
||||||
printf '%s' "${CONFIG_FILE}"
|
printf '%s' "${CONFIG_FILE}"
|
||||||
@@ -195,6 +204,9 @@ report_seeded_component() {
|
|||||||
log-dir)
|
log-dir)
|
||||||
check_seeded_dir "${LOG_DIR}" || ok=0
|
check_seeded_dir "${LOG_DIR}" || ok=0
|
||||||
;;
|
;;
|
||||||
|
subtitles-base-dir)
|
||||||
|
check_seeded_dir "${SUBTITLES_BASE_DIR}" || ok=0
|
||||||
|
;;
|
||||||
ffx-config)
|
ffx-config)
|
||||||
check_seeded_file "${CONFIG_FILE}" || ok=0
|
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 "Config dir" "config-dir" "optional"
|
||||||
report_seeded_component "Var dir" "var-dir" "optional"
|
report_seeded_component "Var dir" "var-dir" "optional"
|
||||||
report_seeded_component "Log dir" "log-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"
|
report_seeded_component "ffx config" "ffx-config" "optional"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,12 +353,23 @@ seed_default_config() {
|
|||||||
created_any=1
|
created_any=1
|
||||||
fi
|
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
|
if [ ! -f "${CONFIG_FILE}" ]; then
|
||||||
printf 'Seeding ffx config at %s...\n' "${CONFIG_FILE}"
|
printf 'Seeding ffx config at %s...\n' "${CONFIG_FILE}"
|
||||||
if ! cat >"${CONFIG_FILE}" <<EOF
|
if ! cat >"${CONFIG_FILE}" <<EOF
|
||||||
{
|
{
|
||||||
"databasePath": "${DATABASE_FILE}",
|
"databasePath": "${DATABASE_FILE}",
|
||||||
"logDirectory": "${LOG_DIR}",
|
"logDirectory": "${LOG_DIR}",
|
||||||
|
"subtitlesDirectory": "${SUBTITLES_BASE_DIR}",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"signature": {
|
"signature": {
|
||||||
"RECODED_WITH": "FFX"
|
"RECODED_WITH": "FFX"
|
||||||
|
|||||||
Reference in New Issue
Block a user