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.
|
- 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.
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
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,
|
- 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.
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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.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)
|
||||||
|
|
||||||
|
|||||||
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