Opt pattern matching
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
68
requirements/pattern_management.md
Normal file
68
requirements/pattern_management.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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': <a regex match obj>, 'pattern': <ffx pattern obj>} 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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
1
tests/integration/pattern_management/__init__.py
Normal file
1
tests/integration/pattern_management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
240
tests/unit/test_pattern_management.py
Normal file
240
tests/unit/test_pattern_management.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user