From d6e885517da072021f8fa04d0e7cea5cce8f48a5 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Sun, 12 Apr 2026 20:34:33 +0200 Subject: [PATCH] Adds inspect --shift option --- src/ffx/cli.py | 40 +++++- src/ffx/shifted_season_controller.py | 26 +++- tests/unit/test_cli_inspect_shift.py | 135 +++++++++++++++++++ tests/unit/test_shifted_season_controller.py | 8 +- 4 files changed, 193 insertions(+), 16 deletions(-) create mode 100644 tests/unit/test_cli_inspect_shift.py diff --git a/src/ffx/cli.py b/src/ffx/cli.py index 08de4d7..77c3b66 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -461,13 +461,47 @@ def upgrade(ctx, branch): @ffx.command() @click.pass_context -@click.argument('filename', nargs=1) -def inspect(ctx, filename): +@click.option('--shift', is_flag=True, default=False, help='Print resolved season-shift mapping for each file instead of opening the TUI') +@click.argument('filenames', nargs=-1) +def inspect(ctx, shift, filenames): + if not filenames: + raise click.ClickException("At least one filename is required.") + + if shift: + from ffx.file_properties import FileProperties + from ffx.shifted_season_controller import ShiftedSeasonController + + shiftedSeasonController = ShiftedSeasonController(ctx.obj) + + for filename in filenames: + fileProperties = FileProperties(ctx.obj, filename) + season = fileProperties.getSeason() + episode = fileProperties.getEpisode() + + if season == -1 or episode == -1: + click.echo(f"{filename}: no season/episode recognized") + continue + + currentPattern = fileProperties.getPattern() + shiftedSeason, shiftedEpisode, sourceLabel = shiftedSeasonController.resolveShiftSeason( + fileProperties.getShowId(), + season=season, + episode=episode, + patternId=currentPattern.getId() if currentPattern is not None else None, + ) + click.echo( + f"{filename}: {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}" + ) + return + + if len(filenames) != 1: + raise click.ClickException("Inspect without --shift requires exactly one filename.") + from ffx.ffx_app import FfxApp ctx.obj['command'] = 'inspect' ctx.obj['arguments'] = {} - ctx.obj['arguments']['filename'] = filename + ctx.obj['arguments']['filename'] = filenames[0] app = FfxApp(ctx.obj) app.run() diff --git a/src/ffx/shifted_season_controller.py b/src/ffx/shifted_season_controller.py index 98d8ab3..b3d6377 100644 --- a/src/ffx/shifted_season_controller.py +++ b/src/ffx/shifted_season_controller.py @@ -383,10 +383,27 @@ class ShiftedSeasonController: session.close() def shiftSeason(self, showId, season, episode, patternId=None): - if season == -1 or episode == -1: return season, episode + shiftedSeason, shiftedEpisode, sourceLabel = self.resolveShiftSeason( + showId, + season, + episode, + patternId=patternId, + ) + + if shiftedSeason != season or shiftedEpisode != episode: + self.context['logger'].info( + f"Setting season shift {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}" + ) + + return shiftedSeason, shiftedEpisode + + def resolveShiftSeason(self, showId, season, episode, patternId=None): + if season == -1 or episode == -1: + return season, episode, "unrecognized" + session = None try: session = self.Session() @@ -420,12 +437,7 @@ class ShiftedSeasonController: if activeShift.getPatternId() is not None else "show" ) - - self.context['logger'].info( - f"Setting season shift {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}" - ) - - return shiftedSeason, shiftedEpisode + return shiftedSeason, shiftedEpisode, sourceLabel except ShiftedSeasonOwnerException as ex: raise click.ClickException(str(ex)) diff --git a/tests/unit/test_cli_inspect_shift.py b/tests/unit/test_cli_inspect_shift.py new file mode 100644 index 0000000..53ec0c0 --- /dev/null +++ b/tests/unit/test_cli_inspect_shift.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +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 _FakePattern: + def __init__(self, pattern_id: int): + self._pattern_id = pattern_id + + def getId(self): + return self._pattern_id + + +class _FakeFileProperties: + def __init__(self, context, source_path): + self.source_path = source_path + + def getShowId(self): + return 42 if self.source_path.endswith("mapped.mkv") else -1 + + def getSeason(self): + if self.source_path.endswith("unknown.mkv"): + return -1 + return 1 + + def getEpisode(self): + if self.source_path.endswith("unknown.mkv"): + return -1 + return 3 + + def getPattern(self): + if self.source_path.endswith("mapped.mkv"): + return _FakePattern(7) + return None + + +class _FakeShiftedSeasonController: + def __init__(self, context): + self.context = context + + def resolveShiftSeason(self, show_id, season, episode, patternId=None): + if patternId is not None: + return 2, 1, "pattern" + return season, episode, "default" + + +class InspectShiftCliTests(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" + self.source_dir = Path(self.tempdir.name) / "source" + self.source_dir.mkdir() + self.mapped_path = self.source_dir / "mapped.mkv" + self.mapped_path.write_bytes(b"mapped") + self.unknown_path = self.source_dir / "unknown.mkv" + self.unknown_path.write_bytes(b"unknown") + + def tearDown(self): + self.tempdir.cleanup() + + def test_inspect_shift_prints_resolved_mapping_for_each_file(self): + runner = CliRunner() + + with ( + patch("ffx.file_properties.FileProperties", _FakeFileProperties), + patch( + "ffx.shifted_season_controller.ShiftedSeasonController", + _FakeShiftedSeasonController, + ), + ): + result = runner.invoke( + cli.ffx, + [ + "--database-file", + str(self.database_path), + "inspect", + "--shift", + str(self.mapped_path), + str(self.unknown_path), + ], + env={**os.environ, "HOME": str(self.home_dir)}, + ) + + self.assertEqual(0, result.exit_code, result.output) + self.assertIn( + f"{self.mapped_path}: 1/3 -> 2/1 from pattern", + result.output, + ) + self.assertIn( + f"{self.unknown_path}: no season/episode recognized", + result.output, + ) + + def test_inspect_without_shift_requires_exactly_one_filename(self): + runner = CliRunner() + + result = runner.invoke( + cli.ffx, + [ + "--database-file", + str(self.database_path), + "inspect", + str(self.mapped_path), + str(self.unknown_path), + ], + env={**os.environ, "HOME": str(self.home_dir)}, + ) + + self.assertNotEqual(0, result.exit_code) + self.assertIn( + "Inspect without --shift requires exactly one filename.", + result.output, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_shifted_season_controller.py b/tests/unit/test_shifted_season_controller.py index b8abde2..c3534f0 100644 --- a/tests/unit/test_shifted_season_controller.py +++ b/tests/unit/test_shifted_season_controller.py @@ -183,9 +183,7 @@ class ShiftedSeasonControllerTests(unittest.TestCase): ) self.assertEqual((1, 3), (shifted_season, shifted_episode)) - mocked_info.assert_called_once_with( - "Setting season shift 1/3 -> 1/3 from pattern" - ) + mocked_info.assert_not_called() def test_shift_season_falls_back_to_identity_when_no_rule_matches(self): pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$") @@ -199,9 +197,7 @@ class ShiftedSeasonControllerTests(unittest.TestCase): ) self.assertEqual((4, 20), (shifted_season, shifted_episode)) - mocked_info.assert_called_once_with( - "Setting season shift 4/20 -> 4/20 from default" - ) + mocked_info.assert_not_called() if __name__ == "__main__":