diff --git a/SCRATCHPAD.md b/SCRATCHPAD.md index 869e37b..ac125c2 100644 --- a/SCRATCHPAD.md +++ b/SCRATCHPAD.md @@ -73,3 +73,8 @@ ## TODO: Review styled ASS separate handling + + + +. ~/.bashrc && cd /home/devnut/.local/src/codex/ffx/tests/assets && /home/devnut/.local/src/codex/ffx/.venv/bin/ffx --debug inspect gits_sac_s02e02_r3.mkv + diff --git a/src/ffx/ffx_app.py b/src/ffx/ffx_app.py index 58d0bf8..d9ae72b 100644 --- a/src/ffx/ffx_app.py +++ b/src/ffx/ffx_app.py @@ -1,9 +1,11 @@ from textual.app import App from .i18n import set_current_language, t +from .confirm_screen import ConfirmScreen from .shows_screen import ShowsScreen from .inspect_details_screen import InspectDetailsScreen from .media_edit_screen import MediaEditScreen +from .pattern_controller import InvalidPatternCaptureGroupError, PatternController from .screen_support import configure_screen_log_handler, set_screen_log_pane_enabled @@ -40,7 +42,20 @@ class FfxApp(App): self.push_screen(ShowsScreen()) if self.context['command'] == 'inspect': - self.push_screen(InspectDetailsScreen()) + try: + self.push_screen(InspectDetailsScreen()) + except InvalidPatternCaptureGroupError as ex: + self._invalid_pattern_error = ex + self.push_screen( + ConfirmScreen( + t( + "Pattern #{pattern_id} matches the filename but has no capture group for the season/episode indicator. Delete this pattern and quit?" + ).format(pattern_id=ex.pattern_id), + confirm_label=t("Delete"), + cancel_label=t("Quit"), + ), + self._handle_invalid_pattern_confirmation, + ) if self.context['command'] == 'edit': self.push_screen(MediaEditScreen()) @@ -49,3 +64,13 @@ class FfxApp(App): def getContext(self): """Data 'output' method""" return self.context + + def _handle_invalid_pattern_confirmation(self, confirmed: bool): + invalid_pattern_error = getattr(self, "_invalid_pattern_error", None) + + if confirmed and invalid_pattern_error is not None: + PatternController(self.context).deletePattern( + invalid_pattern_error.pattern_id + ) + + self.exit() diff --git a/src/ffx/inspect_details_screen.py b/src/ffx/inspect_details_screen.py index a4d41e6..ab7b475 100644 --- a/src/ffx/inspect_details_screen.py +++ b/src/ffx/inspect_details_screen.py @@ -10,6 +10,7 @@ from textual.widgets._data_table import CellDoesNotExist from ffx.file_properties import FileProperties from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY from ffx.media_descriptor_change_set import MediaDescriptorChangeSet +from ffx.pattern_controller import InvalidPatternCaptureGroupError from ffx.show_descriptor import ShowDescriptor from ffx.track_descriptor import TrackDescriptor @@ -472,11 +473,15 @@ class InspectDetailsScreen(MediaWorkflowScreenBase): ): mediaTags[tagKey] = tagValue - patternId = self._pc.savePatternSchema( - patternObj, - trackDescriptors=self._sourceMediaDescriptor.getTrackDescriptors(), - mediaTags=mediaTags, - ) + try: + patternId = self._pc.savePatternSchema( + patternObj, + trackDescriptors=self._sourceMediaDescriptor.getTrackDescriptors(), + mediaTags=mediaTags, + ) + except InvalidPatternCaptureGroupError as ex: + self.notify(str(ex), severity="error") + return if patternId: self.reloadProperties(reset_draft=True) self.updateMediaTags() @@ -498,10 +503,14 @@ class InspectDetailsScreen(MediaWorkflowScreenBase): patternObj and self._currentPattern.getPattern() != patternObj["pattern"] ): - updated = self._pc.updatePattern( - self._currentPattern.getId(), - patternObj, - ) + try: + updated = self._pc.updatePattern( + self._currentPattern.getId(), + patternObj, + ) + except InvalidPatternCaptureGroupError as ex: + self.notify(str(ex), severity="error") + return False if updated: self.reloadProperties(reset_draft=True) self.updateMediaTags() diff --git a/src/ffx/pattern_controller.py b/src/ffx/pattern_controller.py index 828881c..e28b0b7 100644 --- a/src/ffx/pattern_controller.py +++ b/src/ffx/pattern_controller.py @@ -19,6 +19,21 @@ class InvalidPatternSchemaError(click.ClickException): pass +class InvalidPatternCaptureGroupError(click.ClickException): + def __init__(self, pattern_id: int, pattern_expression: str): + self.pattern_id = int(pattern_id) + self.pattern_expression = str(pattern_expression) + pattern_label = ( + f"Pattern #{self.pattern_id}" + if self.pattern_id >= 0 + else "Pattern" + ) + super().__init__( + f"{pattern_label} must contain at least one capture group " + f"for the season/episode indicator: {self.pattern_expression!r}" + ) + + class PatternController: _compiled_regex_cache: dict[str, re.Pattern] = {} @@ -66,6 +81,13 @@ class PatternController: cls._compiled_regex_cache[expression_text] = compiled return compiled + @classmethod + def _validate_pattern_capture_groups(cls, pattern_id: int, expression: str): + compiled = cls._compile_pattern_expression(pattern_id, expression) + if compiled.groups < 1: + raise InvalidPatternCaptureGroupError(pattern_id, expression) + return compiled + def _coerce_pattern_fields(self, patternObj): return { "show_id": int(patternObj["show_id"]), @@ -212,6 +234,10 @@ class PatternController: fields = self._coerce_pattern_fields(patternObj) normalized_tracks = self._normalize_track_descriptors(trackDescriptors) normalized_tags = self._coerce_media_tags(mediaTags) + self._validate_pattern_capture_groups( + patternId if patternId is not None else -1, + fields["pattern"], + ) session = None try: @@ -274,6 +300,7 @@ class PatternController: def updatePattern(self, patternId, patternObj): fields = self._coerce_pattern_fields(patternObj) + self._validate_pattern_capture_groups(patternId, fields["pattern"]) session = None try: @@ -409,6 +436,11 @@ class PatternController: patternMatch = compiled.search(str(filename)) if patternMatch is None: continue + if not patternMatch.groups(): + raise InvalidPatternCaptureGroupError( + pattern.getId(), + pattern.getPattern(), + ) self._validate_persisted_pattern(pattern) matches.append({"match": patternMatch, "pattern": pattern}) diff --git a/src/ffx/pattern_details_screen.py b/src/ffx/pattern_details_screen.py index eb8948d..6998a35 100644 --- a/src/ffx/pattern_details_screen.py +++ b/src/ffx/pattern_details_screen.py @@ -8,6 +8,7 @@ from textual.widgets import Header, Footer, Static, Button, Input, DataTable, Te from textual.containers import Grid from ffx.model.pattern import Pattern +from ffx.pattern_controller import InvalidPatternCaptureGroupError from .track_details_screen import TrackDetailsScreen from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen @@ -575,7 +576,7 @@ class PatternDetailsScreen(Screen): yield Static(" ", classes="five") # Row 20 - yield Static(" ", classes="seven") + yield Static(" ", id="messagestatic", classes="seven") yield build_screen_log_pane() yield Footer() @@ -610,6 +611,12 @@ class PatternDetailsScreen(Screen): def getNotesFromInput(self): return str(self.query_one("#notes_textarea", TextArea).text) + def setMessage(self, message: str): + try: + self.query_one("#messagestatic", Static).update(str(message) or " ") + except (NoMatches, AttributeError): + pass + def getSelectedTrackDescriptor(self): @@ -657,25 +664,26 @@ class PatternDetailsScreen(Screen): patternDescriptor['quality'] = self.getQualityFromInput() patternDescriptor['notes'] = self.getNotesFromInput() - if self.__pattern is not None: + try: + if self.__pattern is not None: - if self.__pc.updatePattern(self.__pattern.getId(), patternDescriptor): - self.dismiss(patternDescriptor) - else: - #TODO: Meldung - self.app.pop_screen() + if self.__pc.updatePattern(self.__pattern.getId(), patternDescriptor): + self.dismiss(patternDescriptor) + else: + self.setMessage(t("Unable to update pattern.")) - else: - patternId = self.__pc.savePatternSchema( - patternDescriptor, - trackDescriptors=self.__draftTracks, - mediaTags=self.__draftTags, - ) - if patternId: - self.dismiss(patternDescriptor) else: - #TODO: Meldung - self.app.pop_screen() + patternId = self.__pc.savePatternSchema( + patternDescriptor, + trackDescriptors=self.__draftTracks, + mediaTags=self.__draftTags, + ) + if patternId: + self.dismiss(patternDescriptor) + else: + self.setMessage(t("Unable to save pattern.")) + except InvalidPatternCaptureGroupError as ex: + self.setMessage(str(ex)) if event.button.id == "cancel_button": diff --git a/tests/unit/test_pattern_management.py b/tests/unit/test_pattern_management.py index 11190c1..ff403a7 100644 --- a/tests/unit/test_pattern_management.py +++ b/tests/unit/test_pattern_management.py @@ -21,6 +21,7 @@ from ffx.file_properties import FileProperties # noqa: E402 from ffx.model.pattern import Pattern # noqa: E402 from ffx.pattern_controller import ( # noqa: E402 DuplicatePatternMatchError, + InvalidPatternCaptureGroupError, InvalidPatternSchemaError, PatternController, ) @@ -144,7 +145,7 @@ class PatternManagementTests(unittest.TestCase): def test_match_filename_raises_for_duplicate_matches_in_same_show(self): self.save_pattern(1, r"^same_(s[0-9]+e[0-9]+)\.mkv$") - self.save_pattern(1, r"^same_.*$") + self.save_pattern(1, r"^same_(.*)$") with self.assertRaises(DuplicatePatternMatchError) as caught: self.pattern_controller.matchFilename("same_s01e01.mkv") @@ -154,7 +155,7 @@ class PatternManagementTests(unittest.TestCase): def test_match_filename_raises_for_duplicate_matches_across_shows(self): self.save_pattern(1, r"^cross_(s[0-9]+e[0-9]+)\.mkv$") - self.save_pattern(2, r"^cross_.*$") + self.save_pattern(2, r"^cross_(.*)$") with self.assertRaises(DuplicatePatternMatchError) as caught: self.pattern_controller.matchFilename("cross_s01e01.mkv") @@ -181,6 +182,48 @@ class PatternManagementTests(unittest.TestCase): match = self.pattern_controller.matchFilename("after_s01e01.mkv") self.assertEqual(pattern_id, match["pattern"].getId()) + def test_save_pattern_schema_rejects_pattern_without_capture_group(self): + self.add_show(1, "No Capture Pattern Show") + + with self.assertRaises(InvalidPatternCaptureGroupError) as caught: + self.pattern_controller.savePatternSchema( + { + "show_id": 1, + "pattern": r"^nocapture_s[0-9]+e[0-9]+\.mkv$", + }, + trackDescriptors=[make_track_descriptor(0)], + ) + + self.assertIn("capture group", str(caught.exception)) + + def test_update_pattern_rejects_pattern_without_capture_group(self): + pattern_id = self.save_pattern(1, r"^valid_(s[0-9]+e[0-9]+)\.mkv$") + + with self.assertRaises(InvalidPatternCaptureGroupError): + self.pattern_controller.updatePattern( + pattern_id, + { + "show_id": 1, + "pattern": r"^invalid_s[0-9]+e[0-9]+\.mkv$", + "quality": 0, + "notes": "", + }, + ) + + match = self.pattern_controller.matchFilename("valid_s01e01.mkv") + self.assertEqual(pattern_id, match["pattern"].getId()) + + def test_match_filename_raises_specific_error_for_legacy_pattern_without_capture_group(self): + pattern_id = self.insert_trackless_pattern_row( + 1, + r"^legacy_s[0-9]+e[0-9]+\.mkv$", + ) + + with self.assertRaises(InvalidPatternCaptureGroupError) as caught: + self.pattern_controller.matchFilename("legacy_s01e01.mkv") + + self.assertEqual(pattern_id, caught.exception.pattern_id) + def test_save_pattern_schema_rejects_zero_track_patterns(self): self.add_show(1, "Empty Pattern Show") @@ -276,7 +319,7 @@ class PatternManagementTests(unittest.TestCase): def test_file_properties_skips_pattern_matching_when_disabled(self): self.save_pattern(1, r"^nopattern_(s[0-9]+e[0-9]+)\.mkv$") - self.save_pattern(2, r"^nopattern_.*$") + self.save_pattern(2, r"^nopattern_(.*)$") no_pattern_context = dict(self.context) no_pattern_context["use_pattern"] = False