Extd rename/unmux to pad with zeroes

This commit is contained in:
Javanaut
2026-04-12 11:44:32 +02:00
parent d05b01cfb2
commit ef0d6e9274
13 changed files with 730 additions and 73 deletions

36
assets/ffx.json.j2 Normal file
View File

@@ -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"
]
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 }}'

View File

@@ -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']

View File

@@ -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])

View File

@@ -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):

View File

@@ -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:

View File

@@ -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()

View File

@@ -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")

View File

@@ -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()

View File

@@ -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()

View File

@@ -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}" <<EOF
{
"databasePath": "${DATABASE_FILE}",
"logDirectory": "${LOG_DIR}",
"subtitlesDirectory": "${SUBTITLES_BASE_DIR}",
"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"
]
}
}
}
EOF
then
printf 'Failed to write ffx config at %s.\n' "${CONFIG_FILE}" >&2
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
if ! render_default_config "${CONFIG_FILE}"; then
return 1
fi
created_any=1