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

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