Opt pattern matching

This commit is contained in:
Javanaut
2026-04-09 16:11:51 +02:00
parent be0f4b4c4e
commit d19e69990a
16 changed files with 1248 additions and 522 deletions

View File

@@ -12,13 +12,13 @@
- FFX logger setup now reuses named handlers, and fallback logger access no longer mutates handlers in ordinary constructors and helpers. - 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. - 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. - 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 ## Focused Snapshot
- Highest-leverage application optimizations: - Highest-leverage application optimizations:
- Lazy-load CLI command dependencies so lightweight commands do not import most of the app. - 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. - 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: - Highest-leverage repo and workflow optimizations:
- Consolidate setup and upgrade tooling to reduce overlapping shell-script responsibilities. - Consolidate setup and upgrade tooling to reduce overlapping shell-script responsibilities.
@@ -35,17 +35,7 @@
- Faster startup for scripting and tooling commands. - Faster startup for scripting and tooling commands.
- Less coupling between maintenance commands and the runtime stack. - Less coupling between maintenance commands and the runtime stack.
2. Filename pattern matching scales linearly across all patterns 2. Media probing does two separate `ffprobe` subprocesses per file
- [`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
- [`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. - [`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: - Optimization:
- Use one probe call that requests both format and streams. - Use one probe call that requests both format and streams.
@@ -54,7 +44,7 @@
- Less subprocess overhead. - Less subprocess overhead.
- Faster inspect and convert flows. - 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. - [`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: - Optimization:
- Cache crop results for repeated runs on the same source. - Cache crop results for repeated runs on the same source.
@@ -62,7 +52,7 @@
- Expected value: - Expected value:
- Lower latency on repeated experimentation. - 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. - 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: - Optimization:
- Decide which scripts remain canonical. - Decide which scripts remain canonical.
@@ -72,7 +62,7 @@
- Less operator confusion. - Less operator confusion.
- Fewer duplicated procedures to maintain. - 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. - [`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: - Optimization:
- Either remove them from the active UI surface or complete them. - Either remove them from the active UI surface or complete them.
@@ -81,7 +71,7 @@
- Leaner interface. - Leaner interface.
- Lower UX ambiguity. - 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. - 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: - Optimization:
- Extract a shared screen base or helper for common config/controller/bootstrap logic. - Extract a shared screen base or helper for common config/controller/bootstrap logic.
@@ -90,7 +80,7 @@
- Lower maintenance overhead. - Lower maintenance overhead.
- Easier UI iteration. - 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`. - [`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. - There are many combinator and conversion placeholders across tests and migrations.
- Optimization: - Optimization:
@@ -100,7 +90,7 @@
- Smaller mental model. - Smaller mental model.
- Less time spent re-evaluating inactive paths. - 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). - 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. - 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: - Optimization:
@@ -111,7 +101,7 @@
- Faster contributor onboarding. - Faster contributor onboarding.
- Easier CI adoption later. - 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. - [`src/ffx/process.py`](/home/osgw/.local/src/codex/ffx/src/ffx/process.py) prepends `nice` and `cpulimit` directly when values are set.
- Optimization: - Optimization:
- Validate and document effective behavior for combined `nice` + `cpulimit`. - Validate and document effective behavior for combined `nice` + `cpulimit`.
@@ -120,7 +110,7 @@
- Fewer surprises in production-like runs. - Fewer surprises in production-like runs.
- Easier support for user-reported performance behavior. - 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. - Even after recent CLI maintenance additions, the top-level CLI module still imports most application modules before Click dispatch.
- Optimization: - Optimization:
- Push imports for ORM, Textual, TMDB, ffmpeg helpers, and descriptors behind the commands that actually need them. - 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. - Maintenance commands such as setup and upgrade stay usable when optional runtime dependencies are broken.
- Better separation between media runtime code and maintenance tooling. - 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`. - [`src/ffx/helper.py`](/home/osgw/.local/src/codex/ffx/src/ffx/helper.py) still emits a `SyntaxWarning` for `RICH_COLOR_PATTERN`.
- Optimization: - Optimization:
- Convert regex literals to raw strings where appropriate. - Convert regex literals to raw strings where appropriate.
@@ -137,7 +127,7 @@
- Cleaner runtime output. - Cleaner runtime output.
- Less warning noise during dry-run maintenance commands. - 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. - [`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: - Optimization:
- Measure startup cost and consider separating bootstrapping from ordinary command execution. - Measure startup cost and consider separating bootstrapping from ordinary command execution.

View File

@@ -49,5 +49,6 @@ norecursedirs = ["tests/legacy", "tests/support"]
addopts = "-ra" addopts = "-ra"
markers = [ markers = [
"integration: exercises the FFX bundle with real ffmpeg/ffprobe processes", "integration: exercises the FFX bundle with real ffmpeg/ffprobe processes",
"pattern_management: covers requirements/pattern_management.md",
"subtrack_mapping: covers requirements/subtrack_mapping.md", "subtrack_mapping: covers requirements/subtrack_mapping.md",
] ]

View 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.

View File

@@ -47,6 +47,7 @@
- per-pattern stream definitions, - per-pattern stream definitions,
- shifted-season mappings, - shifted-season mappings,
- internal database version properties. - 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 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 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. - The system shall match filenames against stored regex patterns to decide whether an input file should inherit a target stream and metadata schema.

View File

@@ -44,9 +44,10 @@ class FileProperties():
self.__sourceFilenameExtension = '' self.__sourceFilenameExtension = ''
self.__pc = PatternController(context) self.__pc = PatternController(context)
self.__usePattern = bool(self.context.get('use_pattern', True))
# Checking if database contains matching pattern # 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}") self.__logger.debug(f"FileProperties.__init__(): Match result: {matchResult}")

View File

@@ -602,19 +602,20 @@ class MediaDetailsScreen(Screen):
patternObj = self.getPatternObjFromInput() patternObj = self.getPatternObjFromInput()
if patternObj: if patternObj:
patternId = self.__pc.addPattern(patternObj) mediaTags = {}
if patternId:
self.highlightPattern(False)
for tagKey, tagValue in self.__sourceMediaDescriptor.getTags().items(): for tagKey, tagValue in self.__sourceMediaDescriptor.getTags().items():
# Filter tags that make no sense to preserve # Filter tags that make no sense to preserve
if tagKey not in self.__ignoreGlobalKeys and not tagKey in self.__removeGlobalKeys: if tagKey not in self.__ignoreGlobalKeys and not tagKey in self.__removeGlobalKeys:
self.__tac.updateMediaTag(patternId, tagKey, tagValue) mediaTags[tagKey] = tagValue
# for trackDescriptor in self.__sourceMediaDescriptor.getAllTrackDescriptors(): patternId = self.__pc.savePatternSchema(
for trackDescriptor in self.__sourceMediaDescriptor.getTrackDescriptors(): patternObj,
self.__tc.addTrack(trackDescriptor, patternId = patternId) trackDescriptors=self.__sourceMediaDescriptor.getTrackDescriptors(),
mediaTags=mediaTags,
)
if patternId:
self.highlightPattern(False)
def action_new_pattern(self): def action_new_pattern(self):
@@ -754,4 +755,3 @@ class MediaDetailsScreen(Screen):
def handle_edit_pattern(self, screenResult): def handle_edit_pattern(self, screenResult):
self.query_one("#pattern_input", Input).value = screenResult['pattern'] self.query_one("#pattern_input", Input).value = screenResult['pattern']
self.updateDifferences() self.updateDifferences()

View File

@@ -1,6 +1,6 @@
import click 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 sqlalchemy.orm import relationship
from .show import Base, Show from .show import Base, Show
@@ -12,6 +12,9 @@ from ffx.show_descriptor import ShowDescriptor
class Pattern(Base): class Pattern(Base):
__tablename__ = 'patterns' __tablename__ = 'patterns'
__table_args__ = (
UniqueConstraint('show_id', 'pattern', name='uq_patterns_show_id_pattern'),
)
# v1.x # v1.x
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)

View File

@@ -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.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): def __init__(self, context):
self.context = 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): metadataConfiguration = (
"""Adds pattern to database from obj self.__configurationData["metadata"]
if "metadata" in self.__configurationData.keys()
else {}
)
Returns database id or 0 if pattern already exists""" 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: try:
session = self.Session()
self._ensure_unique_pattern_definition(
session,
fields["show_id"],
fields["pattern"],
exclude_pattern_id=patternId,
)
s = self.Session() if patternId is None:
pattern = s.query(Pattern).filter( pattern = Pattern(
Pattern.show_id == int(patternObj['show_id']), show_id=fields["show_id"],
Pattern.pattern == str(patternObj['pattern']), pattern=fields["pattern"],
).first() quality=fields["quality"],
notes=fields["notes"],
if pattern is None: )
pattern = Pattern(show_id = int(patternObj['show_id']), session.add(pattern)
pattern = str(patternObj['pattern'])) session.flush()
s.add(pattern)
s.commit()
return pattern.getId()
else: 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: except Exception as ex:
raise click.ClickException(f"PatternController.addPattern(): {repr(ex)}") raise click.ClickException(
f"PatternController.savePatternSchema(): {repr(ex)}"
)
finally: 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): def updatePattern(self, patternId, patternObj):
fields = self._coerce_pattern_fields(patternObj)
session = None
try: try:
s = self.Session() session = self.Session()
pattern = s.query(Pattern).filter(Pattern.id == int(patternId)).first() pattern = session.query(Pattern).filter(Pattern.id == int(patternId)).first()
if pattern is not None: 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.show_id = fields["show_id"]
pattern.pattern = str(patternObj['pattern']) pattern.pattern = fields["pattern"]
pattern.quality = str(patternObj['quality']) pattern.quality = fields["quality"]
pattern.notes = str(patternObj['notes']) pattern.notes = fields["notes"]
s.commit() session.commit()
self._clear_regex_cache()
return True return True
else:
return False return False
except click.ClickException:
raise
except Exception as ex: except Exception as ex:
raise click.ClickException(f"PatternController.updatePattern(): {repr(ex)}") raise click.ClickException(f"PatternController.updatePattern(): {repr(ex)}")
finally: finally:
s.close() if session is not None:
session.close()
def findPattern(self, patternObj): def findPattern(self, patternObj):
session = None
try: try:
s = self.Session() session = self.Session()
pattern = s.query(Pattern).filter( pattern = (
Pattern.show_id == int(patternObj['show_id']), session.query(Pattern)
Pattern.pattern == str(patternObj['pattern']), .filter(
).first() Pattern.show_id == int(patternObj["show_id"]),
Pattern.pattern == str(patternObj["pattern"]),
)
.first()
)
if pattern is not None: if pattern is not None:
return int(pattern.id) return int(pattern.id)
else:
return None return None
except Exception as ex: except Exception as ex:
raise click.ClickException(f"PatternController.findPattern(): {repr(ex)}") raise click.ClickException(f"PatternController.findPattern(): {repr(ex)}")
finally: 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: 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: try:
s = self.Session() session = self.Session()
return s.query(Pattern).filter(Pattern.id == int(patternId)).first() return session.query(Pattern).filter(Pattern.id == int(patternId)).first()
except Exception as ex: except Exception as ex:
raise click.ClickException(f"PatternController.getPattern(): {repr(ex)}") raise click.ClickException(f"PatternController.getPattern(): {repr(ex)}")
finally: finally:
s.close() if session is not None:
session.close()
def deletePattern(self, patternId): def deletePattern(self, patternId):
session = None
try: try:
s = self.Session() session = self.Session()
pattern = s.query(Pattern).filter(Pattern.id == int(patternId)).first() pattern = session.query(Pattern).filter(Pattern.id == int(patternId)).first()
if pattern is not None: if pattern is not None:
session.delete(pattern)
#DAFUQ: https://stackoverflow.com/a/19245058 session.commit()
# q.delete() self._clear_regex_cache()
s.delete(pattern)
s.commit()
return True return True
return False return False
except Exception as ex: except Exception as ex:
raise click.ClickException(f"PatternController.deletePattern(): {repr(ex)}") raise click.ClickException(f"PatternController.deletePattern(): {repr(ex)}")
finally: finally:
s.close() if session is not None:
session.close()
def matchFilename(self, filename: str) -> dict: 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""" """Return {'match': regex match, 'pattern': Pattern} or {} when unmatched."""
session = None
try: try:
s = self.Session() session = self.Session()
q = s.query(Pattern) matches = []
query = session.query(Pattern).order_by(Pattern.show_id, Pattern.id)
matchResult = {} for pattern in query.all():
compiled = self._compile_pattern_expression(
pattern.getId(),
pattern.getPattern(),
)
patternMatch = compiled.search(str(filename))
if patternMatch is None:
continue
for pattern in q.all(): self._validate_persisted_pattern(pattern)
patternMatch = re.search(str(pattern.pattern), str(filename)) matches.append({"match": patternMatch, "pattern": pattern})
if patternMatch is not None:
matchResult['match'] = patternMatch
matchResult['pattern'] = pattern
return matchResult 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: except Exception as ex:
raise click.ClickException(f"PatternController.matchFilename(): {repr(ex)}") raise click.ClickException(f"PatternController.matchFilename(): {repr(ex)}")
finally: finally:
s.close() if session is not None:
session.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()

View File

@@ -6,7 +6,6 @@ from textual.widgets import Header, Footer, Static, Button, Input, DataTable, Te
from textual.containers import Grid from textual.containers import Grid
from ffx.model.pattern import Pattern from ffx.model.pattern import Pattern
from ffx.model.track import Track
from .pattern_controller import PatternController from .pattern_controller import PatternController
from .show_controller import ShowController 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.__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.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None
self.__draftTracks : List[TrackDescriptor] = []
self.__draftTags : dict[str, str] = {}
#TODO: per controller #TODO: per controller
@@ -158,16 +159,12 @@ class PatternDetailsScreen(Screen):
self.tracksTable.clear() self.tracksTable.clear()
if self.__pattern is not None: tracks = self.getCurrentTrackDescriptors()
tracks = self.__tc.findTracks(self.__pattern.getId())
typeCounter = {} typeCounter = {}
tr: Track td: TrackDescriptor
for tr in tracks: for td in tracks:
td : TrackDescriptor = tr.getDescriptor(self.context)
if (trackType := td.getType()) != TrackType.ATTACHMENT: if (trackType := td.getType()) != TrackType.ATTACHMENT:
@@ -196,11 +193,47 @@ class PatternDetailsScreen(Screen):
typeCounter[trackType] += 1 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)
def normalizeDraftTracks(self):
typeCounter = {}
for index, trackDescriptor in enumerate(self.__draftTracks):
trackDescriptor.setIndex(index)
trackType = trackDescriptor.getType()
subIndex = typeCounter.get(trackType, 0)
trackDescriptor.setSubIndex(subIndex)
typeCounter[trackType] = subIndex + 1
if trackDescriptor.getSourceIndex() < 0:
trackDescriptor.setSourceIndex(index)
def swapTracks(self, trackIndex1: int, trackIndex2: int): def swapTracks(self, trackIndex1: int, trackIndex2: int):
ti1 = int(trackIndex1) ti1 = int(trackIndex1)
ti2 = int(trackIndex2) 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()) siblingDescriptors: List[TrackDescriptor] = self.__tc.findSiblingDescriptors(self.__pattern.getId())
numSiblings = len(siblingDescriptors) numSiblings = len(siblingDescriptors)
@@ -236,9 +269,11 @@ class PatternDetailsScreen(Screen):
self.tagsTable.clear() self.tagsTable.clear()
if self.__pattern is not None: tags = (
self.__tac.findAllMediaTags(self.__pattern.getId())
tags = self.__tac.findAllMediaTags(self.__pattern.getId()) if self.__pattern is not None
else self.__draftTags
)
for tagKey, tagValue in tags.items(): for tagKey, tagValue in tags.items():
@@ -248,7 +283,6 @@ class PatternDetailsScreen(Screen):
if tagKey in self.__removeGlobalKeys: if tagKey in self.__removeGlobalKeys:
textColor = 'red' textColor = 'red'
# if tagKey not in self.__ignoreTrackKeys:
row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor)) row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor))
self.tagsTable.add_row(*map(str, row)) self.tagsTable.add_row(*map(str, row))
@@ -340,16 +374,9 @@ class PatternDetailsScreen(Screen):
# 9 # 9
yield Static("Media Tags") yield Static("Media Tags")
if self.__pattern is not None:
yield Button("Add", id="button_add_tag") yield Button("Add", id="button_add_tag")
yield Button("Edit", id="button_edit_tag") yield Button("Edit", id="button_edit_tag")
yield Button("Delete", id="button_delete_tag") yield Button("Delete", id="button_delete_tag")
else:
yield Static(" ")
yield Static(" ")
yield Static(" ")
yield Static(" ") yield Static(" ")
yield Static(" ") yield Static(" ")
@@ -363,16 +390,9 @@ class PatternDetailsScreen(Screen):
# 12 # 12
yield Static("Streams") yield Static("Streams")
if self.__pattern is not None:
yield Button("Add", id="button_add_track") yield Button("Add", id="button_add_track")
yield Button("Edit", id="button_edit_track") yield Button("Edit", id="button_edit_track")
yield Button("Delete", id="button_delete_track") yield Button("Delete", id="button_delete_track")
else:
yield Static(" ")
yield Static(" ")
yield Static(" ")
yield Static(" ") yield Static(" ")
yield Button("Up", id="button_track_up") yield Button("Up", id="button_track_up")
@@ -413,13 +433,8 @@ class PatternDetailsScreen(Screen):
def getSelectedTrackDescriptor(self): def getSelectedTrackDescriptor(self):
if not self.__pattern:
return None
try: 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) row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
@@ -428,9 +443,11 @@ class PatternDetailsScreen(Screen):
trackIndex = int(selected_track_data[0]) trackIndex = int(selected_track_data[0])
trackSubIndex = int(selected_track_data[2]) 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: except CellDoesNotExist:
@@ -482,7 +499,11 @@ class PatternDetailsScreen(Screen):
self.app.pop_screen() self.app.pop_screen()
else: else:
patternId = self.__pc.addPattern(patternDescriptor) patternId = self.__pc.savePatternSchema(
patternDescriptor,
trackDescriptors=self.__draftTracks,
mediaTags=self.__draftTags,
)
if patternId: if patternId:
self.dismiss(patternDescriptor) self.dismiss(patternDescriptor)
else: else:
@@ -494,32 +515,51 @@ class PatternDetailsScreen(Screen):
self.app.pop_screen() self.app.pop_screen()
# Save pattern when just created before adding streams numTracks = len(self.getCurrentTrackDescriptors())
if self.__pattern is not None:
numTracks = len(self.tracksTable.rows)
if event.button.id == "button_add_track": if event.button.id == "button_add_track":
self.app.push_screen(TrackDetailsScreen(patternId = self.__pattern.getId(), index = numTracks), self.handle_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,
)
selectedTrack = self.getSelectedTrackDescriptor() selectedTrack = self.getSelectedTrackDescriptor()
if selectedTrack is not None: if selectedTrack is not None:
if event.button.id == "button_edit_track": if event.button.id == "button_edit_track":
self.app.push_screen(TrackDetailsScreen(trackDescriptor = selectedTrack), self.handle_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": if event.button.id == "button_delete_track":
self.app.push_screen(TrackDeleteScreen(trackDescriptor = selectedTrack), self.handle_delete_track) self.app.push_screen(
TrackDeleteScreen(trackDescriptor = selectedTrack),
self.handle_delete_track,
)
if event.button.id == "button_add_tag": 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": if event.button.id == "button_edit_tag":
tagKey, tagValue = self.getSelectedTag() selectedTag = self.getSelectedTag()
if selectedTag is not None:
tagKey, tagValue = selectedTag
self.app.push_screen(TagDetailsScreen(key=tagKey, value=tagValue), self.handle_update_tag) self.app.push_screen(TagDetailsScreen(key=tagKey, value=tagValue), self.handle_update_tag)
if event.button.id == "button_delete_tag": if event.button.id == "button_delete_tag":
tagKey, tagValue = self.getSelectedTag() selectedTag = self.getSelectedTag()
if selectedTag is not None:
tagKey, tagValue = selectedTag
self.app.push_screen(TagDeleteScreen(key=tagKey, value=tagValue), self.handle_delete_tag) self.app.push_screen(TagDeleteScreen(key=tagKey, value=tagValue), self.handle_delete_tag)
@@ -537,6 +577,7 @@ class PatternDetailsScreen(Screen):
if event.button.id == "button_track_up": if event.button.id == "button_track_up":
selectedTrackDescriptor = self.getSelectedTrackDescriptor() selectedTrackDescriptor = self.getSelectedTrackDescriptor()
if selectedTrackDescriptor is not None:
selectedTrackIndex = selectedTrackDescriptor.getIndex() selectedTrackIndex = selectedTrackDescriptor.getIndex()
if selectedTrackIndex > 0 and selectedTrackIndex < self.tracksTable.row_count: if selectedTrackIndex > 0 and selectedTrackIndex < self.tracksTable.row_count:
@@ -547,6 +588,7 @@ class PatternDetailsScreen(Screen):
if event.button.id == "button_track_down": if event.button.id == "button_track_down":
selectedTrackDescriptor = self.getSelectedTrackDescriptor() selectedTrackDescriptor = self.getSelectedTrackDescriptor()
if selectedTrackDescriptor is not None:
selectedTrackIndex = selectedTrackDescriptor.getIndex() selectedTrackIndex = selectedTrackDescriptor.getIndex()
if selectedTrackIndex >= 0 and selectedTrackIndex < (self.tracksTable.row_count - 1): if selectedTrackIndex >= 0 and selectedTrackIndex < (self.tracksTable.row_count - 1):
@@ -555,65 +597,86 @@ class PatternDetailsScreen(Screen):
def handle_add_track(self, trackDescriptor : TrackDescriptor): def handle_add_track(self, trackDescriptor : TrackDescriptor):
if trackDescriptor is None:
return
dispoSet = trackDescriptor.getDispositionSet() if self.__pattern is not None:
trackType = trackDescriptor.getType() self.__tc.addTrack(trackDescriptor, patternId=self.__pattern.getId())
index = trackDescriptor.getIndex() else:
subIndex = trackDescriptor.getSubIndex() self.__draftTracks.append(trackDescriptor)
codec = trackDescriptor.getCodec() self.normalizeDraftTracks()
language = trackDescriptor.getLanguage()
title = trackDescriptor.getTitle()
row = (index, self.updateTracks()
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))
def handle_edit_track(self, trackDescriptor : TrackDescriptor): 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.updateTracks()
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
def handle_delete_track(self, trackDescriptor : TrackDescriptor): 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() self.updateTracks()
def handle_update_tag(self, tag): def handle_update_tag(self, tag):
if tag is None:
return
if self.__pattern is None: 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): def handle_delete_tag(self, tag):
if tag is None:
return
if self.__pattern is None: 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]): if self.__tac.deleteMediaTagByKey(self.__pattern.getId(), tag[0]):
self.updateTags() self.updateTags()

View File

@@ -244,9 +244,15 @@ class TrackController():
patternId = int(track.pattern_id) patternId = int(track.pattern_id)
q_siblings = s.query(Track).filter(Track.pattern_id == patternId).order_by(Track.index) 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 index = 0
for track in q_siblings.all(): for track in siblingTracks:
if track.id == int(trackId): if track.id == int(trackId):
s.delete(track) s.delete(track)

View File

@@ -6,8 +6,6 @@ from textual.containers import Grid
from ffx.track_descriptor import TrackDescriptor from ffx.track_descriptor import TrackDescriptor
from .track_controller import TrackController
# Screen[dict[int, str, int]] # Screen[dict[int, str, int]]
class TrackDeleteScreen(Screen): class TrackDeleteScreen(Screen):
@@ -52,14 +50,9 @@ class TrackDeleteScreen(Screen):
def __init__(self, trackDescriptor : TrackDescriptor): def __init__(self, trackDescriptor : TrackDescriptor):
super().__init__() super().__init__()
self.context = self.app.getContext()
self.Session = self.context['database']['session'] # convenience
if type(trackDescriptor) is not TrackDescriptor: if type(trackDescriptor) is not TrackDescriptor:
raise click.ClickException('TrackDeleteScreen.init(): trackDescriptor is required to be of type TrackDescriptor') raise click.ClickException('TrackDeleteScreen.init(): trackDescriptor is required to be of type TrackDescriptor')
self.__tc = TrackController(context = self.context)
self.__trackDescriptor = trackDescriptor self.__trackDescriptor = trackDescriptor
@@ -116,21 +109,7 @@ class TrackDeleteScreen(Screen):
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "delete_button": 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) self.dismiss(self.__trackDescriptor)
else:
#TODO: Meldung
self.app.pop_screen()
if event.button.id == "cancel_button": if event.button.id == "cancel_button":
self.app.pop_screen() self.app.pop_screen()

View File

@@ -3,31 +3,20 @@ import click
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, SelectionList, Select, DataTable, Input from textual.widgets import Header, Footer, Static, Button, SelectionList, Select, DataTable, Input
from textual.containers import Grid 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 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 from ffx.helper import formatRichColor, removeRichColor
# Screen[dict[int, str, int]]
class TrackDetailsScreen(Screen): class TrackDetailsScreen(Screen):
CSS = """ CSS = """
@@ -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__() super().__init__()
self.context = self.app.getContext() 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.__removeTrackKeys = (
self.__removeGlobalKeys = metadataConfiguration['remove'] if 'remove' in metadataConfiguration.keys() else [] metadataConfiguration["streams"]["remove"]
self.__ignoreGlobalKeys = metadataConfiguration['ignore'] if 'ignore' in metadataConfiguration.keys() else [] if "streams" in metadataConfiguration.keys()
self.__removeTrackKeys = (metadataConfiguration['streams']['remove'] and "remove" in metadataConfiguration["streams"].keys()
if 'streams' in metadataConfiguration.keys() else []
and 'remove' in metadataConfiguration['streams'].keys() else []) )
self.__ignoreTrackKeys = (metadataConfiguration['streams']['ignore'] self.__ignoreTrackKeys = (
if 'streams' in metadataConfiguration.keys() metadataConfiguration["streams"]["ignore"]
and 'ignore' in metadataConfiguration['streams'].keys() else []) 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.__isNew = trackDescriptor is None 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: if self.__isNew:
self.__trackType = trackType self.__trackType = trackType
self.__trackCodec = TrackCodec.UNKNOWN self.__trackCodec = TrackCodec.UNKNOWN
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED
self.__index = index self.__index = index
self.__subIndex = subIndex self.__subIndex = subIndex
self.__trackDescriptor : TrackDescriptor = None self.__draftTrackTags = {}
self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else {}
else: else:
self.__trackType = trackDescriptor.getType() self.__trackType = trackDescriptor.getType()
self.__trackCodec = trackDescriptor.getCodec() self.__trackCodec = trackDescriptor.getCodec()
self.__audioLayout = trackDescriptor.getAudioLayout() self.__audioLayout = trackDescriptor.getAudioLayout()
self.__index = trackDescriptor.getIndex() self.__index = trackDescriptor.getIndex()
self.__subIndex = trackDescriptor.getSubIndex() self.__subIndex = trackDescriptor.getSubIndex()
self.__trackDescriptor : TrackDescriptor = trackDescriptor self.__draftTrackTags = {
self.__pattern : Pattern = self.__pc.getPattern(self.__trackDescriptor.getPatternId()) 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): def updateTags(self):
self.trackTagsTable.clear() self.trackTagsTable.clear()
trackId = self.__trackDescriptor.getId() for key, value in self.__draftTrackTags.items():
if trackId != -1:
trackTags = self.__tac.findAllTrackTags(trackId)
for k,v in trackTags.items():
if k != 'language' and k != 'title':
textColor = None textColor = None
if k in self.__ignoreTrackKeys: if key in self.__ignoreTrackKeys:
textColor = 'blue' textColor = "blue"
if k in self.__removeTrackKeys: if key in self.__removeTrackKeys:
textColor = 'red' textColor = "red"
row = (formatRichColor(k, textColor), formatRichColor(v, textColor)) row = (formatRichColor(key, textColor), formatRichColor(value, textColor))
self.trackTagsTable.add_row(*map(str, row)) self.trackTagsTable.add_row(*map(str, row))
def on_mount(self): def on_mount(self):
self.query_one("#index_label", Static).update(str(self.__index) if self.__index is not None else '-') self.query_one("#index_label", Static).update(
self.query_one("#subindex_label", Static).update(str(self.__subIndex)if self.__subIndex is not None else '-') str(self.__index) if self.__index is not None else "-"
)
if self.__pattern is not None: self.query_one("#subindex_label", Static).update(
self.query_one("#pattern_label", Static).update(self.__pattern.getPattern()) 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: if self.__trackType is not None:
self.query_one("#type_select", Select).value = self.__trackType.label() 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() self.query_one("#audio_layout_select", Select).value = self.__audioLayout.label()
for d in TrackDisposition: for disposition in TrackDisposition:
dispositionIsSet = (self.__trackDescriptor is not None dispositionIsSet = (
and d in self.__trackDescriptor.getDispositionSet()) self.__trackDescriptor is not None
and disposition in self.__trackDescriptor.getDispositionSet()
)
dispositionOption = (d.label(), d.index(), dispositionIsSet) dispositionOption = (
self.query_one("#dispositions_selection_list", SelectionList).add_option(dispositionOption) disposition.label(),
disposition.index(),
dispositionIsSet,
)
self.query_one("#dispositions_selection_list", SelectionList).add_option(
dispositionOption
)
if self.__trackDescriptor is not None: if self.__trackDescriptor is not None:
self.query_one("#language_select", Select).value = (
self.query_one("#language_select", Select).value = self.__trackDescriptor.getLanguage().label() self.__trackDescriptor.getLanguage().label()
)
self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle() self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle()
self.updateTags() self.updateTags()
def compose(self): def compose(self):
self.trackTagsTable = DataTable(classes="five") 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_key = self.trackTagsTable.add_column("Key", width=50)
self.column_key_track_tag_value = self.trackTagsTable.add_column("Value", width=100) self.column_key_track_tag_value = self.trackTagsTable.add_column("Value", width=100)
self.trackTagsTable.cursor_type = 'row' self.trackTagsTable.cursor_type = "row"
languages = [language.label() for language in IsoLanguage]
languages = [l.label() for l in IsoLanguage]
yield Header() yield Header()
with Grid(): with Grid():
# 1 yield Static(
yield Static(f"New stream" if self.__isNew else f"Edit stream", id="toplabel", classes="five") "New stream" if self.__isNew else "Edit stream",
id="toplabel",
classes="five",
)
# 2
yield Static("for pattern") yield Static("for pattern")
yield Static("", id="pattern_label", classes="four", markup=False) yield Static("", id="pattern_label", classes="four", markup=False)
# 3
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 4
yield Static("Index / Subindex") yield Static("Index / Subindex")
yield Static("", id="index_label", classes="two") yield Static("", id="index_label", classes="two")
yield Static("", id="subindex_label", classes="two") yield Static("", id="subindex_label", classes="two")
# 5
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 6
yield Static("Type") 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 Static("Audio Layout")
yield Select.from_values([t.label() for t in AudioLayout], classes="four", id="audio_layout_select") yield Select.from_values(
else: [layout.label() for layout in AudioLayout],
classes="four",
id="audio_layout_select",
)
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 8
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 9
yield Static(" ", classes="five")
# 10
yield Static("Language") yield Static("Language")
yield Select.from_values(languages, classes="four", id="language_select") yield Select.from_values(languages, classes="four", id="language_select")
# 11
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 12
yield Static("Title") yield Static("Title")
yield Input(id="title_input", classes="four") yield Input(id="title_input", classes="four")
# 13
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 14
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 15
yield Static("Stream tags") yield Static("Stream tags")
yield Static(" ") yield Static(" ")
yield Button("Add", id="button_add_stream_tag") yield Button("Add", id="button_add_stream_tag")
yield Button("Edit", id="button_edit_stream_tag") yield Button("Edit", id="button_edit_stream_tag")
yield Button("Delete", id="button_delete_stream_tag") yield Button("Delete", id="button_delete_stream_tag")
# 16
yield self.trackTagsTable yield self.trackTagsTable
# 17
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 18
yield Static("Stream dispositions", classes="five") yield Static("Stream dispositions", classes="five")
# 19
yield SelectionList[int]( yield SelectionList[int](
classes="five", classes="five",
id = "dispositions_selection_list" id="dispositions_selection_list",
) )
# 20
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 21
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 22
yield Button("Save", id="save_button") yield Button("Save", id="save_button")
yield Button("Cancel", id="cancel_button") yield Button("Cancel", id="cancel_button")
# 23
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 24
yield Static(" ", classes="five", id="messagestatic") yield Static(" ", classes="five", id="messagestatic")
yield Footer(id="footer") yield Footer(id="footer")
def getTrackDescriptorFromInput(self): def getTrackDescriptorFromInput(self):
kwargs = {} kwargs = {}
kwargs[TrackDescriptor.CONTEXT_KEY] = self.context 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 if self.__patternId != -1:
kwargs[TrackDescriptor.SUB_INDEX_KEY] = self.__subIndex #! 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 kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec
if self.__trackType == TrackType.AUDIO: if selectedTrackType == TrackType.AUDIO:
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(self.query_one("#audio_layout_select", Select).value) kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(
self.query_one("#audio_layout_select", Select).value
)
else: else:
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_UNDEFINED kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_UNDEFINED
trackTags = {} trackTags = dict(self.__draftTrackTags)
language = self.query_one("#language_select", Select).value language = self.query_one("#language_select", Select).value
if language: if language:
trackTags['language'] = IsoLanguage.find(language).threeLetter() trackTags["language"] = IsoLanguage.find(language).threeLetter()
title = self.query_one("#title_input", Input).value title = self.query_one("#title_input", Input).value
if title: 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 ** flag for flag in self.query_one("#dispositions_selection_list", SelectionList).selected]
dispositionFlags = sum([2**f for f in self.query_one("#dispositions_selection_list", SelectionList).selected]) )
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = TrackDisposition.toSet(dispositionFlags) kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = TrackDisposition.toSet(
dispositionFlags
)
return TrackDescriptor(**kwargs) return TrackDescriptor(**kwargs)
def getSelectedTag(self): def getSelectedTag(self):
try: try:
row_key, _ = self.trackTagsTable.coordinate_to_cell_key(
# Fetch the currently selected row when 'Enter' is pressed self.trackTagsTable.cursor_coordinate
#selected_row_index = self.table.cursor_row )
row_key, col_key = self.trackTagsTable.coordinate_to_cell_key(self.trackTagsTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
selected_tag_data = self.trackTagsTable.get_row(row_key) selected_tag_data = self.trackTagsTable.get_row(row_key)
@@ -357,101 +377,92 @@ class TrackDetailsScreen(Screen):
return tagKey, tagValue return tagKey, tagValue
else:
return None return None
except CellDoesNotExist: except CellDoesNotExist:
return None return None
# Event handler for button press
def on_button_pressed(self, event: Button.Pressed) -> None: 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": 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() trackDescriptor = self.getTrackDescriptorFromInput()
if ((TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet() and numDefaultTracks) siblingTrackList = [
or (TrackDisposition.FORCED in trackDescriptor.getDispositionSet() and numForcedTracks)): 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(
[
else: descriptor
for descriptor in siblingTrackList
self.query_one("#messagestatic", Static).update(" ") if TrackDisposition.DEFAULT in descriptor.getDispositionSet()
]
)
numForcedTracks = len(
[
descriptor
for descriptor in siblingTrackList
if TrackDisposition.FORCED in descriptor.getDispositionSet()
]
)
if self.__isNew: if self.__isNew:
trackDescriptor.setSubIndex(len(siblingTrackList))
elif self.__subIndex is not None and int(self.__subIndex) >= 0:
trackDescriptor.setSubIndex(int(self.__subIndex))
# Track per Screen hinzufügen if (
self.__tc.addTrack(trackDescriptor) TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet()
self.dismiss(trackDescriptor) 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: else:
self.query_one("#messagestatic", Static).update(" ")
track = self.__tc.getTrack(self.__pattern.getId(), self.__index)
# Track per details screen updaten
if self.__tc.updateTrack(track.getId(), trackDescriptor):
self.dismiss(trackDescriptor) self.dismiss(trackDescriptor)
else:
self.app.pop_screen()
if event.button.id == "cancel_button": if event.button.id == "cancel_button":
self.app.pop_screen() self.app.pop_screen()
if event.button.id == "button_add_stream_tag": 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": if event.button.id == "button_edit_stream_tag":
tagKey, tagValue = self.getSelectedTag() selectedTag = self.getSelectedTag()
self.app.push_screen(TagDetailsScreen(key=tagKey, value=tagValue), self.handle_update_tag) 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": if event.button.id == "button_delete_stream_tag":
tagKey, tagValue = self.getSelectedTag() selectedTag = self.getSelectedTag()
self.app.push_screen(TagDeleteScreen(key=tagKey, value=tagValue), self.handle_delete_tag) 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): def handle_update_tag(self, tag):
if tag is None:
trackId = self.__trackDescriptor.getId() return
self.__draftTrackTags[str(tag[0])] = str(tag[1])
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() self.updateTags()
def handle_delete_tag(self, trackTag): def handle_delete_tag(self, trackTag):
if trackTag is None:
trackId = self.__trackDescriptor.getId() return
self.__draftTrackTags.pop(str(trackTag[0]), None)
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() self.updateTags()

View File

@@ -0,0 +1 @@

View File

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

View File

@@ -22,7 +22,6 @@ from ffx.database import databaseContext
from ffx.pattern_controller import PatternController from ffx.pattern_controller import PatternController
from ffx.show_controller import ShowController from ffx.show_controller import ShowController
from ffx.show_descriptor import ShowDescriptor from ffx.show_descriptor import ShowDescriptor
from ffx.track_controller import TrackController
from ffx.track_descriptor import TrackDescriptor from ffx.track_descriptor import TrackDescriptor
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
from ffx.track_type import TrackType from ffx.track_type import TrackType
@@ -219,26 +218,20 @@ def create_source_fixture(workdir: Path, filename: str, tracks: list[SourceTrack
return output_path 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( show_descriptor = ShowDescriptor(
id=show_id, id=show_id,
name="Bundle Test Show", name="Bundle Test Show",
year=2000, year=2000,
) )
ShowController(context).updateShow(show_descriptor) 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: def prepare_pattern_database(database_path: Path, filename_pattern: str, track_specs: list[PatternTrackSpec], show_id: int = 1) -> None:
track_controller = TrackController(context) context = build_controller_context(database_path)
try:
add_show(context, show_id=show_id)
track_descriptors = []
for track in track_specs: for track in track_specs:
kwargs = { kwargs = {
TrackDescriptor.INDEX_KEY: track.index, TrackDescriptor.INDEX_KEY: track.index,
@@ -249,14 +242,17 @@ def add_pattern_tracks(context: dict, pattern_id: int, track_specs: list[Pattern
} }
if track.track_type == TrackType.AUDIO: if track.track_type == TrackType.AUDIO:
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = track.audio_layout kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = track.audio_layout
track_controller.addTrack(TrackDescriptor(**kwargs), pattern_id) track_descriptors.append(TrackDescriptor(**kwargs))
pattern_id = PatternController(context).savePatternSchema(
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) "show_id": show_id,
try: "pattern": filename_pattern,
pattern_id = add_show_and_pattern(context, filename_pattern, show_id=show_id) },
add_pattern_tracks(context, pattern_id, track_specs) trackDescriptors=track_descriptors,
)
if not pattern_id:
raise AssertionError("Failed to create pattern in test database")
finally: finally:
dispose_controller_context(context) dispose_controller_context(context)

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