diff --git a/assets/ffx.json.j2 b/assets/ffx.json.j2 new file mode 100644 index 0000000..666e603 --- /dev/null +++ b/assets/ffx.json.j2 @@ -0,0 +1,36 @@ +{ + "databasePath": {{ database_path_json }}, + "logDirectory": {{ log_directory_json }}, + "subtitlesDirectory": {{ subtitles_directory_json }}, + "defaultIndexSeasonDigits": {{ default_index_season_digits }}, + "defaultIndexEpisodeDigits": {{ default_index_episode_digits }}, + "defaultIndicatorSeasonDigits": {{ default_indicator_season_digits }}, + "defaultIndicatorEpisodeDigits": {{ default_indicator_episode_digits }}, + "metadata": { + "signature": { + "RECODED_WITH": "FFX" + }, + "remove": [ + "VERSION-eng", + "creation_time", + "NAME" + ], + "streams": { + "remove": [ + "BPS", + "NUMBER_OF_FRAMES", + "NUMBER_OF_BYTES", + "_STATISTICS_WRITING_APP", + "_STATISTICS_WRITING_DATE_UTC", + "_STATISTICS_TAGS", + "BPS-eng", + "DURATION-eng", + "NUMBER_OF_FRAMES-eng", + "NUMBER_OF_BYTES-eng", + "_STATISTICS_WRITING_APP-eng", + "_STATISTICS_WRITING_DATE_UTC-eng", + "_STATISTICS_TAGS-eng" + ] + } + } +} diff --git a/src/ffx/cli.py b/src/ffx/cli.py index e3c3594..f3040c2 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -185,8 +185,32 @@ def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label): return os.path.join(configuredSubtitlesBaseDirectory, resolvedLabel), True -def buildRenameTargetFilename(sourcePath, prefix, seasonOverride=None, suffix=''): +def resolveIndicatorDigitLengths(context=None, showDescriptor=None): + from ffx.show_descriptor import ShowDescriptor + + defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(context) + if showDescriptor is None: + return ( + defaultDigitLengths[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY], + defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY], + ) + + return ( + int(showDescriptor.getIndicatorSeasonDigits()), + int(showDescriptor.getIndicatorEpisodeDigits()), + ) + + +def buildRenameTargetFilename( + sourcePath, + prefix, + seasonOverride=None, + suffix='', + indicatorSeasonDigits=None, + indicatorEpisodeDigits=None, +): from ffx.file_properties import FileProperties + from ffx.show_descriptor import ShowDescriptor sourceFilename = os.path.basename(sourcePath) seasonEpisodeValues = FileProperties.extractSeasonEpisodeValues(sourceFilename) @@ -197,12 +221,22 @@ def buildRenameTargetFilename(sourcePath, prefix, seasonOverride=None, suffix='' resolvedSeason = int(seasonOverride) if seasonOverride is not None else ( int(sourceSeason) if sourceSeason is not None else 1 ) + resolvedIndicatorSeasonDigits = ( + int(indicatorSeasonDigits) + if indicatorSeasonDigits is not None + else ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS + ) + resolvedIndicatorEpisodeDigits = ( + int(indicatorEpisodeDigits) + if indicatorEpisodeDigits is not None + else ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS + ) _sourceBasename, sourceExtension = os.path.splitext(sourceFilename) targetFilenameTokens = [ str(prefix).strip(), - f"s{resolvedSeason}e{int(sourceEpisode)}", + f"s{resolvedSeason:0{resolvedIndicatorSeasonDigits}d}e{int(sourceEpisode):0{resolvedIndicatorEpisodeDigits}d}", ] resolvedSuffix = str(suffix).strip() @@ -444,10 +478,15 @@ def inspect(ctx, filename): @click.option('--dry-run', is_flag=True, default=False, help='Only print planned renames') def rename(ctx, paths, prefix, season, suffix, dry_run): """Rename matching episode files in place.""" + from ffx.configuration_controller import ConfigurationController resolvedPrefix = str(prefix).strip() resolvedSuffix = str(suffix).strip() effectiveDryRun = bool(ctx.obj.get('dry_run', False) or dry_run) + renameContext = { + 'config': ctx.obj.get('config') or ConfigurationController(), + } + indicatorSeasonDigits, indicatorEpisodeDigits = resolveIndicatorDigitLengths(renameContext) if not resolvedPrefix: raise click.ClickException("Rename prefix must not be empty.") @@ -463,6 +502,8 @@ def rename(ctx, paths, prefix, season, suffix, dry_run): resolvedPrefix, seasonOverride=season, suffix=resolvedSuffix, + indicatorSeasonDigits=indicatorSeasonDigits, + indicatorEpisodeDigits=indicatorEpisodeDigits, ) if targetFilename is None: continue @@ -571,13 +612,25 @@ def unmux(ctx, try: sourceMediaDescriptor = fp.getMediaDescriptor() + currentPattern = fp.getPattern() + currentShowDescriptor = ( + currentPattern.getShowDescriptor(ctx.obj) if currentPattern is not None else None + ) + indicatorSeasonDigits, indicatorEpisodeDigits = resolveIndicatorDigitLengths( + ctx.obj, + currentShowDescriptor, + ) season = fp.getSeason() episode = fp.getEpisode() #TODO: Recognition für alle Formate anpassen targetLabel = label if label else fp.getFileBasename() - targetIndicator = f"_S{season}E{episode}" if label and season != -1 and episode != -1 else '' + targetIndicator = ( + f"_S{season:0{indicatorSeasonDigits}d}E{episode:0{indicatorEpisodeDigits}d}" + if label and season != -1 and episode != -1 + else '' + ) if label and not targetIndicator: ctx.obj['logger'].warning(f"Skipping file {fp.getFilename()}: Label set but no indicator recognized") @@ -1226,10 +1279,11 @@ def convert(ctx, fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor) - indexSeasonDigits = currentShowDescriptor.getIndexSeasonDigits() if not currentPattern is None else ShowDescriptor.DEFAULT_INDEX_SEASON_DIGITS - indexEpisodeDigits = currentShowDescriptor.getIndexEpisodeDigits() if not currentPattern is None else ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS - indicatorSeasonDigits = currentShowDescriptor.getIndicatorSeasonDigits() if not currentPattern is None else ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS - indicatorEpisodeDigits = currentShowDescriptor.getIndicatorEpisodeDigits() if not currentPattern is None else ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS + defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(context) + indexSeasonDigits = currentShowDescriptor.getIndexSeasonDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY] + indexEpisodeDigits = currentShowDescriptor.getIndexEpisodeDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY] + indicatorSeasonDigits = currentShowDescriptor.getIndicatorSeasonDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY] + indicatorEpisodeDigits = currentShowDescriptor.getIndicatorEpisodeDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] # Shift season and episode if defined for this show diff --git a/src/ffx/configuration_controller.py b/src/ffx/configuration_controller.py index 8f907a3..4a6bd29 100644 --- a/src/ffx/configuration_controller.py +++ b/src/ffx/configuration_controller.py @@ -1,5 +1,12 @@ import os, json +from .constants import ( + DEFAULT_SHOW_INDEX_EPISODE_DIGITS, + DEFAULT_SHOW_INDEX_SEASON_DIGITS, + DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS, + DEFAULT_SHOW_INDICATOR_SEASON_DIGITS, +) + class ConfigurationController(): CONFIG_FILENAME = 'ffx.json' @@ -10,6 +17,10 @@ class ConfigurationController(): LOG_DIRECTORY_CONFIG_KEY = 'logDirectory' SUBTITLES_DIRECTORY_CONFIG_KEY = 'subtitlesDirectory' OUTPUT_FILENAME_TEMPLATE_KEY = 'outputFilenameTemplate' + DEFAULT_INDEX_SEASON_DIGITS_CONFIG_KEY = 'defaultIndexSeasonDigits' + DEFAULT_INDEX_EPISODE_DIGITS_CONFIG_KEY = 'defaultIndexEpisodeDigits' + DEFAULT_INDICATOR_SEASON_DIGITS_CONFIG_KEY = 'defaultIndicatorSeasonDigits' + DEFAULT_INDICATOR_EPISODE_DIGITS_CONFIG_KEY = 'defaultIndicatorEpisodeDigits' def __init__(self): @@ -57,6 +68,42 @@ class ConfigurationController(): ) return os.path.expanduser(str(subtitlesDirectory)) if subtitlesDirectory else '' + @classmethod + def getConfiguredIntegerValue(cls, configurationData: dict, configKey: str, defaultValue: int) -> int: + configuredValue = configurationData.get(configKey, defaultValue) + try: + return int(configuredValue) + except (TypeError, ValueError): + return int(defaultValue) + + def getDefaultIndexSeasonDigits(self): + return ConfigurationController.getConfiguredIntegerValue( + self.__configurationData, + ConfigurationController.DEFAULT_INDEX_SEASON_DIGITS_CONFIG_KEY, + DEFAULT_SHOW_INDEX_SEASON_DIGITS, + ) + + def getDefaultIndexEpisodeDigits(self): + return ConfigurationController.getConfiguredIntegerValue( + self.__configurationData, + ConfigurationController.DEFAULT_INDEX_EPISODE_DIGITS_CONFIG_KEY, + DEFAULT_SHOW_INDEX_EPISODE_DIGITS, + ) + + def getDefaultIndicatorSeasonDigits(self): + return ConfigurationController.getConfiguredIntegerValue( + self.__configurationData, + ConfigurationController.DEFAULT_INDICATOR_SEASON_DIGITS_CONFIG_KEY, + DEFAULT_SHOW_INDICATOR_SEASON_DIGITS, + ) + + def getDefaultIndicatorEpisodeDigits(self): + return ConfigurationController.getConfiguredIntegerValue( + self.__configurationData, + ConfigurationController.DEFAULT_INDICATOR_EPISODE_DIGITS_CONFIG_KEY, + DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS, + ) + def getData(self): return self.__configurationData diff --git a/src/ffx/constants.py b/src/ffx/constants.py index 63f67b9..eb212ef 100644 --- a/src/ffx/constants.py +++ b/src/ffx/constants.py @@ -22,4 +22,9 @@ DEFAULT_CROPDETECT_DURATION_SECONDS = 180 DEFAULT_cut_start = 60 DEFAULT_cut_length = 180 +DEFAULT_SHOW_INDEX_SEASON_DIGITS = 2 +DEFAULT_SHOW_INDEX_EPISODE_DIGITS = 2 +DEFAULT_SHOW_INDICATOR_SEASON_DIGITS = 2 +DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS = 2 + DEFAULT_OUTPUT_FILENAME_TEMPLATE = '{{ ffx_show_name }} - {{ ffx_index }}{{ ffx_index_separator }}{{ ffx_episode_name }}{{ ffx_indicator_separator }}{{ ffx_indicator }}' diff --git a/src/ffx/helper.py b/src/ffx/helper.py index 742dbc1..00f1d45 100644 --- a/src/ffx/helper.py +++ b/src/ffx/helper.py @@ -4,6 +4,7 @@ from jinja2 import Environment, Undefined from .constants import DEFAULT_OUTPUT_FILENAME_TEMPLATE from .configuration_controller import ConfigurationController from .logging_utils import get_ffx_logger +from .show_descriptor import ShowDescriptor class EmptyStringUndefined(Undefined): @@ -164,10 +165,10 @@ def getEpisodeFileBasename(showName, episodeName, season, episode, - indexSeasonDigits = 2, - indexEpisodeDigits = 2, - indicatorSeasonDigits = 2, - indicatorEpisodeDigits = 2, + indexSeasonDigits = None, + indexEpisodeDigits = None, + indicatorSeasonDigits = None, + indicatorEpisodeDigits = None, context = None): """ One Piece: @@ -199,6 +200,16 @@ def getEpisodeFileBasename(showName, configData = cc.getData() if cc is not None else {} outputFilenameTemplate = configData.get(ConfigurationController.OUTPUT_FILENAME_TEMPLATE_KEY, DEFAULT_OUTPUT_FILENAME_TEMPLATE) + defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(context) + + if indexSeasonDigits is None: + indexSeasonDigits = defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY] + if indexEpisodeDigits is None: + indexEpisodeDigits = defaultDigitLengths[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY] + if indicatorSeasonDigits is None: + indicatorSeasonDigits = defaultDigitLengths[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY] + if indicatorEpisodeDigits is None: + indicatorEpisodeDigits = defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] if context is not None and 'logger' in context.keys(): logger = context['logger'] diff --git a/src/ffx/media_details_screen.py b/src/ffx/media_details_screen.py index 7f61622..5a3c3c3 100644 --- a/src/ffx/media_details_screen.py +++ b/src/ffx/media_details_screen.py @@ -559,6 +559,7 @@ class MediaDetailsScreen(Screen): try: kwargs = {} + kwargs[ShowDescriptor.CONTEXT_KEY] = self.context kwargs[ShowDescriptor.ID_KEY] = int(selected_row_data[0]) kwargs[ShowDescriptor.NAME_KEY] = str(selected_row_data[1]) kwargs[ShowDescriptor.YEAR_KEY] = int(selected_row_data[2]) diff --git a/src/ffx/show_descriptor.py b/src/ffx/show_descriptor.py index 9a5a270..e1b3398 100644 --- a/src/ffx/show_descriptor.py +++ b/src/ffx/show_descriptor.py @@ -1,3 +1,10 @@ +from .configuration_controller import ConfigurationController +from .constants import ( + DEFAULT_SHOW_INDEX_EPISODE_DIGITS, + DEFAULT_SHOW_INDEX_SEASON_DIGITS, + DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS, + DEFAULT_SHOW_INDICATOR_SEASON_DIGITS, +) from .logging_utils import get_ffx_logger @@ -15,10 +22,42 @@ class ShowDescriptor(): INDICATOR_SEASON_DIGITS_KEY = 'indicator_season_digits' INDICATOR_EPISODE_DIGITS_KEY = 'indicator_episode_digits' - DEFAULT_INDEX_SEASON_DIGITS = 2 - DEFAULT_INDEX_EPISODE_DIGITS = 2 - DEFAULT_INDICATOR_SEASON_DIGITS = 2 - DEFAULT_INDICATOR_EPISODE_DIGITS = 2 + DEFAULT_INDEX_SEASON_DIGITS = DEFAULT_SHOW_INDEX_SEASON_DIGITS + DEFAULT_INDEX_EPISODE_DIGITS = DEFAULT_SHOW_INDEX_EPISODE_DIGITS + DEFAULT_INDICATOR_SEASON_DIGITS = DEFAULT_SHOW_INDICATOR_SEASON_DIGITS + DEFAULT_INDICATOR_EPISODE_DIGITS = DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS + + @classmethod + def getDefaultDigitLengths(cls, context: dict | None = None) -> dict[str, int]: + configurationData = {} + + if context is not None: + configController = context.get('config') + if configController is not None and hasattr(configController, 'getData'): + configurationData = configController.getData() + + return { + cls.INDEX_SEASON_DIGITS_KEY: ConfigurationController.getConfiguredIntegerValue( + configurationData, + ConfigurationController.DEFAULT_INDEX_SEASON_DIGITS_CONFIG_KEY, + cls.DEFAULT_INDEX_SEASON_DIGITS, + ), + cls.INDEX_EPISODE_DIGITS_KEY: ConfigurationController.getConfiguredIntegerValue( + configurationData, + ConfigurationController.DEFAULT_INDEX_EPISODE_DIGITS_CONFIG_KEY, + cls.DEFAULT_INDEX_EPISODE_DIGITS, + ), + cls.INDICATOR_SEASON_DIGITS_KEY: ConfigurationController.getConfiguredIntegerValue( + configurationData, + ConfigurationController.DEFAULT_INDICATOR_SEASON_DIGITS_CONFIG_KEY, + cls.DEFAULT_INDICATOR_SEASON_DIGITS, + ), + cls.INDICATOR_EPISODE_DIGITS_KEY: ConfigurationController.getConfiguredIntegerValue( + configurationData, + ConfigurationController.DEFAULT_INDICATOR_EPISODE_DIGITS_CONFIG_KEY, + cls.DEFAULT_INDICATOR_EPISODE_DIGITS, + ), + } def __init__(self, **kwargs): @@ -53,36 +92,37 @@ class ShowDescriptor(): raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.YEAR_KEY} is required to be of type int") self.__showYear = kwargs[ShowDescriptor.YEAR_KEY] else: - self.__showYear = -1 + self.__showYear = -1 + defaultDigitLengths = self.getDefaultDigitLengths(self.__context) if ShowDescriptor.INDEX_SEASON_DIGITS_KEY in kwargs.keys(): if type(kwargs[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]) is not int: raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.INDEX_SEASON_DIGITS_KEY} is required to be of type int") self.__indexSeasonDigits = kwargs[ShowDescriptor.INDEX_SEASON_DIGITS_KEY] else: - self.__indexSeasonDigits = ShowDescriptor.DEFAULT_INDEX_SEASON_DIGITS + self.__indexSeasonDigits = defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY] if ShowDescriptor.INDEX_EPISODE_DIGITS_KEY in kwargs.keys(): if type(kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY]) is not int: raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.INDEX_EPISODE_DIGITS_KEY} is required to be of type int") self.__indexEpisodeDigits = kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY] else: - self.__indexEpisodeDigits = ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS + self.__indexEpisodeDigits = defaultDigitLengths[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY] if ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY in kwargs.keys(): if type(kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY]) is not int: raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY} is required to be of type int") self.__indicatorSeasonDigits = kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY] else: - self.__indicatorSeasonDigits = ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS + self.__indicatorSeasonDigits = defaultDigitLengths[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY] if ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY in kwargs.keys(): if type(kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]) is not int: raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY} is required to be of type int") self.__indicatorEpisodeDigits = kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] else: - self.__indicatorEpisodeDigits = ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS + self.__indicatorEpisodeDigits = defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] def getId(self): diff --git a/src/ffx/show_details_screen.py b/src/ffx/show_details_screen.py index 8d840c1..311a91f 100644 --- a/src/ffx/show_details_screen.py +++ b/src/ffx/show_details_screen.py @@ -160,11 +160,20 @@ class ShowDetailsScreen(Screen): self.updateShiftedSeasons() else: - - self.query_one("#index_season_digits_input", Input).value = "2" - self.query_one("#index_episode_digits_input", Input).value = "2" - self.query_one("#indicator_season_digits_input", Input).value = "2" - self.query_one("#indicator_episode_digits_input", Input).value = "2" + defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(self.context) + + self.query_one("#index_season_digits_input", Input).value = str( + defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY] + ) + self.query_one("#index_episode_digits_input", Input).value = str( + defaultDigitLengths[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY] + ) + self.query_one("#indicator_season_digits_input", Input).value = str( + defaultDigitLengths[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY] + ) + self.query_one("#indicator_episode_digits_input", Input).value = str( + defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] + ) def getSelectedPatternDescriptor(self): @@ -387,7 +396,7 @@ class ShowDetailsScreen(Screen): def getShowDescriptorFromInput(self) -> ShowDescriptor: - kwargs = {} + kwargs = {ShowDescriptor.CONTEXT_KEY: self.context} try: if self.__showDescriptor: diff --git a/tests/integration/test_cli_unmux.py b/tests/integration/test_cli_unmux.py index a47241a..84b4b52 100644 --- a/tests/integration/test_cli_unmux.py +++ b/tests/integration/test_cli_unmux.py @@ -8,8 +8,18 @@ import sys import tempfile import unittest -from tests.support.ffx_bundle import SourceTrackSpec, create_source_fixture +from tests.support.ffx_bundle import ( + SourceTrackSpec, + build_controller_context, + create_source_fixture, + dispose_controller_context, +) +from ffx.pattern_controller import PatternController +from ffx.show_controller import ShowController +from ffx.show_descriptor import ShowDescriptor +from ffx.track_codec import TrackCodec +from ffx.track_descriptor import TrackDescriptor from ffx.track_type import TrackType try: @@ -66,6 +76,39 @@ class UnmuxCliTests(unittest.TestCase): f"STDERR:\n{completed.stderr}" ) + def seed_matching_show(self, pattern_expression: str, *, indicator_season_digits: int, indicator_episode_digits: int) -> None: + context = build_controller_context(self.database_path) + try: + ShowController(context).updateShow( + ShowDescriptor( + id=1, + name="Unmux Test Show", + year=2000, + indicator_season_digits=indicator_season_digits, + indicator_episode_digits=indicator_episode_digits, + ) + ) + PatternController(context).savePatternSchema( + { + "show_id": 1, + "pattern": pattern_expression, + "quality": 0, + "notes": "", + }, + trackDescriptors=[ + TrackDescriptor( + index=0, + source_index=0, + track_type=TrackType.VIDEO, + codec_name=TrackCodec.H264, + tags={}, + disposition_set=set(), + ) + ], + ) + finally: + dispose_controller_context(context) + def test_subtitles_only_without_output_directory_uses_configured_base_plus_label(self): self.write_config( { @@ -101,6 +144,85 @@ class UnmuxCliTests(unittest.TestCase): expected_directory = self.home_dir / ".local" / "var" / "sync" / "subtitles" / "dball" self.assertTrue(expected_directory.is_dir(), expected_directory) + def test_unmux_uses_configured_indicator_digits_in_output_filenames(self): + self.write_config( + { + "defaultIndicatorSeasonDigits": 3, + "defaultIndicatorEpisodeDigits": 4, + } + ) + source_filename = "unmux_s01e01.mkv" + output_directory = self.workdir / "unmux-output" + output_directory.mkdir() + source_path = create_source_fixture( + self.workdir, + source_filename, + [ + SourceTrackSpec(TrackType.VIDEO, identity="video-0"), + ], + ) + + completed = run_ffx_unmux( + self.workdir, + self.home_dir, + self.database_path, + "--label", + "dball", + "--output-directory", + str(output_directory), + str(source_path), + ) + self.assertCompleted(completed) + + output_filenames = sorted(path.name for path in output_directory.iterdir()) + self.assertEqual(1, len(output_filenames), output_filenames) + self.assertTrue( + output_filenames[0].startswith("dball_S001E0001_"), + output_filenames, + ) + + def test_unmux_prefers_matched_show_indicator_digits_over_config_defaults(self): + self.write_config( + { + "defaultIndicatorSeasonDigits": 4, + "defaultIndicatorEpisodeDigits": 4, + } + ) + self.seed_matching_show( + r"^unmux_([sS][0-9]+[eE][0-9]+)\.mkv$", + indicator_season_digits=1, + indicator_episode_digits=3, + ) + source_filename = "unmux_s01e01.mkv" + output_directory = self.workdir / "unmux-output" + output_directory.mkdir() + source_path = create_source_fixture( + self.workdir, + source_filename, + [ + SourceTrackSpec(TrackType.VIDEO, identity="video-0"), + ], + ) + + completed = run_ffx_unmux( + self.workdir, + self.home_dir, + self.database_path, + "--label", + "dball", + "--output-directory", + str(output_directory), + str(source_path), + ) + self.assertCompleted(completed) + + output_filenames = sorted(path.name for path in output_directory.iterdir()) + self.assertEqual(1, len(output_filenames), output_filenames) + self.assertTrue( + output_filenames[0].startswith("dball_S1E001_"), + output_filenames, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_cli_rename.py b/tests/unit/test_cli_rename.py index 5d0d57e..813f2f1 100644 --- a/tests/unit/test_cli_rename.py +++ b/tests/unit/test_cli_rename.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +import os from pathlib import Path import sys import tempfile @@ -21,6 +23,8 @@ class RenameCliTests(unittest.TestCase): def setUp(self): self.tempdir = tempfile.TemporaryDirectory() self.workspace = Path(self.tempdir.name) + self.home_dir = self.workspace / "home" + self.home_dir.mkdir() def tearDown(self): self.tempdir.cleanup() @@ -30,9 +34,18 @@ class RenameCliTests(unittest.TestCase): source_path.write_bytes(payload) return source_path + 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_rename(self, *args: str): runner = CliRunner() - result = runner.invoke(cli.ffx, ["rename", *args]) + result = runner.invoke( + cli.ffx, + ["rename", *args], + env={**os.environ, "HOME": str(self.home_dir)}, + ) self.assertEqual(0, result.exit_code, result.output) return result @@ -41,8 +54,8 @@ class RenameCliTests(unittest.TestCase): result = self.invoke_rename("--prefix", "dball", str(source_path)) - target_path = self.workspace / "dball_s2e3.mkv" - self.assertIn("demo_S02E03.mkv -> dball_s2e3.mkv", result.output) + target_path = self.workspace / "dball_s02e03.mkv" + self.assertIn("demo_S02E03.mkv -> dball_s02e03.mkv", result.output) self.assertFalse(source_path.exists()) self.assertTrue(target_path.exists()) self.assertEqual(b"season-episode", target_path.read_bytes()) @@ -58,8 +71,8 @@ class RenameCliTests(unittest.TestCase): str(source_path), ) - target_path = self.workspace / "dball_s1e7_bonus.mp4" - self.assertIn("demo_E07.mp4 -> dball_s1e7_bonus.mp4", result.output) + target_path = self.workspace / "dball_s01e07_bonus.mp4" + self.assertIn("demo_E07.mp4 -> dball_s01e07_bonus.mp4", result.output) self.assertFalse(source_path.exists()) self.assertTrue(target_path.exists()) self.assertEqual(b"episode-only", target_path.read_bytes()) @@ -75,8 +88,8 @@ class RenameCliTests(unittest.TestCase): str(source_path), ) - target_path = self.workspace / "dball_s5e7.webm" - self.assertIn("demo_s02e07.webm -> dball_s5e7.webm", result.output) + target_path = self.workspace / "dball_s05e07.webm" + self.assertIn("demo_s02e07.webm -> dball_s05e07.webm", result.output) self.assertFalse(source_path.exists()) self.assertTrue(target_path.exists()) @@ -90,11 +103,27 @@ class RenameCliTests(unittest.TestCase): str(source_path), ) - target_path = self.workspace / "dball_s1e7.mkv" - self.assertIn("demo_E07.mkv -> dball_s1e7.mkv", result.output) + target_path = self.workspace / "dball_s01e07.mkv" + self.assertIn("demo_E07.mkv -> dball_s01e07.mkv", result.output) self.assertTrue(source_path.exists()) self.assertFalse(target_path.exists()) + def test_rename_uses_configured_indicator_digit_lengths(self): + self.write_config( + { + "defaultIndicatorSeasonDigits": 3, + "defaultIndicatorEpisodeDigits": 4, + } + ) + source_path = self.write_source("demo_E07.mkv") + + result = self.invoke_rename("--prefix", "dball", str(source_path)) + + target_path = self.workspace / "dball_s001e0007.mkv" + self.assertIn("demo_E07.mkv -> dball_s001e0007.mkv", result.output) + self.assertFalse(source_path.exists()) + self.assertTrue(target_path.exists()) + def test_rename_skips_non_matching_filenames(self): source_path = self.write_source("demo_finale.mkv") diff --git a/tests/unit/test_configure_workstation_script.py b/tests/unit/test_configure_workstation_script.py new file mode 100644 index 0000000..cf5e76a --- /dev/null +++ b/tests/unit/test_configure_workstation_script.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import textwrap +import unittest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT_PATH = REPO_ROOT / "tools" / "configure_workstation.sh" +BUNDLE_PYTHON = Path.home() / ".local" / "share" / "ffx.venv" / "bin" / "python" + + +class ConfigureWorkstationScriptTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home_dir = Path(self.tempdir.name) / "home" + self.home_dir.mkdir() + self.stub_bin_dir = Path(self.tempdir.name) / "bin" + self.stub_bin_dir.mkdir() + + for command_name in ("git", "python3", "ffmpeg", "ffprobe", "cpulimit"): + self.write_stub_command(command_name) + + def tearDown(self): + self.tempdir.cleanup() + + def write_stub_command(self, name: str, body: str = "") -> None: + script_path = self.stub_bin_dir / name + script_path.write_text( + "#!/usr/bin/env bash\n" + + body + + "\n", + encoding="utf-8", + ) + script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR) + + def run_script(self, **env_overrides: str) -> subprocess.CompletedProcess[str]: + if not BUNDLE_PYTHON.is_file(): + self.skipTest(f"Missing bundle Python at {BUNDLE_PYTHON}") + + env = { + **os.environ, + "HOME": str(self.home_dir), + "PATH": f"{self.stub_bin_dir}:{os.environ.get('PATH', '')}", + "FFX_PYTHON": str(BUNDLE_PYTHON), + **env_overrides, + } + + return subprocess.run( + ["bash", str(SCRIPT_PATH)], + capture_output=True, + cwd=REPO_ROOT, + env=env, + text=True, + ) + + def test_script_seeds_default_config_from_template(self): + completed = self.run_script() + + self.assertEqual( + 0, + completed.returncode, + f"STDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}", + ) + + config_path = self.home_dir / ".local" / "etc" / "ffx.json" + self.assertTrue(config_path.exists()) + + config_data = json.loads(config_path.read_text(encoding="utf-8")) + self.assertEqual( + { + "databasePath": str(self.home_dir / ".local" / "var" / "ffx" / "ffx.db"), + "logDirectory": str(self.home_dir / ".local" / "var" / "log"), + "subtitlesDirectory": str( + self.home_dir / ".local" / "var" / "sync" / "subtitles" + ), + "defaultIndexSeasonDigits": 2, + "defaultIndexEpisodeDigits": 2, + "defaultIndicatorSeasonDigits": 2, + "defaultIndicatorEpisodeDigits": 2, + "metadata": { + "signature": {"RECODED_WITH": "FFX"}, + "remove": [ + "VERSION-eng", + "creation_time", + "NAME", + ], + "streams": { + "remove": [ + "BPS", + "NUMBER_OF_FRAMES", + "NUMBER_OF_BYTES", + "_STATISTICS_WRITING_APP", + "_STATISTICS_WRITING_DATE_UTC", + "_STATISTICS_TAGS", + "BPS-eng", + "DURATION-eng", + "NUMBER_OF_FRAMES-eng", + "NUMBER_OF_BYTES-eng", + "_STATISTICS_WRITING_APP-eng", + "_STATISTICS_WRITING_DATE_UTC-eng", + "_STATISTICS_TAGS-eng", + ] + }, + }, + }, + config_data, + ) + + def test_script_honors_custom_template_override(self): + custom_template_path = Path(self.tempdir.name) / "custom-config.j2" + custom_template_path.write_text( + textwrap.dedent( + """ + { + "databasePath": {{ database_path_json }}, + "marker": "from-template", + "subtitlesDirectory": {{ subtitles_directory_json }} + } + """ + ).lstrip(), + encoding="utf-8", + ) + + completed = self.run_script(FFX_CONFIG_TEMPLATE=str(custom_template_path)) + + self.assertEqual( + 0, + completed.returncode, + f"STDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}", + ) + + config_path = self.home_dir / ".local" / "etc" / "ffx.json" + config_data = json.loads(config_path.read_text(encoding="utf-8")) + + self.assertEqual("from-template", config_data["marker"]) + self.assertEqual( + str(self.home_dir / ".local" / "var" / "ffx" / "ffx.db"), + config_data["databasePath"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_show_descriptor_defaults.py b/tests/unit/test_show_descriptor_defaults.py new file mode 100644 index 0000000..159931c --- /dev/null +++ b/tests/unit/test_show_descriptor_defaults.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import logging +from pathlib import Path +import sys +import unittest + + +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.constants import ( + DEFAULT_SHOW_INDEX_EPISODE_DIGITS, + DEFAULT_SHOW_INDEX_SEASON_DIGITS, + DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS, + DEFAULT_SHOW_INDICATOR_SEASON_DIGITS, +) +from ffx.helper import getEpisodeFileBasename +from ffx.show_descriptor import ShowDescriptor + + +class StaticConfig: + def __init__(self, data: dict | None = None): + self._data = data or {} + + def getData(self): + return self._data + + +class ShowDescriptorDefaultTests(unittest.TestCase): + def make_context(self, config_data: dict | None = None) -> dict: + logger = logging.getLogger("ffx-test-show-descriptor-defaults") + logger.handlers = [] + logger.addHandler(logging.NullHandler()) + return {"config": StaticConfig(config_data), "logger": logger} + + def test_show_descriptor_uses_config_defaults_when_context_is_present(self): + descriptor = ShowDescriptor( + context=self.make_context( + { + "defaultIndexSeasonDigits": "1", + "defaultIndexEpisodeDigits": "3", + "defaultIndicatorSeasonDigits": "3", + "defaultIndicatorEpisodeDigits": "4", + } + ), + id=1, + name="Configured Show", + year=2024, + ) + + self.assertEqual(1, descriptor.getIndexSeasonDigits()) + self.assertEqual(3, descriptor.getIndexEpisodeDigits()) + self.assertEqual(3, descriptor.getIndicatorSeasonDigits()) + self.assertEqual(4, descriptor.getIndicatorEpisodeDigits()) + + def test_show_descriptor_without_context_uses_shared_constants(self): + descriptor = ShowDescriptor(id=1, name="Default Show", year=2024) + + self.assertEqual(DEFAULT_SHOW_INDEX_SEASON_DIGITS, descriptor.getIndexSeasonDigits()) + self.assertEqual(DEFAULT_SHOW_INDEX_EPISODE_DIGITS, descriptor.getIndexEpisodeDigits()) + self.assertEqual( + DEFAULT_SHOW_INDICATOR_SEASON_DIGITS, + descriptor.getIndicatorSeasonDigits(), + ) + self.assertEqual( + DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS, + descriptor.getIndicatorEpisodeDigits(), + ) + + def test_episode_basename_uses_configured_digit_defaults_when_omitted(self): + basename = getEpisodeFileBasename( + "Configured Show", + "Episode Name", + 2, + 7, + context=self.make_context( + { + "defaultIndexSeasonDigits": 1, + "defaultIndexEpisodeDigits": 3, + "defaultIndicatorSeasonDigits": 3, + "defaultIndicatorEpisodeDigits": 4, + } + ), + ) + + self.assertEqual( + "Configured Show - 2007 Episode Name - S002E0007", + basename, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/configure_workstation.sh b/tools/configure_workstation.sh index 5c2112f..30f56ab 100755 --- a/tools/configure_workstation.sh +++ b/tools/configure_workstation.sh @@ -2,12 +2,15 @@ set -u +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" CONFIG_DIR="${FFX_CONFIG_DIR:-${HOME}/.local/etc}" 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}" +FFX_PYTHON="${FFX_PYTHON:-${HOME}/.local/share/ffx.venv/bin/python}" +CONFIG_TEMPLATE_FILE="${FFX_CONFIG_TEMPLATE:-${ROOT_DIR}/assets/ffx.json.j2}" CHECK_ONLY=0 WITH_TESTS=0 @@ -49,6 +52,8 @@ Environment overrides: 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. + FFX_PYTHON Override the bundle venv Python used to render the seeded config. + FFX_CONFIG_TEMPLATE Override the Jinja2 template path used to seed the config. Notes: - tools/setup.sh is the first installation step and owns bundle venv setup. @@ -316,6 +321,93 @@ install_system_requirements() { return 0 } +render_default_config() { + local output_path="$1" + local temporary_output_path="" + + if [ ! -x "${FFX_PYTHON}" ]; then + printf 'Missing bundle Python interpreter at %s.\n' "${FFX_PYTHON}" >&2 + INSTALL_FAILURES=$((INSTALL_FAILURES + 1)) + return 1 + fi + + if [ ! -f "${CONFIG_TEMPLATE_FILE}" ]; then + printf 'Missing FFX config template at %s.\n' "${CONFIG_TEMPLATE_FILE}" >&2 + INSTALL_FAILURES=$((INSTALL_FAILURES + 1)) + return 1 + fi + + if ! temporary_output_path="$(mktemp "${output_path}.tmp.XXXXXX")"; then + printf 'Failed to create a temporary config file next to %s.\n' "${output_path}" >&2 + INSTALL_FAILURES=$((INSTALL_FAILURES + 1)) + return 1 + fi + + if ! FFX_CONFIG_TEMPLATE_FILE="${CONFIG_TEMPLATE_FILE}" \ + FFX_REPO_ROOT="${ROOT_DIR}" \ + FFX_DATABASE_PATH="${DATABASE_FILE}" \ + FFX_LOG_DIRECTORY="${LOG_DIR}" \ + FFX_SUBTITLES_DIRECTORY="${SUBTITLES_BASE_DIR}" \ + "${FFX_PYTHON}" - >"${temporary_output_path}" <<'PY' +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, StrictUndefined + +repo_root = Path(os.environ["FFX_REPO_ROOT"]) +src_root = repo_root / "src" +if str(src_root) not in sys.path: + sys.path.insert(0, str(src_root)) + +from ffx.constants import ( + DEFAULT_SHOW_INDEX_EPISODE_DIGITS, + DEFAULT_SHOW_INDEX_SEASON_DIGITS, + DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS, + DEFAULT_SHOW_INDICATOR_SEASON_DIGITS, +) + +template_path = Path(os.environ["FFX_CONFIG_TEMPLATE_FILE"]) +environment = Environment( + loader=FileSystemLoader(str(template_path.parent)), + undefined=StrictUndefined, + autoescape=False, + keep_trailing_newline=True, +) +template = environment.get_template(template_path.name) + +sys.stdout.write( + template.render( + database_path_json=json.dumps(os.environ["FFX_DATABASE_PATH"]), + log_directory_json=json.dumps(os.environ["FFX_LOG_DIRECTORY"]), + subtitles_directory_json=json.dumps(os.environ["FFX_SUBTITLES_DIRECTORY"]), + default_index_season_digits=DEFAULT_SHOW_INDEX_SEASON_DIGITS, + default_index_episode_digits=DEFAULT_SHOW_INDEX_EPISODE_DIGITS, + default_indicator_season_digits=DEFAULT_SHOW_INDICATOR_SEASON_DIGITS, + default_indicator_episode_digits=DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS, + ) +) +PY + then + rm -f "${temporary_output_path}" + printf 'Failed to render ffx config from template %s.\n' "${CONFIG_TEMPLATE_FILE}" >&2 + INSTALL_FAILURES=$((INSTALL_FAILURES + 1)) + return 1 + fi + + if ! mv "${temporary_output_path}" "${output_path}"; then + rm -f "${temporary_output_path}" + printf 'Failed to move rendered ffx config into place at %s.\n' "${output_path}" >&2 + INSTALL_FAILURES=$((INSTALL_FAILURES + 1)) + return 1 + fi + + return 0 +} + seed_default_config() { if [ "${CHECK_ONLY}" -eq 1 ]; then return 0 @@ -365,43 +457,7 @@ seed_default_config() { if [ ! -f "${CONFIG_FILE}" ]; then printf 'Seeding ffx config at %s...\n' "${CONFIG_FILE}" - if ! cat >"${CONFIG_FILE}" <&2 - INSTALL_FAILURES=$((INSTALL_FAILURES + 1)) + if ! render_default_config "${CONFIG_FILE}"; then return 1 fi created_any=1