From d19e69990a8883734a39087b65f1430e6a7f160d Mon Sep 17 00:00:00 2001 From: Javanaut Date: Thu, 9 Apr 2026 16:11:51 +0200 Subject: [PATCH] Opt pattern matching --- SCRATCHPAD.md | 34 +- pyproject.toml | 1 + requirements/pattern_management.md | 68 +++ requirements/project.md | 1 + src/ffx/file_properties.py | 3 +- src/ffx/media_details_screen.py | 24 +- src/ffx/model/pattern.py | 5 +- src/ffx/pattern_controller.py | 412 +++++++++++++---- src/ffx/pattern_details_screen.py | 331 ++++++++------ src/ffx/track_controller.py | 8 +- src/ffx/track_delete_screen.py | 23 +- src/ffx/track_details_screen.py | 429 +++++++++--------- .../pattern_management/__init__.py | 1 + .../test_cli_pattern_matching.py | 138 ++++++ tests/support/ffx_bundle.py | 52 +-- tests/unit/test_pattern_management.py | 240 ++++++++++ 16 files changed, 1248 insertions(+), 522 deletions(-) create mode 100644 requirements/pattern_management.md create mode 100644 tests/integration/pattern_management/__init__.py create mode 100644 tests/integration/pattern_management/test_cli_pattern_matching.py create mode 100644 tests/unit/test_pattern_management.py diff --git a/SCRATCHPAD.md b/SCRATCHPAD.md index 4502742..c0ab74d 100644 --- a/SCRATCHPAD.md +++ b/SCRATCHPAD.md @@ -12,13 +12,13 @@ - FFX logger setup now reuses named handlers, and fallback logger access no longer mutates handlers in ordinary constructors and helpers. - The process wrapper now uses `subprocess.run(...)` with centralized command formatting plus stable timeout and missing-command error mapping. - Active ORM controllers now use single-query accessors instead of paired `count()` plus `first()` lookups. +- Pattern matching now uses cached compiled regexes plus explicit duplicate-match errors, and pattern creation flows no longer persist zero-track patterns. ## Focused Snapshot - Highest-leverage application optimizations: - Lazy-load CLI command dependencies so lightweight commands do not import most of the app. - Collapse repeated `ffprobe` calls into a single probe result per source file. - - Cache or precompile filename pattern regexes instead of scanning every pattern for every file. - Highest-leverage repo and workflow optimizations: - Consolidate setup and upgrade tooling to reduce overlapping shell-script responsibilities. @@ -35,17 +35,7 @@ - Faster startup for scripting and tooling commands. - Less coupling between maintenance commands and the runtime stack. -2. Filename pattern matching scales linearly across all patterns -- [`src/ffx/pattern_controller.py`](/home/osgw/.local/src/codex/ffx/src/ffx/pattern_controller.py) loads every pattern and runs `re.search` against each filename on every lookup. -- Optimization: - - Cache compiled regexes in process memory. - - Stop after the first intentional match instead of silently returning the last match. - - Consider explicit pattern priority if overlapping rules are valid. -- Expected value: - - Faster per-file setup when many patterns exist. - - More predictable matching behavior. - -3. Media probing does two separate `ffprobe` subprocesses per file +2. Media probing does two separate `ffprobe` subprocesses per file - [`src/ffx/file_properties.py`](/home/osgw/.local/src/codex/ffx/src/ffx/file_properties.py) calls `ffprobe` once for format data and once for stream data. - Optimization: - Use one probe call that requests both format and streams. @@ -54,7 +44,7 @@ - Less subprocess overhead. - Faster inspect and convert flows. -4. Crop detection is always a full extra ffmpeg scan +3. Crop detection is always a full extra ffmpeg scan - [`src/ffx/file_properties.py`](/home/osgw/.local/src/codex/ffx/src/ffx/file_properties.py) runs a dedicated `ffmpeg -vf cropdetect` pass for each file when crop detection is requested. - Optimization: - Cache crop results for repeated runs on the same source. @@ -62,7 +52,7 @@ - Expected value: - Lower latency on repeated experimentation. -5. Tooling overlap and naming drift +4. Tooling overlap and naming drift - There are still overlapping workstation-setup entrypoints across [`tools/configure_workstation.sh`](/home/osgw/.local/src/codex/ffx/tools/configure_workstation.sh), [`tools/setup.sh`](/home/osgw/.local/src/codex/ffx/tools/setup.sh), and newer CLI maintenance commands. - Optimization: - Decide which scripts remain canonical. @@ -72,7 +62,7 @@ - Less operator confusion. - Fewer duplicated procedures to maintain. -6. Placeholder UI surfaces should either ship or disappear +5. Placeholder UI surfaces should either ship or disappear - [`src/ffx/help_screen.py`](/home/osgw/.local/src/codex/ffx/src/ffx/help_screen.py) and [`src/ffx/settings_screen.py`](/home/osgw/.local/src/codex/ffx/src/ffx/settings_screen.py) are placeholders. - Optimization: - Either remove them from the active UI surface or complete them. @@ -81,7 +71,7 @@ - Leaner interface. - Lower UX ambiguity. -7. Large Textual screens repeat configuration and controller loading +6. Large Textual screens repeat configuration and controller loading - Screens such as [`src/ffx/media_details_screen.py`](/home/osgw/.local/src/codex/ffx/src/ffx/media_details_screen.py), [`src/ffx/pattern_details_screen.py`](/home/osgw/.local/src/codex/ffx/src/ffx/pattern_details_screen.py), and [`src/ffx/show_details_screen.py`](/home/osgw/.local/src/codex/ffx/src/ffx/show_details_screen.py) repeat setup patterns and local metadata filtering extraction. - Optimization: - Extract a shared screen base or helper for common config/controller/bootstrap logic. @@ -90,7 +80,7 @@ - Lower maintenance overhead. - Easier UI iteration. -8. Several helper functions are unfinished or dead-weight +7. Several helper functions are unfinished or dead-weight - [`src/ffx/helper.py`](/home/osgw/.local/src/codex/ffx/src/ffx/helper.py) contains `permutateList(...): pass`. - There are many combinator and conversion placeholders across tests and migrations. - Optimization: @@ -100,7 +90,7 @@ - Smaller mental model. - Less time spent re-evaluating inactive paths. -9. Test suite shape is expensive to understand and likely expensive to run +8. Test suite shape is expensive to understand and likely expensive to run - The project still carries a large legacy matrix of combinator files under [`tests/legacy`](/home/osgw/.local/src/codex/ffx/tests/legacy), several placeholder `pass` implementations, and at least one suspicious filename with an embedded space: [`tests/legacy/disposition_combinator_2_3 .py`](/home/osgw/.local/src/codex/ffx/tests/legacy/disposition_combinator_2_3 .py). - A first focused replacement slice now exists in [`tests/integration/subtrack_mapping/test_cli_bundle.py`](/home/osgw/.local/src/codex/ffx/tests/integration/subtrack_mapping/test_cli_bundle.py), so the remaining work is migration and consolidation rather than creating the modern test shape from scratch. - Optimization: @@ -111,7 +101,7 @@ - Faster contributor onboarding. - Easier CI adoption later. -10. Process resource limiting semantics could be clearer +9. Process resource limiting semantics could be clearer - [`src/ffx/process.py`](/home/osgw/.local/src/codex/ffx/src/ffx/process.py) prepends `nice` and `cpulimit` directly when values are set. - Optimization: - Validate and document effective behavior for combined `nice` + `cpulimit`. @@ -120,7 +110,7 @@ - Fewer surprises in production-like runs. - Easier support for user-reported performance behavior. -11. Import-time dependency coupling makes maintenance commands brittle +10. Import-time dependency coupling makes maintenance commands brittle - Even after recent CLI maintenance additions, the top-level CLI module still imports most application modules before Click dispatch. - Optimization: - Push imports for ORM, Textual, TMDB, ffmpeg helpers, and descriptors behind the commands that actually need them. @@ -128,7 +118,7 @@ - Maintenance commands such as setup and upgrade stay usable when optional runtime dependencies are broken. - Better separation between media runtime code and maintenance tooling. -12. Regex and string utility cleanup +11. Regex and string utility cleanup - [`src/ffx/helper.py`](/home/osgw/.local/src/codex/ffx/src/ffx/helper.py) still emits a `SyntaxWarning` for `RICH_COLOR_PATTERN`. - Optimization: - Convert regex literals to raw strings where appropriate. @@ -137,7 +127,7 @@ - Cleaner runtime output. - Less warning noise during dry-run maintenance commands. -13. Database startup always runs schema creation and version checks +12. Database startup always runs schema creation and version checks - [`src/ffx/database.py`](/home/osgw/.local/src/codex/ffx/src/ffx/database.py) runs `Base.metadata.create_all(...)` and version checks every time a DB-backed context is created. - Optimization: - Measure startup cost and consider separating bootstrapping from ordinary command execution. diff --git a/pyproject.toml b/pyproject.toml index da2dc02..7c00f6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,5 +49,6 @@ norecursedirs = ["tests/legacy", "tests/support"] addopts = "-ra" markers = [ "integration: exercises the FFX bundle with real ffmpeg/ffprobe processes", + "pattern_management: covers requirements/pattern_management.md", "subtrack_mapping: covers requirements/subtrack_mapping.md", ] diff --git a/requirements/pattern_management.md b/requirements/pattern_management.md new file mode 100644 index 0000000..51f6674 --- /dev/null +++ b/requirements/pattern_management.md @@ -0,0 +1,68 @@ +# Pattern Management + +This file defines the behavioral contract for managing shows, patterns, and +pattern-backed filename matching. + +Primary source: actual tool code in `src/ffx/`. +Secondary source: operator intent captured in task discussion. + +## Scope + +- The show, pattern, and track hierarchy stored in SQLite. +- The role of a pattern as a reusable normalization definition for related media files. +- Filename-driven assignment of a scanned media file to one show through one matching pattern. +- Duplicate-match handling when more than one pattern matches the same filename. + +## Terms + +- `show`: logical series identity such as one TV show entry in the database. +- `pattern`: regex-backed normalization definition attached to one show. +- `track`: one persisted target-track definition attached to one pattern. +- `scanned media file`: one source file currently being inspected or converted. +- `duplicate pattern match`: a filename state where more than one stored pattern matches the same scanned media file. +- `pattern-backed target schema`: the combination of one pattern's stored media tags and stored track definitions. + +## Rules + +- `PATTERN_MANAGEMENT-0001`: The domain model shall treat a show as the parent entity for patterns that describe distinct release families or normalization schemas for that show. A show may temporarily exist without patterns during editing or initial TUI creation. +- `PATTERN_MANAGEMENT-0002`: Each persisted pattern shall belong to exactly one show. +- `PATTERN_MANAGEMENT-0003`: The domain model shall treat a pattern as the reusable normalization definition for a series of media files expected to share the same internal track layout and materially similar stream and container metadata. +- `PATTERN_MANAGEMENT-0004`: Each persisted track definition shall belong to exactly one pattern. +- `PATTERN_MANAGEMENT-0005`: A pattern may also carry pattern-level media tags. The pattern's media tags plus its track definitions together form the pattern-backed target schema. +- `PATTERN_MANAGEMENT-0006`: A scanned media file shall resolve to at most one pattern and therefore at most one show. +- `PATTERN_MANAGEMENT-0007`: If no pattern matches a filename, the file shall remain unmatched rather than being assigned implicitly. +- `PATTERN_MANAGEMENT-0008`: If more than one pattern matches the same filename, the system shall raise a duplicate pattern match error instead of silently selecting one. +- `PATTERN_MANAGEMENT-0009`: Duplicate-match detection shall apply regardless of whether the competing patterns belong to the same show or to different shows. +- `PATTERN_MANAGEMENT-0010`: Exact duplicate pattern definitions for the same show should not create multiple persisted pattern rows. +- `PATTERN_MANAGEMENT-0011`: A persisted pattern shall define one or more tracks. Creating or retaining a zero-track pattern in the database is invalid managed state and shall be prohibited. +- `PATTERN_MANAGEMENT-0012`: A show may exist without patterns as an intermediate editing state, for example when a user creates the show first in the TUI and adds patterns later. +- `PATTERN_MANAGEMENT-0013`: Operator-facing pattern management should expose the owning show, regex pattern, stored track set, and stored media-tag set so a user can reason about matching and normalization behavior. +- `PATTERN_MANAGEMENT-0014`: Matching semantics shall be deterministic and documented. Implicit "last matching pattern wins" behavior is not acceptable released behavior. + +## Acceptance + +- A filename that matches exactly one pattern yields one matched pattern and one show identity. +- A filename that matches no pattern yields no matched pattern and an unmatched state. +- A filename that matches more than one pattern yields an explicit duplicate-match error. +- A pattern-backed target schema can be reconstructed from one pattern's stored media tags and stored track definitions. +- A show may be stored before any patterns are attached to it. +- A pattern cannot be stored or retained as a valid managed pattern unless at least one track is defined for it. +- Pattern-backed conversion never proceeds with two competing matching patterns for the same input filename. + +## Current Code Fit + +- `src/ffx/model/show.py` implements a one-to-many `Show -> Pattern` relationship. +- `src/ffx/model/pattern.py` implements `Pattern.show_id`, a one-to-many `Pattern -> Track` relationship, a one-to-many `Pattern -> MediaTag` relationship, and a unique `(show_id, pattern)` constraint for freshly created databases. +- `src/ffx/model/track.py` implements `Track.pattern_id`, so each persisted track belongs to one pattern. +- `src/ffx/model/pattern.py` reconstructs a pattern-backed target schema through `Pattern.getMediaDescriptor(...)`, combining stored media tags and stored tracks. +- `src/ffx/file_properties.py` assumes a scanned file resolves to at most one pattern, because it stores only one `self.__pattern` and derives one `show_id` from it. +- `src/ffx/pattern_controller.py` prevents exact duplicate `(show_id, pattern)` definitions during create and update flows, and it refreshes cached compiled regexes when stored pattern expressions change. +- `src/ffx/pattern_controller.py` now complies with duplicate-match safety. `matchFilename(...)` scans deterministically, returns exactly one match, returns `{}` for no match, and raises an explicit duplicate-pattern-match error when more than one pattern matches the same filename. +- The current persistence layer already aligns with the intended empty-show workflow because a show can exist without patterns. +- New pattern creation and schema replacement flows now require at least one track, and `TrackController.deleteTrack(...)` prevents deleting the last persisted track from a pattern. +- Trackless legacy rows can still exist in preexisting databases, but matching now rejects them explicitly instead of letting them participate silently. + +## Risks + +- The intended "release family" meaning of a pattern is a domain assumption, not something the code verifies automatically across all files matching that pattern. +- Preexisting databases created before the newer validation rules may still contain invalid rows, so upgrade and cleanup paths should continue to treat explicit validation failures as recoverable operator signals. diff --git a/requirements/project.md b/requirements/project.md index d47b826..2e8130c 100644 --- a/requirements/project.md +++ b/requirements/project.md @@ -47,6 +47,7 @@ - per-pattern stream definitions, - shifted-season mappings, - internal database version properties. +- Detailed show, pattern, and duplicate-match management rules live in `requirements/pattern_management.md`. - The system shall inspect source media using `ffprobe` and derive a structured description of container metadata and streams. - The system shall optionally open a Textual UI to browse shows, inspect files, and create, edit, or delete shows, patterns, stream definitions, tags, and shifted-season rules. - The system shall match filenames against stored regex patterns to decide whether an input file should inherit a target stream and metadata schema. diff --git a/src/ffx/file_properties.py b/src/ffx/file_properties.py index e8134db..09f676e 100644 --- a/src/ffx/file_properties.py +++ b/src/ffx/file_properties.py @@ -44,9 +44,10 @@ class FileProperties(): self.__sourceFilenameExtension = '' self.__pc = PatternController(context) + self.__usePattern = bool(self.context.get('use_pattern', True)) # Checking if database contains matching pattern - matchResult = self.__pc.matchFilename(self.__sourceFilename) + matchResult = self.__pc.matchFilename(self.__sourceFilename) if self.__usePattern else {} self.__logger.debug(f"FileProperties.__init__(): Match result: {matchResult}") diff --git a/src/ffx/media_details_screen.py b/src/ffx/media_details_screen.py index 9afbd71..dfb837f 100644 --- a/src/ffx/media_details_screen.py +++ b/src/ffx/media_details_screen.py @@ -602,20 +602,21 @@ class MediaDetailsScreen(Screen): patternObj = self.getPatternObjFromInput() if patternObj: - patternId = self.__pc.addPattern(patternObj) + mediaTags = {} + for tagKey, tagValue in self.__sourceMediaDescriptor.getTags().items(): + + # Filter tags that make no sense to preserve + if tagKey not in self.__ignoreGlobalKeys and not tagKey in self.__removeGlobalKeys: + mediaTags[tagKey] = tagValue + + patternId = self.__pc.savePatternSchema( + patternObj, + trackDescriptors=self.__sourceMediaDescriptor.getTrackDescriptors(), + mediaTags=mediaTags, + ) if patternId: self.highlightPattern(False) - for tagKey, tagValue in self.__sourceMediaDescriptor.getTags().items(): - - # Filter tags that make no sense to preserve - if tagKey not in self.__ignoreGlobalKeys and not tagKey in self.__removeGlobalKeys: - self.__tac.updateMediaTag(patternId, tagKey, tagValue) - - # for trackDescriptor in self.__sourceMediaDescriptor.getAllTrackDescriptors(): - for trackDescriptor in self.__sourceMediaDescriptor.getTrackDescriptors(): - self.__tc.addTrack(trackDescriptor, patternId = patternId) - def action_new_pattern(self): """Adding new patterns @@ -754,4 +755,3 @@ class MediaDetailsScreen(Screen): def handle_edit_pattern(self, screenResult): self.query_one("#pattern_input", Input).value = screenResult['pattern'] self.updateDifferences() - diff --git a/src/ffx/model/pattern.py b/src/ffx/model/pattern.py index 9fc8595..8d810d3 100644 --- a/src/ffx/model/pattern.py +++ b/src/ffx/model/pattern.py @@ -1,6 +1,6 @@ import click -from sqlalchemy import Column, Integer, String, Text, ForeignKey +from sqlalchemy import Column, Integer, String, Text, ForeignKey, UniqueConstraint from sqlalchemy.orm import relationship from .show import Base, Show @@ -12,6 +12,9 @@ from ffx.show_descriptor import ShowDescriptor class Pattern(Base): __tablename__ = 'patterns' + __table_args__ = ( + UniqueConstraint('show_id', 'pattern', name='uq_patterns_show_id_pattern'), + ) # v1.x id = Column(Integer, primary_key=True) diff --git a/src/ffx/pattern_controller.py b/src/ffx/pattern_controller.py index 2e2cdd1..e10d5a7 100644 --- a/src/ffx/pattern_controller.py +++ b/src/ffx/pattern_controller.py @@ -1,160 +1,388 @@ -import click, re +import re +import click + +from ffx.model.media_tag import MediaTag from ffx.model.pattern import Pattern +from ffx.model.track import Track +from ffx.model.track_tag import TrackTag +from ffx.track_descriptor import TrackDescriptor +from ffx.track_disposition import TrackDisposition -class PatternController(): - +class DuplicatePatternMatchError(click.ClickException): + pass + + +class InvalidPatternSchemaError(click.ClickException): + pass + + +class PatternController: + _compiled_regex_cache: dict[str, re.Pattern] = {} + def __init__(self, context): - + self.context = context - self.Session = self.context['database']['session'] # convenience + self.Session = self.context["database"]["session"] + self.__configurationData = self.context["config"].getData() - def addPattern(self, patternObj): - """Adds pattern to database from obj - - Returns database id or 0 if pattern already exists""" + metadataConfiguration = ( + self.__configurationData["metadata"] + if "metadata" in self.__configurationData.keys() + else {} + ) + + self.__removeTrackKeys = ( + metadataConfiguration["streams"]["remove"] + if "streams" in metadataConfiguration.keys() + and "remove" in metadataConfiguration["streams"].keys() + else [] + ) + self.__ignoreTrackKeys = ( + metadataConfiguration["streams"]["ignore"] + if "streams" in metadataConfiguration.keys() + and "ignore" in metadataConfiguration["streams"].keys() + else [] + ) + + @classmethod + def _clear_regex_cache(cls): + cls._compiled_regex_cache.clear() + + @classmethod + def _compile_pattern_expression(cls, pattern_id: int, expression: str) -> re.Pattern: + expression_text = str(expression) + compiled = cls._compiled_regex_cache.get(expression_text) + if compiled is None: + try: + compiled = re.compile(expression_text) + except re.error as ex: + raise click.ClickException( + f"Pattern #{pattern_id} contains an invalid regex {expression_text!r}: {ex}" + ) + cls._compiled_regex_cache[expression_text] = compiled + return compiled + + def _coerce_pattern_fields(self, patternObj): + return { + "show_id": int(patternObj["show_id"]), + "pattern": str(patternObj["pattern"]), + "quality": int(patternObj.get("quality", 0) or 0), + "notes": str(patternObj.get("notes", "")), + } + + def _coerce_media_tags(self, mediaTags): + return { + str(tagKey): str(tagValue) + for tagKey, tagValue in (mediaTags or {}).items() + } + + def _normalize_track_descriptors(self, trackDescriptors): + if trackDescriptors is None: + raise InvalidPatternSchemaError( + "Patterns must define at least one track before they can be stored." + ) + + normalized_descriptors = [] + for trackDescriptor in trackDescriptors: + if type(trackDescriptor) is not TrackDescriptor: + raise TypeError( + "PatternController: All track descriptors are required to be of type TrackDescriptor" + ) + normalized_descriptors.append(trackDescriptor) + + if not normalized_descriptors: + raise InvalidPatternSchemaError( + "Patterns must define at least one track before they can be stored." + ) + + normalized_descriptors = sorted( + normalized_descriptors, key=lambda descriptor: descriptor.getIndex() + ) + + index_set = {descriptor.getIndex() for descriptor in normalized_descriptors} + expected_indexes = set(range(len(normalized_descriptors))) + if index_set != expected_indexes: + raise click.ClickException( + "Pattern tracks must use a contiguous zero-based index order." + ) + + return normalized_descriptors + + def _ensure_unique_pattern_definition( + self, + session, + show_id: int, + pattern_expression: str, + exclude_pattern_id: int | None = None, + ): + query = session.query(Pattern).filter( + Pattern.show_id == show_id, + Pattern.pattern == pattern_expression, + ) + if exclude_pattern_id is not None: + query = query.filter(Pattern.id != int(exclude_pattern_id)) + + existing_pattern = query.first() + if existing_pattern is not None: + raise click.ClickException( + f"Pattern {pattern_expression!r} already exists for show #{show_id}." + ) + + def _build_track_row(self, trackDescriptor: TrackDescriptor) -> Track: + track = Track( + track_type=int(trackDescriptor.getType().index()), + codec_name=str(trackDescriptor.getCodec().identifier()), + index=int(trackDescriptor.getIndex()), + source_index=int(trackDescriptor.getSourceIndex()), + disposition_flags=int( + TrackDisposition.toFlags(trackDescriptor.getDispositionSet()) + ), + audio_layout=trackDescriptor.getAudioLayout().index(), + ) + + for tagKey, tagValue in trackDescriptor.getTags().items(): + if tagKey in self.__ignoreTrackKeys or tagKey in self.__removeTrackKeys: + continue + track.track_tags.append(TrackTag(key=str(tagKey), value=str(tagValue))) + + return track + + def _replace_pattern_schema( + self, + session, + pattern: Pattern, + mediaTags: dict[str, str], + trackDescriptors: list[TrackDescriptor], + ): + for mediaTag in list(pattern.media_tags): + session.delete(mediaTag) + for track in list(pattern.tracks): + session.delete(track) + session.flush() + + for tagKey, tagValue in mediaTags.items(): + pattern.media_tags.append(MediaTag(key=str(tagKey), value=str(tagValue))) + + for trackDescriptor in trackDescriptors: + pattern.tracks.append(self._build_track_row(trackDescriptor)) + + def _validate_persisted_pattern(self, pattern: Pattern): + if not pattern.tracks: + raise InvalidPatternSchemaError( + f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no tracks." + ) + + def savePatternSchema( + self, + patternObj, + trackDescriptors, + mediaTags=None, + patternId: int | None = None, + ) -> int: + fields = self._coerce_pattern_fields(patternObj) + normalized_tracks = self._normalize_track_descriptors(trackDescriptors) + normalized_tags = self._coerce_media_tags(mediaTags) + session = None try: + session = self.Session() + self._ensure_unique_pattern_definition( + session, + fields["show_id"], + fields["pattern"], + exclude_pattern_id=patternId, + ) - s = self.Session() - pattern = s.query(Pattern).filter( - Pattern.show_id == int(patternObj['show_id']), - Pattern.pattern == str(patternObj['pattern']), - ).first() - - if pattern is None: - pattern = Pattern(show_id = int(patternObj['show_id']), - pattern = str(patternObj['pattern'])) - s.add(pattern) - s.commit() - return pattern.getId() + if patternId is None: + pattern = Pattern( + show_id=fields["show_id"], + pattern=fields["pattern"], + quality=fields["quality"], + notes=fields["notes"], + ) + session.add(pattern) + session.flush() else: - return 0 + pattern = session.query(Pattern).filter(Pattern.id == int(patternId)).first() + if pattern is None: + raise click.ClickException( + f"PatternController.savePatternSchema(): Pattern #{patternId} not found" + ) + pattern.show_id = fields["show_id"] + pattern.pattern = fields["pattern"] + pattern.quality = fields["quality"] + pattern.notes = fields["notes"] + self._replace_pattern_schema( + session, + pattern, + normalized_tags, + normalized_tracks, + ) + + session.commit() + self._clear_regex_cache() + return pattern.getId() + + except click.ClickException: + raise except Exception as ex: - raise click.ClickException(f"PatternController.addPattern(): {repr(ex)}") + raise click.ClickException( + f"PatternController.savePatternSchema(): {repr(ex)}" + ) finally: - s.close() + if session is not None: + session.close() + def addPattern(self, patternObj, trackDescriptors=None, mediaTags=None): + return self.savePatternSchema( + patternObj, + trackDescriptors=trackDescriptors, + mediaTags=mediaTags, + ) def updatePattern(self, patternId, patternObj): + fields = self._coerce_pattern_fields(patternObj) + session = None + try: - s = self.Session() - pattern = s.query(Pattern).filter(Pattern.id == int(patternId)).first() + session = self.Session() + pattern = session.query(Pattern).filter(Pattern.id == int(patternId)).first() if pattern is not None: + self._ensure_unique_pattern_definition( + session, + fields["show_id"], + fields["pattern"], + exclude_pattern_id=patternId, + ) + self._validate_persisted_pattern(pattern) - pattern.show_id = int(patternObj['show_id']) - pattern.pattern = str(patternObj['pattern']) - pattern.quality = str(patternObj['quality']) - pattern.notes = str(patternObj['notes']) + pattern.show_id = fields["show_id"] + pattern.pattern = fields["pattern"] + pattern.quality = fields["quality"] + pattern.notes = fields["notes"] - s.commit() + session.commit() + self._clear_regex_cache() return True - else: - return False + return False + except click.ClickException: + raise except Exception as ex: raise click.ClickException(f"PatternController.updatePattern(): {repr(ex)}") finally: - s.close() - - + if session is not None: + session.close() def findPattern(self, patternObj): - + session = None + try: - s = self.Session() - pattern = s.query(Pattern).filter( - Pattern.show_id == int(patternObj['show_id']), - Pattern.pattern == str(patternObj['pattern']), - ).first() + session = self.Session() + pattern = ( + session.query(Pattern) + .filter( + Pattern.show_id == int(patternObj["show_id"]), + Pattern.pattern == str(patternObj["pattern"]), + ) + .first() + ) if pattern is not None: return int(pattern.id) - else: - return None + return None except Exception as ex: raise click.ClickException(f"PatternController.findPattern(): {repr(ex)}") finally: - s.close() + if session is not None: + session.close() - - def getPattern(self, patternId : int): + def getPattern(self, patternId: int): if type(patternId) is not int: - raise ValueError(f"PatternController.getPattern(): Argument patternId is required to be of type int") + raise ValueError( + "PatternController.getPattern(): Argument patternId is required to be of type int" + ) + session = None try: - s = self.Session() - return s.query(Pattern).filter(Pattern.id == int(patternId)).first() + session = self.Session() + return session.query(Pattern).filter(Pattern.id == int(patternId)).first() except Exception as ex: raise click.ClickException(f"PatternController.getPattern(): {repr(ex)}") finally: - s.close() - + if session is not None: + session.close() def deletePattern(self, patternId): + session = None try: - s = self.Session() - pattern = s.query(Pattern).filter(Pattern.id == int(patternId)).first() + session = self.Session() + pattern = session.query(Pattern).filter(Pattern.id == int(patternId)).first() if pattern is not None: - - #DAFUQ: https://stackoverflow.com/a/19245058 - # q.delete() - s.delete(pattern) - - s.commit() + session.delete(pattern) + session.commit() + self._clear_regex_cache() return True return False except Exception as ex: raise click.ClickException(f"PatternController.deletePattern(): {repr(ex)}") finally: - s.close() + if session is not None: + session.close() - - def matchFilename(self, filename : str) -> dict: - """Returns dict {'match': , 'pattern': } or empty dict of no pattern was found""" + def matchFilename(self, filename: str) -> dict: + """Return {'match': regex match, 'pattern': Pattern} or {} when unmatched.""" + session = None try: - s = self.Session() - q = s.query(Pattern) + session = self.Session() + matches = [] + query = session.query(Pattern).order_by(Pattern.show_id, Pattern.id) - matchResult = {} - - for pattern in q.all(): - patternMatch = re.search(str(pattern.pattern), str(filename)) - if patternMatch is not None: - matchResult['match'] = patternMatch - matchResult['pattern'] = pattern + for pattern in query.all(): + compiled = self._compile_pattern_expression( + pattern.getId(), + pattern.getPattern(), + ) + patternMatch = compiled.search(str(filename)) + if patternMatch is None: + continue - return matchResult - + self._validate_persisted_pattern(pattern) + matches.append({"match": patternMatch, "pattern": pattern}) + + if not matches: + return {} + + if len(matches) > 1: + duplicateDescriptions = ", ".join( + [ + f"show #{match['pattern'].getShowId()} pattern #{match['pattern'].getId()} {match['pattern'].getPattern()!r}" + for match in matches + ] + ) + raise DuplicatePatternMatchError( + f"Filename {filename!r} matched more than one pattern: {duplicateDescriptions}" + ) + + return matches[0] + + except click.ClickException: + raise except Exception as ex: raise click.ClickException(f"PatternController.matchFilename(): {repr(ex)}") finally: - s.close() - -# def getMediaDescriptor(self, context, patternId): -# -# try: -# s = self.Session() -# q = s.query(Pattern).filter(Pattern.id == int(patternId)) -# -# if q.count(): -# return q.first().getMediaDescriptor(context) -# else: -# return None -# -# except Exception as ex: -# raise click.ClickException(f"PatternController.getMediaDescriptor(): {repr(ex)}") -# finally: -# s.close() + if session is not None: + session.close() diff --git a/src/ffx/pattern_details_screen.py b/src/ffx/pattern_details_screen.py index da64b7b..bdb1257 100644 --- a/src/ffx/pattern_details_screen.py +++ b/src/ffx/pattern_details_screen.py @@ -6,7 +6,6 @@ from textual.widgets import Header, Footer, Static, Button, Input, DataTable, Te from textual.containers import Grid from ffx.model.pattern import Pattern -from ffx.model.track import Track from .pattern_controller import PatternController from .show_controller import ShowController @@ -132,6 +131,8 @@ class PatternDetailsScreen(Screen): self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else None self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None + self.__draftTracks : List[TrackDescriptor] = [] + self.__draftTags : dict[str, str] = {} #TODO: per controller @@ -158,42 +159,60 @@ class PatternDetailsScreen(Screen): self.tracksTable.clear() + tracks = self.getCurrentTrackDescriptors() + + typeCounter = {} + + td: TrackDescriptor + for td in tracks: + + if (trackType := td.getType()) != TrackType.ATTACHMENT: + + if not trackType in typeCounter.keys(): + typeCounter[trackType] = 0 + + dispoSet = td.getDispositionSet() + + trackLanguage = td.getLanguage() + audioLayout = td.getAudioLayout() + + row = (td.getIndex(), + trackType.label(), + typeCounter[trackType], + td.getCodec().label(), + audioLayout.label() if trackType == TrackType.AUDIO + and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ', + trackLanguage.label() if trackLanguage != IsoLanguage.UNDEFINED else ' ', + td.getTitle(), + 'Yes' if TrackDisposition.DEFAULT in dispoSet else 'No', + 'Yes' if TrackDisposition.FORCED in dispoSet else 'No', + td.getSourceIndex()) + + self.tracksTable.add_row(*map(str, row)) + + typeCounter[trackType] += 1 + + + def getCurrentTrackDescriptors(self) -> List[TrackDescriptor]: if self.__pattern is not None: + return self.__tc.findSiblingDescriptors(self.__pattern.getId()) + return list(self.__draftTracks) - tracks = self.__tc.findTracks(self.__pattern.getId()) - typeCounter = {} + def normalizeDraftTracks(self): - tr: Track - for tr in tracks: + typeCounter = {} - td : TrackDescriptor = tr.getDescriptor(self.context) + for index, trackDescriptor in enumerate(self.__draftTracks): + trackDescriptor.setIndex(index) - if (trackType := td.getType()) != TrackType.ATTACHMENT: + trackType = trackDescriptor.getType() + subIndex = typeCounter.get(trackType, 0) + trackDescriptor.setSubIndex(subIndex) + typeCounter[trackType] = subIndex + 1 - if not trackType in typeCounter.keys(): - typeCounter[trackType] = 0 - - dispoSet = td.getDispositionSet() - - trackLanguage = td.getLanguage() - audioLayout = td.getAudioLayout() - - row = (td.getIndex(), - trackType.label(), - typeCounter[trackType], - td.getCodec().label(), - audioLayout.label() if trackType == TrackType.AUDIO - and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ', - trackLanguage.label() if trackLanguage != IsoLanguage.UNDEFINED else ' ', - td.getTitle(), - 'Yes' if TrackDisposition.DEFAULT in dispoSet else 'No', - 'Yes' if TrackDisposition.FORCED in dispoSet else 'No', - td.getSourceIndex()) - - self.tracksTable.add_row(*map(str, row)) - - typeCounter[trackType] += 1 + if trackDescriptor.getSourceIndex() < 0: + trackDescriptor.setSourceIndex(index) def swapTracks(self, trackIndex1: int, trackIndex2: int): @@ -201,6 +220,20 @@ class PatternDetailsScreen(Screen): ti1 = int(trackIndex1) ti2 = int(trackIndex2) + if self.__pattern is None: + numSiblings = len(self.__draftTracks) + + if ti1 < 0 or ti1 >= numSiblings: + raise ValueError(f"PatternDetailsScreen.swapTracks(): trackIndex1 ({ti1}) is out of range ({numSiblings})") + + if ti2 < 0 or ti2 >= numSiblings: + raise ValueError(f"PatternDetailsScreen.swapTracks(): trackIndex2 ({ti2}) is out of range ({numSiblings})") + + self.__draftTracks[ti1], self.__draftTracks[ti2] = self.__draftTracks[ti2], self.__draftTracks[ti1] + self.normalizeDraftTracks() + self.updateTracks() + return + siblingDescriptors: List[TrackDescriptor] = self.__tc.findSiblingDescriptors(self.__pattern.getId()) numSiblings = len(siblingDescriptors) @@ -236,21 +269,22 @@ class PatternDetailsScreen(Screen): self.tagsTable.clear() - if self.__pattern is not None: + tags = ( + self.__tac.findAllMediaTags(self.__pattern.getId()) + if self.__pattern is not None + else self.__draftTags + ) - tags = self.__tac.findAllMediaTags(self.__pattern.getId()) + for tagKey, tagValue in tags.items(): - for tagKey, tagValue in tags.items(): + textColor = None + if tagKey in self.__ignoreGlobalKeys: + textColor = 'blue' + if tagKey in self.__removeGlobalKeys: + textColor = 'red' - textColor = None - if tagKey in self.__ignoreGlobalKeys: - textColor = 'blue' - if tagKey in self.__removeGlobalKeys: - textColor = 'red' - - # if tagKey not in self.__ignoreTrackKeys: - row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor)) - self.tagsTable.add_row(*map(str, row)) + row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor)) + self.tagsTable.add_row(*map(str, row)) def on_mount(self): @@ -340,16 +374,9 @@ class PatternDetailsScreen(Screen): # 9 yield Static("Media Tags") - - - if self.__pattern is not None: - yield Button("Add", id="button_add_tag") - yield Button("Edit", id="button_edit_tag") - yield Button("Delete", id="button_delete_tag") - else: - yield Static(" ") - yield Static(" ") - yield Static(" ") + yield Button("Add", id="button_add_tag") + yield Button("Edit", id="button_edit_tag") + yield Button("Delete", id="button_delete_tag") yield Static(" ") yield Static(" ") @@ -363,16 +390,9 @@ class PatternDetailsScreen(Screen): # 12 yield Static("Streams") - - - if self.__pattern is not None: - yield Button("Add", id="button_add_track") - yield Button("Edit", id="button_edit_track") - yield Button("Delete", id="button_delete_track") - else: - yield Static(" ") - yield Static(" ") - yield Static(" ") + yield Button("Add", id="button_add_track") + yield Button("Edit", id="button_edit_track") + yield Button("Delete", id="button_delete_track") yield Static(" ") yield Button("Up", id="button_track_up") @@ -413,13 +433,8 @@ class PatternDetailsScreen(Screen): def getSelectedTrackDescriptor(self): - if not self.__pattern: - return None - try: - # Fetch the currently selected row when 'Enter' is pressed - #selected_row_index = self.table.cursor_row row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate) if row_key is not None: @@ -428,10 +443,12 @@ class PatternDetailsScreen(Screen): trackIndex = int(selected_track_data[0]) trackSubIndex = int(selected_track_data[2]) - return self.__tc.getTrack(self.__pattern.getId(), trackIndex).getDescriptor(self.context, subIndex=trackSubIndex) + for trackDescriptor in self.getCurrentTrackDescriptors(): + if (trackDescriptor.getIndex() == trackIndex + and trackDescriptor.getSubIndex() == trackSubIndex): + return trackDescriptor - else: - return None + return None except CellDoesNotExist: return None @@ -482,7 +499,11 @@ class PatternDetailsScreen(Screen): self.app.pop_screen() else: - patternId = self.__pc.addPattern(patternDescriptor) + patternId = self.__pc.savePatternSchema( + patternDescriptor, + trackDescriptors=self.__draftTracks, + mediaTags=self.__draftTags, + ) if patternId: self.dismiss(patternDescriptor) else: @@ -494,33 +515,52 @@ class PatternDetailsScreen(Screen): self.app.pop_screen() - # Save pattern when just created before adding streams - if self.__pattern is not None: + numTracks = len(self.getCurrentTrackDescriptors()) - numTracks = len(self.tracksTable.rows) + if event.button.id == "button_add_track": + self.app.push_screen( + TrackDetailsScreen( + patternId=self.__pattern.getId() if self.__pattern is not None else None, + patternLabel=self.getPatternFromInput(), + siblingTrackDescriptors=self.getCurrentTrackDescriptors(), + index=numTracks, + ), + self.handle_add_track, + ) - if event.button.id == "button_add_track": - self.app.push_screen(TrackDetailsScreen(patternId = self.__pattern.getId(), index = numTracks), self.handle_add_track) - - selectedTrack = self.getSelectedTrackDescriptor() - if selectedTrack is not None: - if event.button.id == "button_edit_track": - self.app.push_screen(TrackDetailsScreen(trackDescriptor = selectedTrack), self.handle_edit_track) - if event.button.id == "button_delete_track": - self.app.push_screen(TrackDeleteScreen(trackDescriptor = selectedTrack), self.handle_delete_track) + selectedTrack = self.getSelectedTrackDescriptor() + if selectedTrack is not None: + if event.button.id == "button_edit_track": + self.app.push_screen( + TrackDetailsScreen( + trackDescriptor=selectedTrack, + patternId=self.__pattern.getId() if self.__pattern is not None else None, + patternLabel=self.getPatternFromInput(), + siblingTrackDescriptors=self.getCurrentTrackDescriptors(), + ), + self.handle_edit_track, + ) + if event.button.id == "button_delete_track": + self.app.push_screen( + TrackDeleteScreen(trackDescriptor = selectedTrack), + self.handle_delete_track, + ) if event.button.id == "button_add_tag": - if self.__pattern is not None: - self.app.push_screen(TagDetailsScreen(), self.handle_update_tag) + self.app.push_screen(TagDetailsScreen(), self.handle_update_tag) if event.button.id == "button_edit_tag": - tagKey, tagValue = self.getSelectedTag() - self.app.push_screen(TagDetailsScreen(key=tagKey, value=tagValue), self.handle_update_tag) + selectedTag = self.getSelectedTag() + if selectedTag is not None: + tagKey, tagValue = selectedTag + self.app.push_screen(TagDetailsScreen(key=tagKey, value=tagValue), self.handle_update_tag) if event.button.id == "button_delete_tag": - tagKey, tagValue = self.getSelectedTag() - self.app.push_screen(TagDeleteScreen(key=tagKey, value=tagValue), self.handle_delete_tag) + selectedTag = self.getSelectedTag() + if selectedTag is not None: + tagKey, tagValue = selectedTag + self.app.push_screen(TagDeleteScreen(key=tagKey, value=tagValue), self.handle_delete_tag) if event.button.id == "pattern_button": @@ -537,83 +577,106 @@ class PatternDetailsScreen(Screen): if event.button.id == "button_track_up": selectedTrackDescriptor = self.getSelectedTrackDescriptor() - selectedTrackIndex = selectedTrackDescriptor.getIndex() + if selectedTrackDescriptor is not None: + selectedTrackIndex = selectedTrackDescriptor.getIndex() - if selectedTrackIndex > 0 and selectedTrackIndex < self.tracksTable.row_count: - correspondingTrackIndex = selectedTrackIndex - 1 - self.swapTracks(selectedTrackIndex, correspondingTrackIndex) + if selectedTrackIndex > 0 and selectedTrackIndex < self.tracksTable.row_count: + correspondingTrackIndex = selectedTrackIndex - 1 + self.swapTracks(selectedTrackIndex, correspondingTrackIndex) if event.button.id == "button_track_down": selectedTrackDescriptor = self.getSelectedTrackDescriptor() - selectedTrackIndex = selectedTrackDescriptor.getIndex() + if selectedTrackDescriptor is not None: + selectedTrackIndex = selectedTrackDescriptor.getIndex() - if selectedTrackIndex >= 0 and selectedTrackIndex < (self.tracksTable.row_count - 1): - correspondingTrackIndex = selectedTrackIndex + 1 - self.swapTracks(selectedTrackIndex, correspondingTrackIndex) + if selectedTrackIndex >= 0 and selectedTrackIndex < (self.tracksTable.row_count - 1): + correspondingTrackIndex = selectedTrackIndex + 1 + self.swapTracks(selectedTrackIndex, correspondingTrackIndex) def handle_add_track(self, trackDescriptor : TrackDescriptor): + if trackDescriptor is None: + return - dispoSet = trackDescriptor.getDispositionSet() - trackType = trackDescriptor.getType() - index = trackDescriptor.getIndex() - subIndex = trackDescriptor.getSubIndex() - codec = trackDescriptor.getCodec() - language = trackDescriptor.getLanguage() - title = trackDescriptor.getTitle() + if self.__pattern is not None: + self.__tc.addTrack(trackDescriptor, patternId=self.__pattern.getId()) + else: + self.__draftTracks.append(trackDescriptor) + self.normalizeDraftTracks() - row = (index, - trackType.label(), - subIndex, - codec.label(), - language.label(), - title, - 'Yes' if TrackDisposition.DEFAULT in dispoSet else 'No', - 'Yes' if TrackDisposition.FORCED in dispoSet else 'No') - - self.tracksTable.add_row(*map(str, row)) + self.updateTracks() def handle_edit_track(self, trackDescriptor : TrackDescriptor): + if trackDescriptor is None: + return - try: + if self.__pattern is not None: + if not self.__tc.updateTrack(trackDescriptor.getId(), trackDescriptor): + raise click.ClickException("PatternDetailsScreen.handle_edit_track(): track update failed") + else: + selectedTrack = self.getSelectedTrackDescriptor() + for index, currentTrack in enumerate(self.__draftTracks): + if (selectedTrack is not None + and currentTrack.getIndex() == selectedTrack.getIndex() + and currentTrack.getSubIndex() == selectedTrack.getSubIndex()): + self.__draftTracks[index] = trackDescriptor + break + self.normalizeDraftTracks() - row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate) - - self.tracksTable.update_cell(row_key, self.column_key_track_audio_layout, - trackDescriptor.getAudioLayout().label() - if trackDescriptor.getType() == TrackType.AUDIO else ' ') - - self.tracksTable.update_cell(row_key, self.column_key_track_language, trackDescriptor.getLanguage().label()) - self.tracksTable.update_cell(row_key, self.column_key_track_title, trackDescriptor.getTitle()) - self.tracksTable.update_cell(row_key, self.column_key_track_default, - 'Yes' if TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet() else 'No') - self.tracksTable.update_cell(row_key, self.column_key_track_forced, - 'Yes' if TrackDisposition.FORCED in trackDescriptor.getDispositionSet() else 'No') - - except CellDoesNotExist: - pass + self.updateTracks() def handle_delete_track(self, trackDescriptor : TrackDescriptor): + if trackDescriptor is None: + return + + if self.__pattern is not None: + track = self.__tc.getTrack(trackDescriptor.getPatternId(), trackDescriptor.getIndex()) + + if track is None: + raise click.ClickException( + f"Track is none: patternId={trackDescriptor.getPatternId()} type={trackDescriptor.getType()} subIndex={trackDescriptor.getSubIndex()}" + ) + + self.__tc.deleteTrack(track.getId()) + else: + self.__draftTracks = [ + currentTrack + for currentTrack in self.__draftTracks + if not ( + currentTrack.getIndex() == trackDescriptor.getIndex() + and currentTrack.getSubIndex() == trackDescriptor.getSubIndex() + ) + ] + self.normalizeDraftTracks() + self.updateTracks() def handle_update_tag(self, tag): + if tag is None: + return if self.__pattern is None: - raise click.ClickException(f"PatternDetailsScreen.handle_update_tag: pattern not set") + self.__draftTags[str(tag[0])] = str(tag[1]) + else: + if self.__tac.updateMediaTag(self.__pattern.getId(), tag[0], tag[1]) is None: + raise click.ClickException("PatternDetailsScreen.handle_update_tag(): tag update failed") - if self.__tac.updateMediaTag(self.__pattern.getId(), tag[0], tag[1]) is not None: - self.updateTags() + self.updateTags() def handle_delete_tag(self, tag): + if tag is None: + return if self.__pattern is None: - raise click.ClickException(f"PatternDetailsScreen.handle_delete_tag: pattern not set") + self.__draftTags.pop(str(tag[0]), None) + self.updateTags() + return if self.__tac.deleteMediaTagByKey(self.__pattern.getId(), tag[0]): self.updateTags() diff --git a/src/ffx/track_controller.py b/src/ffx/track_controller.py index ece0a24..3288dd8 100644 --- a/src/ffx/track_controller.py +++ b/src/ffx/track_controller.py @@ -244,9 +244,15 @@ class TrackController(): patternId = int(track.pattern_id) q_siblings = s.query(Track).filter(Track.pattern_id == patternId).order_by(Track.index) + siblingTracks = q_siblings.all() + + if len(siblingTracks) <= 1: + raise click.ClickException( + f"Cannot delete the last track from pattern #{patternId}. Patterns must define at least one track." + ) index = 0 - for track in q_siblings.all(): + for track in siblingTracks: if track.id == int(trackId): s.delete(track) diff --git a/src/ffx/track_delete_screen.py b/src/ffx/track_delete_screen.py index c944525..4743538 100644 --- a/src/ffx/track_delete_screen.py +++ b/src/ffx/track_delete_screen.py @@ -6,8 +6,6 @@ from textual.containers import Grid from ffx.track_descriptor import TrackDescriptor -from .track_controller import TrackController - # Screen[dict[int, str, int]] class TrackDeleteScreen(Screen): @@ -52,14 +50,9 @@ class TrackDeleteScreen(Screen): def __init__(self, trackDescriptor : TrackDescriptor): super().__init__() - self.context = self.app.getContext() - self.Session = self.context['database']['session'] # convenience - if type(trackDescriptor) is not TrackDescriptor: raise click.ClickException('TrackDeleteScreen.init(): trackDescriptor is required to be of type TrackDescriptor') - self.__tc = TrackController(context = self.context) - self.__trackDescriptor = trackDescriptor @@ -116,21 +109,7 @@ class TrackDeleteScreen(Screen): def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "delete_button": - - track = self.__tc.getTrack(self.__trackDescriptor.getPatternId(), self.__trackDescriptor.getIndex()) - - if track is None: - raise click.ClickException(f"Track is none: patternId={self.__trackDescriptor.getPatternId()} type={self.__trackDescriptor.getType()} subIndex={self.__trackDescriptor.getSubIndex()}") - - if track is not None: - - if self.__tc.deleteTrack(track.getId()): - self.dismiss(self.__trackDescriptor) - - else: - #TODO: Meldung - self.app.pop_screen() + self.dismiss(self.__trackDescriptor) if event.button.id == "cancel_button": self.app.pop_screen() - diff --git a/src/ffx/track_details_screen.py b/src/ffx/track_details_screen.py index dfa226c..f0d1c15 100644 --- a/src/ffx/track_details_screen.py +++ b/src/ffx/track_details_screen.py @@ -3,31 +3,20 @@ import click from textual.screen import Screen from textual.widgets import Header, Footer, Static, Button, SelectionList, Select, DataTable, Input from textual.containers import Grid - -from ffx.model.pattern import Pattern - -from .track_controller import TrackController -from .pattern_controller import PatternController -from .tag_controller import TagController - -from .track_type import TrackType -from .track_codec import TrackCodec - -from .iso_language import IsoLanguage -from .track_disposition import TrackDisposition -from .audio_layout import AudioLayout - -from .track_descriptor import TrackDescriptor - -from .tag_details_screen import TagDetailsScreen -from .tag_delete_screen import TagDeleteScreen - from textual.widgets._data_table import CellDoesNotExist +from .audio_layout import AudioLayout +from .iso_language import IsoLanguage +from .tag_delete_screen import TagDeleteScreen +from .tag_details_screen import TagDetailsScreen +from .track_codec import TrackCodec +from .track_descriptor import TrackDescriptor +from .track_disposition import TrackDisposition +from .track_type import TrackType + from ffx.helper import formatRichColor, removeRichColor -# Screen[dict[int, str, int]] class TrackDetailsScreen(Screen): CSS = """ @@ -79,7 +68,7 @@ class TrackDetailsScreen(Screen): .three { column-span: 3; } - + .four { column-span: 4; } @@ -97,257 +86,288 @@ class TrackDetailsScreen(Screen): } """ - def __init__(self, trackDescriptor : TrackDescriptor = None, patternId = None, trackType : TrackType = None, index = None, subIndex = None): + def __init__( + self, + trackDescriptor: TrackDescriptor = None, + patternId=None, + patternLabel: str = "", + siblingTrackDescriptors=None, + trackType: TrackType = None, + index=None, + subIndex=None, + ): super().__init__() self.context = self.app.getContext() - self.Session = self.context['database']['session'] # convenience - self.__configurationData = self.context['config'].getData() + self.__configurationData = self.context["config"].getData() - metadataConfiguration = self.__configurationData['metadata'] if 'metadata' in self.__configurationData.keys() else {} + metadataConfiguration = ( + self.__configurationData["metadata"] + if "metadata" in self.__configurationData.keys() + else {} + ) - self.__signatureTags = metadataConfiguration['signature'] if 'signature' in metadataConfiguration.keys() else {} - self.__removeGlobalKeys = metadataConfiguration['remove'] if 'remove' in metadataConfiguration.keys() else [] - self.__ignoreGlobalKeys = metadataConfiguration['ignore'] if 'ignore' in metadataConfiguration.keys() else [] - self.__removeTrackKeys = (metadataConfiguration['streams']['remove'] - if 'streams' in metadataConfiguration.keys() - and 'remove' in metadataConfiguration['streams'].keys() else []) - self.__ignoreTrackKeys = (metadataConfiguration['streams']['ignore'] - if 'streams' in metadataConfiguration.keys() - and 'ignore' in metadataConfiguration['streams'].keys() else []) - - - self.__tc = TrackController(context = self.context) - self.__pc = PatternController(context = self.context) - self.__tac = TagController(context = self.context) + self.__removeTrackKeys = ( + metadataConfiguration["streams"]["remove"] + if "streams" in metadataConfiguration.keys() + and "remove" in metadataConfiguration["streams"].keys() + else [] + ) + self.__ignoreTrackKeys = ( + metadataConfiguration["streams"]["ignore"] + if "streams" in metadataConfiguration.keys() + and "ignore" in metadataConfiguration["streams"].keys() + else [] + ) self.__isNew = trackDescriptor is None + self.__trackDescriptor = trackDescriptor + self.__patternId = ( + int(patternId) + if patternId is not None + else ( + int(trackDescriptor.getPatternId()) + if trackDescriptor is not None and trackDescriptor.getPatternId() != -1 + else -1 + ) + ) + self.__patternLabel = str(patternLabel) + self.__siblingTrackDescriptors = list(siblingTrackDescriptors or []) + if self.__isNew: self.__trackType = trackType self.__trackCodec = TrackCodec.UNKNOWN self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED self.__index = index self.__subIndex = subIndex - self.__trackDescriptor : TrackDescriptor = None - self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else {} + self.__draftTrackTags = {} else: self.__trackType = trackDescriptor.getType() self.__trackCodec = trackDescriptor.getCodec() self.__audioLayout = trackDescriptor.getAudioLayout() self.__index = trackDescriptor.getIndex() self.__subIndex = trackDescriptor.getSubIndex() - self.__trackDescriptor : TrackDescriptor = trackDescriptor - self.__pattern : Pattern = self.__pc.getPattern(self.__trackDescriptor.getPatternId()) - + self.__draftTrackTags = { + key: value + for key, value in trackDescriptor.getTags().items() + if key not in ("language", "title") + } + def _descriptor_refs_same_track(self, descriptor: TrackDescriptor) -> bool: + if self.__trackDescriptor is None: + return False + if descriptor.getId() != -1 and self.__trackDescriptor.getId() != -1: + return descriptor.getId() == self.__trackDescriptor.getId() + return ( + descriptor.getPatternId() == self.__trackDescriptor.getPatternId() + and descriptor.getIndex() == self.__trackDescriptor.getIndex() + and descriptor.getSubIndex() == self.__trackDescriptor.getSubIndex() + ) def updateTags(self): self.trackTagsTable.clear() - trackId = self.__trackDescriptor.getId() - - if trackId != -1: - - trackTags = self.__tac.findAllTrackTags(trackId) - - for k,v in trackTags.items(): - - if k != 'language' and k != 'title': - - textColor = None - if k in self.__ignoreTrackKeys: - textColor = 'blue' - if k in self.__removeTrackKeys: - textColor = 'red' - - row = (formatRichColor(k, textColor), formatRichColor(v, textColor)) - self.trackTagsTable.add_row(*map(str, row)) + for key, value in self.__draftTrackTags.items(): + textColor = None + if key in self.__ignoreTrackKeys: + textColor = "blue" + if key in self.__removeTrackKeys: + textColor = "red" + row = (formatRichColor(key, textColor), formatRichColor(value, textColor)) + self.trackTagsTable.add_row(*map(str, row)) def on_mount(self): - self.query_one("#index_label", Static).update(str(self.__index) if self.__index is not None else '-') - self.query_one("#subindex_label", Static).update(str(self.__subIndex)if self.__subIndex is not None else '-') - - if self.__pattern is not None: - self.query_one("#pattern_label", Static).update(self.__pattern.getPattern()) + self.query_one("#index_label", Static).update( + str(self.__index) if self.__index is not None else "-" + ) + self.query_one("#subindex_label", Static).update( + str(self.__subIndex) if self.__subIndex is not None else "-" + ) + self.query_one("#pattern_label", Static).update(self.__patternLabel) if self.__trackType is not None: self.query_one("#type_select", Select).value = self.__trackType.label() - if self.__trackType == TrackType.AUDIO: - self.query_one("#audio_layout_select", Select).value = self.__audioLayout.label() - for d in TrackDisposition: + self.query_one("#audio_layout_select", Select).value = self.__audioLayout.label() - dispositionIsSet = (self.__trackDescriptor is not None - and d in self.__trackDescriptor.getDispositionSet()) + for disposition in TrackDisposition: - dispositionOption = (d.label(), d.index(), dispositionIsSet) - self.query_one("#dispositions_selection_list", SelectionList).add_option(dispositionOption) + dispositionIsSet = ( + self.__trackDescriptor is not None + and disposition in self.__trackDescriptor.getDispositionSet() + ) + + dispositionOption = ( + disposition.label(), + disposition.index(), + dispositionIsSet, + ) + self.query_one("#dispositions_selection_list", SelectionList).add_option( + dispositionOption + ) if self.__trackDescriptor is not None: - - self.query_one("#language_select", Select).value = self.__trackDescriptor.getLanguage().label() + self.query_one("#language_select", Select).value = ( + self.__trackDescriptor.getLanguage().label() + ) self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle() self.updateTags() - def compose(self): self.trackTagsTable = DataTable(classes="five") - # Define the columns with headers self.column_key_track_tag_key = self.trackTagsTable.add_column("Key", width=50) self.column_key_track_tag_value = self.trackTagsTable.add_column("Value", width=100) - self.trackTagsTable.cursor_type = 'row' + self.trackTagsTable.cursor_type = "row" - - languages = [l.label() for l in IsoLanguage] + languages = [language.label() for language in IsoLanguage] yield Header() with Grid(): - # 1 - yield Static(f"New stream" if self.__isNew else f"Edit stream", id="toplabel", classes="five") + yield Static( + "New stream" if self.__isNew else "Edit stream", + id="toplabel", + classes="five", + ) - # 2 yield Static("for pattern") yield Static("", id="pattern_label", classes="four", markup=False) - # 3 yield Static(" ", classes="five") - # 4 yield Static("Index / Subindex") yield Static("", id="index_label", classes="two") yield Static("", id="subindex_label", classes="two") - # 5 yield Static(" ", classes="five") - # 6 yield Static("Type") - yield Select.from_values([t.label() for t in TrackType], classes="four", id="type_select") + yield Select.from_values( + [trackType.label() for trackType in TrackType], + classes="four", + id="type_select", + ) - # 7 - if self.__trackType == TrackType.AUDIO: - yield Static("Audio Layout") - yield Select.from_values([t.label() for t in AudioLayout], classes="four", id="audio_layout_select") - else: - yield Static(" ", classes="five") + yield Static("Audio Layout") + yield Select.from_values( + [layout.label() for layout in AudioLayout], + classes="four", + id="audio_layout_select", + ) - # 8 yield Static(" ", classes="five") - # 9 yield Static(" ", classes="five") - # 10 yield Static("Language") yield Select.from_values(languages, classes="four", id="language_select") - # 11 + yield Static(" ", classes="five") - # 12 yield Static("Title") yield Input(id="title_input", classes="four") - # 13 yield Static(" ", classes="five") - # 14 yield Static(" ", classes="five") - # 15 yield Static("Stream tags") yield Static(" ") yield Button("Add", id="button_add_stream_tag") yield Button("Edit", id="button_edit_stream_tag") yield Button("Delete", id="button_delete_stream_tag") - # 16 + yield self.trackTagsTable - # 17 yield Static(" ", classes="five") - # 18 yield Static("Stream dispositions", classes="five") - # 19 yield SelectionList[int]( classes="five", - id = "dispositions_selection_list" + id="dispositions_selection_list", ) - # 20 yield Static(" ", classes="five") - # 21 yield Static(" ", classes="five") - # 22 yield Button("Save", id="save_button") yield Button("Cancel", id="cancel_button") - # 23 yield Static(" ", classes="five") - # 24 yield Static(" ", classes="five", id="messagestatic") - yield Footer(id="footer") - def getTrackDescriptorFromInput(self): kwargs = {} - kwargs[TrackDescriptor.CONTEXT_KEY] = self.context - kwargs[TrackDescriptor.PATTERN_ID_KEY] = int(self.__pattern.getId()) + if self.__trackDescriptor is not None and self.__trackDescriptor.getId() != -1: + kwargs[TrackDescriptor.ID_KEY] = self.__trackDescriptor.getId() - kwargs[TrackDescriptor.INDEX_KEY] = self.__index - kwargs[TrackDescriptor.SUB_INDEX_KEY] = self.__subIndex #! + if self.__patternId != -1: + kwargs[TrackDescriptor.PATTERN_ID_KEY] = int(self.__patternId) - kwargs[TrackDescriptor.TRACK_TYPE_KEY] = TrackType.fromLabel(self.query_one("#type_select", Select).value) + kwargs[TrackDescriptor.INDEX_KEY] = int(self.__index) + kwargs[TrackDescriptor.SOURCE_INDEX_KEY] = ( + int(self.__trackDescriptor.getSourceIndex()) + if self.__trackDescriptor is not None + else int(self.__index) + ) + if self.__subIndex is not None and int(self.__subIndex) >= 0: + kwargs[TrackDescriptor.SUB_INDEX_KEY] = int(self.__subIndex) + selectedTrackType = TrackType.fromLabel( + self.query_one("#type_select", Select).value + ) + kwargs[TrackDescriptor.TRACK_TYPE_KEY] = selectedTrackType kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec - - if self.__trackType == TrackType.AUDIO: - kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(self.query_one("#audio_layout_select", Select).value) + + if selectedTrackType == TrackType.AUDIO: + kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel( + self.query_one("#audio_layout_select", Select).value + ) else: kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_UNDEFINED - trackTags = {} + trackTags = dict(self.__draftTrackTags) + language = self.query_one("#language_select", Select).value if language: - trackTags['language'] = IsoLanguage.find(language).threeLetter() + trackTags["language"] = IsoLanguage.find(language).threeLetter() + title = self.query_one("#title_input", Input).value if title: - trackTags['title'] = title + trackTags["title"] = title - tableTags = {row[0]:row[1] for r in self.trackTagsTable.rows if (row := self.trackTagsTable.get_row(r)) and row[0] != 'language' and row[0] != 'title'} + kwargs[TrackDescriptor.TAGS_KEY] = trackTags - kwargs[TrackDescriptor.TAGS_KEY] = trackTags | tableTags - - dispositionFlags = sum([2**f for f in self.query_one("#dispositions_selection_list", SelectionList).selected]) - kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = TrackDisposition.toSet(dispositionFlags) + dispositionFlags = sum( + [2 ** flag for flag in self.query_one("#dispositions_selection_list", SelectionList).selected] + ) + kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = TrackDisposition.toSet( + dispositionFlags + ) return TrackDescriptor(**kwargs) - - def getSelectedTag(self): try: - - # Fetch the currently selected row when 'Enter' is pressed - #selected_row_index = self.table.cursor_row - row_key, col_key = self.trackTagsTable.coordinate_to_cell_key(self.trackTagsTable.cursor_coordinate) + row_key, _ = self.trackTagsTable.coordinate_to_cell_key( + self.trackTagsTable.cursor_coordinate + ) if row_key is not None: selected_tag_data = self.trackTagsTable.get_row(row_key) @@ -357,101 +377,92 @@ class TrackDetailsScreen(Screen): return tagKey, tagValue - else: - return None + return None except CellDoesNotExist: return None - - - # Event handler for button press def on_button_pressed(self, event: Button.Pressed) -> None: - # Check if the button pressed is the one we are interested in if event.button.id == "save_button": - - # Check for multiple default/forced disposition flags - - if self.__trackType == TrackType.VIDEO: - trackList = self.__tc.findVideoTracks(self.__pattern.getId()) - if self.__trackType == TrackType.AUDIO: - trackList = self.__tc.findAudioTracks(self.__pattern.getId()) - elif self.__trackType == TrackType.SUBTITLE: - trackList = self.__tc.findSubtitleTracks(self.__pattern.getId()) - else: - trackList = [] - - siblingTrackList = [t for t in trackList if t.getType() == self.__trackType and t.getIndex() != self.__index] - - numDefaultTracks = len([t for t in siblingTrackList if TrackDisposition.DEFAULT in t.getDispositionSet()]) - numForcedTracks = len([t for t in siblingTrackList if TrackDisposition.FORCED in t.getDispositionSet()]) - - self.__subIndex = len(trackList) trackDescriptor = self.getTrackDescriptorFromInput() - if ((TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet() and numDefaultTracks) - or (TrackDisposition.FORCED in trackDescriptor.getDispositionSet() and numForcedTracks)): + siblingTrackList = [ + descriptor + for descriptor in self.__siblingTrackDescriptors + if not self._descriptor_refs_same_track(descriptor) + ] + siblingTrackList = [ + descriptor + for descriptor in siblingTrackList + if descriptor.getType() == trackDescriptor.getType() + ] - self.query_one("#messagestatic", Static).update("Cannot add another stream with disposition flag 'debug' or 'forced' set") + numDefaultTracks = len( + [ + descriptor + for descriptor in siblingTrackList + if TrackDisposition.DEFAULT in descriptor.getDispositionSet() + ] + ) + numForcedTracks = len( + [ + descriptor + for descriptor in siblingTrackList + if TrackDisposition.FORCED in descriptor.getDispositionSet() + ] + ) + if self.__isNew: + trackDescriptor.setSubIndex(len(siblingTrackList)) + elif self.__subIndex is not None and int(self.__subIndex) >= 0: + trackDescriptor.setSubIndex(int(self.__subIndex)) + + if ( + TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet() + and numDefaultTracks + ) or ( + TrackDisposition.FORCED in trackDescriptor.getDispositionSet() + and numForcedTracks + ): + + self.query_one("#messagestatic", Static).update( + "Cannot add another stream with disposition flag 'default' or 'forced' set" + ) else: - self.query_one("#messagestatic", Static).update(" ") - - if self.__isNew: - - # Track per Screen hinzufügen - self.__tc.addTrack(trackDescriptor) - self.dismiss(trackDescriptor) - - else: - - track = self.__tc.getTrack(self.__pattern.getId(), self.__index) - - # Track per details screen updaten - if self.__tc.updateTrack(track.getId(), trackDescriptor): - self.dismiss(trackDescriptor) - - else: - self.app.pop_screen() + self.dismiss(trackDescriptor) if event.button.id == "cancel_button": self.app.pop_screen() - if event.button.id == "button_add_stream_tag": - if not self.__isNew: - self.app.push_screen(TagDetailsScreen(), self.handle_update_tag) + self.app.push_screen(TagDetailsScreen(), self.handle_update_tag) if event.button.id == "button_edit_stream_tag": - tagKey, tagValue = self.getSelectedTag() - self.app.push_screen(TagDetailsScreen(key=tagKey, value=tagValue), self.handle_update_tag) + selectedTag = self.getSelectedTag() + if selectedTag is not None: + self.app.push_screen( + TagDetailsScreen(key=selectedTag[0], value=selectedTag[1]), + self.handle_update_tag, + ) if event.button.id == "button_delete_stream_tag": - tagKey, tagValue = self.getSelectedTag() - self.app.push_screen(TagDeleteScreen(key=tagKey, value=tagValue), self.handle_delete_tag) - + selectedTag = self.getSelectedTag() + if selectedTag is not None: + self.app.push_screen( + TagDeleteScreen(key=selectedTag[0], value=selectedTag[1]), + self.handle_delete_tag, + ) def handle_update_tag(self, tag): - - trackId = self.__trackDescriptor.getId() - - if trackId == -1: - raise click.ClickException(f"TrackDetailsScreen.handle_update_tag: trackId not set (-1) trackDescriptor={self.__trackDescriptor}") - - if self.__tac.updateTrackTag(trackId, tag[0], tag[1]) is not None: - self.updateTags() + if tag is None: + return + self.__draftTrackTags[str(tag[0])] = str(tag[1]) + self.updateTags() def handle_delete_tag(self, trackTag): - - trackId = self.__trackDescriptor.getId() - - if trackId == -1: - raise click.ClickException(f"TrackDetailsScreen.handle_delete_tag: trackId not set (-1) trackDescriptor={self.__trackDescriptor}") - - tag = self.__tac.findTrackTag(trackId, trackTag[0]) - - if tag is not None: - if self.__tac.deleteTrackTag(tag.id): - self.updateTags() + if trackTag is None: + return + self.__draftTrackTags.pop(str(trackTag[0]), None) + self.updateTags() diff --git a/tests/integration/pattern_management/__init__.py b/tests/integration/pattern_management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/integration/pattern_management/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/integration/pattern_management/test_cli_pattern_matching.py b/tests/integration/pattern_management/test_cli_pattern_matching.py new file mode 100644 index 0000000..4e3bf97 --- /dev/null +++ b/tests/integration/pattern_management/test_cli_pattern_matching.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from tests.support.ffx_bundle import ( + PatternTrackSpec, + SourceTrackSpec, + add_show, + build_controller_context, + create_source_fixture, + dispose_controller_context, + expected_output_path, + run_ffx_convert, +) + +from ffx.pattern_controller import PatternController +from ffx.track_type import TrackType + +try: + import pytest +except ImportError: # pragma: no cover - unittest-only environments + pytest = None + +if pytest is not None: + pytestmark = [pytest.mark.integration, pytest.mark.pattern_management] + + +class PatternManagementCliTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.workdir = Path(self.tempdir.name) + self.home_dir = self.workdir / "home" + self.home_dir.mkdir() + self.database_path = self.workdir / "test.db" + + def tearDown(self): + self.tempdir.cleanup() + + def prepare_duplicate_matching_patterns(self): + context = build_controller_context(self.database_path) + try: + add_show(context, show_id=1) + add_show(context, show_id=2) + + controller = PatternController(context) + track_descriptors = [ + PatternTrackSpec(index=0, source_index=0, track_type=TrackType.VIDEO) + ] + + def to_track_descriptor(spec: PatternTrackSpec): + from ffx.track_descriptor import TrackDescriptor + + kwargs = { + TrackDescriptor.INDEX_KEY: spec.index, + TrackDescriptor.SOURCE_INDEX_KEY: spec.source_index, + TrackDescriptor.TRACK_TYPE_KEY: spec.track_type, + TrackDescriptor.TAGS_KEY: dict(spec.tags), + TrackDescriptor.DISPOSITION_SET_KEY: set(spec.dispositions), + } + return TrackDescriptor(**kwargs) + + controller.savePatternSchema( + {"show_id": 1, "pattern": r"^dup_(s[0-9]+e[0-9]+)\.mkv$"}, + [to_track_descriptor(track_descriptors[0])], + ) + controller.savePatternSchema( + {"show_id": 2, "pattern": r"^dup_.*$"}, + [to_track_descriptor(track_descriptors[0])], + ) + finally: + dispose_controller_context(context) + + def test_convert_fails_when_filename_matches_more_than_one_pattern(self): + self.prepare_duplicate_matching_patterns() + source_filename = "dup_s01e01.mkv" + source_path = create_source_fixture( + self.workdir, + source_filename, + [ + SourceTrackSpec(TrackType.VIDEO, identity="video-0"), + SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng"), + ], + ) + + completed = run_ffx_convert( + self.workdir, + self.home_dir, + self.database_path, + "--video-encoder", + "copy", + "--no-tmdb", + "--no-prompt", + "--no-signature", + str(source_path), + ) + + self.assertNotEqual(completed.returncode, 0) + error_output = f"{completed.stdout}\n{completed.stderr}" + self.assertIn("matched more than one pattern", error_output) + self.assertFalse(expected_output_path(self.workdir, source_filename).exists()) + + def test_convert_can_ignore_duplicate_matches_when_no_pattern_is_requested(self): + self.prepare_duplicate_matching_patterns() + source_filename = "dup_s01e01.mkv" + source_path = create_source_fixture( + self.workdir, + source_filename, + [ + SourceTrackSpec(TrackType.VIDEO, identity="video-0"), + SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng"), + ], + ) + + completed = run_ffx_convert( + self.workdir, + self.home_dir, + self.database_path, + "--video-encoder", + "copy", + "--no-pattern", + "--no-tmdb", + "--no-prompt", + "--no-signature", + str(source_path), + ) + + self.assertEqual( + 0, + completed.returncode, + f"STDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}", + ) + self.assertTrue(expected_output_path(self.workdir, source_filename).exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/support/ffx_bundle.py b/tests/support/ffx_bundle.py index 943d33b..1fa5942 100644 --- a/tests/support/ffx_bundle.py +++ b/tests/support/ffx_bundle.py @@ -22,7 +22,6 @@ from ffx.database import databaseContext from ffx.pattern_controller import PatternController from ffx.show_controller import ShowController from ffx.show_descriptor import ShowDescriptor -from ffx.track_controller import TrackController from ffx.track_descriptor import TrackDescriptor from ffx.track_disposition import TrackDisposition from ffx.track_type import TrackType @@ -219,44 +218,41 @@ def create_source_fixture(workdir: Path, filename: str, tracks: list[SourceTrack return output_path -def add_show_and_pattern(context: dict, filename_pattern: str, show_id: int = 1) -> int: +def add_show(context: dict, show_id: int = 1) -> None: show_descriptor = ShowDescriptor( id=show_id, name="Bundle Test Show", year=2000, ) ShowController(context).updateShow(show_descriptor) - pattern_id = PatternController(context).addPattern( - { - "show_id": show_id, - "pattern": filename_pattern, - } - ) - if not pattern_id: - raise AssertionError("Failed to create pattern in test database") - return pattern_id - - -def add_pattern_tracks(context: dict, pattern_id: int, track_specs: list[PatternTrackSpec]) -> None: - track_controller = TrackController(context) - for track in track_specs: - kwargs = { - TrackDescriptor.INDEX_KEY: track.index, - TrackDescriptor.SOURCE_INDEX_KEY: track.source_index, - TrackDescriptor.TRACK_TYPE_KEY: track.track_type, - TrackDescriptor.TAGS_KEY: dict(track.tags), - TrackDescriptor.DISPOSITION_SET_KEY: set(track.dispositions), - } - if track.track_type == TrackType.AUDIO: - kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = track.audio_layout - track_controller.addTrack(TrackDescriptor(**kwargs), pattern_id) def prepare_pattern_database(database_path: Path, filename_pattern: str, track_specs: list[PatternTrackSpec], show_id: int = 1) -> None: context = build_controller_context(database_path) try: - pattern_id = add_show_and_pattern(context, filename_pattern, show_id=show_id) - add_pattern_tracks(context, pattern_id, track_specs) + add_show(context, show_id=show_id) + track_descriptors = [] + for track in track_specs: + kwargs = { + TrackDescriptor.INDEX_KEY: track.index, + TrackDescriptor.SOURCE_INDEX_KEY: track.source_index, + TrackDescriptor.TRACK_TYPE_KEY: track.track_type, + TrackDescriptor.TAGS_KEY: dict(track.tags), + TrackDescriptor.DISPOSITION_SET_KEY: set(track.dispositions), + } + if track.track_type == TrackType.AUDIO: + kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = track.audio_layout + track_descriptors.append(TrackDescriptor(**kwargs)) + + pattern_id = PatternController(context).savePatternSchema( + { + "show_id": show_id, + "pattern": filename_pattern, + }, + trackDescriptors=track_descriptors, + ) + if not pattern_id: + raise AssertionError("Failed to create pattern in test database") finally: dispose_controller_context(context) diff --git a/tests/unit/test_pattern_management.py b/tests/unit/test_pattern_management.py new file mode 100644 index 0000000..eb5ef60 --- /dev/null +++ b/tests/unit/test_pattern_management.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import logging +from pathlib import Path +import sys +import tempfile +import unittest + +import click + + +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.audio_layout import AudioLayout # noqa: E402 +from ffx.database import databaseContext # noqa: E402 +from ffx.file_properties import FileProperties # noqa: E402 +from ffx.model.pattern import Pattern # noqa: E402 +from ffx.pattern_controller import ( # noqa: E402 + DuplicatePatternMatchError, + InvalidPatternSchemaError, + PatternController, +) +from ffx.show_controller import ShowController # noqa: E402 +from ffx.show_descriptor import ShowDescriptor # noqa: E402 +from ffx.track_controller import TrackController # noqa: E402 +from ffx.track_descriptor import TrackDescriptor # noqa: E402 +from ffx.track_disposition import TrackDisposition # noqa: E402 +from ffx.track_type import TrackType # noqa: E402 + + +class StaticConfig: + def __init__(self, data: dict | None = None): + self._data = data or {} + + def getData(self): + return self._data + + +def make_logger(name: str) -> logging.Logger: + logger = logging.getLogger(name) + logger.handlers = [] + logger.setLevel(logging.DEBUG) + logger.propagate = False + logger.addHandler(logging.NullHandler()) + return logger + + +def make_context(database_path: Path) -> dict: + return { + "logger": make_logger(f"ffx-test-pattern-{database_path.stem}"), + "config": StaticConfig(), + "database": databaseContext(str(database_path)), + "use_pattern": True, + } + + +def make_track_descriptor( + index: int = 0, + *, + source_index: int | None = None, + track_type: TrackType = TrackType.VIDEO, + title: str = "", + dispositions: set[TrackDisposition] | None = None, +) -> TrackDescriptor: + kwargs = { + TrackDescriptor.INDEX_KEY: index, + TrackDescriptor.SOURCE_INDEX_KEY: index if source_index is None else source_index, + TrackDescriptor.TRACK_TYPE_KEY: track_type, + TrackDescriptor.TAGS_KEY: {"title": title} if title else {}, + TrackDescriptor.DISPOSITION_SET_KEY: dispositions or set(), + } + if track_type == TrackType.AUDIO: + kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_STEREO + return TrackDescriptor(**kwargs) + + +class PatternManagementTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.database_path = Path(self.tempdir.name) / "pattern-test.db" + self.context = make_context(self.database_path) + self.pattern_controller = PatternController(self.context) + self.track_controller = TrackController(self.context) + self.show_controller = ShowController(self.context) + PatternController._clear_regex_cache() + + def tearDown(self): + self.context["database"]["engine"].dispose() + self.tempdir.cleanup() + PatternController._clear_regex_cache() + + def add_show(self, show_id: int, name: str) -> None: + self.show_controller.updateShow( + ShowDescriptor( + id=show_id, + name=name, + year=2000 + show_id, + ) + ) + + def save_pattern( + self, + show_id: int, + pattern_expression: str, + *, + tracks: list[TrackDescriptor] | None = None, + ) -> int: + self.add_show(show_id, f"Show {show_id}") + return self.pattern_controller.savePatternSchema( + { + "show_id": show_id, + "pattern": pattern_expression, + "quality": 0, + "notes": "", + }, + trackDescriptors=tracks or [make_track_descriptor(0)], + ) + + def insert_trackless_pattern_row(self, show_id: int, pattern_expression: str) -> int: + self.add_show(show_id, f"Show {show_id}") + Session = self.context["database"]["session"] + session = Session() + try: + pattern = Pattern(show_id=show_id, pattern=pattern_expression) + session.add(pattern) + session.commit() + return int(pattern.id) + finally: + session.close() + + def test_match_filename_returns_single_matching_pattern(self): + pattern_id = self.save_pattern(1, r"^single_(s[0-9]+e[0-9]+)\.mkv$") + + match = self.pattern_controller.matchFilename("single_s01e01.mkv") + + self.assertEqual(pattern_id, match["pattern"].getId()) + self.assertEqual("s01e01", match["match"].group(1)) + + 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_.*$") + + with self.assertRaises(DuplicatePatternMatchError) as caught: + self.pattern_controller.matchFilename("same_s01e01.mkv") + + self.assertIn("matched more than one pattern", str(caught.exception)) + self.assertIn("show #1", str(caught.exception)) + + 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_.*$") + + with self.assertRaises(DuplicatePatternMatchError) as caught: + self.pattern_controller.matchFilename("cross_s01e01.mkv") + + self.assertIn("show #1", str(caught.exception)) + self.assertIn("show #2", str(caught.exception)) + + def test_update_pattern_refreshes_regex_matching_after_change(self): + pattern_id = self.save_pattern(1, r"^before_(s[0-9]+e[0-9]+)\.mkv$") + + self.assertTrue( + self.pattern_controller.updatePattern( + pattern_id, + { + "show_id": 1, + "pattern": r"^after_(s[0-9]+e[0-9]+)\.mkv$", + "quality": 0, + "notes": "", + }, + ) + ) + + self.assertEqual({}, self.pattern_controller.matchFilename("before_s01e01.mkv")) + match = self.pattern_controller.matchFilename("after_s01e01.mkv") + self.assertEqual(pattern_id, match["pattern"].getId()) + + def test_save_pattern_schema_rejects_zero_track_patterns(self): + self.add_show(1, "Empty Pattern Show") + + with self.assertRaises(InvalidPatternSchemaError) as caught: + self.pattern_controller.savePatternSchema( + { + "show_id": 1, + "pattern": r"^empty_(s[0-9]+e[0-9]+)\.mkv$", + }, + trackDescriptors=[], + ) + + self.assertIn("at least one track", str(caught.exception)) + + def test_match_filename_rejects_existing_trackless_pattern_rows(self): + self.insert_trackless_pattern_row(1, r"^invalid_(s[0-9]+e[0-9]+)\.mkv$") + + with self.assertRaises(InvalidPatternSchemaError) as caught: + self.pattern_controller.matchFilename("invalid_s01e01.mkv") + + self.assertIn("has no tracks", str(caught.exception)) + + 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_.*$") + + no_pattern_context = dict(self.context) + no_pattern_context["use_pattern"] = False + + file_properties = FileProperties( + no_pattern_context, + "/tmp/nopattern_s01e01.mkv", + ) + + self.assertIsNone(file_properties.getPattern()) + self.assertEqual(-1, file_properties.getShowId()) + self.assertEqual(1, file_properties.getSeason()) + self.assertEqual(1, file_properties.getEpisode()) + + def test_track_controller_refuses_to_delete_last_track(self): + pattern_id = self.save_pattern(1, r"^delete_(s[0-9]+e[0-9]+)\.mkv$") + track = self.track_controller.getTrack(pattern_id, 0) + + with self.assertRaises(click.ClickException) as caught: + self.track_controller.deleteTrack(track.getId()) + + self.assertIn("last track", str(caught.exception)) + + def test_exact_duplicate_pattern_definition_is_rejected(self): + self.save_pattern(1, r"^unique_(s[0-9]+e[0-9]+)\.mkv$") + + with self.assertRaises(click.ClickException) as caught: + self.save_pattern(1, r"^unique_(s[0-9]+e[0-9]+)\.mkv$") + + self.assertIn("already exists", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main()