Compare commits
40 Commits
381a62046b
...
season-shi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
008c643272 | ||
|
|
c302b30e63 | ||
|
|
7926407534 | ||
|
|
0894ac2fab | ||
|
|
353759b983 | ||
|
|
454f5f0656 | ||
|
|
0e51d6337f | ||
|
|
a24b6dedaa | ||
|
|
8361fc536b | ||
|
|
4d4272e5e8 | ||
|
|
559869ca68 | ||
|
|
0e4fae538b | ||
|
|
2595bfe4f4 | ||
|
|
fc9d94aeee | ||
|
|
111df11199 | ||
|
|
f0d4c36bc3 | ||
|
|
ef0d6e9274 | ||
|
|
d05b01cfb2 | ||
|
|
9dc08d48e9 | ||
|
|
20bdfc0dd7 | ||
|
|
4365e083dc | ||
|
|
528915a235 | ||
|
|
9a980b5766 | ||
|
|
5eee7e1161 | ||
|
|
0a41998e29 | ||
|
|
ebdc23c3ce | ||
|
|
9611930949 | ||
|
|
609f93b783 | ||
|
|
52c6462fa8 | ||
|
|
358ef18f77 | ||
|
|
fc729a2414 | ||
|
|
0939a0c6c2 | ||
|
|
c384d54c12 | ||
|
|
71553aad32 | ||
|
|
d19e69990a | ||
|
|
be0f4b4c4e | ||
|
|
01b5fdb289 | ||
|
|
60ae58500a | ||
|
|
f9c8b8ac5e | ||
|
|
5871ae30ad |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
__pycache__
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
junk/
|
||||
.vscode
|
||||
.ipynb_checkpoints/
|
||||
@@ -12,4 +13,11 @@ bin/conversiontest.py
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.codex
|
||||
|
||||
|
||||
*.mkv
|
||||
*.webm
|
||||
ffmpeg2pass-0.log
|
||||
|
||||
141
README.md
141
README.md
@@ -1,48 +1,147 @@
|
||||
# FFX
|
||||
|
||||
FFX is a local CLI and Textual TUI for inspecting TV episode files, storing normalization rules in SQLite, and converting outputs into a predictable stream, metadata, and filename layout.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux-like environment
|
||||
- `python3`
|
||||
- `ffmpeg`
|
||||
- `ffprobe`
|
||||
- `cpulimit`
|
||||
|
||||
## Installation
|
||||
|
||||
per https:
|
||||
FFX uses a two-step local setup flow.
|
||||
|
||||
### 1. Install The Bundle
|
||||
|
||||
This step creates or reuses the persistent bundle virtualenv in `~/.local/share/ffx.venv`, installs FFX into it, and ensures `ffx` is exposed through a shell alias.
|
||||
|
||||
```sh
|
||||
pip install https://<URL>/<Releaser>/ffx.git@<Branch>
|
||||
bash tools/setup.sh
|
||||
```
|
||||
|
||||
per git:
|
||||
If you also want the Python packages needed for the modern test suite:
|
||||
|
||||
```sh
|
||||
pip install git+ssh://<Username>@<URL>/<Releaser>/ffx.git@<Branch>
|
||||
bash tools/setup.sh --with-tests
|
||||
```
|
||||
|
||||
## Version history
|
||||
You can verify the bundle state without changing anything:
|
||||
|
||||
### 0.1.1
|
||||
```sh
|
||||
bash tools/setup.sh --check
|
||||
```
|
||||
|
||||
Bugfixes, TMBD identify shows
|
||||
### 2. Prepare System Dependencies And Local User Files
|
||||
|
||||
### 0.1.2
|
||||
This step installs or verifies workstation dependencies and seeds local config and data directories. It is the step wrapped by the CLI command `ffx configure_workstation`.
|
||||
|
||||
Bugfixes
|
||||
Run it directly:
|
||||
|
||||
### 0.1.3
|
||||
```sh
|
||||
bash tools/configure_workstation.sh
|
||||
```
|
||||
|
||||
Subtitle file imports
|
||||
Or through the installed CLI:
|
||||
|
||||
### 0.2.0
|
||||
```sh
|
||||
ffx configure_workstation
|
||||
```
|
||||
|
||||
Tests, Config-File
|
||||
Check-only mode is available in both forms:
|
||||
|
||||
### 0.2.1
|
||||
```sh
|
||||
bash tools/configure_workstation.sh --check
|
||||
ffx configure_workstation --check
|
||||
```
|
||||
|
||||
Signature, Tags cleaning, Bugfixes, Refactoring
|
||||
`tools/configure_workstation.sh` does not manage the bundle virtualenv. Python-side test packages belong to `tools/setup.sh --with-tests`.
|
||||
|
||||
### 0.2.2
|
||||
## Basic Usage
|
||||
|
||||
CLI-Overrides
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
ffx version
|
||||
ffx inspect /path/to/episode.mkv
|
||||
ffx convert /path/to/episode.mkv
|
||||
ffx shows
|
||||
```
|
||||
|
||||
## Modern Tests
|
||||
|
||||
Install Python test packages first:
|
||||
|
||||
```sh
|
||||
bash tools/setup.sh --with-tests
|
||||
```
|
||||
|
||||
Then run the modern automatically discovered test suite:
|
||||
|
||||
```sh
|
||||
./tools/test.sh
|
||||
```
|
||||
|
||||
This runner uses `pytest` and intentionally excludes the legacy harness under `tests/legacy/`.
|
||||
|
||||
## Default Local Paths
|
||||
|
||||
- Config: `~/.local/etc/ffx.json`
|
||||
- Database: `~/.local/var/ffx/ffx.db`
|
||||
- Log file: `~/.local/var/log/ffx.log`
|
||||
- Bundle venv: `~/.local/share/ffx.venv`
|
||||
|
||||
## TMDB
|
||||
|
||||
TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environment.
|
||||
|
||||
## Version History
|
||||
|
||||
### 0.2.4
|
||||
|
||||
- lightweight CLI commands now stay import-light via lazy runtime loading
|
||||
- setup/config templating moved to `assets/ffx.json.j2`
|
||||
- aligned two-step local setup wrappers: `ffx setup` and `ffx configure_workstation`
|
||||
- combined `ffprobe` payload reuse in `FileProperties`
|
||||
- configurable crop-detect sampling plus per-process crop result caching
|
||||
- single-query controller accessors and conditional DB schema bootstrap
|
||||
- shared screen bootstrap/controller wiring for large detail screens
|
||||
- configurable default season/episode digit lengths
|
||||
- digit-aware `rename` and padded `unmux` filename markers
|
||||
|
||||
### 0.2.3
|
||||
|
||||
PyPi packaging
|
||||
Templating output filename
|
||||
Season shiftung
|
||||
DB-Versionierung
|
||||
- PyPI packaging
|
||||
- output filename templating
|
||||
- season shifting
|
||||
- DB versioning
|
||||
|
||||
### 0.2.2
|
||||
|
||||
- CLI overrides
|
||||
|
||||
### 0.2.1
|
||||
|
||||
- signature handling
|
||||
- tag cleanup
|
||||
- bugfixes and refactoring
|
||||
|
||||
### 0.2.0
|
||||
|
||||
- tests
|
||||
- config file
|
||||
|
||||
### 0.1.3
|
||||
|
||||
- subtitle file imports
|
||||
|
||||
### 0.1.2
|
||||
|
||||
- bugfixes
|
||||
|
||||
### 0.1.1
|
||||
|
||||
- bugfixes
|
||||
- TMDB show identification
|
||||
|
||||
144
SCRATCHPAD.md
144
SCRATCHPAD.md
@@ -1,62 +1,128 @@
|
||||
<!--
|
||||
|
||||
# Scratchpad
|
||||
|
||||
Temporary information holder for the next iteration. Developers may create or
|
||||
delete this file at any time. Anything durable should move into code, tests, or
|
||||
canonical docs, then this file should disappear.
|
||||
|
||||
|
||||
## Goal
|
||||
|
||||
Use this section for the current slice of work. It should explain what the
|
||||
scratchpad is helping us move forward right now.
|
||||
- Capture a compact, project-wide list of optimization candidates after a broad scan of the current FFX codebase, tooling, and requirements.
|
||||
|
||||
## Settled
|
||||
|
||||
Use this for decisions that are stable enough to guide the next steps, but are
|
||||
still temporary enough to live in the scratchpad for now.
|
||||
- The biggest near-term wins are in startup cost, repeated subprocess work, repeated database query patterns, and general repo hygiene.
|
||||
- This list is intentionally optimization-oriented rather than bug-oriented. Some items below also improve correctness or maintainability, but they were selected because they can reduce runtime cost, operator friction, or iteration overhead.
|
||||
- A first modern integration slice now exists under [`tests/integration/subtrack_mapping`](/home/osgw/.local/src/codex/ffx/tests/integration/subtrack_mapping). Remaining test-suite cleanup is now mostly about migrating and shrinking the legacy harness surface under [`tests/legacy`](/home/osgw/.local/src/codex/ffx/tests/legacy).
|
||||
- Shared CLI defaults for container/output tokens now live outside [`src/ffx/ffx_controller.py`](/home/osgw/.local/src/codex/ffx/src/ffx/ffx_controller.py), and a focused unit test locks in the lazy-import contract.
|
||||
- Helper filename and rich-text utilities now use compiled raw regexes plus translate-based filename filtering, with unit coverage for TMDB suffix rewriting and Rich color stripping.
|
||||
- Process resource limiting now has explicit disabled/default states in the CLI and requirements, and combined CPU-plus-niceness wrapping now executes as `cpulimit -- nice -n ... <command>` instead of a less explicit prefix chain.
|
||||
- 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.
|
||||
- Pattern matching now uses cached compiled regexes plus explicit duplicate-match errors, and pattern creation flows no longer persist zero-track patterns.
|
||||
|
||||
## Focused Snapshot
|
||||
|
||||
Use an extra section like this only when one slice needs its own compact
|
||||
summary. This is useful when a specific API, boundary, or model was recently
|
||||
recreated and should be captured clearly.
|
||||
- Highest-leverage application optimizations:
|
||||
- Decide whether placeholder help/settings screens should ship or disappear.
|
||||
- Trim dead helpers and other dormant surface that still looks active.
|
||||
|
||||
- Highest-leverage repo and workflow optimizations:
|
||||
- Continue migrating the oversized legacy test/combinator surface into focused modern tests so it is easier to run, debug, and extend.
|
||||
|
||||
## Optimization Candidates
|
||||
|
||||
1. Placeholder UI surfaces should either ship or disappear
|
||||
- [`src/ffx/help_screen.py`](/home/osgw/.local/src/codex/ffx/src/ffx/help_screen.py) and [`src/ffx/settings_screen.py`](/home/osgw/.local/src/codex/ffx/src/ffx/settings_screen.py) are placeholders.
|
||||
- Optimization:
|
||||
- Either remove them from the active UI surface or complete them.
|
||||
- Avoid paying ongoing maintenance cost for unfinished navigation targets.
|
||||
- Expected value:
|
||||
- Leaner interface.
|
||||
- Lower UX ambiguity.
|
||||
|
||||
2. Several helper functions are unfinished or dead-weight
|
||||
- [`src/ffx/helper.py`](/home/osgw/.local/src/codex/ffx/src/ffx/helper.py) contains `permutateList(...): pass`.
|
||||
- There are many combinator and conversion placeholders across tests and migrations.
|
||||
- Optimization:
|
||||
- Remove dead code, finish it, or isolate it behind a clearly dormant area.
|
||||
- Avoid carrying stubbed utility surface that looks reusable but is not.
|
||||
- Expected value:
|
||||
- Smaller mental model.
|
||||
- Less time spent re-evaluating inactive paths.
|
||||
|
||||
3. Test suite shape is expensive to understand and likely expensive to run
|
||||
- The project still carries a large legacy matrix of combinator files under [`tests/legacy`](/home/osgw/.local/src/codex/ffx/tests/legacy), several placeholder `pass` implementations, and at least one suspicious filename with an embedded space: [`tests/legacy/disposition_combinator_2_3 .py`](/home/osgw/.local/src/codex/ffx/tests/legacy/disposition_combinator_2_3 .py).
|
||||
- A first focused replacement slice now exists in [`tests/integration/subtrack_mapping/test_cli_bundle.py`](/home/osgw/.local/src/codex/ffx/tests/integration/subtrack_mapping/test_cli_bundle.py), so the remaining work is migration and consolidation rather than creating the modern test shape from scratch.
|
||||
- Optimization:
|
||||
- Continue replacing broad combinator matrices with focused parametrized integration and unit tests.
|
||||
- Retire the bespoke legacy discovery and runner path once equivalent coverage exists.
|
||||
- Normalize file naming and test discovery conventions.
|
||||
- Expected value:
|
||||
- Faster contributor onboarding.
|
||||
- Easier CI adoption later.
|
||||
|
||||
## Open
|
||||
|
||||
Use this for unresolved questions, design choices, and risks that still need a
|
||||
decision.
|
||||
|
||||
## Sketches
|
||||
|
||||
Use this for rough candidate structures, names, or shapes. Keep it explicit
|
||||
that these are sketches, not committed architecture.
|
||||
|
||||
- Should optimization work focus first on operator-perceived latency, internal maintainability, or correctness-risk cleanup that also has performance upside?
|
||||
- Is the long-term supported model still “local Linux workstation plus Textual UI,” or should optimization decisions bias toward a more scriptable/headless CLI?
|
||||
|
||||
## Gaps Right Now
|
||||
|
||||
Use this for concrete missing pieces in the current repo state. This section
|
||||
should describe what is absent or incomplete, not broad future ambitions.
|
||||
- No explicit prioritization owner or milestone for the optimization backlog.
|
||||
- No benchmark or timing harness exists for startup, probe, DB, or conversion orchestration overhead.
|
||||
- Repo hygiene is still mixed with generated artifacts and some clearly unfinished files.
|
||||
- The legacy TMDB-backed `Scenario 4` path is currently blocked by a pattern/track regression: `Patterns must define at least one track before they can be stored.` This surfaced while rerunning TMDB-dependent checks after the zero-track pattern hardening.
|
||||
|
||||
## Next
|
||||
|
||||
Use this for the immediate sequence of work. It should be short, ordered, and
|
||||
biased toward the next deliverable rather than a long roadmap.
|
||||
1. Triage the list into quick wins, medium refactors, and long-horizon cleanup.
|
||||
2. Tackle the cheapest remaining product-surface cleanup first:
|
||||
- placeholder UI surfaces and dead helper cleanup.
|
||||
3. Continue replacing oversized legacy test matrices with focused modern integration and unit coverage.
|
||||
4. Triage the legacy `Scenario 4` pattern/track failure and decide whether to fix the harness, adapt it to the zero-track guard, or retire that path during the ongoing test-suite migration.
|
||||
|
||||
## Shifted Season Status (2026-04-12)
|
||||
|
||||
- Current assessment:
|
||||
- The shifted-season subsystem is present end to end and looks feature-complete in shape, but it is not yet hardened.
|
||||
- The storage, TUI CRUD surface, and CLI/TMDB filename application path all exist, so this is no longer a stubbed or half-started area.
|
||||
- The main gap is correctness and direct verification rather than missing surface area.
|
||||
|
||||
- Implemented surface confirmed:
|
||||
- Requirements still treat shifted seasons as part of the accepted product surface in [`requirements/project.md`](/home/osgw/.local/src/codex/ffx/requirements/project.md) and [`requirements/architecture.md`](/home/osgw/.local/src/codex/ffx/requirements/architecture.md).
|
||||
- Persistence exists via [`src/ffx/model/shifted_season.py`](/home/osgw/.local/src/codex/ffx/src/ffx/model/shifted_season.py) plus the `Show.shifted_seasons` relationship in [`src/ffx/model/show.py`](/home/osgw/.local/src/codex/ffx/src/ffx/model/show.py).
|
||||
- CRUD logic exists in [`src/ffx/shifted_season_controller.py`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_controller.py).
|
||||
- Textual add/edit/delete flows are wired through [`src/ffx/shifted_season_details_screen.py`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_details_screen.py), [`src/ffx/shifted_season_delete_screen.py`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_delete_screen.py), and the show details table in [`src/ffx/show_details_screen.py`](/home/osgw/.local/src/codex/ffx/src/ffx/show_details_screen.py).
|
||||
- CLI conversion applies season shifts before TMDB lookup and output suffix generation in [`src/ffx/cli.py`](/home/osgw/.local/src/codex/ffx/src/ffx/cli.py).
|
||||
|
||||
- Verified current behavior:
|
||||
- `~/.local/share/ffx.venv/bin/python -m unittest discover -s tests/unit -p 'test_*.py'` passed on 2026-04-12: `75` tests in `0.795s`.
|
||||
- That run emitted `ResourceWarning` messages for unclosed SQLite connections, so the suite is green but not perfectly clean.
|
||||
- There is almost no direct shifted-season coverage in the modern tests:
|
||||
- [`tests/unit/test_cli_rename_only.py`](/home/osgw/.local/src/codex/ffx/tests/unit/test_cli_rename_only.py) stubs `ShiftedSeasonController` rather than exercising it.
|
||||
- [`tests/unit/test_screen_support.py`](/home/osgw/.local/src/codex/ffx/tests/unit/test_screen_support.py) only verifies controller bootstrap wiring.
|
||||
- Net effect: the subsystem is integrated, but its core rules are effectively untested by the current modern suite.
|
||||
|
||||
- Reproduced correctness gaps:
|
||||
- Overlap validation is broken in [`src/ffx/shifted_season_controller.py:41`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_controller.py:41) because `getOriginalSeason` is compared as a method object instead of being called.
|
||||
- Reproduction on 2026-04-12 with a temp SQLite DB:
|
||||
- Added `S1 E1-E10`.
|
||||
- `checkShiftedSeason(...)` incorrectly returned `True` for overlapping `S1 E5-E15`.
|
||||
- `addShiftedSeason(...)` then stored the overlapping row successfully.
|
||||
- `updateShiftedSeason(...)` in [`src/ffx/shifted_season_controller.py:93`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_controller.py:93) does not enforce episode ordering, so an invalid range like `first_episode=20`, `last_episode=10` was accepted in the same reproduction.
|
||||
- Because [`src/ffx/shifted_season_controller.py:213`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_controller.py:213) returns the first matching sibling and [`src/ffx/shifted_season_controller.py:163`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_controller.py:163) applies no explicit ordering, overlapping rows would also make runtime shifting ambiguous.
|
||||
|
||||
- Progress summary:
|
||||
- Good progress:
|
||||
- The subsystem exists across requirements, schema, UI, and conversion flow.
|
||||
- It appears fully integrated into the show-editing workflow rather than parked as dead code.
|
||||
- Incomplete progress:
|
||||
- Validation logic is not trustworthy yet.
|
||||
- Modern tests do not currently protect the subsystem's real behavior.
|
||||
- User-facing error feedback in the shifted-season screens still has placeholder `#TODO: Meldung` branches.
|
||||
|
||||
- Recommended next slice:
|
||||
1. Add direct controller tests for overlap rejection, episode-order validation, and `shiftSeason(...)` selection behavior.
|
||||
2. Fix `checkShiftedSeason(...)` and add the same range/order validation to `updateShiftedSeason(...)`.
|
||||
3. Make sibling selection deterministic or enforce non-overlap strongly enough that ordering no longer matters in practice.
|
||||
4. Add at least one focused integration test that proves a stored shifted season changes TMDB lookup and/or generated filename numbering during conversion.
|
||||
|
||||
## Delete When
|
||||
|
||||
Use this to define when the scratchpad should disappear. That keeps it clearly
|
||||
temporary and helps prevent it from turning into shadow documentation.
|
||||
|
||||
|
||||
## Suggested Style
|
||||
|
||||
- Prefer short bullets over long prose.
|
||||
- Keep facts, questions, and rough sketches in separate sections.
|
||||
- Add custom sections only when they help the next iteration move faster.
|
||||
- Move durable outcomes out of the scratchpad once they stop being temporary.
|
||||
|
||||
|
||||
-->
|
||||
- Delete this scratchpad once the optimization backlog is either converted into issues/work items or distilled into durable project guidance.
|
||||
|
||||
36
assets/ffx.json.j2
Normal file
36
assets/ffx.json.j2
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"databasePath": {{ database_path_json }},
|
||||
"logDirectory": {{ log_directory_json }},
|
||||
"subtitlesDirectory": {{ subtitles_directory_json }},
|
||||
"defaultIndexSeasonDigits": {{ default_index_season_digits }},
|
||||
"defaultIndexEpisodeDigits": {{ default_index_episode_digits }},
|
||||
"defaultIndicatorSeasonDigits": {{ default_indicator_season_digits }},
|
||||
"defaultIndicatorEpisodeDigits": {{ default_indicator_episode_digits }},
|
||||
"metadata": {
|
||||
"signature": {
|
||||
"RECODED_WITH": "FFX"
|
||||
},
|
||||
"remove": [
|
||||
"VERSION-eng",
|
||||
"creation_time",
|
||||
"NAME"
|
||||
],
|
||||
"streams": {
|
||||
"remove": [
|
||||
"BPS",
|
||||
"NUMBER_OF_FRAMES",
|
||||
"NUMBER_OF_BYTES",
|
||||
"_STATISTICS_WRITING_APP",
|
||||
"_STATISTICS_WRITING_DATE_UTC",
|
||||
"_STATISTICS_TAGS",
|
||||
"BPS-eng",
|
||||
"DURATION-eng",
|
||||
"NUMBER_OF_FRAMES-eng",
|
||||
"NUMBER_OF_BYTES-eng",
|
||||
"_STATISTICS_WRITING_APP-eng",
|
||||
"_STATISTICS_WRITING_DATE_UTC-eng",
|
||||
"_STATISTICS_TAGS-eng"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "ffx"
|
||||
description = "FFX recoding and metadata managing tool"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
license = {file = "LICENSE.md"}
|
||||
dependencies = [
|
||||
"requests",
|
||||
@@ -27,6 +27,11 @@ Homepage = "https://gitea.maveno.de/Javanaut/ffx"
|
||||
Repository = "https://gitea.maveno.de/Javanaut/ffx.git"
|
||||
Issues = "https://gitea.maveno.de/Javanaut/ffx/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = [
|
||||
"setuptools",
|
||||
@@ -35,4 +40,15 @@ requires = [
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project.scripts]
|
||||
ffx = "ffx.ffx:ffx"
|
||||
ffx = "ffx.cli:ffx"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
norecursedirs = ["tests/legacy", "tests/support"]
|
||||
addopts = "-ra"
|
||||
markers = [
|
||||
"integration: exercises the FFX bundle with real ffmpeg/ffprobe processes",
|
||||
"pattern_management: covers requirements/pattern_management.md",
|
||||
"subtrack_mapping: covers requirements/subtrack_mapping.md",
|
||||
]
|
||||
|
||||
@@ -32,27 +32,29 @@
|
||||
## High-Level Building Blocks
|
||||
|
||||
- Frontend, CLI, API, or worker:
|
||||
- A Click-based CLI in [`src/ffx/ffx.py`](/home/osgw/.local/src/codex/ffx/src/ffx/ffx.py).
|
||||
- A Click-based CLI in [`src/ffx/cli.py`](/home/osgw/.local/src/codex/ffx/src/ffx/cli.py), exposed as the `ffx` command and via `python -m ffx`, including lightweight maintenance wrappers for bundle setup, workstation preparation, and upgrade tasks.
|
||||
- A Textual terminal UI rooted in [`src/ffx/ffx_app.py`](/home/osgw/.local/src/codex/ffx/src/ffx/ffx_app.py) with screens for shows, patterns, file inspection, tracks, tags, and shifted seasons.
|
||||
- Core business logic:
|
||||
- Descriptor objects model media files, shows, and tracks.
|
||||
- Controllers encapsulate CRUD operations and workflow orchestration for shows, patterns, tags, tracks, season shifts, configuration, and conversion.
|
||||
- `MediaDescriptorChangeSet` computes differences between a file and its stored target schema to drive metadata and disposition updates.
|
||||
- File inspection caches combined `ffprobe` data and crop-detection results per source and sampling window within one process to avoid repeated subprocess work.
|
||||
- Storage:
|
||||
- SQLite via SQLAlchemy ORM, with schema rooted in shows, patterns, tracks, media tags, track tags, shifted seasons, and generic properties.
|
||||
- Ordered schema migrations are loaded dynamically from per-version-step modules under [`src/ffx/model/migration/`](/home/osgw/.local/src/codex/ffx/src/ffx/model/migration/).
|
||||
- A configuration JSON file supplies optional path, metadata-filtering, and filename-template settings.
|
||||
- Integration adapters:
|
||||
- Process execution wrapper for `ffmpeg`, `ffprobe`, `nice`, and `cpulimit`.
|
||||
- Process execution wrapper for `ffmpeg`, `ffprobe`, `nice`, and `cpulimit`, with explicit disabled states for niceness and CPU limiting, support for both absolute `cpulimit` values and machine-wide percent input, and a combined `cpulimit -- nice -n ... <command>` execution shape when both limits are configured.
|
||||
- HTTP adapter for TMDB via `requests`.
|
||||
|
||||
## Data And Interface Notes
|
||||
|
||||
- Key entities or records:
|
||||
- `Show`: canonical TV show metadata plus digit-formatting rules for generated filenames.
|
||||
- `Show`: canonical TV show metadata plus digit-formatting rules, optional show-level notes, and an optional show-level encoding-quality fallback.
|
||||
- `Pattern`: regex rule tying filenames to one show and one target media schema.
|
||||
- `Track` and `TrackTag`: persisted target stream layout, codec, dispositions, audio layout, and stream-level tags.
|
||||
- `Track` and `TrackTag`: persisted target stream records, codec, dispositions, audio layout, and stream-level tags. Detailed source-to-target mapping rules live in `requirements/subtrack_mapping.md`.
|
||||
- `MediaTag`: persisted container-level metadata for a pattern.
|
||||
- `ShiftedSeason`: mapping from source numbering ranges to adjusted season and episode numbers.
|
||||
- `ShiftedSeason`: mapping from source numbering ranges to adjusted season and episode numbers, owned either by a show as fallback or by a pattern as override.
|
||||
- `Property`: internal key-value storage currently used for database versioning.
|
||||
- External interfaces:
|
||||
- CLI commands for conversion, inspection, extraction, and crop detection.
|
||||
@@ -61,10 +63,9 @@
|
||||
- Config keys `databasePath`, `logDirectory`, and `outputFilenameTemplate`, plus optional metadata-filter rules.
|
||||
- Validation rules:
|
||||
- Only supported media-file extensions are accepted for conversion.
|
||||
- Stored database version must match the runtime-required version.
|
||||
- Stored database version must either match the runtime-required version already or have a supported sequential migration path to it.
|
||||
- A normalized descriptor may have at most one default and one forced stream per relevant track type.
|
||||
- Stored target tracks must refer to valid source tracks of matching types.
|
||||
- Shifted-season ranges are intended not to overlap for the same show and season.
|
||||
- Shifted-season ranges are intended not to overlap within the same owner scope and season, and runtime resolution prefers pattern-owned matches over show-owned matches.
|
||||
- TMDB lookups require a show ID and season and episode numbers.
|
||||
- Error-handling approach:
|
||||
- User-facing operational failures are raised as `click.ClickException` or warnings.
|
||||
|
||||
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.
|
||||
@@ -18,7 +18,7 @@
|
||||
- Inspect existing media files through `ffprobe` and compare discovered stream metadata with stored normalization rules.
|
||||
- Convert media files through `ffmpeg` into a normalized output layout, including video recoding, audio transcoding to Opus, metadata cleanup and rewrite, and controlled disposition flags.
|
||||
- Build output filenames from detected or configured show, season, and episode information, optionally enriched from TMDB and a configurable Jinja-style filename template.
|
||||
- Support auxiliary file operations such as subtitle import, unmuxing, crop detection, and rename-only runs.
|
||||
- Support auxiliary file operations such as subtitle import, unmuxing, crop detection, rename-only conversion runs, and direct in-place episode renaming.
|
||||
- Supported environments:
|
||||
- Local execution on a Python-capable workstation.
|
||||
- Best-supported on Linux-like systems because the implementation assumes `~/.local`, `/dev/null`, `nice`, and `cpulimit`.
|
||||
@@ -31,18 +31,30 @@
|
||||
- As an operator, I want to inspect a file before conversion so that I can compare its actual streams and tags against the stored target schema.
|
||||
- As a user preparing web-playback files, I want to recode video and audio with a small set of predictable options so that results are compatible and consistently named.
|
||||
- As a user dealing with nonstandard releases, I want CLI overrides for language, title, stream order, default and forced tracks, and season and episode data so that one-off fixes do not require database edits first.
|
||||
- As a user importing anime or other shifted numbering schemes, I want season and episode offsets per show so that generated filenames align with TMDB and media-library expectations.
|
||||
- As a user importing anime or other shifted numbering schemes, I want season and episode offsets at the show level with optional pattern-specific overrides so that generated filenames align with TMDB and media-library expectations.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
- The system shall provide a CLI entrypoint named `ffx` with commands for `convert`, `inspect`, `shows`, `unmux`, `cropdetect`, `version`, and `help`.
|
||||
- The system shall provide a CLI entrypoint named `ffx` with commands for `convert`, `inspect`, `shows`, `rename`, `unmux`, `cropdetect`, `setup`, `configure_workstation`, `upgrade`, `version`, and `help`.
|
||||
- The system shall support a two-step local installation and preparation flow:
|
||||
- `tools/setup.sh` is the bootstrap entrypoint for the first step and shall own bundle virtualenv creation, package installation, shell alias exposure, and optional Python test-package installation.
|
||||
- `tools/configure_workstation.sh` is the bootstrap entrypoint for the second step and shall own workstation dependency checks and installation plus local config and directory seeding.
|
||||
- After the bundle is installed, `ffx setup` and `ffx configure_workstation` shall remain aligned wrapper entrypoints for those same two steps.
|
||||
- The CLI command `ffx setup` shall act as a wrapper for the first-step bundle-preparation flow in `tools/setup.sh`.
|
||||
- The CLI command `ffx configure_workstation` shall act as a wrapper for the second-step preparation flow in `tools/configure_workstation.sh`.
|
||||
- The system shall persist reusable normalization rules in SQLite for:
|
||||
- shows and show formatting digits,
|
||||
- optional show-level notes,
|
||||
- optional show-level quality defaults,
|
||||
- regex-based filename patterns,
|
||||
- per-pattern media tags,
|
||||
- per-pattern stream definitions,
|
||||
- shifted-season mappings,
|
||||
- show-level and pattern-level shifted-season mappings,
|
||||
- internal database version properties.
|
||||
- The system shall apply supported ordered database migrations automatically when opening an older local database file and shall fail fast when no supported path exists.
|
||||
- Before applying a required database migration, the system shall show the current version, target version, required sequential steps, and whether each corresponding migration module is present, then require user confirmation.
|
||||
- Before applying a confirmed file-backed database migration, the system shall create an in-place backup copy whose filename includes the covered version range.
|
||||
- Detailed show, pattern, and duplicate-match management rules live in `requirements/pattern_management.md`.
|
||||
- The system shall inspect source media using `ffprobe` and derive a structured description of container metadata and streams.
|
||||
- The system shall optionally open a Textual UI to browse shows, inspect files, and create, edit, or delete shows, patterns, stream definitions, tags, and shifted-season rules.
|
||||
- The system shall match filenames against stored regex patterns to decide whether an input file should inherit a target stream and metadata schema.
|
||||
@@ -53,11 +65,19 @@
|
||||
- optional crop detection and crop application,
|
||||
- optional deinterlacing and denoising,
|
||||
- optional subtitle import from external files,
|
||||
- rename-only copy mode.
|
||||
- rename-only move mode.
|
||||
- The system shall support optional TMDB lookups to resolve show names, years, and episode titles when a show ID, season, and episode are available.
|
||||
- The system shall generate output filenames from show metadata, season and episode indices, and episode names using the configured filename template.
|
||||
- The system shall allow CLI overrides for stream languages, stream titles, default and forced tracks, stream order, TMDB show and episode data, output directory, label prefix, and processing resource limits.
|
||||
- The system shall resolve encoding quality by precedence `CLI override -> pattern -> show -> encoder default` and shall report the chosen value and source.
|
||||
- The system shall resolve season shifting by precedence `pattern -> show -> identity default` and shall report the chosen mapping and source.
|
||||
- Processing resource limit rules:
|
||||
- `--nice` shall accept niceness values from `-20` through `19`; omitting the option shall disable niceness adjustment.
|
||||
- `--cpu` shall accept either a positive absolute `cpulimit` value such as `200`, or a percentage suffixed with `%` such as `25%` to represent a share of present CPUs; omitting the option or using `0` shall disable CPU limiting.
|
||||
- When both limits are configured, the process wrapper shall execute the target command through `cpulimit` around a `nice -n ...` invocation so both limits apply to the launched media command.
|
||||
- The system shall support extracting streams into separate files via `unmux` and reporting suggested crop parameters via `cropdetect`.
|
||||
- The system shall support in-place episode renaming via `rename`, requiring a `--prefix`, accepting optional `--season` and `--suffix` overrides, preserving the source extension, and supporting dry-run output without moving files.
|
||||
- Crop detection shall use a configurable sampling window, defaulting to a 60-second seek and a 180-second analysis duration, and repeated crop-detection requests for the same source plus sampling window shall reuse cached results within one process.
|
||||
- The system shall handle invalid input and system failures gracefully by logging warnings or raising `click` errors for missing files, invalid media, missing TMDB credentials, incompatible database versions, and ambiguous track dispositions when prompting is disabled.
|
||||
|
||||
## Quality Requirements
|
||||
@@ -65,7 +85,7 @@
|
||||
- The system should stay understandable as a small local tool: controllers, descriptors, models, and screens should remain separate enough for contributors to trace a workflow end to end.
|
||||
- The system should produce predictable output for the same database rules, CLI overrides, and source files.
|
||||
- The system should preserve a lightweight operational footprint: local SQLite state, local log file, no mandatory background services.
|
||||
- The system should be testable through the existing combinatorial CLI-oriented test harness and through isolated logic in descriptors and controllers.
|
||||
- The system should be testable through modern automatically discovered tests and through remaining legacy harness coverage during migration.
|
||||
- The system should expose enough logging to diagnose failed probes, failed conversions, and rule mismatches without requiring a debugger.
|
||||
|
||||
## Constraints And Assumptions
|
||||
@@ -78,12 +98,15 @@
|
||||
- Intended for local execution, not server deployment.
|
||||
- Stores default state in `~/.local/etc/ffx.json`, `~/.local/var/ffx/ffx.db`, and `~/.local/var/log/ffx.log`.
|
||||
- Timeline constraints:
|
||||
- The current implemented scope reflects a compact alpha release stream up to version `0.2.3`.
|
||||
- The current implemented scope reflects a compact alpha release stream up to version `0.2.4`.
|
||||
- Team capacity assumptions:
|
||||
- Maintained as a small codebase where simple patterns and direct controller logic are preferred over framework-heavy abstractions.
|
||||
- Third-party dependencies:
|
||||
- `ffmpeg`, `ffprobe`, and `cpulimit`.
|
||||
- TMDB API access through `TMDB_API_KEY` for metadata enrichment.
|
||||
- Installation assumptions:
|
||||
- The Python-side bundle install step and optional Python test extras are managed by `tools/setup.sh`, with `ffx setup` as the aligned wrapper after bootstrap.
|
||||
- The workstation-preparation step is managed separately by `tools/configure_workstation.sh` or `ffx configure_workstation`.
|
||||
|
||||
## Acceptance Scope
|
||||
|
||||
|
||||
177
requirements/shifted_seasons_handling.md
Normal file
177
requirements/shifted_seasons_handling.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Shifted Seasons Handling
|
||||
|
||||
This file defines the behavioral contract for mapping source season and episode
|
||||
numbering to target season and episode numbering through stored shifted-season
|
||||
rules.
|
||||
|
||||
Primary sources:
|
||||
- `requirements/project.md`
|
||||
- `requirements/architecture.md`
|
||||
- actual tool code in `src/ffx/`
|
||||
|
||||
Secondary source:
|
||||
- `SCRATCHPAD.md`, used only to clarify current hardening gaps and not as the
|
||||
primary contract source.
|
||||
|
||||
## Scope
|
||||
|
||||
- Persisting shifted-season rules in SQLite.
|
||||
- Allowing shifted-season rules to be attached either to a show or to a
|
||||
specific pattern.
|
||||
- Selecting at most one active shifted-season rule for one concrete source
|
||||
season and episode tuple.
|
||||
- Applying additive season and episode offsets to produce target numbering.
|
||||
- Using shifted target numbering during `convert` for TMDB episode lookup and
|
||||
generated season and episode filename tokens.
|
||||
- Managing show-level default mappings and pattern-level override mappings from
|
||||
the Textual editing workflows.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- General filename parsing rules for detecting season and episode values.
|
||||
- Standalone `rename` command behavior, which currently uses explicit rename
|
||||
inputs rather than stored shifted-season rules.
|
||||
- Stream or track mapping behavior unrelated to season and episode numbering.
|
||||
|
||||
## Terms
|
||||
|
||||
- `shifted-season rule`: one persisted row describing how one source-numbering
|
||||
range maps to target numbering through additive offsets.
|
||||
- `show-level shifted-season rule`: a rule attached directly to a show and used
|
||||
as the fallback mapping layer for that show.
|
||||
- `pattern-level shifted-season rule`: a rule attached directly to a pattern and
|
||||
used as the override mapping layer for that pattern.
|
||||
- `source numbering`: the season and episode values detected from the current
|
||||
source file or supplied as source-side conversion inputs before shifting.
|
||||
- `target numbering`: the season and episode values after one active
|
||||
shifted-season rule has been applied.
|
||||
- `original season`: the source-domain season number a shifted-season rule is
|
||||
eligible to match.
|
||||
- `episode range`: the optional source-domain episode interval covered by one
|
||||
shifted-season rule.
|
||||
- `open bound`: an unbounded start or end of the episode range. Current storage
|
||||
uses `-1` as the internal sentinel for an open bound.
|
||||
- `active shifted-season rule`: the single rule selected for one concrete input
|
||||
after precedence resolution.
|
||||
- `identity mapping`: the default `1:1` outcome where source numbering is used
|
||||
unchanged.
|
||||
|
||||
## Rules
|
||||
|
||||
- `SHIFTED_SEASONS_HANDLING-0001`: The domain model shall allow a
|
||||
shifted-season rule to be owned by exactly one of:
|
||||
- one show
|
||||
- one pattern
|
||||
- `SHIFTED_SEASONS_HANDLING-0002`: A single shifted-season rule shall not
|
||||
belong to both a show and a pattern at the same time.
|
||||
- `SHIFTED_SEASONS_HANDLING-0003`: A shifted-season rule shall carry these
|
||||
fields: `original_season`, `first_episode`, `last_episode`,
|
||||
`season_offset`, and `episode_offset`.
|
||||
- `SHIFTED_SEASONS_HANDLING-0004`: `season_offset` and `episode_offset` shall
|
||||
be additive signed integers applied to matched source numbering to produce
|
||||
target numbering.
|
||||
- `SHIFTED_SEASONS_HANDLING-0005`: A shifted-season rule shall match a source
|
||||
tuple only when:
|
||||
- the source season equals `original_season`
|
||||
- the source episode is greater than or equal to `first_episode` when the
|
||||
lower bound is closed
|
||||
- the source episode is less than or equal to `last_episode` when the upper
|
||||
bound is closed
|
||||
- `SHIFTED_SEASONS_HANDLING-0006`: An open lower or upper episode bound shall
|
||||
represent an unbounded side of the covered source episode range.
|
||||
- `SHIFTED_SEASONS_HANDLING-0007`: If one shifted-season rule matches, target
|
||||
numbering shall be:
|
||||
- `target season = source season + season_offset`
|
||||
- `target episode = source episode + episode_offset`
|
||||
- `SHIFTED_SEASONS_HANDLING-0008`: If no shifted-season rule matches, source
|
||||
numbering shall pass through unchanged.
|
||||
- `SHIFTED_SEASONS_HANDLING-0009`: Shifted-season handling shall operate in a
|
||||
source-to-target numbering model. Stored rules map detected source numbering
|
||||
to the target numbering used by conversion-facing metadata and output naming.
|
||||
- `SHIFTED_SEASONS_HANDLING-0010`: Pattern matching identifies the owning show
|
||||
and optionally a more specific owning pattern. Resolution of the active
|
||||
shifted-season rule shall use this precedence order:
|
||||
- matching pattern-level rule
|
||||
- matching show-level rule
|
||||
- identity mapping
|
||||
- `SHIFTED_SEASONS_HANDLING-0011`: At most one shifted-season rule may be
|
||||
active for one concrete source season and episode tuple. Shifted-season rules
|
||||
shall never stack or compose.
|
||||
- `SHIFTED_SEASONS_HANDLING-0012`: Within one owner scope, shifted-season rules
|
||||
shall not overlap in their effective episode coverage for the same
|
||||
`original_season`.
|
||||
- `SHIFTED_SEASONS_HANDLING-0013`: If a shifted-season rule uses two closed
|
||||
episode bounds, `last_episode` shall be greater than or equal to
|
||||
`first_episode`.
|
||||
- `SHIFTED_SEASONS_HANDLING-0014`: Shifted-season rule evaluation shall be
|
||||
deterministic. Released behavior shall not depend on arbitrary database row
|
||||
order when invalid overlapping rules exist.
|
||||
- `SHIFTED_SEASONS_HANDLING-0015`: A pattern-level rule is permitted to map to
|
||||
zero offsets. Such a rule is a valid explicit override that beats show-level
|
||||
fallback and produces identity mapping for its covered source range.
|
||||
- `SHIFTED_SEASONS_HANDLING-0016`: During `convert`, when show, season, and
|
||||
episode values are available and stored shifting is active, the shifted target
|
||||
numbering shall drive:
|
||||
- TMDB episode lookup
|
||||
- season and episode filename tokens such as `S01E02`
|
||||
- generated episode basenames that include season and episode numbering
|
||||
- `SHIFTED_SEASONS_HANDLING-0017`: When conversion is supplied explicit
|
||||
target-domain season or episode values for TMDB naming, the system shall not
|
||||
apply stored shifting on top of those already-targeted values.
|
||||
- `SHIFTED_SEASONS_HANDLING-0018`: Operator-facing editing shall expose
|
||||
shifted-season rule management in both of these places:
|
||||
- show editing for show-level default mappings
|
||||
- pattern editing for pattern-level override mappings
|
||||
- `SHIFTED_SEASONS_HANDLING-0019`: User-facing shifted-season editing should
|
||||
present open episode bounds as a natural empty-state input rather than forcing
|
||||
operators to type the internal sentinel directly.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A show can exist with zero or more show-level shifted-season rules.
|
||||
- A pattern can exist with zero or more pattern-level shifted-season rules.
|
||||
- A shifted-season rule is stored against exactly one owner scope.
|
||||
- A source tuple matching a pattern-level rule yields target numbering from that
|
||||
rule even when a matching show-level rule also exists.
|
||||
- A source tuple matching no pattern-level rule but matching a show-level rule
|
||||
yields target numbering from the show-level rule.
|
||||
- A source tuple matching neither scope yields identity mapping.
|
||||
- A pattern-level zero-offset rule can explicitly override a nonzero show-level
|
||||
rule for the same covered source range.
|
||||
- Two shifted-season rules for the same owner scope and original season cannot
|
||||
both be valid if they cover overlapping episode ranges.
|
||||
- During `convert`, shifted numbering is what TMDB episode lookup and generated
|
||||
season and episode tokens see when stored shifting is active.
|
||||
- The TUI can display and maintain shifted-season rules from both the show and
|
||||
pattern editing flows.
|
||||
|
||||
## Current Code Fit
|
||||
|
||||
- `src/ffx/model/show.py` and `src/ffx/model/pattern.py` now both expose
|
||||
shifted-season relationships, and `src/ffx/model/shifted_season.py` stores
|
||||
each rule against exactly one owner scope through `show_id` or `pattern_id`.
|
||||
- `src/ffx/shifted_season_controller.py` now resolves mappings with
|
||||
pattern-over-show precedence and applies at most one active rule for a source
|
||||
tuple.
|
||||
- `src/ffx/show_details_screen.py`,
|
||||
`src/ffx/shifted_season_details_screen.py`, and
|
||||
`src/ffx/shifted_season_delete_screen.py` provide reusable shifted-season
|
||||
editing dialogs, and `src/ffx/pattern_details_screen.py` now exposes the
|
||||
pattern-level override flow.
|
||||
- `src/ffx/cli.py` now resolves shifted numbering during `convert` from:
|
||||
pattern-level match, then show-level match, then identity mapping.
|
||||
- `src/ffx/database.py` now migrates version-2 databases to version 3 by
|
||||
preserving existing show-level rows and extending the schema for pattern-level
|
||||
ownership.
|
||||
|
||||
## Risks
|
||||
|
||||
- The current CLI groups `--show`, `--season`, and `--episode` under one
|
||||
override bucket used for TMDB-related behavior. Source-domain versus
|
||||
target-domain semantics of each override must stay documented clearly so
|
||||
stored shifting is neither skipped nor double-applied unexpectedly.
|
||||
- Existing version-2 databases only contain show-owned shifted-season rows, so a
|
||||
version-3 migration must preserve those rows as the show-level fallback layer.
|
||||
- Current modern automated test coverage for shifted-season behavior is light,
|
||||
so precedence, migration, and convert-time numbering behavior need focused
|
||||
tests.
|
||||
74
requirements/subtrack_mapping.md
Normal file
74
requirements/subtrack_mapping.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Subtrack Mapping
|
||||
|
||||
This file defines the behavioral contract for mapping input subtracks to output
|
||||
subtracks during conversion.
|
||||
|
||||
Primary source: actual tool code in `src/ffx/`.
|
||||
Secondary source: `tests/legacy/`, used only to clarify intent and reveal gaps.
|
||||
|
||||
## Scope
|
||||
|
||||
- Ensuring each target subtrack is created from the corresponding source-subtrack information, including stream-level metadata.
|
||||
- Mapping input streams to output streams during conversion.
|
||||
- Using persisted pattern-track definitions from the database as the target schema.
|
||||
- Allowing omission and reordering of retained tracks.
|
||||
- Keeping stream-level metadata attached to the correct source-derived logical track after remapping.
|
||||
- Normalizing target output into ordered track groups: video, audio, subtitle, then special types such as fonts or images.
|
||||
|
||||
## Terms
|
||||
|
||||
- `source_index`: identity of the originating input stream from ffprobe or an imported source descriptor.
|
||||
- `index`: final output-track order across all retained tracks.
|
||||
- `sub_index`: per-type position within the retained tracks of one type, for example audio stream `0` or subtitle stream `1`.
|
||||
- `target schema`: stored or constructed output-track definition that decides which tracks are kept, omitted, reordered, and rewritten.
|
||||
- `separate source file`: additional file bound to one target track slot whose media payload replaces the regular source payload for that slot.
|
||||
|
||||
## Rules
|
||||
|
||||
- `SUBTRACK_MAPPING-0001`: The system shall represent source-stream identity separately from output order. `source_index`, `index`, and `sub_index` are distinct concepts and shall not be collapsed into one field.
|
||||
- `SUBTRACK_MAPPING-0002`: The system shall derive `source_index` for probed tracks from the original ffprobe stream index and preserve that identity through conversion planning.
|
||||
- `SUBTRACK_MAPPING-0003`: Pattern-backed track definitions stored in the database shall persist both target output order and originating source-stream identity.
|
||||
- `SUBTRACK_MAPPING-0004`: When a filename matches a pattern, the pattern target schema shall be the source of truth for which source tracks are retained, which are omitted, and in what order retained tracks appear in the output.
|
||||
- `SUBTRACK_MAPPING-0005`: A target track may refer only to an existing source track of the same type. Conversion shall fail fast when a target track refers to a nonexistent source stream or a source stream of a different type.
|
||||
- `SUBTRACK_MAPPING-0006`: The ffmpeg mapping phase shall be generated from target output order while resolving each retained output track back to its originating source stream via `source_index`.
|
||||
- `SUBTRACK_MAPPING-0007`: Reordering and omission shall preserve logical track identity. Stream-level metadata, titles, languages, and disposition decisions shall stay attached to the correct source-derived logical track after mapping.
|
||||
- `SUBTRACK_MAPPING-0008`: The system shall support one-off CLI stream-order overrides without requiring prior database edits.
|
||||
- `SUBTRACK_MAPPING-0009`: Operator-facing inspection and editing surfaces shall expose enough source-versus-target information to let a user reason about subtrack mapping decisions.
|
||||
- `SUBTRACK_MAPPING-0010`: Test coverage for subtrack mapping shall assert source-derived identity, omission, and output order explicitly. Final track counts or final type sequences alone are insufficient proof of correct mapping.
|
||||
- `SUBTRACK_MAPPING-0011`: Retained target tracks shall appear in ordered groups: video track or tracks first, then audio tracks, then subtitle tracks, then special types such as fonts or images. Within each group, the target schema shall define the order.
|
||||
- `SUBTRACK_MAPPING-0012`: Track omission is valid when required by output compatibility, when needed to normalize source tracks into the required target group order and schema, or when explicitly requested by database rules or CLI options.
|
||||
- `SUBTRACK_MAPPING-0013`: If source tracks do not already comply with the required target group order, conversion shall reorder retained tracks to match the target ordering contract without losing source-track identity or stream-level metadata lineage.
|
||||
|
||||
## Separate Additional Source Files
|
||||
|
||||
- `SUBTRACK_MAPPING-0014`: A separate source file may substitute the media payload of one target subtrack without changing that target track's intended output position.
|
||||
- `SUBTRACK_MAPPING-0015`: When a separate source file is used, the target track shall remain bound to the corresponding logical source track for mapping, validation, and metadata lineage.
|
||||
- `SUBTRACK_MAPPING-0016`: Metadata for a substituted target track shall be merged from the regular source track and the separate source file when available.
|
||||
- `SUBTRACK_MAPPING-0017`: If the separate source file provides a metadata field that is also present on the regular source track, the separate source file value shall win in the target output.
|
||||
- `SUBTRACK_MAPPING-0018`: If a metadata field is absent from the separate source file, the system shall fall back to the corresponding metadata from the regular source track or target schema rewrite rules.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Given a source media descriptor and a pattern-backed target schema, the planned output tracks can be listed in final output order and each retained track can still be traced to one originating source stream.
|
||||
- Planned output order follows grouped target order: video, audio, subtitle, then special types.
|
||||
- Tracks not referenced by the target schema are omitted from output mapping.
|
||||
- Tracks may also be omitted when they are incompatible with the chosen output format or explicitly excluded by database or CLI rules.
|
||||
- Two retained target tracks never originate from the same source stream unless duplication is implemented explicitly as a separate feature.
|
||||
- If target-track metadata is rewritten after reordering, it is written onto the correct source-derived logical track rather than the track that merely occupies the same final output position.
|
||||
- Invalid target-to-source references fail deterministically before the conversion job is launched.
|
||||
- If a separate source file substitutes one target track, that track keeps its target slot and ordering while metadata is merged with separate-file values taking precedence when both sides provide the same field.
|
||||
- A test proving subtrack mapping must assert at least one of: exact `source_index` to output-order mapping, omission of named source tracks, or preservation of per-track metadata after reorder.
|
||||
|
||||
## Test Notes
|
||||
|
||||
- `tests/legacy/scenario.py` names pattern behavior as `Filter/Reorder Tracks`.
|
||||
- `tests/legacy/scenario_4.py` is the strongest end-to-end signal because it runs DB-backed conversion and reapplies source indices before assertion.
|
||||
- `tests/legacy/track_tag_combinator_2_0.py` and `tests/legacy/track_tag_combinator_3_4.py` sort result tracks by `source_index` before checking tags, which matches the intended identity model.
|
||||
- Legacy permutation combinators define permutations but their assertion functions are stubs.
|
||||
- Some legacy scenarios produce `AP` and `SP` selectors but do not execute them.
|
||||
|
||||
## Risks
|
||||
|
||||
- `src/ffx/media_descriptor.py` contains an explicit `rearrangeTrackDescriptors()` path whose current implementation appears defective and under-tested.
|
||||
- Separate-source-file metadata precedence is only partly expressed in current implementation paths and should be covered directly in the rewritten test suite.
|
||||
- Production code expresses the mapping contract more clearly than the legacy harness, so a rewrite should add direct logic-level tests for mapping and reorder planning.
|
||||
144
requirements/tests.md
Normal file
144
requirements/tests.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Test Rewrite
|
||||
|
||||
This file captures the structure executed by `tests/legacy_runner.py` today and
|
||||
defines the target shape for a complete rewrite.
|
||||
|
||||
Detailed product rules for source-to-target subtrack mapping live in
|
||||
`requirements/subtrack_mapping.md`. This file describes only how tests cover
|
||||
that area.
|
||||
|
||||
## Interpreter Requirement
|
||||
|
||||
- Agents shall run Python-side test commands with `~/.local/share/ffx.venv/bin/python`.
|
||||
- This applies to the legacy harness, `unittest`, `pytest`, helper scripts, and `python -m ffx ...` test invocations.
|
||||
- Agents shall not silently substitute `python`, `python3`, or another interpreter for Python-side test work.
|
||||
- If `~/.local/share/ffx.venv/bin/python` is missing or not executable, agents shall stop and report the missing venv instead of continuing with Python-side test execution.
|
||||
|
||||
## Shell Environment Requirement
|
||||
|
||||
- Agents shall source `~/.bashrc` from an interactive Bash shell before running TMDB-dependent test commands or TMDB-dependent `python -m ffx ...` test invocations.
|
||||
- Agents shall not source `~/.bashrc.d/interactive/77_tmdb.sh` directly for normal test work; `~/.bashrc` is the required entry point.
|
||||
- In automation this means agents shall use an interactive Bash invocation such as `bash -ic 'source ~/.bashrc && ...'`, because a non-interactive `bash -lc` returns from `~/.bashrc` before the interactive fragments are loaded.
|
||||
- If sourcing `~/.bashrc` still does not provide required shell environment such as `TMDB_API_KEY`, agents shall stop and report the missing environment instead of continuing with TMDB-dependent test execution.
|
||||
|
||||
## Current Harness
|
||||
|
||||
- Entrypoint: `~/.local/share/ffx.venv/bin/python tests/legacy_runner.py run`
|
||||
- Runner style: custom Click CLI, not `pytest` or `unittest`
|
||||
- Commands:
|
||||
- `run`: discover scenario files, instantiate each scenario, run yielded jobs
|
||||
- `dupe`: helper command that creates duplicate media fixtures; not part of the test run
|
||||
- Filters: `--scenario`, `--variant`, `--limit`
|
||||
- Shared context:
|
||||
- builds one mutable dict for the whole run
|
||||
- installs loggers and writes `ffx_test_report.log`
|
||||
- creates `ConfigurationController` eagerly
|
||||
- tracks only passed and failed counters
|
||||
- Discovery:
|
||||
- scenario files: `tests/legacy/scenario_*.py`
|
||||
- combinators: `glob + importlib + inspect` by filename convention
|
||||
- ordering: implicit glob order, no explicit sorting
|
||||
- Skip behavior:
|
||||
- Scenario 4 is skipped when `TMDB_API_KEY` is missing
|
||||
- only `TMDB_API_KEY_NOT_PRESENT_EXCEPTION` is caught at scenario construction time
|
||||
|
||||
## Current Scenarios
|
||||
|
||||
- `1`: `tests/legacy/scenario_1.py`
|
||||
- focus: basename generation without pattern lookup or TMDB
|
||||
- inputs per job: `1`
|
||||
- jobs: `140`
|
||||
- expected failures: `0`
|
||||
- execution: build one synthetic source file, run `~/.local/share/ffx.venv/bin/python -m ffx convert`, assert filename selectors only
|
||||
- selectors executed: `B`, `L`, `I`
|
||||
- selectors defined but not executed: `S`, `R`
|
||||
- `2`: `tests/legacy/scenario_2.py`
|
||||
- focus: conversion matrix over media layouts, dispositions, tags, and permutations
|
||||
- inputs per job: `1`
|
||||
- jobs: `8193`
|
||||
- expected failures: `3267`
|
||||
- execution: build one synthetic source file, run `~/.local/share/ffx.venv/bin/python -m ffx convert`, probe result with `FileProperties`, assert track layout and selected audio and subtitle metadata
|
||||
- selectors executed: `M`, `AD`, `AT`, `SD`, `ST`
|
||||
- selectors defined but not executed: `MT`, `AP`, `SP`, `J`
|
||||
- `4`: `tests/legacy/scenario_4.py`
|
||||
- focus: pattern-driven batch conversion with SQLite state and live TMDB naming
|
||||
- inputs per job: `6`
|
||||
- jobs: `768`
|
||||
- expected failures: `336`
|
||||
- execution: build six synthetic preset files, recreate temp SQLite DB, insert show and pattern, run one batch convert command via `~/.local/share/ffx.venv/bin/python`, query TMDB during assertions
|
||||
- selectors executed: `M`, `AD`, `AT`, `SD`, `ST`
|
||||
- selectors defined but not executed: `MT`, `AP`, `SP`, `J`
|
||||
- notes:
|
||||
- uses `MediaCombinator6` only
|
||||
- issues live HTTP requests through `TmdbController` with no request cache
|
||||
|
||||
## Current Combinator Families
|
||||
|
||||
- scenario files discovered: `3`
|
||||
- basename combinators discovered: `2`
|
||||
- media combinators discovered: `8`
|
||||
- media tag combinators discovered: `3`
|
||||
- disposition combinator 2 variants: `4`
|
||||
- disposition combinator 3 variants: `5`
|
||||
- track tag combinator 2 variants: `4`
|
||||
- track tag combinator 3 variants: `5`
|
||||
- indicator variants: `7`
|
||||
- label variants: `2`
|
||||
- show variants: `3`
|
||||
- release variants: `3`
|
||||
- permutation 2 variants: `2`
|
||||
- permutation 3 variants: `3`
|
||||
|
||||
## Current Totals
|
||||
|
||||
- full run without TMDB: `8333`
|
||||
- full run with TMDB: `9101`
|
||||
- Scenario 4 generated source files: `4608`
|
||||
- Scenario 4 live TMDB episode queries: `4608`
|
||||
|
||||
## Current Behavior Areas
|
||||
|
||||
- output basename rules for label, season and episode indicator, show name, and release suffix combinations
|
||||
- track layout normalization across the eight media combinator shapes from `VA` through `VAASSS`
|
||||
- two-track and three-track disposition edge cases, including intentional failure cases
|
||||
- two-track and three-track track-tag preservation checks, including checks that sort results by source identity
|
||||
- container-level media tag handling
|
||||
- pattern-backed conversion against a temporary SQLite database
|
||||
- TMDB-assisted episode naming for batch conversion
|
||||
|
||||
## Structural Findings
|
||||
|
||||
- The suite is process-heavy: most jobs run `ffmpeg` to generate a fixture and then spawn the FFX CLI as a subprocess.
|
||||
- The suite is integration-first and has almost no isolated unit-level coverage for pure logic.
|
||||
- The base `Combinator` class is a placeholder and is not the real abstraction boundary used by the suite.
|
||||
- Many combinator methods are placeholders: there are `25` `pass` statements across the current test modules.
|
||||
- Several assertion families are never executed because scenario selector dispatch is incomplete.
|
||||
- Scenario comments mention a Scenario 3, but no `scenario_3.py` exists.
|
||||
- `tests/legacy/_basename_combinator_1.py` is effectively orphaned because discovery only matches `basename_combinator_*.py`.
|
||||
- `tests/legacy/disposition_combinator_2_3 .py` contains an embedded space in the filename and is still part of discovery.
|
||||
- Expected failures are validated only as subprocess return-code matches, not as specific error types or messages.
|
||||
- The current suite depends on `ffmpeg`, `ffprobe`, SQLite, the local Python environment, and for Scenario 4 a live TMDB API key plus network access.
|
||||
|
||||
## Rewrite Target
|
||||
|
||||
- Replace the custom Click harness with a standard test runner, preferably `pytest`.
|
||||
- Split the suite into explicit layers: unit, integration, and optional external-system tests.
|
||||
- Keep unit tests as the default path and make them runnable without `ffmpeg`, `ffprobe`, TMDB, or a user config directory.
|
||||
- Model discovery explicitly in code instead of relying on glob-plus-reflection naming conventions.
|
||||
- Convert the current Cartesian-product combinators into readable parametrized cases grouped by behavior area.
|
||||
- Preserve the current behavior areas, but represent them with targeted cases instead of thousands of opaque variant IDs.
|
||||
- Make every assertion family explicit and executable; there must be no selector that is produced but never consumed.
|
||||
- Replace live TMDB access with fixtures or mocks in normal runs; any live-contract test must be opt-in.
|
||||
- Replace ad hoc subprocess return-code checks with assertions on typed exceptions, stderr content, or structured outputs.
|
||||
- Provide small reusable media fixtures or fixture builders so only a narrow integration slice needs `ffmpeg`-generated media.
|
||||
- Make database tests self-contained and fast through temporary databases and direct controller-level assertions.
|
||||
- Make ordering, naming, and selection deterministic so a contributor can predict exactly what will run.
|
||||
- Expose a small smoke suite for quick local runs and CI, plus a separately marked slower integration suite.
|
||||
- Prefer domain-oriented test modules over combinator-family modules: basename, pattern matching, metadata rewrite, track ordering, TMDB naming, CLI smoke, and failure handling.
|
||||
|
||||
## Rewrite Acceptance
|
||||
|
||||
- A default local test run finishes quickly and without network access.
|
||||
- A contributor can identify which behavior a failing test covers without decoding variant strings like `VAASSS-A:D10-S:T001`.
|
||||
- All current intended failure behaviors remain covered, but each one is asserted directly and readably.
|
||||
- The rewritten suite can be adopted by CI without requiring live TMDB credentials.
|
||||
9
src/ffx/__main__.py
Normal file
9
src/ffx/__main__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .cli import ffx
|
||||
|
||||
|
||||
def main():
|
||||
ffx()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,42 +1,250 @@
|
||||
#! /usr/bin/python3
|
||||
|
||||
import os, click, time, logging, shutil, subprocess
|
||||
from __future__ import annotations
|
||||
|
||||
from ffx.configuration_controller import ConfigurationController
|
||||
import os, sys, click, time, shutil, subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ffx.file_properties import FileProperties
|
||||
# Allow direct execution via `python src/ffx/cli.py` by preferring the package
|
||||
# root on sys.path.
|
||||
if __package__ in (None, ''):
|
||||
script_dir = os.path.dirname(__file__)
|
||||
package_root = os.path.dirname(os.path.dirname(__file__))
|
||||
sys.path = [p for p in sys.path if os.path.abspath(p) != os.path.abspath(script_dir)]
|
||||
sys.path.insert(0, package_root)
|
||||
|
||||
from ffx.ffx_app import FfxApp
|
||||
from ffx.ffx_controller import FfxController
|
||||
from ffx.tmdb_controller import TmdbController
|
||||
from ffx.constants import (
|
||||
DEFAULT_AC3_BANDWIDTH,
|
||||
DEFAULT_CROPDETECT_DURATION_SECONDS,
|
||||
DEFAULT_CROPDETECT_SEEK_SECONDS,
|
||||
DEFAULT_cut_length,
|
||||
DEFAULT_cut_start,
|
||||
DEFAULT_CONTAINER_EXTENSION,
|
||||
DEFAULT_CONTAINER_FORMAT,
|
||||
DEFAULT_DTS_BANDWIDTH,
|
||||
DEFAULT_STEREO_BANDWIDTH,
|
||||
DEFAULT_VIDEO_ENCODER_LABEL,
|
||||
FFMPEG_COMMAND_TOKENS,
|
||||
SUPPORTED_INPUT_FILE_EXTENSIONS,
|
||||
VERSION,
|
||||
)
|
||||
|
||||
from ffx.database import databaseContext
|
||||
if TYPE_CHECKING:
|
||||
from ffx.media_descriptor import MediaDescriptor
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
|
||||
from ffx.media_descriptor import MediaDescriptor
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
LIGHTWEIGHT_COMMANDS = {None, 'version', 'help', 'setup', 'configure_workstation', 'upgrade', 'rename'}
|
||||
CPU_OPTION_HELP = (
|
||||
"Limit CPU for started processes. Use an absolute cpulimit value such as 200 "
|
||||
+ "(about 2 cores), or use a percentage such as 25% for a share of present cores. "
|
||||
+ "Omit to disable; 0 also disables."
|
||||
)
|
||||
SUBTITLE_DIRECTORY_OPTION_HELP = (
|
||||
"Load subtitles from here. When omitted and --subtitle-prefix is set, "
|
||||
+ "FFX uses the configured subtitlesDirectory base path plus the prefix as a subdirectory."
|
||||
)
|
||||
SUBTITLE_PREFIX_OPTION_HELP = (
|
||||
"Subtitle filename prefix. Requires --subtitle-directory, or a configured "
|
||||
+ "subtitlesDirectory base path that contains a matching <prefix>/ subdirectory."
|
||||
)
|
||||
UNMUX_OUTPUT_DIRECTORY_OPTION_HELP = (
|
||||
"Write extracted streams here. When omitted together with --subtitles-only and "
|
||||
+ "--label, FFX uses the configured subtitlesDirectory base path plus the label."
|
||||
)
|
||||
CROPDETECT_SEEK_OPTION_HELP = (
|
||||
"Start crop detection this many seconds into the input. "
|
||||
+ "Useful for skipping logos, intros, or black frames."
|
||||
)
|
||||
CROPDETECT_DURATION_OPTION_HELP = (
|
||||
"Analyze this many seconds for crop detection. "
|
||||
+ "Shorter windows are faster; longer windows are usually steadier."
|
||||
)
|
||||
DEFAULT_CUT_OPTION_VALUE = f"{DEFAULT_cut_start},{DEFAULT_cut_length}"
|
||||
CUT_OPTION_HELP = (
|
||||
"Cut output in seconds. "
|
||||
+ f"Use --cut for the default {DEFAULT_CUT_OPTION_VALUE}, "
|
||||
+ "--cut DURATION to cut from 0 for DURATION seconds, "
|
||||
+ "or --cut START,DURATION for an explicit start and duration. "
|
||||
+ "Omit to disable."
|
||||
)
|
||||
|
||||
from ffx.track_type import TrackType
|
||||
from ffx.video_encoder import VideoEncoder
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
from ffx.track_codec import TrackCodec
|
||||
|
||||
from ffx.process import executeProcess
|
||||
from ffx.helper import filterFilename, substituteTmdbFilename
|
||||
from ffx.helper import getEpisodeFileBasename
|
||||
def normalizeNicenessOption(ctx, param, value):
|
||||
from ffx.process import normalizeNiceness
|
||||
|
||||
from ffx.constants import DEFAULT_STEREO_BANDWIDTH, DEFAULT_AC3_BANDWIDTH, DEFAULT_DTS_BANDWIDTH, DEFAULT_7_1_BANDWIDTH
|
||||
try:
|
||||
return normalizeNiceness(value)
|
||||
except ValueError as ex:
|
||||
raise click.BadParameter(str(ex)) from ex
|
||||
|
||||
from ffx.filter.quality_filter import QualityFilter
|
||||
from ffx.filter.preset_filter import PresetFilter
|
||||
|
||||
from ffx.filter.crop_filter import CropFilter
|
||||
from ffx.filter.nlmeans_filter import NlmeansFilter
|
||||
from ffx.filter.deinterlace_filter import DeinterlaceFilter
|
||||
def normalizeCpuOption(ctx, param, value):
|
||||
from ffx.process import normalizeCpuPercent
|
||||
|
||||
from ffx.constants import VERSION
|
||||
try:
|
||||
return normalizeCpuPercent(value)
|
||||
except ValueError as ex:
|
||||
raise click.BadParameter(str(ex)) from ex
|
||||
|
||||
|
||||
def parseCutOptionValue(value) -> tuple[int, int] | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
cutValue = str(value).strip()
|
||||
if not cutValue:
|
||||
raise ValueError(
|
||||
"Cut value must be DURATION or START,DURATION, or use --cut without a value."
|
||||
)
|
||||
|
||||
cutTokens = [token.strip() for token in cutValue.split(',')]
|
||||
|
||||
try:
|
||||
if len(cutTokens) == 1:
|
||||
cutStart = 0
|
||||
cutLength = int(cutTokens[0])
|
||||
elif len(cutTokens) == 2:
|
||||
cutStart = int(cutTokens[0])
|
||||
cutLength = int(cutTokens[1])
|
||||
else:
|
||||
raise ValueError
|
||||
except ValueError as ex:
|
||||
raise ValueError(
|
||||
"Cut value must be DURATION or START,DURATION, or use --cut without a value."
|
||||
) from ex
|
||||
|
||||
if cutStart < 0:
|
||||
raise ValueError("Cut start must be 0 or greater.")
|
||||
|
||||
if cutLength <= 0:
|
||||
raise ValueError("Cut duration must be greater than 0.")
|
||||
|
||||
return cutStart, cutLength
|
||||
|
||||
|
||||
def normalizeCutOption(ctx, param, value):
|
||||
try:
|
||||
return parseCutOptionValue(value)
|
||||
except ValueError as ex:
|
||||
raise click.BadParameter(str(ex)) from ex
|
||||
|
||||
|
||||
def resolveSubtitleImportOptions(context, subtitleDirectory, subtitlePrefix):
|
||||
resolvedSubtitlePrefix = str(subtitlePrefix).strip()
|
||||
resolvedSubtitleDirectory = (
|
||||
os.path.expanduser(str(subtitleDirectory).strip())
|
||||
if subtitleDirectory
|
||||
else ''
|
||||
)
|
||||
|
||||
if not resolvedSubtitlePrefix:
|
||||
return False, resolvedSubtitleDirectory, resolvedSubtitlePrefix
|
||||
|
||||
if resolvedSubtitleDirectory:
|
||||
return True, resolvedSubtitleDirectory, resolvedSubtitlePrefix
|
||||
|
||||
configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath()
|
||||
if not configuredSubtitlesBaseDirectory:
|
||||
raise click.ClickException(
|
||||
"Subtitle prefix was set but no --subtitle-directory was provided and "
|
||||
+ "no subtitlesDirectory default is configured in ffx.json."
|
||||
)
|
||||
|
||||
resolvedSubtitleDirectory = os.path.join(
|
||||
configuredSubtitlesBaseDirectory,
|
||||
resolvedSubtitlePrefix,
|
||||
)
|
||||
|
||||
if not os.path.isdir(resolvedSubtitleDirectory):
|
||||
raise click.ClickException(
|
||||
"Subtitle prefix was set but the resolved subtitle directory does not exist: "
|
||||
+ resolvedSubtitleDirectory
|
||||
)
|
||||
|
||||
return True, resolvedSubtitleDirectory, resolvedSubtitlePrefix
|
||||
|
||||
|
||||
def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label):
|
||||
resolvedOutputDirectory = (
|
||||
os.path.expanduser(str(outputDirectory).strip())
|
||||
if outputDirectory
|
||||
else ''
|
||||
)
|
||||
resolvedLabel = str(label).strip()
|
||||
|
||||
if resolvedOutputDirectory or not subtitlesOnly or not resolvedLabel:
|
||||
return resolvedOutputDirectory, False
|
||||
|
||||
configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath()
|
||||
if not configuredSubtitlesBaseDirectory:
|
||||
raise click.ClickException(
|
||||
"Subtitles-only unmux with --label requires --output-directory or a configured "
|
||||
+ "subtitlesDirectory default in ffx.json."
|
||||
)
|
||||
|
||||
return os.path.join(configuredSubtitlesBaseDirectory, resolvedLabel), True
|
||||
|
||||
|
||||
def resolveIndicatorDigitLengths(context=None, showDescriptor=None):
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
|
||||
defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(context)
|
||||
if showDescriptor is None:
|
||||
return (
|
||||
defaultDigitLengths[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY],
|
||||
defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY],
|
||||
)
|
||||
|
||||
return (
|
||||
int(showDescriptor.getIndicatorSeasonDigits()),
|
||||
int(showDescriptor.getIndicatorEpisodeDigits()),
|
||||
)
|
||||
|
||||
|
||||
def buildRenameTargetFilename(
|
||||
sourcePath,
|
||||
prefix,
|
||||
seasonOverride=None,
|
||||
suffix='',
|
||||
indicatorSeasonDigits=None,
|
||||
indicatorEpisodeDigits=None,
|
||||
):
|
||||
from ffx.file_properties import FileProperties
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
|
||||
sourceFilename = os.path.basename(sourcePath)
|
||||
seasonEpisodeValues = FileProperties.extractSeasonEpisodeValues(sourceFilename)
|
||||
if seasonEpisodeValues is None:
|
||||
return None
|
||||
|
||||
sourceSeason, sourceEpisode = seasonEpisodeValues
|
||||
resolvedSeason = int(seasonOverride) if seasonOverride is not None else (
|
||||
int(sourceSeason) if sourceSeason is not None else 1
|
||||
)
|
||||
resolvedIndicatorSeasonDigits = (
|
||||
int(indicatorSeasonDigits)
|
||||
if indicatorSeasonDigits is not None
|
||||
else ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS
|
||||
)
|
||||
resolvedIndicatorEpisodeDigits = (
|
||||
int(indicatorEpisodeDigits)
|
||||
if indicatorEpisodeDigits is not None
|
||||
else ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS
|
||||
)
|
||||
|
||||
_sourceBasename, sourceExtension = os.path.splitext(sourceFilename)
|
||||
|
||||
targetFilenameTokens = [
|
||||
str(prefix).strip(),
|
||||
f"s{resolvedSeason:0{resolvedIndicatorSeasonDigits}d}e{int(sourceEpisode):0{resolvedIndicatorEpisodeDigits}d}",
|
||||
]
|
||||
|
||||
resolvedSuffix = str(suffix).strip()
|
||||
if resolvedSuffix:
|
||||
targetFilenameTokens.append(resolvedSuffix)
|
||||
|
||||
return f"{'_'.join(targetFilenameTokens)}{sourceExtension}"
|
||||
|
||||
from ffx.shifted_season_controller import ShiftedSeasonController
|
||||
|
||||
|
||||
@click.group()
|
||||
@@ -49,11 +257,18 @@ def ffx(ctx, database_file, verbose, dry_run):
|
||||
|
||||
ctx.obj = {}
|
||||
|
||||
if ctx.invoked_subcommand in ('setup_dependencies', 'upgrade'):
|
||||
if ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
if ctx.invoked_subcommand in LIGHTWEIGHT_COMMANDS:
|
||||
ctx.obj['dry_run'] = dry_run
|
||||
ctx.obj['verbosity'] = verbose
|
||||
return
|
||||
|
||||
from ffx.configuration_controller import ConfigurationController
|
||||
from ffx.database import databaseContext
|
||||
from ffx.logging_utils import configure_ffx_logger
|
||||
|
||||
ctx.obj['config'] = ConfigurationController()
|
||||
|
||||
ctx.obj['database'] = databaseContext(databasePath=database_file
|
||||
@@ -70,23 +285,11 @@ def ffx(ctx, database_file, verbose, dry_run):
|
||||
fileLogVerbosity = max(40 - verbose * 10, 10)
|
||||
consoleLogVerbosity = max(20 - verbose * 10, 10)
|
||||
|
||||
ctx.obj['logger'] = logging.getLogger('FFX')
|
||||
ctx.obj['logger'].setLevel(logging.DEBUG)
|
||||
|
||||
ffxFileHandler = logging.FileHandler(ctx.obj['config'].getLogFilePath())
|
||||
ffxFileHandler.setLevel(fileLogVerbosity)
|
||||
ffxConsoleHandler = logging.StreamHandler()
|
||||
ffxConsoleHandler.setLevel(consoleLogVerbosity)
|
||||
|
||||
fileFormatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
ffxFileHandler.setFormatter(fileFormatter)
|
||||
consoleFormatter = logging.Formatter(
|
||||
'%(message)s')
|
||||
ffxConsoleHandler.setFormatter(consoleFormatter)
|
||||
|
||||
ctx.obj['logger'].addHandler(ffxConsoleHandler)
|
||||
ctx.obj['logger'].addHandler(ffxFileHandler)
|
||||
ctx.obj['logger'] = configure_ffx_logger(
|
||||
ctx.obj['config'].getLogFilePath(),
|
||||
fileLogVerbosity,
|
||||
consoleLogVerbosity,
|
||||
)
|
||||
|
||||
|
||||
# Define a subcommand
|
||||
@@ -99,7 +302,9 @@ def version():
|
||||
@ffx.command()
|
||||
def help():
|
||||
click.echo(f"ffx {VERSION}\n")
|
||||
click.echo(f"Usage: ffx [input file] [output file] [vp9|av1] [q=[nn[,nn,...]]] [p=nn] [a=nnn[k]] [ac3=nnn[k]] [dts=nnn[k]] [crop]")
|
||||
click.echo("Maintenance commands: setup, configure_workstation, upgrade")
|
||||
click.echo("Media commands: shows, inspect, convert, rename, unmux, cropdetect")
|
||||
click.echo("Use 'ffx --help' or 'ffx <command> --help' for full command help.")
|
||||
|
||||
|
||||
def getRepoRootPath():
|
||||
@@ -107,8 +312,12 @@ def getRepoRootPath():
|
||||
return os.path.dirname(os.path.dirname(os.path.dirname(currentFilePath)))
|
||||
|
||||
|
||||
def getPrepareScriptPath():
|
||||
return os.path.join(getRepoRootPath(), 'tools', 'prepare.sh')
|
||||
def getConfigureWorkstationScriptPath():
|
||||
return os.path.join(getRepoRootPath(), 'tools', 'configure_workstation.sh')
|
||||
|
||||
|
||||
def getSetupScriptPath():
|
||||
return os.path.join(getRepoRootPath(), 'tools', 'setup.sh')
|
||||
|
||||
|
||||
def getBundleVenvDirectory():
|
||||
@@ -123,22 +332,29 @@ def getBundleRepoPath():
|
||||
return getRepoRootPath()
|
||||
|
||||
|
||||
@ffx.command(name='setup_dependencies')
|
||||
@click.pass_context
|
||||
@click.option('--check', is_flag=True, default=False, help='Only verify dependency readiness')
|
||||
@click.argument('prepare_args', nargs=-1, type=click.UNPROCESSED)
|
||||
def setup_dependencies(ctx, check, prepare_args):
|
||||
prepareScriptPath = getPrepareScriptPath()
|
||||
def getTrackedGitChanges(repoPath):
|
||||
completed = subprocess.run(
|
||||
['git', 'status', '--porcelain', '--untracked-files=no'],
|
||||
cwd=repoPath,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if not os.path.isfile(prepareScriptPath):
|
||||
raise click.ClickException(f"Preparation script not found at {prepareScriptPath}")
|
||||
if completed.returncode != 0:
|
||||
commandLabel = 'git status --porcelain --untracked-files=no'
|
||||
errorOutput = completed.stderr.strip() or completed.stdout.strip()
|
||||
raise click.ClickException(
|
||||
f"Unable to inspect bundle repository state using '{commandLabel}': {errorOutput}"
|
||||
)
|
||||
|
||||
commandSequence = ['bash', prepareScriptPath]
|
||||
return [line for line in completed.stdout.splitlines() if line.strip()]
|
||||
|
||||
if check:
|
||||
commandSequence.append('--check')
|
||||
|
||||
commandSequence += list(prepare_args)
|
||||
def runScriptWrapper(ctx, scriptPath, missingDescription, commandArgs):
|
||||
if not os.path.isfile(scriptPath):
|
||||
raise click.ClickException(f"{missingDescription} not found at {scriptPath}")
|
||||
|
||||
commandSequence = ['bash', scriptPath] + list(commandArgs)
|
||||
|
||||
if ctx.obj.get('dry_run', False):
|
||||
click.echo(' '.join(commandSequence))
|
||||
@@ -148,6 +364,44 @@ def setup_dependencies(ctx, check, prepare_args):
|
||||
ctx.exit(completed.returncode)
|
||||
|
||||
|
||||
@ffx.command(name='setup')
|
||||
@click.pass_context
|
||||
@click.option('--check', is_flag=True, default=False, help='Only verify bundle-setup readiness')
|
||||
@click.option('--with-tests', is_flag=True, default=False, help='Also install or verify Python test packages in the bundle venv')
|
||||
@click.argument('setup_args', nargs=-1, type=click.UNPROCESSED)
|
||||
def setup(ctx, check, with_tests, setup_args):
|
||||
"""Prepare or repair the FFX bundle virtualenv and shell alias."""
|
||||
commandArgs = []
|
||||
|
||||
if check:
|
||||
commandArgs.append('--check')
|
||||
if with_tests:
|
||||
commandArgs.append('--with-tests')
|
||||
|
||||
commandArgs += list(setup_args)
|
||||
runScriptWrapper(ctx, getSetupScriptPath(), "Bundle setup script", commandArgs)
|
||||
|
||||
|
||||
@ffx.command(name='configure_workstation')
|
||||
@click.pass_context
|
||||
@click.option('--check', is_flag=True, default=False, help='Only verify workstation-configuration readiness')
|
||||
@click.argument('configure_args', nargs=-1, type=click.UNPROCESSED)
|
||||
def configure_workstation(ctx, check, configure_args):
|
||||
"""Prepare workstation dependencies and local config after bundle install."""
|
||||
commandArgs = []
|
||||
|
||||
if check:
|
||||
commandArgs.append('--check')
|
||||
|
||||
commandArgs += list(configure_args)
|
||||
runScriptWrapper(
|
||||
ctx,
|
||||
getConfigureWorkstationScriptPath(),
|
||||
"Workstation configuration script",
|
||||
commandArgs,
|
||||
)
|
||||
|
||||
|
||||
@ffx.command(name='upgrade')
|
||||
@click.pass_context
|
||||
@click.option('--branch', type=str, default='', help='Checkout this branch before pulling')
|
||||
@@ -162,12 +416,35 @@ def upgrade(ctx, branch):
|
||||
raise click.ClickException(f"Bundle pip not found at {bundlePipPath}")
|
||||
|
||||
commandSequences = []
|
||||
trackedChanges = getTrackedGitChanges(bundleRepoPath)
|
||||
|
||||
if trackedChanges:
|
||||
click.echo("Tracked local changes detected in the bundle repository:")
|
||||
for trackedChange in trackedChanges:
|
||||
click.echo(f" {trackedChange}")
|
||||
|
||||
shouldReset = click.confirm(
|
||||
"Discard these tracked changes with 'git reset --hard HEAD' before upgrade?",
|
||||
default=False,
|
||||
)
|
||||
|
||||
if not shouldReset:
|
||||
raise click.ClickException(
|
||||
"Upgrade aborted because tracked local changes are present."
|
||||
)
|
||||
|
||||
commandSequences.append(['git', 'reset', '--hard', 'HEAD'])
|
||||
|
||||
if branch:
|
||||
commandSequences.append(['git', 'checkout', branch])
|
||||
commandSequences += [
|
||||
['git', 'fetch', 'origin', branch],
|
||||
['git', 'checkout', '-B', branch, 'FETCH_HEAD'],
|
||||
]
|
||||
else:
|
||||
commandSequences.append(['git', 'pull'])
|
||||
|
||||
commandSequences += [
|
||||
['git', 'pull'],
|
||||
[bundlePipPath, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
||||
[bundlePipPath, 'install', '--editable', '.'],
|
||||
]
|
||||
|
||||
@@ -186,6 +463,7 @@ def upgrade(ctx, branch):
|
||||
@click.pass_context
|
||||
@click.argument('filename', nargs=1)
|
||||
def inspect(ctx, filename):
|
||||
from ffx.ffx_app import FfxApp
|
||||
|
||||
ctx.obj['command'] = 'inspect'
|
||||
ctx.obj['arguments'] = {}
|
||||
@@ -195,10 +473,66 @@ def inspect(ctx, filename):
|
||||
app.run()
|
||||
|
||||
|
||||
@ffx.command()
|
||||
@click.pass_context
|
||||
@click.argument('paths', nargs=-1)
|
||||
@click.option('--prefix', type=str, required=True, help='Required target filename prefix')
|
||||
@click.option('--season', type=int, default=None, help='Override target season index')
|
||||
@click.option('--suffix', type=str, default='', help='Optional target filename suffix')
|
||||
@click.option('--dry-run', is_flag=True, default=False, help='Only print planned renames')
|
||||
def rename(ctx, paths, prefix, season, suffix, dry_run):
|
||||
"""Rename matching episode files in place."""
|
||||
from ffx.configuration_controller import ConfigurationController
|
||||
|
||||
resolvedPrefix = str(prefix).strip()
|
||||
resolvedSuffix = str(suffix).strip()
|
||||
effectiveDryRun = bool(ctx.obj.get('dry_run', False) or dry_run)
|
||||
renameContext = {
|
||||
'config': ctx.obj.get('config') or ConfigurationController(),
|
||||
}
|
||||
indicatorSeasonDigits, indicatorEpisodeDigits = resolveIndicatorDigitLengths(renameContext)
|
||||
|
||||
if not resolvedPrefix:
|
||||
raise click.ClickException("Rename prefix must not be empty.")
|
||||
|
||||
processedCount = 0
|
||||
|
||||
for sourcePath in paths:
|
||||
if not os.path.isfile(sourcePath):
|
||||
continue
|
||||
|
||||
targetFilename = buildRenameTargetFilename(
|
||||
sourcePath,
|
||||
resolvedPrefix,
|
||||
seasonOverride=season,
|
||||
suffix=resolvedSuffix,
|
||||
indicatorSeasonDigits=indicatorSeasonDigits,
|
||||
indicatorEpisodeDigits=indicatorEpisodeDigits,
|
||||
)
|
||||
if targetFilename is None:
|
||||
continue
|
||||
|
||||
sourceFilename = os.path.basename(sourcePath)
|
||||
targetPath = os.path.join(os.path.dirname(sourcePath), targetFilename)
|
||||
click.echo(f"{sourceFilename} -> {targetFilename}")
|
||||
processedCount += 1
|
||||
|
||||
if effectiveDryRun or os.path.abspath(sourcePath) == os.path.abspath(targetPath):
|
||||
continue
|
||||
|
||||
if os.path.exists(targetPath):
|
||||
raise click.ClickException(f"Target file already exists: {targetPath}")
|
||||
|
||||
shutil.move(sourcePath, targetPath)
|
||||
|
||||
if processedCount == 0:
|
||||
click.echo("No matching files found.")
|
||||
|
||||
|
||||
def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix, targetDirectory = ''):
|
||||
|
||||
# executable and input file
|
||||
commandTokens = FfxController.COMMAND_TOKENS + ['-i', sourcePath]
|
||||
commandTokens = list(FFMPEG_COMMAND_TOKENS) + ['-i', sourcePath]
|
||||
|
||||
trackType = trackDescriptor.getType()
|
||||
|
||||
@@ -228,10 +562,24 @@ def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix,
|
||||
|
||||
@click.argument('paths', nargs=-1)
|
||||
@click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix')
|
||||
@click.option("-o", "--output-directory", type=str, default='')
|
||||
@click.option("-o", "--output-directory", type=str, default='', help=UNMUX_OUTPUT_DIRECTORY_OPTION_HELP)
|
||||
@click.option("-s", "--subtitles-only", is_flag=True, default=False)
|
||||
@click.option('--nice', type=int, default=99, help='Niceness of started processes')
|
||||
@click.option('--cpu', type=int, default=0, help='Limit CPU for started processes to percent')
|
||||
@click.option(
|
||||
'--nice',
|
||||
type=int,
|
||||
default=None,
|
||||
callback=normalizeNicenessOption,
|
||||
show_default='disabled',
|
||||
help='Adjust niceness of started processes (-20..19). Omit to disable; 99 also disables.',
|
||||
)
|
||||
@click.option(
|
||||
'--cpu',
|
||||
type=str,
|
||||
default=None,
|
||||
callback=normalizeCpuOption,
|
||||
show_default='disabled',
|
||||
help=CPU_OPTION_HELP,
|
||||
)
|
||||
def unmux(ctx,
|
||||
paths,
|
||||
label,
|
||||
@@ -239,14 +587,31 @@ def unmux(ctx,
|
||||
subtitles_only,
|
||||
nice,
|
||||
cpu):
|
||||
from ffx.file_properties import FileProperties
|
||||
from ffx.process import executeProcess
|
||||
from ffx.shifted_season_controller import ShiftedSeasonController
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
existingSourcePaths = [p for p in paths if os.path.isfile(p)]
|
||||
ctx.obj['logger'].debug(f"\nUnmuxing {len(existingSourcePaths)} files")
|
||||
|
||||
ctx.obj['resource_limits'] = {}
|
||||
ctx.obj['resource_limits']['niceness'] = nice
|
||||
ctx.obj['resource_limits']['cpu_limit'] = cpu
|
||||
ctx.obj['resource_limits']['cpu_percent'] = cpu
|
||||
|
||||
output_directory, create_output_directory = resolveUnmuxOutputDirectory(
|
||||
ctx.obj,
|
||||
output_directory,
|
||||
subtitles_only,
|
||||
label,
|
||||
)
|
||||
if create_output_directory and existingSourcePaths and not ctx.obj.get('dry_run', False):
|
||||
os.makedirs(output_directory, exist_ok=True)
|
||||
|
||||
shiftedSeasonController = ShiftedSeasonController(ctx.obj)
|
||||
|
||||
for sourcePath in existingSourcePaths:
|
||||
|
||||
fp = FileProperties(ctx.obj, sourcePath)
|
||||
@@ -254,13 +619,29 @@ def unmux(ctx,
|
||||
|
||||
try:
|
||||
sourceMediaDescriptor = fp.getMediaDescriptor()
|
||||
currentPattern = fp.getPattern()
|
||||
currentShowDescriptor = (
|
||||
currentPattern.getShowDescriptor(ctx.obj) if currentPattern is not None else None
|
||||
)
|
||||
indicatorSeasonDigits, indicatorEpisodeDigits = resolveIndicatorDigitLengths(
|
||||
ctx.obj,
|
||||
currentShowDescriptor,
|
||||
)
|
||||
|
||||
season = fp.getSeason()
|
||||
episode = fp.getEpisode()
|
||||
season, episode = shiftedSeasonController.shiftSeason(
|
||||
fp.getShowId(),
|
||||
season=fp.getSeason(),
|
||||
episode=fp.getEpisode(),
|
||||
patternId=currentPattern.getId() if currentPattern is not None else None,
|
||||
)
|
||||
|
||||
#TODO: Recognition für alle Formate anpassen
|
||||
targetLabel = label if label else fp.getFileBasename()
|
||||
targetIndicator = f"_S{season}E{episode}" if label and season != -1 and episode != -1 else ''
|
||||
targetIndicator = (
|
||||
f"_S{season:0{indicatorSeasonDigits}d}E{episode:0{indicatorEpisodeDigits}d}"
|
||||
if label and season != -1 and episode != -1
|
||||
else ''
|
||||
)
|
||||
|
||||
if label and not targetIndicator:
|
||||
ctx.obj['logger'].warning(f"Skipping file {fp.getFilename()}: Label set but no indicator recognized")
|
||||
@@ -303,19 +684,55 @@ def unmux(ctx,
|
||||
@click.pass_context
|
||||
|
||||
@click.argument('paths', nargs=-1)
|
||||
@click.option('--nice', type=int, default=99, help='Niceness of started processes')
|
||||
@click.option('--cpu', type=int, default=0, help='Limit CPU for started processes to percent')
|
||||
@click.option(
|
||||
'--nice',
|
||||
type=int,
|
||||
default=None,
|
||||
callback=normalizeNicenessOption,
|
||||
show_default='disabled',
|
||||
help='Adjust niceness of started processes (-20..19). Omit to disable; 99 also disables.',
|
||||
)
|
||||
@click.option(
|
||||
'--cpu',
|
||||
type=str,
|
||||
default=None,
|
||||
callback=normalizeCpuOption,
|
||||
show_default='disabled',
|
||||
help=CPU_OPTION_HELP,
|
||||
)
|
||||
@click.option(
|
||||
'--crop-seek',
|
||||
type=click.IntRange(min=0),
|
||||
default=DEFAULT_CROPDETECT_SEEK_SECONDS,
|
||||
show_default=True,
|
||||
help=CROPDETECT_SEEK_OPTION_HELP,
|
||||
)
|
||||
@click.option(
|
||||
'--crop-duration',
|
||||
type=click.IntRange(min=1),
|
||||
default=DEFAULT_CROPDETECT_DURATION_SECONDS,
|
||||
show_default=True,
|
||||
help=CROPDETECT_DURATION_OPTION_HELP,
|
||||
)
|
||||
def cropdetect(ctx,
|
||||
paths,
|
||||
nice,
|
||||
cpu):
|
||||
cpu,
|
||||
crop_seek,
|
||||
crop_duration):
|
||||
from ffx.file_properties import FileProperties
|
||||
|
||||
existingSourcePaths = [p for p in paths if os.path.isfile(p)]
|
||||
ctx.obj['logger'].debug(f"\nUnmuxing {len(existingSourcePaths)} files")
|
||||
|
||||
ctx.obj['resource_limits'] = {}
|
||||
ctx.obj['resource_limits']['niceness'] = nice
|
||||
ctx.obj['resource_limits']['cpu_limit'] = cpu
|
||||
ctx.obj['resource_limits']['cpu_percent'] = cpu
|
||||
ctx.obj['cropdetect'] = {
|
||||
'seek_seconds': crop_seek,
|
||||
'duration_seconds': crop_duration,
|
||||
}
|
||||
|
||||
for sourcePath in existingSourcePaths:
|
||||
|
||||
@@ -323,7 +740,7 @@ def cropdetect(ctx,
|
||||
try:
|
||||
|
||||
fp = FileProperties(ctx.obj, sourcePath)
|
||||
cropParams = fp.findCropParams()
|
||||
cropParams = fp.findCropArguments()
|
||||
|
||||
click.echo(cropParams)
|
||||
|
||||
@@ -335,6 +752,7 @@ def cropdetect(ctx,
|
||||
@click.pass_context
|
||||
|
||||
def shows(ctx):
|
||||
from ffx.ffx_app import FfxApp
|
||||
|
||||
ctx.obj['command'] = 'shows'
|
||||
|
||||
@@ -343,6 +761,8 @@ def shows(ctx):
|
||||
|
||||
|
||||
def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
# Check for multiple default or forced dispositions if not set by user input or database requirements
|
||||
#
|
||||
@@ -392,7 +812,7 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
||||
|
||||
@click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix')
|
||||
|
||||
@click.option('-v', '--video-encoder', type=str, default=FfxController.DEFAULT_VIDEO_ENCODER, help=f"Target video encoder (vp9, av1 or h264)", show_default=True)
|
||||
@click.option('-v', '--video-encoder', type=str, default=DEFAULT_VIDEO_ENCODER_LABEL, help=f"Target video encoder (vp9, av1, h264 or copy)", show_default=True)
|
||||
|
||||
@click.option('-q', '--quality', type=str, default="", help=f"Quality settings to be used with VP9/H264 encoder")
|
||||
@click.option('-p', '--preset', type=str, default="", help=f"Quality preset to be used with AV1 encoder")
|
||||
@@ -401,8 +821,8 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
||||
@click.option('--ac3', type=int, default=DEFAULT_AC3_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 5.1 audio streams", show_default=True)
|
||||
@click.option('--dts', type=int, default=DEFAULT_DTS_BANDWIDTH, help=f"Bitrate in kbit/s to be used to encode 6.1 audio streams", show_default=True)
|
||||
|
||||
@click.option('--subtitle-directory', type=str, default='', help='Load subtitles from here')
|
||||
@click.option('--subtitle-prefix', type=str, default='', help='Subtitle filename prefix')
|
||||
@click.option('--subtitle-directory', type=str, default='', help=SUBTITLE_DIRECTORY_OPTION_HELP)
|
||||
@click.option('--subtitle-prefix', type=str, default='', help=SUBTITLE_PREFIX_OPTION_HELP)
|
||||
|
||||
@click.option('--language', type=str, multiple=True, help='Set stream language. Use format <stream index>:<3 letter iso code>')
|
||||
@click.option('--title', type=str, multiple=True, help='Set stream title. Use format <stream index>:<title>')
|
||||
@@ -417,7 +837,30 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
||||
@click.option('--rearrange-streams', type=str, default="", help='Rearrange output streams order. Use format comma separated integers')
|
||||
|
||||
@click.option("--crop", is_flag=False, flag_value="auto", default="none")
|
||||
@click.option("--cut", is_flag=False, flag_value="default", default="none")
|
||||
@click.option(
|
||||
'--crop-seek',
|
||||
type=click.IntRange(min=0),
|
||||
default=DEFAULT_CROPDETECT_SEEK_SECONDS,
|
||||
show_default=True,
|
||||
help='When --crop auto is used, start crop detection this many seconds into the input.',
|
||||
)
|
||||
@click.option(
|
||||
'--crop-duration',
|
||||
type=click.IntRange(min=1),
|
||||
default=DEFAULT_CROPDETECT_DURATION_SECONDS,
|
||||
show_default=True,
|
||||
help='When --crop auto is used, analyze this many seconds for crop detection.',
|
||||
)
|
||||
@click.option(
|
||||
"--cut",
|
||||
type=str,
|
||||
metavar="DURATION|START,DURATION",
|
||||
is_flag=False,
|
||||
flag_value=DEFAULT_CUT_OPTION_VALUE,
|
||||
default=None,
|
||||
callback=normalizeCutOption,
|
||||
help=CUT_OPTION_HELP,
|
||||
)
|
||||
|
||||
@click.option("--output-directory", type=str, default='')
|
||||
|
||||
@@ -444,10 +887,24 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
||||
@click.option("--no-signature", is_flag=True, default=False)
|
||||
@click.option("--keep-mkvmerge-metadata", is_flag=True, default=False)
|
||||
|
||||
@click.option('--nice', type=int, default=99, help='Niceness of started processes')
|
||||
@click.option('--cpu', type=int, default=0, help='Limit CPU for started processes to percent')
|
||||
@click.option(
|
||||
'--nice',
|
||||
type=int,
|
||||
default=None,
|
||||
callback=normalizeNicenessOption,
|
||||
show_default='disabled',
|
||||
help='Adjust niceness of started processes (-20..19). Omit to disable; 99 also disables.',
|
||||
)
|
||||
@click.option(
|
||||
'--cpu',
|
||||
type=str,
|
||||
default=None,
|
||||
callback=normalizeCpuOption,
|
||||
show_default='disabled',
|
||||
help=CPU_OPTION_HELP,
|
||||
)
|
||||
|
||||
@click.option('--rename-only', is_flag=True, default=False, help='Only renaming, no recoding')
|
||||
@click.option('--rename-only', is_flag=True, default=False, help='Only renaming and moving, no recoding')
|
||||
|
||||
def convert(ctx,
|
||||
paths,
|
||||
@@ -475,6 +932,8 @@ def convert(ctx,
|
||||
rearrange_streams,
|
||||
|
||||
crop,
|
||||
crop_seek,
|
||||
crop_duration,
|
||||
cut,
|
||||
|
||||
output_directory,
|
||||
@@ -509,6 +968,21 @@ def convert(ctx,
|
||||
Filename extensions will be changed appropriately.
|
||||
Suffices will we appended to filename in case of multiple created files
|
||||
or if the filename has not changed."""
|
||||
from ffx.ffx_controller import FfxController
|
||||
from ffx.file_properties import FileProperties
|
||||
from ffx.filter.crop_filter import CropFilter
|
||||
from ffx.filter.deinterlace_filter import DeinterlaceFilter
|
||||
from ffx.filter.nlmeans_filter import NlmeansFilter
|
||||
from ffx.filter.preset_filter import PresetFilter
|
||||
from ffx.filter.quality_filter import QualityFilter
|
||||
from ffx.helper import filterFilename, getEpisodeFileBasename, substituteTmdbFilename
|
||||
from ffx.shifted_season_controller import ShiftedSeasonController
|
||||
from ffx.show_controller import ShowController
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
from ffx.tmdb_controller import TmdbController
|
||||
from ffx.track_codec import TrackCodec
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
from ffx.video_encoder import VideoEncoder
|
||||
|
||||
startTime = time.perf_counter()
|
||||
|
||||
@@ -516,9 +990,13 @@ def convert(ctx,
|
||||
|
||||
context['video_encoder'] = VideoEncoder.fromLabel(video_encoder)
|
||||
|
||||
#HINT: quick and dirty override for h264, todo improve
|
||||
targetFormat = '' if context['video_encoder'] == VideoEncoder.H264 else FfxController.DEFAULT_FILE_FORMAT
|
||||
targetExtension = 'mkv' if context['video_encoder'] == VideoEncoder.H264 else FfxController.DEFAULT_FILE_EXTENSION
|
||||
# HINT: quick and dirty override for h264, todo improve
|
||||
if context['video_encoder'] in (VideoEncoder.H264, VideoEncoder.COPY):
|
||||
targetFormat = ''
|
||||
targetExtension = 'mkv'
|
||||
else:
|
||||
targetFormat = DEFAULT_CONTAINER_FORMAT
|
||||
targetExtension = DEFAULT_CONTAINER_EXTENSION
|
||||
|
||||
context['use_tmdb'] = not no_tmdb
|
||||
context['use_pattern'] = not no_pattern
|
||||
@@ -529,16 +1007,29 @@ def convert(ctx,
|
||||
|
||||
context['resource_limits'] = {}
|
||||
context['resource_limits']['niceness'] = nice
|
||||
context['resource_limits']['cpu_limit'] = cpu
|
||||
context['resource_limits']['cpu_percent'] = cpu
|
||||
context['cropdetect'] = {
|
||||
'seek_seconds': crop_seek,
|
||||
'duration_seconds': crop_duration,
|
||||
}
|
||||
|
||||
|
||||
context['import_subtitles'] = (subtitle_directory and subtitle_prefix)
|
||||
(
|
||||
context['import_subtitles'],
|
||||
resolvedSubtitleDirectory,
|
||||
resolvedSubtitlePrefix,
|
||||
) = resolveSubtitleImportOptions(
|
||||
context,
|
||||
subtitle_directory,
|
||||
subtitle_prefix,
|
||||
)
|
||||
if context['import_subtitles']:
|
||||
context['subtitle_directory'] = subtitle_directory
|
||||
context['subtitle_prefix'] = subtitle_prefix
|
||||
context['subtitle_directory'] = resolvedSubtitleDirectory
|
||||
context['subtitle_prefix'] = resolvedSubtitlePrefix
|
||||
|
||||
|
||||
existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in FfxController.INPUT_FILE_EXTENSIONS]
|
||||
existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in SUPPORTED_INPUT_FILE_EXTENSIONS]
|
||||
|
||||
|
||||
# CLI Overrides
|
||||
@@ -619,13 +1110,15 @@ def convert(ctx,
|
||||
|
||||
#->
|
||||
# Process cut parameters
|
||||
context['perform_cut'] = (cut != 'none')
|
||||
context['perform_cut'] = (cut is not None)
|
||||
if context['perform_cut']:
|
||||
cutTokens = cut.split(',')
|
||||
if cutTokens and len(cutTokens) == 2:
|
||||
context['cut_start'] = int(cutTokens[0])
|
||||
context['cut_length'] = int(cutTokens[1])
|
||||
ctx.obj['logger'].debug(f"Cut start={context['cut_start']} length={context['cut_length']}")
|
||||
context['cut_start'], context['cut_length'] = cut
|
||||
click.echo(
|
||||
f"Cutting enabled: start {context['cut_start']} s, duration {context['cut_length']} s."
|
||||
)
|
||||
ctx.obj['logger'].debug(
|
||||
f"Cut start={context['cut_start']} length={context['cut_length']}"
|
||||
)
|
||||
|
||||
|
||||
tc = TmdbController() if context['use_tmdb'] else None
|
||||
@@ -668,6 +1161,7 @@ def convert(ctx,
|
||||
ctx.obj['logger'].info(f"\nRunning {len(existingSourcePaths) * len(chainYield)} jobs")
|
||||
|
||||
jobIndex = 0
|
||||
showController = ShowController(context)
|
||||
|
||||
for sourcePath in existingSourcePaths:
|
||||
|
||||
@@ -700,7 +1194,7 @@ def convert(ctx,
|
||||
|
||||
ssc = ShiftedSeasonController(context)
|
||||
|
||||
showId = mediaFileProperties.getShowId()
|
||||
matchedShowId = mediaFileProperties.getShowId()
|
||||
|
||||
#HINT: -1 if not set
|
||||
if 'tmdb' in cliOverrides.keys() and 'season' in cliOverrides['tmdb']:
|
||||
@@ -782,7 +1276,8 @@ def convert(ctx,
|
||||
targetMediaDescriptor.importSubtitles(context['subtitle_directory'],
|
||||
context['subtitle_prefix'],
|
||||
showSeason,
|
||||
showEpisode)
|
||||
showEpisode,
|
||||
preserve_dispositions=True)
|
||||
|
||||
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
|
||||
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")
|
||||
@@ -797,26 +1292,59 @@ def convert(ctx,
|
||||
|
||||
fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor)
|
||||
|
||||
|
||||
indexSeasonDigits = currentShowDescriptor.getIndexSeasonDigits() if not currentPattern is None else ShowDescriptor.DEFAULT_INDEX_SEASON_DIGITS
|
||||
indexEpisodeDigits = currentShowDescriptor.getIndexEpisodeDigits() if not currentPattern is None else ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS
|
||||
indicatorSeasonDigits = currentShowDescriptor.getIndicatorSeasonDigits() if not currentPattern is None else ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS
|
||||
indicatorEpisodeDigits = currentShowDescriptor.getIndicatorEpisodeDigits() if not currentPattern is None else ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS
|
||||
qualityShowId = (
|
||||
cliOverrides['tmdb']['show']
|
||||
if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb']
|
||||
else matchedShowId
|
||||
)
|
||||
if currentShowDescriptor is None and qualityShowId != -1:
|
||||
currentShowDescriptor = showController.getShowDescriptor(qualityShowId)
|
||||
|
||||
|
||||
# Shift season and episode if defined for this show
|
||||
if ('tmdb' not in cliOverrides.keys() and showId != -1
|
||||
and showSeason != -1 and showEpisode != -1):
|
||||
shiftedShowSeason, shiftedShowEpisode = ssc.shiftSeason(showId,
|
||||
defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(context)
|
||||
indexSeasonDigits = currentShowDescriptor.getIndexSeasonDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]
|
||||
indexEpisodeDigits = currentShowDescriptor.getIndexEpisodeDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY]
|
||||
indicatorSeasonDigits = currentShowDescriptor.getIndicatorSeasonDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY]
|
||||
indicatorEpisodeDigits = currentShowDescriptor.getIndicatorEpisodeDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
|
||||
|
||||
|
||||
showIdForShift = (
|
||||
cliOverrides['tmdb']['show']
|
||||
if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb']
|
||||
else matchedShowId
|
||||
)
|
||||
patternIdForShift = currentPattern.getId() if currentPattern is not None else None
|
||||
hasExplicitTargetSeasonOrEpisode = (
|
||||
'tmdb' in cliOverrides.keys()
|
||||
and (
|
||||
'season' in cliOverrides['tmdb']
|
||||
or 'episode' in cliOverrides['tmdb']
|
||||
)
|
||||
)
|
||||
|
||||
# Shift season and episode if defined for the matched pattern or show
|
||||
if (
|
||||
not hasExplicitTargetSeasonOrEpisode
|
||||
and showSeason != -1
|
||||
and showEpisode != -1
|
||||
):
|
||||
shiftedShowSeason, shiftedShowEpisode = ssc.shiftSeason(
|
||||
showIdForShift,
|
||||
season=showSeason,
|
||||
episode=showEpisode)
|
||||
episode=showEpisode,
|
||||
patternId=patternIdForShift,
|
||||
)
|
||||
else:
|
||||
shiftedShowSeason = showSeason
|
||||
shiftedShowEpisode = showEpisode
|
||||
|
||||
# Assemble target filename accordingly depending on TMDB lookup is enabled
|
||||
#HINT: -1 if not set
|
||||
showId = cliOverrides['tmdb']['show'] if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb'] else (-1 if currentShowDescriptor is None else currentShowDescriptor.getId())
|
||||
showId = (
|
||||
cliOverrides['tmdb']['show']
|
||||
if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb']
|
||||
else (-1 if currentShowDescriptor is None else currentShowDescriptor.getId())
|
||||
)
|
||||
|
||||
if context['use_tmdb'] and showId != -1 and shiftedShowSeason != -1 and shiftedShowEpisode != -1:
|
||||
|
||||
@@ -895,14 +1423,15 @@ def convert(ctx,
|
||||
|
||||
|
||||
if rename_only:
|
||||
shutil.copyfile(sourcePath, targetPath)
|
||||
shutil.move(sourcePath, targetPath)
|
||||
else:
|
||||
fc.runJob(sourcePath,
|
||||
targetPath,
|
||||
targetFormat,
|
||||
chainIteration,
|
||||
cropArguments,
|
||||
currentPattern)
|
||||
currentPattern,
|
||||
currentShowDescriptor)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import os, json
|
||||
|
||||
from .constants import (
|
||||
DEFAULT_SHOW_INDEX_EPISODE_DIGITS,
|
||||
DEFAULT_SHOW_INDEX_SEASON_DIGITS,
|
||||
DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS,
|
||||
DEFAULT_SHOW_INDICATOR_SEASON_DIGITS,
|
||||
)
|
||||
|
||||
class ConfigurationController():
|
||||
|
||||
CONFIG_FILENAME = 'ffx.json'
|
||||
@@ -8,7 +15,12 @@ class ConfigurationController():
|
||||
|
||||
DATABASE_PATH_CONFIG_KEY = 'databasePath'
|
||||
LOG_DIRECTORY_CONFIG_KEY = 'logDirectory'
|
||||
SUBTITLES_DIRECTORY_CONFIG_KEY = 'subtitlesDirectory'
|
||||
OUTPUT_FILENAME_TEMPLATE_KEY = 'outputFilenameTemplate'
|
||||
DEFAULT_INDEX_SEASON_DIGITS_CONFIG_KEY = 'defaultIndexSeasonDigits'
|
||||
DEFAULT_INDEX_EPISODE_DIGITS_CONFIG_KEY = 'defaultIndexEpisodeDigits'
|
||||
DEFAULT_INDICATOR_SEASON_DIGITS_CONFIG_KEY = 'defaultIndicatorSeasonDigits'
|
||||
DEFAULT_INDICATOR_EPISODE_DIGITS_CONFIG_KEY = 'defaultIndicatorEpisodeDigits'
|
||||
|
||||
|
||||
def __init__(self):
|
||||
@@ -49,6 +61,48 @@ class ConfigurationController():
|
||||
def getDatabaseFilePath(self):
|
||||
return self.__databaseFilePath
|
||||
|
||||
def getSubtitlesDirectoryPath(self):
|
||||
subtitlesDirectory = self.__configurationData.get(
|
||||
ConfigurationController.SUBTITLES_DIRECTORY_CONFIG_KEY,
|
||||
'',
|
||||
)
|
||||
return os.path.expanduser(str(subtitlesDirectory)) if subtitlesDirectory else ''
|
||||
|
||||
@classmethod
|
||||
def getConfiguredIntegerValue(cls, configurationData: dict, configKey: str, defaultValue: int) -> int:
|
||||
configuredValue = configurationData.get(configKey, defaultValue)
|
||||
try:
|
||||
return int(configuredValue)
|
||||
except (TypeError, ValueError):
|
||||
return int(defaultValue)
|
||||
|
||||
def getDefaultIndexSeasonDigits(self):
|
||||
return ConfigurationController.getConfiguredIntegerValue(
|
||||
self.__configurationData,
|
||||
ConfigurationController.DEFAULT_INDEX_SEASON_DIGITS_CONFIG_KEY,
|
||||
DEFAULT_SHOW_INDEX_SEASON_DIGITS,
|
||||
)
|
||||
|
||||
def getDefaultIndexEpisodeDigits(self):
|
||||
return ConfigurationController.getConfiguredIntegerValue(
|
||||
self.__configurationData,
|
||||
ConfigurationController.DEFAULT_INDEX_EPISODE_DIGITS_CONFIG_KEY,
|
||||
DEFAULT_SHOW_INDEX_EPISODE_DIGITS,
|
||||
)
|
||||
|
||||
def getDefaultIndicatorSeasonDigits(self):
|
||||
return ConfigurationController.getConfiguredIntegerValue(
|
||||
self.__configurationData,
|
||||
ConfigurationController.DEFAULT_INDICATOR_SEASON_DIGITS_CONFIG_KEY,
|
||||
DEFAULT_SHOW_INDICATOR_SEASON_DIGITS,
|
||||
)
|
||||
|
||||
def getDefaultIndicatorEpisodeDigits(self):
|
||||
return ConfigurationController.getConfiguredIntegerValue(
|
||||
self.__configurationData,
|
||||
ConfigurationController.DEFAULT_INDICATOR_EPISODE_DIGITS_CONFIG_KEY,
|
||||
DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS,
|
||||
)
|
||||
|
||||
def getData(self):
|
||||
return self.__configurationData
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
VERSION='0.2.3'
|
||||
DATABASE_VERSION = 2
|
||||
VERSION='0.2.4'
|
||||
DATABASE_VERSION = 3
|
||||
|
||||
DEFAULT_QUALITY = 32
|
||||
DEFAULT_AV1_PRESET = 5
|
||||
|
||||
DEFAULT_VIDEO_ENCODER_LABEL = "vp9"
|
||||
DEFAULT_CONTAINER_FORMAT = "webm"
|
||||
DEFAULT_CONTAINER_EXTENSION = "webm"
|
||||
SUPPORTED_INPUT_FILE_EXTENSIONS = ("mkv", "mp4", "avi", "flv", "webm")
|
||||
FFMPEG_COMMAND_TOKENS = ("ffmpeg", "-y")
|
||||
FFMPEG_NULL_OUTPUT_TOKENS = ("-f", "null", "/dev/null")
|
||||
|
||||
DEFAULT_STEREO_BANDWIDTH = "112"
|
||||
DEFAULT_AC3_BANDWIDTH = "256"
|
||||
DEFAULT_DTS_BANDWIDTH = "320"
|
||||
DEFAULT_7_1_BANDWIDTH = "384"
|
||||
|
||||
DEFAULT_CROPDETECT_SEEK_SECONDS = 60
|
||||
DEFAULT_CROPDETECT_DURATION_SECONDS = 180
|
||||
|
||||
DEFAULT_cut_start = 60
|
||||
DEFAULT_cut_length = 180
|
||||
|
||||
DEFAULT_SHOW_INDEX_SEASON_DIGITS = 2
|
||||
DEFAULT_SHOW_INDEX_EPISODE_DIGITS = 2
|
||||
DEFAULT_SHOW_INDICATOR_SEASON_DIGITS = 2
|
||||
DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS = 2
|
||||
|
||||
DEFAULT_OUTPUT_FILENAME_TEMPLATE = '{{ ffx_show_name }} - {{ ffx_index }}{{ ffx_index_separator }}{{ ffx_episode_name }}{{ ffx_indicator_separator }}{{ ffx_indicator }}'
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import os, click
|
||||
import os, shutil, click
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Import the full model package so SQLAlchemy registers every mapped class
|
||||
# before metadata creation and the first ORM query.
|
||||
import ffx.model
|
||||
from ffx.model.show import Base
|
||||
|
||||
from ffx.model.property import Property
|
||||
from ffx.model.migration import (
|
||||
DatabaseVersionException,
|
||||
getMigrationPlan,
|
||||
migrateDatabase,
|
||||
)
|
||||
|
||||
from ffx.constants import DATABASE_VERSION
|
||||
|
||||
|
||||
DATABASE_VERSION_KEY = 'database_version'
|
||||
|
||||
class DatabaseVersionException(Exception):
|
||||
def __init__(self, errorMessage):
|
||||
super().__init__(errorMessage)
|
||||
EXPECTED_TABLE_NAMES = set(Base.metadata.tables.keys())
|
||||
|
||||
def databaseContext(databasePath: str = ''):
|
||||
|
||||
@@ -29,12 +34,18 @@ def databaseContext(databasePath: str = ''):
|
||||
if not os.path.exists(ffxVarDir):
|
||||
os.makedirs(ffxVarDir)
|
||||
databasePath = os.path.join(ffxVarDir, 'ffx.db')
|
||||
else:
|
||||
databasePath = os.path.expanduser(databasePath)
|
||||
|
||||
if databasePath != ':memory:':
|
||||
databasePath = os.path.abspath(databasePath)
|
||||
|
||||
databaseContext['path'] = databasePath
|
||||
databaseContext['url'] = f"sqlite:///{databasePath}"
|
||||
databaseContext['engine'] = create_engine(databaseContext['url'])
|
||||
databaseContext['session'] = sessionmaker(bind=databaseContext['engine'])
|
||||
|
||||
Base.metadata.create_all(databaseContext['engine'])
|
||||
bootstrapDatabaseIfNeeded(databaseContext)
|
||||
|
||||
# isSyncronuous = False
|
||||
# while not isSyncronuous:
|
||||
@@ -51,14 +62,126 @@ def databaseContext(databasePath: str = ''):
|
||||
|
||||
return databaseContext
|
||||
|
||||
|
||||
def databaseNeedsBootstrap(databaseContext) -> bool:
|
||||
inspector = inspect(databaseContext['engine'])
|
||||
existingTableNames = set(inspector.get_table_names())
|
||||
return not EXPECTED_TABLE_NAMES.issubset(existingTableNames)
|
||||
|
||||
|
||||
def bootstrapDatabaseIfNeeded(databaseContext):
|
||||
if not databaseNeedsBootstrap(databaseContext):
|
||||
return
|
||||
|
||||
Base.metadata.create_all(databaseContext['engine'])
|
||||
|
||||
|
||||
def ensureDatabaseVersion(databaseContext):
|
||||
|
||||
currentDatabaseVersion = getDatabaseVersion(databaseContext)
|
||||
if currentDatabaseVersion:
|
||||
if currentDatabaseVersion != DATABASE_VERSION:
|
||||
raise DatabaseVersionException(f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})")
|
||||
else:
|
||||
if not currentDatabaseVersion:
|
||||
setDatabaseVersion(databaseContext, DATABASE_VERSION)
|
||||
return
|
||||
|
||||
if currentDatabaseVersion > DATABASE_VERSION:
|
||||
raise DatabaseVersionException(
|
||||
f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})"
|
||||
)
|
||||
|
||||
if currentDatabaseVersion < DATABASE_VERSION:
|
||||
promptForDatabaseMigration(databaseContext, currentDatabaseVersion, DATABASE_VERSION)
|
||||
migrateDatabase(databaseContext, currentDatabaseVersion, DATABASE_VERSION, setDatabaseVersion)
|
||||
currentDatabaseVersion = getDatabaseVersion(databaseContext)
|
||||
|
||||
if currentDatabaseVersion != DATABASE_VERSION:
|
||||
raise DatabaseVersionException(
|
||||
f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})"
|
||||
)
|
||||
|
||||
ensureCurrentSchemaCompatibility(databaseContext)
|
||||
|
||||
|
||||
def ensureCurrentSchemaCompatibility(databaseContext):
|
||||
engine = databaseContext['engine']
|
||||
inspector = inspect(engine)
|
||||
showColumns = {
|
||||
column['name']
|
||||
for column in inspector.get_columns('shows')
|
||||
}
|
||||
|
||||
alterStatements = []
|
||||
if 'quality' not in showColumns:
|
||||
alterStatements.append("ALTER TABLE shows ADD COLUMN quality INTEGER DEFAULT 0")
|
||||
if 'notes' not in showColumns:
|
||||
alterStatements.append("ALTER TABLE shows ADD COLUMN notes TEXT DEFAULT ''")
|
||||
|
||||
if not alterStatements:
|
||||
return
|
||||
|
||||
with engine.begin() as connection:
|
||||
for alterStatement in alterStatements:
|
||||
connection.execute(text(alterStatement))
|
||||
|
||||
|
||||
def promptForDatabaseMigration(databaseContext, currentDatabaseVersion: int, targetDatabaseVersion: int):
|
||||
migrationPlan = getMigrationPlan(currentDatabaseVersion, targetDatabaseVersion)
|
||||
|
||||
click.echo("Database migration required.")
|
||||
click.echo(f"Current version: {currentDatabaseVersion}")
|
||||
click.echo(f"Target version: {targetDatabaseVersion}")
|
||||
click.echo("Steps required:")
|
||||
|
||||
missingSteps = []
|
||||
for migrationStep in migrationPlan:
|
||||
moduleStatus = "present" if migrationStep.modulePresent else "missing"
|
||||
click.echo(
|
||||
f" {migrationStep.versionFrom} -> {migrationStep.versionTo}: "
|
||||
+ f"{migrationStep.moduleName} [{moduleStatus}]"
|
||||
)
|
||||
if not migrationStep.modulePresent:
|
||||
missingSteps.append(migrationStep)
|
||||
|
||||
if missingSteps:
|
||||
firstMissingStep = missingSteps[0]
|
||||
raise DatabaseVersionException(
|
||||
f"No migration path from database version "
|
||||
+ f"{firstMissingStep.versionFrom} to {firstMissingStep.versionTo}"
|
||||
)
|
||||
|
||||
if not click.confirm(
|
||||
"Create a backup and continue with database migration?",
|
||||
default=True,
|
||||
):
|
||||
raise click.ClickException("Database migration aborted by user.")
|
||||
|
||||
backupPath = backupDatabaseBeforeMigration(
|
||||
databaseContext,
|
||||
currentDatabaseVersion,
|
||||
targetDatabaseVersion,
|
||||
)
|
||||
click.echo(f"Database backup created: {backupPath}")
|
||||
|
||||
|
||||
def backupDatabaseBeforeMigration(databaseContext, currentDatabaseVersion: int, targetDatabaseVersion: int) -> str:
|
||||
databasePath = databaseContext.get('path', '')
|
||||
if not databasePath or databasePath == ':memory:':
|
||||
raise click.ClickException("Database migration backup requires a file-backed SQLite database.")
|
||||
|
||||
if not os.path.isfile(databasePath):
|
||||
raise click.ClickException(f"Database file not found for backup: {databasePath}")
|
||||
|
||||
backupPath = f"{databasePath}.v{currentDatabaseVersion}-to-v{targetDatabaseVersion}.bak"
|
||||
backupIndex = 1
|
||||
while os.path.exists(backupPath):
|
||||
backupPath = (
|
||||
f"{databasePath}.v{currentDatabaseVersion}-to-v{targetDatabaseVersion}.{backupIndex}.bak"
|
||||
)
|
||||
backupIndex += 1
|
||||
|
||||
databaseContext['engine'].dispose()
|
||||
shutil.copy2(databasePath, backupPath)
|
||||
|
||||
return backupPath
|
||||
|
||||
|
||||
def getDatabaseVersion(databaseContext):
|
||||
@@ -67,9 +190,9 @@ def getDatabaseVersion(databaseContext):
|
||||
|
||||
Session = databaseContext['session']
|
||||
s = Session()
|
||||
q = s.query(Property).filter(Property.key == DATABASE_VERSION_KEY)
|
||||
versionProperty = s.query(Property).filter(Property.key == DATABASE_VERSION_KEY).first()
|
||||
|
||||
return int(q.first().value) if q.count() else 0
|
||||
return int(versionProperty.value) if versionProperty is not None else 0
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"getDatabaseVersion(): {repr(ex)}")
|
||||
|
||||
@@ -10,7 +10,16 @@ from ffx.track_codec import TrackCodec
|
||||
from ffx.video_encoder import VideoEncoder
|
||||
from ffx.process import executeProcess
|
||||
|
||||
from ffx.constants import DEFAULT_cut_start, DEFAULT_cut_length
|
||||
from ffx.constants import (
|
||||
DEFAULT_CONTAINER_EXTENSION,
|
||||
DEFAULT_CONTAINER_FORMAT,
|
||||
DEFAULT_VIDEO_ENCODER_LABEL,
|
||||
DEFAULT_cut_start,
|
||||
DEFAULT_cut_length,
|
||||
FFMPEG_COMMAND_TOKENS,
|
||||
FFMPEG_NULL_OUTPUT_TOKENS,
|
||||
SUPPORTED_INPUT_FILE_EXTENSIONS,
|
||||
)
|
||||
|
||||
from ffx.filter.quality_filter import QualityFilter
|
||||
from ffx.filter.preset_filter import PresetFilter
|
||||
@@ -21,17 +30,17 @@ from ffx.model.pattern import Pattern
|
||||
|
||||
class FfxController():
|
||||
|
||||
COMMAND_TOKENS = ['ffmpeg', '-y']
|
||||
NULL_TOKENS = ['-f', 'null', '/dev/null'] # -f null /dev/null
|
||||
COMMAND_TOKENS = list(FFMPEG_COMMAND_TOKENS)
|
||||
NULL_TOKENS = list(FFMPEG_NULL_OUTPUT_TOKENS) # -f null /dev/null
|
||||
|
||||
TEMP_FILE_NAME = "ffmpeg2pass-0.log"
|
||||
|
||||
DEFAULT_VIDEO_ENCODER = VideoEncoder.VP9.label()
|
||||
DEFAULT_VIDEO_ENCODER = DEFAULT_VIDEO_ENCODER_LABEL
|
||||
|
||||
DEFAULT_FILE_FORMAT = 'webm'
|
||||
DEFAULT_FILE_EXTENSION = 'webm'
|
||||
DEFAULT_FILE_FORMAT = DEFAULT_CONTAINER_FORMAT
|
||||
DEFAULT_FILE_EXTENSION = DEFAULT_CONTAINER_EXTENSION
|
||||
|
||||
INPUT_FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm']
|
||||
INPUT_FILE_EXTENSIONS = list(SUPPORTED_INPUT_FILE_EXTENSIONS)
|
||||
|
||||
CHANNEL_MAP_5_1 = 'FL-FL|FR-FR|FC-FC|LFE-LFE|SL-BL|SR-BR:5.1'
|
||||
|
||||
@@ -54,6 +63,13 @@ class FfxController():
|
||||
self.__logger: Logger = context['logger']
|
||||
|
||||
|
||||
def executeCommandSequence(self, commandSequence):
|
||||
out, err, rc = executeProcess(commandSequence, context=self.__context)
|
||||
if rc:
|
||||
raise click.ClickException(f"Command resulted in error: rc={rc} error={err}")
|
||||
return out, err, rc
|
||||
|
||||
|
||||
def generateAV1Tokens(self, quality, preset, subIndex : int = 0):
|
||||
|
||||
return [f"-c:v:{int(subIndex)}", 'libsvtav1',
|
||||
@@ -100,6 +116,37 @@ class FfxController():
|
||||
return [f"-c:v:{int(subIndex)}",
|
||||
'copy']
|
||||
|
||||
def generateAudioCopyTokens(self, subIndex):
|
||||
return [f"-c:a:{int(subIndex)}", 'copy']
|
||||
|
||||
def generateSubtitleCopyTokens(self, subIndex):
|
||||
return [f"-c:s:{int(subIndex)}", 'copy']
|
||||
|
||||
def generateAttachmentCopyTokens(self, subIndex):
|
||||
return [f"-c:t:{int(subIndex)}", 'copy']
|
||||
|
||||
def generateCopyTokens(self):
|
||||
copyTokens = []
|
||||
|
||||
for trackDescriptor in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.VIDEO):
|
||||
copyTokens += self.generateVideoCopyTokens(trackDescriptor.getSubIndex())
|
||||
|
||||
for trackDescriptor in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.AUDIO):
|
||||
copyTokens += self.generateAudioCopyTokens(trackDescriptor.getSubIndex())
|
||||
|
||||
for trackDescriptor in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.SUBTITLE):
|
||||
copyTokens += self.generateSubtitleCopyTokens(trackDescriptor.getSubIndex())
|
||||
|
||||
attachmentDescriptors = (
|
||||
self.__sourceMediaDescriptor.getTrackDescriptors(trackType=TrackType.ATTACHMENT)
|
||||
if self.__sourceMediaDescriptor is not None
|
||||
else self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.ATTACHMENT)
|
||||
)
|
||||
for trackDescriptor in attachmentDescriptors:
|
||||
copyTokens += self.generateAttachmentCopyTokens(trackDescriptor.getSubIndex())
|
||||
|
||||
return copyTokens
|
||||
|
||||
|
||||
def generateCropTokens(self):
|
||||
|
||||
@@ -124,6 +171,18 @@ class FfxController():
|
||||
return [outputFilePath]
|
||||
|
||||
|
||||
def generateEncodingMetadataTags(self, videoEncoder: VideoEncoder, quality, preset) -> dict:
|
||||
metadataTags = {}
|
||||
|
||||
if videoEncoder in (VideoEncoder.AV1, VideoEncoder.H264, VideoEncoder.VP9):
|
||||
metadataTags["ENCODING_QUALITY"] = str(quality)
|
||||
|
||||
if videoEncoder == VideoEncoder.AV1:
|
||||
metadataTags["ENCODING_PRESET"] = str(preset)
|
||||
|
||||
return metadataTags
|
||||
|
||||
|
||||
def generateAudioEncodingTokens(self):
|
||||
"""Generates ffmpeg options audio streams including channel remapping, codec and bitrate"""
|
||||
|
||||
@@ -186,7 +245,8 @@ class FfxController():
|
||||
targetFormat: str = '',
|
||||
chainIteration: list = [],
|
||||
cropArguments: dict = {},
|
||||
currentPattern: Pattern = None):
|
||||
currentPattern: Pattern = None,
|
||||
currentShowDescriptor = None):
|
||||
# quality: int = DEFAULT_QUALITY,
|
||||
# preset: int = DEFAULT_AV1_PRESET):
|
||||
|
||||
@@ -203,9 +263,11 @@ class FfxController():
|
||||
|
||||
|
||||
if qualityFilters and (quality := qualityFilters[0]['parameters']['quality']):
|
||||
self.__logger.info(f"Setting quality {quality} from command line parameter")
|
||||
elif (quality := currentPattern.quality):
|
||||
self.__logger.info(f"Setting quality {quality} from pattern default")
|
||||
self.__logger.info(f"Setting quality {quality} from command line")
|
||||
elif currentPattern is not None and (quality := currentPattern.quality):
|
||||
self.__logger.info(f"Setting quality {quality} from pattern")
|
||||
elif currentShowDescriptor is not None and (quality := currentShowDescriptor.getQuality()):
|
||||
self.__logger.info(f"Setting quality {quality} from show")
|
||||
else:
|
||||
quality = (QualityFilter.DEFAULT_H264_QUALITY
|
||||
if (videoEncoder == VideoEncoder.H264)
|
||||
@@ -214,6 +276,11 @@ class FfxController():
|
||||
|
||||
|
||||
preset = presetFilters[0]['parameters']['preset'] if presetFilters else PresetFilter.DEFAULT_PRESET
|
||||
self.__context['encoding_metadata_tags'] = self.generateEncodingMetadataTags(
|
||||
videoEncoder,
|
||||
quality,
|
||||
preset,
|
||||
)
|
||||
|
||||
|
||||
filterParamTokens = []
|
||||
@@ -238,6 +305,28 @@ class FfxController():
|
||||
|
||||
commandTokens = FfxController.COMMAND_TOKENS + ['-i', sourcePath]
|
||||
|
||||
if videoEncoder == VideoEncoder.COPY:
|
||||
|
||||
commandSequence = (commandTokens
|
||||
+ self.__targetMediaDescriptor.getImportFileTokens()
|
||||
+ self.__targetMediaDescriptor.getInputMappingTokens(sourceMediaDescriptor = self.__sourceMediaDescriptor)
|
||||
+ self.__mdcs.generateDispositionTokens())
|
||||
|
||||
commandSequence += self.__mdcs.generateMetadataTokens()
|
||||
commandSequence += self.generateCopyTokens()
|
||||
|
||||
if self.__context['perform_cut']:
|
||||
commandSequence += self.generateCropTokens()
|
||||
|
||||
commandSequence += self.generateOutputTokens(targetPath,
|
||||
targetFormat)
|
||||
|
||||
self.__logger.debug("FfxController.runJob(): Running command sequence")
|
||||
|
||||
if not self.__context['dry_run']:
|
||||
self.executeCommandSequence(commandSequence)
|
||||
return
|
||||
|
||||
if videoEncoder == VideoEncoder.AV1:
|
||||
|
||||
commandSequence = (commandTokens
|
||||
@@ -265,7 +354,7 @@ class FfxController():
|
||||
self.__logger.debug(f"FfxController.runJob(): Running command sequence")
|
||||
|
||||
if not self.__context['dry_run']:
|
||||
executeProcess(commandSequence, context = self.__context)
|
||||
self.executeCommandSequence(commandSequence)
|
||||
|
||||
|
||||
if videoEncoder == VideoEncoder.H264:
|
||||
@@ -295,7 +384,7 @@ class FfxController():
|
||||
self.__logger.debug(f"FfxController.runJob(): Running command sequence")
|
||||
|
||||
if not self.__context['dry_run']:
|
||||
executeProcess(commandSequence, context = self.__context)
|
||||
self.executeCommandSequence(commandSequence)
|
||||
|
||||
|
||||
|
||||
@@ -327,7 +416,7 @@ class FfxController():
|
||||
self.__logger.debug(f"FfxController.runJob(): Running command sequence 1")
|
||||
|
||||
if not self.__context['dry_run']:
|
||||
executeProcess(commandSequence1, context = self.__context)
|
||||
self.executeCommandSequence(commandSequence1)
|
||||
|
||||
commandSequence2 = (commandTokens
|
||||
+ self.__targetMediaDescriptor.getImportFileTokens()
|
||||
@@ -354,9 +443,7 @@ class FfxController():
|
||||
self.__logger.debug(f"FfxController.runJob(): Running command sequence 2")
|
||||
|
||||
if not self.__context['dry_run']:
|
||||
out, err, rc = executeProcess(commandSequence2, context = self.__context)
|
||||
if rc:
|
||||
raise click.ClickException(f"Command resulted in error: rc={rc} error={err}")
|
||||
self.executeCommandSequence(commandSequence2)
|
||||
|
||||
|
||||
|
||||
@@ -381,4 +468,4 @@ class FfxController():
|
||||
str(length),
|
||||
path]
|
||||
|
||||
out, err, rc = executeProcess(commandTokens, context = self.__context)
|
||||
self.executeCommandSequence(commandTokens)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import os, re, json
|
||||
|
||||
from .constants import (
|
||||
DEFAULT_CROPDETECT_DURATION_SECONDS,
|
||||
DEFAULT_CROPDETECT_SEEK_SECONDS,
|
||||
FFMPEG_COMMAND_TOKENS,
|
||||
FFMPEG_NULL_OUTPUT_TOKENS,
|
||||
)
|
||||
from .media_descriptor import MediaDescriptor
|
||||
from .pattern_controller import PatternController
|
||||
|
||||
@@ -11,8 +17,10 @@ from ffx.model.pattern import Pattern
|
||||
|
||||
|
||||
class FileProperties():
|
||||
_cropdetect_cache: dict[tuple[str, int, int, int, int], dict[str, str]] = {}
|
||||
|
||||
FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm']
|
||||
FFPROBE_COMMAND_TOKENS = ["ffprobe", "-hide_banner", "-show_format", "-show_streams", "-of", "json"]
|
||||
|
||||
SE_INDICATOR_PATTERN = '([sS][0-9]+[eE][0-9]+)'
|
||||
SEASON_EPISODE_INDICATOR_MATCH = '[sS]([0-9]+)[eE]([0-9]+)'
|
||||
@@ -22,6 +30,18 @@ class FileProperties():
|
||||
|
||||
DEFAULT_INDEX_DIGITS = 3
|
||||
|
||||
@classmethod
|
||||
def extractSeasonEpisodeValues(cls, sourceText: str) -> tuple[int | None, int] | None:
|
||||
seasonEpisodeMatch = re.search(cls.SEASON_EPISODE_INDICATOR_MATCH, str(sourceText))
|
||||
if seasonEpisodeMatch is not None:
|
||||
return int(seasonEpisodeMatch.group(1)), int(seasonEpisodeMatch.group(2))
|
||||
|
||||
episodeMatch = re.search(cls.EPISODE_INDICATOR_MATCH, str(sourceText))
|
||||
if episodeMatch is not None:
|
||||
return None, int(episodeMatch.group(1))
|
||||
|
||||
return None
|
||||
|
||||
def __init__(self, context, sourcePath):
|
||||
|
||||
self.context = context
|
||||
@@ -44,9 +64,10 @@ class FileProperties():
|
||||
self.__sourceFilenameExtension = ''
|
||||
|
||||
self.__pc = PatternController(context)
|
||||
self.__usePattern = bool(self.context.get('use_pattern', True))
|
||||
|
||||
# Checking if database contains matching pattern
|
||||
matchResult = self.__pc.matchFilename(self.__sourceFilename)
|
||||
matchResult = self.__pc.matchFilename(self.__sourceFilename) if self.__usePattern else {}
|
||||
|
||||
self.__logger.debug(f"FileProperties.__init__(): Match result: {matchResult}")
|
||||
|
||||
@@ -56,26 +77,67 @@ class FileProperties():
|
||||
databaseMatchedGroups = matchResult['match'].groups()
|
||||
self.__logger.debug(f"FileProperties.__init__(): Matched groups: {databaseMatchedGroups}")
|
||||
|
||||
seIndicator = databaseMatchedGroups[0]
|
||||
|
||||
se_match = re.search(FileProperties.SEASON_EPISODE_INDICATOR_MATCH, seIndicator)
|
||||
e_match = re.search(FileProperties.EPISODE_INDICATOR_MATCH, seIndicator)
|
||||
|
||||
indicatorSource = databaseMatchedGroups[0]
|
||||
else:
|
||||
self.__logger.debug(f"FileProperties.__init__(): Checking file name for indicator {self.__sourceFilename}")
|
||||
indicatorSource = self.__sourceFilename
|
||||
|
||||
se_match = re.search(FileProperties.SEASON_EPISODE_INDICATOR_MATCH, self.__sourceFilename)
|
||||
e_match = re.search(FileProperties.EPISODE_INDICATOR_MATCH, self.__sourceFilename)
|
||||
|
||||
if se_match is not None:
|
||||
self.__season = int(se_match.group(1))
|
||||
self.__episode = int(se_match.group(2))
|
||||
elif e_match is not None:
|
||||
self.__season = -1
|
||||
self.__episode = int(e_match.group(1))
|
||||
else:
|
||||
seasonEpisodeValues = self.extractSeasonEpisodeValues(indicatorSource)
|
||||
if seasonEpisodeValues is None:
|
||||
self.__season = -1
|
||||
self.__episode = -1
|
||||
else:
|
||||
sourceSeason, sourceEpisode = seasonEpisodeValues
|
||||
self.__season = -1 if sourceSeason is None else int(sourceSeason)
|
||||
self.__episode = int(sourceEpisode)
|
||||
|
||||
self.__ffprobeData = None
|
||||
|
||||
def _getCropdetectWindow(self):
|
||||
cropdetectContext = self.context.get('cropdetect', {})
|
||||
|
||||
seekSeconds = int(cropdetectContext.get('seek_seconds', DEFAULT_CROPDETECT_SEEK_SECONDS))
|
||||
durationSeconds = int(cropdetectContext.get('duration_seconds', DEFAULT_CROPDETECT_DURATION_SECONDS))
|
||||
|
||||
if seekSeconds < 0:
|
||||
raise ValueError("Crop detection seek seconds must be zero or greater.")
|
||||
if durationSeconds <= 0:
|
||||
raise ValueError("Crop detection duration seconds must be greater than zero.")
|
||||
|
||||
return seekSeconds, durationSeconds
|
||||
|
||||
def _getCropdetectCacheKey(self):
|
||||
sourceStat = os.stat(self.__sourcePath)
|
||||
seekSeconds, durationSeconds = self._getCropdetectWindow()
|
||||
|
||||
return (
|
||||
os.path.abspath(self.__sourcePath),
|
||||
sourceStat.st_mtime_ns,
|
||||
sourceStat.st_size,
|
||||
seekSeconds,
|
||||
durationSeconds,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _clear_cropdetect_cache(cls):
|
||||
cls._cropdetect_cache.clear()
|
||||
|
||||
def _getFfprobeData(self):
|
||||
if self.__ffprobeData is not None:
|
||||
return self.__ffprobeData
|
||||
|
||||
ffprobeOutput, ffprobeError, returnCode = executeProcess(
|
||||
FileProperties.FFPROBE_COMMAND_TOKENS + [self.__sourcePath]
|
||||
)
|
||||
|
||||
if 'Invalid data found when processing input' in ffprobeError:
|
||||
raise Exception(f"File {self.__sourcePath} does not contain valid stream data")
|
||||
|
||||
if returnCode != 0:
|
||||
raise Exception(f"ffprobe returned with error {returnCode}")
|
||||
|
||||
self.__ffprobeData = json.loads(ffprobeOutput)
|
||||
return self.__ffprobeData
|
||||
|
||||
|
||||
def getFormatData(self):
|
||||
@@ -98,22 +160,7 @@ class FileProperties():
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# ffprobe -hide_banner -show_format -of json
|
||||
ffprobeOutput, ffprobeError, returnCode = executeProcess(["ffprobe",
|
||||
"-hide_banner",
|
||||
"-show_format",
|
||||
"-of", "json",
|
||||
self.__sourcePath]) #,
|
||||
#context = self.context)
|
||||
|
||||
if 'Invalid data found when processing input' in ffprobeError:
|
||||
raise Exception(f"File {self.__sourcePath} does not contain valid stream data")
|
||||
|
||||
if returnCode != 0:
|
||||
raise Exception(f"ffprobe returned with error {returnCode}")
|
||||
|
||||
return json.loads(ffprobeOutput)['format']
|
||||
return self._getFfprobeData()['format']
|
||||
|
||||
|
||||
def getStreamData(self):
|
||||
@@ -158,40 +205,32 @@ class FileProperties():
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# ffprobe -hide_banner -show_streams -of json
|
||||
ffprobeOutput, ffprobeError, returnCode = executeProcess(["ffprobe",
|
||||
"-hide_banner",
|
||||
"-show_streams",
|
||||
"-of", "json",
|
||||
self.__sourcePath]) #,
|
||||
#context = self.context)
|
||||
|
||||
if 'Invalid data found when processing input' in ffprobeError:
|
||||
raise Exception(f"File {self.__sourcePath} does not contain valid stream data")
|
||||
|
||||
|
||||
if returnCode != 0:
|
||||
raise Exception(f"ffprobe returned with error {returnCode}")
|
||||
|
||||
|
||||
return json.loads(ffprobeOutput)['streams']
|
||||
return self._getFfprobeData()['streams']
|
||||
|
||||
|
||||
|
||||
def findCropArguments(self):
|
||||
""""""
|
||||
|
||||
# ffmpeg -i <input.file> -vf cropdetect -f null -
|
||||
ffprobeOutput, ffprobeError, returnCode = executeProcess(["ffmpeg", "-i",
|
||||
cacheKey = self._getCropdetectCacheKey()
|
||||
cachedCropArguments = FileProperties._cropdetect_cache.get(cacheKey)
|
||||
if cachedCropArguments is not None:
|
||||
self.__logger.debug(
|
||||
"FileProperties.findCropArguments(): Reusing cached cropdetect result for %s",
|
||||
self.__sourcePath,
|
||||
"-vf", "cropdetect",
|
||||
"-ss", "60",
|
||||
"-t", "180",
|
||||
"-f", "null", "-"
|
||||
])
|
||||
)
|
||||
return dict(cachedCropArguments)
|
||||
|
||||
errorLines = ffprobeError.split('\n')
|
||||
seekSeconds, durationSeconds = self._getCropdetectWindow()
|
||||
|
||||
cropdetectCommand = (
|
||||
list(FFMPEG_COMMAND_TOKENS)
|
||||
+ ["-ss", str(seekSeconds), "-i", self.__sourcePath, "-t", str(durationSeconds), "-vf", "cropdetect"]
|
||||
+ list(FFMPEG_NULL_OUTPUT_TOKENS)
|
||||
)
|
||||
_ffmpegOutput, ffmpegError, returnCode = executeProcess(cropdetectCommand, context=self.context)
|
||||
|
||||
errorLines = ffmpegError.split('\n')
|
||||
|
||||
crops = {}
|
||||
for el in errorLines:
|
||||
@@ -204,20 +243,25 @@ class FileProperties():
|
||||
crops[cropParam] = crops.get(cropParam, 0) + 1
|
||||
|
||||
if crops:
|
||||
cropHistogram = sorted(crops, reverse=True)
|
||||
cropString = cropHistogram[0]
|
||||
cropString = max(crops.items(), key=lambda item: (item[1], item[0]))[0]
|
||||
|
||||
cropTokens = cropString.split('=')
|
||||
cropValueTokens = cropTokens[1]
|
||||
cropValues = cropValueTokens.split(':')
|
||||
|
||||
return {
|
||||
cropArguments = {
|
||||
CropFilter.OUTPUT_WIDTH_KEY: cropValues[0],
|
||||
CropFilter.OUTPUT_HEIGHT_KEY: cropValues[1],
|
||||
CropFilter.OFFSET_X_KEY: cropValues[2],
|
||||
CropFilter.OFFSET_Y_KEY: cropValues[3]
|
||||
}
|
||||
else:
|
||||
FileProperties._cropdetect_cache[cacheKey] = dict(cropArguments)
|
||||
return cropArguments
|
||||
|
||||
if returnCode != 0:
|
||||
raise Exception(f"ffmpeg cropdetect returned with error {returnCode}")
|
||||
|
||||
FileProperties._cropdetect_cache[cacheKey] = {}
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import re, logging
|
||||
import re
|
||||
|
||||
from jinja2 import Environment, Undefined
|
||||
from .constants import DEFAULT_OUTPUT_FILENAME_TEMPLATE
|
||||
from .configuration_controller import ConfigurationController
|
||||
from .logging_utils import get_ffx_logger
|
||||
from .show_descriptor import ShowDescriptor
|
||||
|
||||
|
||||
class EmptyStringUndefined(Undefined):
|
||||
@@ -15,7 +17,21 @@ DIFF_REMOVED_KEY = 'removed'
|
||||
DIFF_CHANGED_KEY = 'changed'
|
||||
DIFF_UNCHANGED_KEY = 'unchanged'
|
||||
|
||||
RICH_COLOR_PATTERN = '\[[a-z_]+\](.+)\[\/[a-z_]+\]'
|
||||
FILENAME_FILTER_TRANSLATION = str.maketrans(
|
||||
{
|
||||
"/": "-",
|
||||
":": ";",
|
||||
"*": "",
|
||||
"'": "",
|
||||
"?": "#",
|
||||
"♥": "",
|
||||
"’": "",
|
||||
}
|
||||
)
|
||||
TMDB_FILLER_MARKERS = (" (*)", "(*)")
|
||||
TMDB_EPISODE_RANGE_SUFFIX_REGEX = re.compile(r"\(([0-9]+)[-/]([0-9]+)\)$")
|
||||
TMDB_EPISODE_PART_SUFFIX_REGEX = re.compile(r"\(([0-9]+)\)$")
|
||||
RICH_COLOR_REGEX = re.compile(r"\[[a-z_]+\](.+)\[/[a-z_]+\]")
|
||||
|
||||
|
||||
def dictDiff(a : dict, b : dict, ignoreKeys: list = [], removeKeys: list = []):
|
||||
@@ -114,49 +130,45 @@ def filterFilename(fileName: str) -> str:
|
||||
"""This filter replaces charactes from TMDB responses with characters
|
||||
less problemating when using in filenames or removes them"""
|
||||
|
||||
fileName = str(fileName).replace('/', '-')
|
||||
fileName = str(fileName).replace(':', ';')
|
||||
fileName = str(fileName).replace('*', '')
|
||||
fileName = str(fileName).replace("'", '')
|
||||
fileName = str(fileName).replace("?", '#')
|
||||
fileName = str(fileName).replace('♥', '')
|
||||
fileName = str(fileName).replace('’', '')
|
||||
|
||||
return fileName.strip()
|
||||
return str(fileName).translate(FILENAME_FILTER_TRANSLATION).strip()
|
||||
|
||||
def substituteTmdbFilename(fileName: str) -> str:
|
||||
"""If chaining this method with filterFilename use this one first as the latter will destroy some patterns"""
|
||||
|
||||
# This indicates filler episodes in TMDB episode names
|
||||
fileName = str(fileName).replace(' (*)', '')
|
||||
fileName = str(fileName).replace('(*)', '')
|
||||
normalizedFileName = str(fileName)
|
||||
|
||||
# This indicates the index of multi-episode files
|
||||
episodePartMatch = re.search("\\(([0-9]+)\\)$", fileName)
|
||||
for fillerMarker in TMDB_FILLER_MARKERS:
|
||||
normalizedFileName = normalizedFileName.replace(fillerMarker, '')
|
||||
|
||||
episodeRangeMatch = TMDB_EPISODE_RANGE_SUFFIX_REGEX.search(normalizedFileName)
|
||||
if episodeRangeMatch is not None:
|
||||
partFirstIndex, partLastIndex = episodeRangeMatch.groups()
|
||||
return TMDB_EPISODE_RANGE_SUFFIX_REGEX.sub(
|
||||
f"Teil {partFirstIndex}-{partLastIndex}",
|
||||
normalizedFileName,
|
||||
count=1,
|
||||
)
|
||||
|
||||
episodePartMatch = TMDB_EPISODE_PART_SUFFIX_REGEX.search(normalizedFileName)
|
||||
if episodePartMatch is not None:
|
||||
partSuffix = str(episodePartMatch.group(0))
|
||||
partIndex = episodePartMatch.groups()[0]
|
||||
fileName = str(fileName).replace(partSuffix, f"Teil {partIndex}")
|
||||
partIndex = episodePartMatch.group(1)
|
||||
return TMDB_EPISODE_PART_SUFFIX_REGEX.sub(
|
||||
f"Teil {partIndex}",
|
||||
normalizedFileName,
|
||||
count=1,
|
||||
)
|
||||
|
||||
# Also multi-episodes with first and last episode index
|
||||
episodePartMatch = re.search("\\(([0-9]+)[-\\/]([0-9]+)\\)$", fileName)
|
||||
if episodePartMatch is not None:
|
||||
partSuffix = str(episodePartMatch.group(0))
|
||||
partFirstIndex = episodePartMatch.groups()[0]
|
||||
partLastIndex = episodePartMatch.groups()[1]
|
||||
fileName = str(fileName).replace(partSuffix, f"Teil {partFirstIndex}-{partLastIndex}")
|
||||
|
||||
return fileName
|
||||
return normalizedFileName
|
||||
|
||||
|
||||
def getEpisodeFileBasename(showName,
|
||||
episodeName,
|
||||
season,
|
||||
episode,
|
||||
indexSeasonDigits = 2,
|
||||
indexEpisodeDigits = 2,
|
||||
indicatorSeasonDigits = 2,
|
||||
indicatorEpisodeDigits = 2,
|
||||
indexSeasonDigits = None,
|
||||
indexEpisodeDigits = None,
|
||||
indicatorSeasonDigits = None,
|
||||
indicatorEpisodeDigits = None,
|
||||
context = None):
|
||||
"""
|
||||
One Piece:
|
||||
@@ -188,12 +200,21 @@ def getEpisodeFileBasename(showName,
|
||||
configData = cc.getData() if cc is not None else {}
|
||||
outputFilenameTemplate = configData.get(ConfigurationController.OUTPUT_FILENAME_TEMPLATE_KEY,
|
||||
DEFAULT_OUTPUT_FILENAME_TEMPLATE)
|
||||
defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(context)
|
||||
|
||||
if indexSeasonDigits is None:
|
||||
indexSeasonDigits = defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]
|
||||
if indexEpisodeDigits is None:
|
||||
indexEpisodeDigits = defaultDigitLengths[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY]
|
||||
if indicatorSeasonDigits is None:
|
||||
indicatorSeasonDigits = defaultDigitLengths[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY]
|
||||
if indicatorEpisodeDigits is None:
|
||||
indicatorEpisodeDigits = defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
|
||||
|
||||
if context is not None and 'logger' in context.keys():
|
||||
logger = context['logger']
|
||||
else:
|
||||
logger = logging.getLogger('FFX')
|
||||
logger.addHandler(logging.NullHandler())
|
||||
logger = get_ffx_logger()
|
||||
|
||||
|
||||
indexSeparator = ' ' if indexSeasonDigits or indexEpisodeDigits else ''
|
||||
@@ -231,9 +252,8 @@ def formatRichColor(text: str, color: str = None):
|
||||
return f"[{color}]{text}[/{color}]"
|
||||
|
||||
def removeRichColor(text: str):
|
||||
richColorMatch = re.search(RICH_COLOR_PATTERN, text)
|
||||
richColorMatch = RICH_COLOR_REGEX.search(str(text))
|
||||
if richColorMatch is None:
|
||||
return text
|
||||
else:
|
||||
return str(richColorMatch.group(1))
|
||||
|
||||
|
||||
@@ -1,68 +1,155 @@
|
||||
from enum import Enum
|
||||
import difflib
|
||||
|
||||
|
||||
class IsoLanguage(Enum):
|
||||
|
||||
ABKHAZIAN = {"name": "Abkhazian", "iso639_1": "ab", "iso639_2": ["abk"]}
|
||||
AFAR = {"name": "Afar", "iso639_1": "aa", "iso639_2": ["aar"]}
|
||||
AFRIKAANS = {"name": "Afrikaans", "iso639_1": "af", "iso639_2": ["afr"]}
|
||||
ALBANIAN = {"name": "Albanian", "iso639_1": "sq", "iso639_2": ["alb"]}
|
||||
AKAN = {"name": "Akan", "iso639_1": "ak", "iso639_2": ["aka"]}
|
||||
ALBANIAN = {"name": "Albanian", "iso639_1": "sq", "iso639_2": ["sqi", "alb"]}
|
||||
AMHARIC = {"name": "Amharic", "iso639_1": "am", "iso639_2": ["amh"]}
|
||||
ARABIC = {"name": "Arabic", "iso639_1": "ar", "iso639_2": ["ara"]}
|
||||
ARMENIAN = {"name": "Armenian", "iso639_1": "hy", "iso639_2": ["arm"]}
|
||||
ARAGONESE = {"name": "Aragonese", "iso639_1": "an", "iso639_2": ["arg"]}
|
||||
ARMENIAN = {"name": "Armenian", "iso639_1": "hy", "iso639_2": ["hye", "arm"]}
|
||||
ASSAMESE = {"name": "Assamese", "iso639_1": "as", "iso639_2": ["asm"]}
|
||||
AVARIC = {"name": "Avaric", "iso639_1": "av", "iso639_2": ["ava"]}
|
||||
AVESTAN = {"name": "Avestan", "iso639_1": "ae", "iso639_2": ["ave"]}
|
||||
AYMARA = {"name": "Aymara", "iso639_1": "ay", "iso639_2": ["aym"]}
|
||||
AZERBAIJANI = {"name": "Azerbaijani", "iso639_1": "az", "iso639_2": ["aze"]}
|
||||
BASQUE = {"name": "Basque", "iso639_1": "eu", "iso639_2": ["baq"]}
|
||||
BAMBARA = {"name": "Bambara", "iso639_1": "bm", "iso639_2": ["bam"]}
|
||||
BASHKIR = {"name": "Bashkir", "iso639_1": "ba", "iso639_2": ["bak"]}
|
||||
BASQUE = {"name": "Basque", "iso639_1": "eu", "iso639_2": ["eus", "baq"]}
|
||||
BELARUSIAN = {"name": "Belarusian", "iso639_1": "be", "iso639_2": ["bel"]}
|
||||
BOKMAL = {"name": "Bokmål", "iso639_1": "nb", "iso639_2": ["nob"]} # Norwegian Bokmål
|
||||
BENGALI = {"name": "Bengali", "iso639_1": "bn", "iso639_2": ["ben"]}
|
||||
BISLAMA = {"name": "Bislama", "iso639_1": "bi", "iso639_2": ["bis"]}
|
||||
BOKMAL = {"name": "Bokmål", "iso639_1": "nb", "iso639_2": ["nob"]}
|
||||
BOSNIAN = {"name": "Bosnian", "iso639_1": "bs", "iso639_2": ["bos"]}
|
||||
BRETON = {"name": "Breton", "iso639_1": "br", "iso639_2": ["bre"]}
|
||||
BULGARIAN = {"name": "Bulgarian", "iso639_1": "bg", "iso639_2": ["bul"]}
|
||||
BURMESE = {"name": "Burmese", "iso639_1": "my", "iso639_2": ["mya", "bur"]}
|
||||
CATALAN = {"name": "Catalan", "iso639_1": "ca", "iso639_2": ["cat"]}
|
||||
CHAMORRO = {"name": "Chamorro", "iso639_1": "ch", "iso639_2": ["cha"]}
|
||||
CHECHEN = {"name": "Chechen", "iso639_1": "ce", "iso639_2": ["che"]}
|
||||
CHICHEWA = {"name": "Chichewa", "iso639_1": "ny", "iso639_2": ["nya"]}
|
||||
CHINESE = {"name": "Chinese", "iso639_1": "zh", "iso639_2": ["zho", "chi"]}
|
||||
CHURCH_SLAVIC = {"name": "Church Slavic", "iso639_1": "cu", "iso639_2": ["chu"]}
|
||||
CHUVASH = {"name": "Chuvash", "iso639_1": "cv", "iso639_2": ["chv"]}
|
||||
CORNISH = {"name": "Cornish", "iso639_1": "kw", "iso639_2": ["cor"]}
|
||||
CORSICAN = {"name": "Corsican", "iso639_1": "co", "iso639_2": ["cos"]}
|
||||
CREE = {"name": "Cree", "iso639_1": "cr", "iso639_2": ["cre"]}
|
||||
CROATIAN = {"name": "Croatian", "iso639_1": "hr", "iso639_2": ["hrv"]}
|
||||
CZECH = {"name": "Czech", "iso639_1": "cs", "iso639_2": ["cze"]}
|
||||
CZECH = {"name": "Czech", "iso639_1": "cs", "iso639_2": ["ces", "cze"]}
|
||||
DANISH = {"name": "Danish", "iso639_1": "da", "iso639_2": ["dan"]}
|
||||
DIVEHI = {"name": "Divehi", "iso639_1": "dv", "iso639_2": ["div"]}
|
||||
DUTCH = {"name": "Dutch", "iso639_1": "nl", "iso639_2": ["nld", "dut"]}
|
||||
DZONGKHA = {"name": "Dzongkha", "iso639_1": "dz", "iso639_2": ["dzo"]}
|
||||
ENGLISH = {"name": "English", "iso639_1": "en", "iso639_2": ["eng"]}
|
||||
ESPERANTO = {"name": "Esperanto", "iso639_1": "eo", "iso639_2": ["epo"]}
|
||||
ESTONIAN = {"name": "Estonian", "iso639_1": "et", "iso639_2": ["est"]}
|
||||
FILIPINO = {"name": "Filipino", "iso639_1": "tl", "iso639_2": ["fil"]} # Tagalog
|
||||
EWE = {"name": "Ewe", "iso639_1": "ee", "iso639_2": ["ewe"]}
|
||||
FAROESE = {"name": "Faroese", "iso639_1": "fo", "iso639_2": ["fao"]}
|
||||
FIJIAN = {"name": "Fijian", "iso639_1": "fj", "iso639_2": ["fij"]}
|
||||
FINNISH = {"name": "Finnish", "iso639_1": "fi", "iso639_2": ["fin"]}
|
||||
FRENCH = {"name": "French", "iso639_1": "fr", "iso639_2": ["fra", "fre"]}
|
||||
FULAH = {"name": "Fulah", "iso639_1": "ff", "iso639_2": ["ful"]}
|
||||
GALICIAN = {"name": "Galician", "iso639_1": "gl", "iso639_2": ["glg"]}
|
||||
GEORGIAN = {"name": "Georgian", "iso639_1": "ka", "iso639_2": ["geo"]}
|
||||
GANDA = {"name": "Ganda", "iso639_1": "lg", "iso639_2": ["lug"]}
|
||||
GEORGIAN = {"name": "Georgian", "iso639_1": "ka", "iso639_2": ["kat", "geo"]}
|
||||
GERMAN = {"name": "German", "iso639_1": "de", "iso639_2": ["deu", "ger"]}
|
||||
GREEK = {"name": "Greek", "iso639_1": "el", "iso639_2": ["gre"]}
|
||||
GREEK = {"name": "Greek", "iso639_1": "el", "iso639_2": ["ell", "gre"]}
|
||||
GUARANI = {"name": "Guarani", "iso639_1": "gn", "iso639_2": ["grn"]}
|
||||
GUJARATI = {"name": "Gujarati", "iso639_1": "gu", "iso639_2": ["guj"]}
|
||||
HAITIAN = {"name": "Haitian", "iso639_1": "ht", "iso639_2": ["hat"]}
|
||||
HAUSA = {"name": "Hausa", "iso639_1": "ha", "iso639_2": ["hau"]}
|
||||
HEBREW = {"name": "Hebrew", "iso639_1": "he", "iso639_2": ["heb"]}
|
||||
HERERO = {"name": "Herero", "iso639_1": "hz", "iso639_2": ["her"]}
|
||||
HINDI = {"name": "Hindi", "iso639_1": "hi", "iso639_2": ["hin"]}
|
||||
HIRI_MOTU = {"name": "Hiri Motu", "iso639_1": "ho", "iso639_2": ["hmo"]}
|
||||
HUNGARIAN = {"name": "Hungarian", "iso639_1": "hu", "iso639_2": ["hun"]}
|
||||
ICELANDIC = {"name": "Icelandic", "iso639_1": "is", "iso639_2": ["ice"]}
|
||||
ICELANDIC = {"name": "Icelandic", "iso639_1": "is", "iso639_2": ["isl", "ice"]}
|
||||
IDO = {"name": "Ido", "iso639_1": "io", "iso639_2": ["ido"]}
|
||||
IGBO = {"name": "Igbo", "iso639_1": "ig", "iso639_2": ["ibo"]}
|
||||
INDONESIAN = {"name": "Indonesian", "iso639_1": "id", "iso639_2": ["ind"]}
|
||||
INTERLINGUA = {"name": "Interlingua", "iso639_1": "ia", "iso639_2": ["ina"]}
|
||||
INTERLINGUE = {"name": "Interlingue", "iso639_1": "ie", "iso639_2": ["ile"]}
|
||||
INUKTITUT = {"name": "Inuktitut", "iso639_1": "iu", "iso639_2": ["iku"]}
|
||||
INUPIAQ = {"name": "Inupiaq", "iso639_1": "ik", "iso639_2": ["ipk"]}
|
||||
IRISH = {"name": "Irish", "iso639_1": "ga", "iso639_2": ["gle"]}
|
||||
ITALIAN = {"name": "Italian", "iso639_1": "it", "iso639_2": ["ita"]}
|
||||
JAPANESE = {"name": "Japanese", "iso639_1": "ja", "iso639_2": ["jpn"]}
|
||||
JAVANESE = {"name": "Javanese", "iso639_1": "jv", "iso639_2": ["jav"]}
|
||||
KALAALLISUT = {"name": "Kalaallisut", "iso639_1": "kl", "iso639_2": ["kal"]}
|
||||
KANNADA = {"name": "Kannada", "iso639_1": "kn", "iso639_2": ["kan"]}
|
||||
KANURI = {"name": "Kanuri", "iso639_1": "kr", "iso639_2": ["kau"]}
|
||||
KASHMIRI = {"name": "Kashmiri", "iso639_1": "ks", "iso639_2": ["kas"]}
|
||||
KAZAKH = {"name": "Kazakh", "iso639_1": "kk", "iso639_2": ["kaz"]}
|
||||
KHMER = {"name": "Khmer", "iso639_1": "km", "iso639_2": ["khm"]}
|
||||
KIKUYU = {"name": "Kikuyu", "iso639_1": "ki", "iso639_2": ["kik"]}
|
||||
KINYARWANDA = {"name": "Kinyarwanda", "iso639_1": "rw", "iso639_2": ["kin"]}
|
||||
KIRGHIZ = {"name": "Kirghiz", "iso639_1": "ky", "iso639_2": ["kir"]}
|
||||
KOMI = {"name": "Komi", "iso639_1": "kv", "iso639_2": ["kom"]}
|
||||
KONGO = {"name": "Kongo", "iso639_1": "kg", "iso639_2": ["kon"]}
|
||||
KOREAN = {"name": "Korean", "iso639_1": "ko", "iso639_2": ["kor"]}
|
||||
KUANYAMA = {"name": "Kuanyama", "iso639_1": "kj", "iso639_2": ["kua"]}
|
||||
KURDISH = {"name": "Kurdish", "iso639_1": "ku", "iso639_2": ["kur"]}
|
||||
LAO = {"name": "Lao", "iso639_1": "lo", "iso639_2": ["lao"]}
|
||||
LATIN = {"name": "Latin", "iso639_1": "la", "iso639_2": ["lat"]}
|
||||
LATVIAN = {"name": "Latvian", "iso639_1": "lv", "iso639_2": ["lav"]}
|
||||
LIMBURGAN = {"name": "Limburgan", "iso639_1": "li", "iso639_2": ["lim"]}
|
||||
LINGALA = {"name": "Lingala", "iso639_1": "ln", "iso639_2": ["lin"]}
|
||||
LITHUANIAN = {"name": "Lithuanian", "iso639_1": "lt", "iso639_2": ["lit"]}
|
||||
MACEDONIAN = {"name": "Macedonian", "iso639_1": "mk", "iso639_2": ["mac"]}
|
||||
MALAY = {"name": "Malay", "iso639_1": "ms", "iso639_2": ["may"]}
|
||||
LUBA_KATANGA = {"name": "Luba-Katanga", "iso639_1": "lu", "iso639_2": ["lub"]}
|
||||
LUXEMBOURGISH = {"name": "Luxembourgish", "iso639_1": "lb", "iso639_2": ["ltz"]}
|
||||
MACEDONIAN = {"name": "Macedonian", "iso639_1": "mk", "iso639_2": ["mkd", "mac"]}
|
||||
MALAGASY = {"name": "Malagasy", "iso639_1": "mg", "iso639_2": ["mlg"]}
|
||||
MALAY = {"name": "Malay", "iso639_1": "ms", "iso639_2": ["msa", "may"]}
|
||||
MALAYALAM = {"name": "Malayalam", "iso639_1": "ml", "iso639_2": ["mal"]}
|
||||
MALTESE = {"name": "Maltese", "iso639_1": "mt", "iso639_2": ["mlt"]}
|
||||
MANX = {"name": "Manx", "iso639_1": "gv", "iso639_2": ["glv"]}
|
||||
MAORI = {"name": "Maori", "iso639_1": "mi", "iso639_2": ["mri", "mao"]}
|
||||
MARATHI = {"name": "Marathi", "iso639_1": "mr", "iso639_2": ["mar"]}
|
||||
MARSHALLESE = {"name": "Marshallese", "iso639_1": "mh", "iso639_2": ["mah"]}
|
||||
MONGOLIAN = {"name": "Mongolian", "iso639_1": "mn", "iso639_2": ["mon"]}
|
||||
NAURU = {"name": "Nauru", "iso639_1": "na", "iso639_2": ["nau"]}
|
||||
NAVAJO = {"name": "Navajo", "iso639_1": "nv", "iso639_2": ["nav"]}
|
||||
NDONGA = {"name": "Ndonga", "iso639_1": "ng", "iso639_2": ["ndo"]}
|
||||
NEPALI = {"name": "Nepali", "iso639_1": "ne", "iso639_2": ["nep"]}
|
||||
NORTH_NDEBELE = {"name": "North Ndebele", "iso639_1": "nd", "iso639_2": ["nde"]}
|
||||
NORTHERN_SAMI = {"name": "Northern Sami", "iso639_1": "se", "iso639_2": ["sme"]}
|
||||
NORWEGIAN = {"name": "Norwegian", "iso639_1": "no", "iso639_2": ["nor"]}
|
||||
PERSIAN = {"name": "Persian", "iso639_1": "fa", "iso639_2": ["per"]}
|
||||
NORWEGIAN_NYNORSK = {"name": "Nynorsk", "iso639_1": "nn", "iso639_2": ["nno"]}
|
||||
OCCITAN = {"name": "Occitan", "iso639_1": "oc", "iso639_2": ["oci"]}
|
||||
OJIBWA = {"name": "Ojibwa", "iso639_1": "oj", "iso639_2": ["oji"]}
|
||||
ORIYA = {"name": "Oriya", "iso639_1": "or", "iso639_2": ["ori"]}
|
||||
OROMO = {"name": "Oromo", "iso639_1": "om", "iso639_2": ["orm"]}
|
||||
OSSETIAN = {"name": "Ossetian", "iso639_1": "os", "iso639_2": ["oss"]}
|
||||
PALI = {"name": "Pali", "iso639_1": "pi", "iso639_2": ["pli"]}
|
||||
PANJABI = {"name": "Panjabi", "iso639_1": "pa", "iso639_2": ["pan"]}
|
||||
PERSIAN = {"name": "Persian", "iso639_1": "fa", "iso639_2": ["fas", "per"]}
|
||||
POLISH = {"name": "Polish", "iso639_1": "pl", "iso639_2": ["pol"]}
|
||||
PORTUGUESE = {"name": "Portuguese", "iso639_1": "pt", "iso639_2": ["por"]}
|
||||
ROMANIAN = {"name": "Romanian", "iso639_1": "ro", "iso639_2": ["rum"]}
|
||||
PUSHTO = {"name": "Pushto", "iso639_1": "ps", "iso639_2": ["pus"]}
|
||||
QUECHUA = {"name": "Quechua", "iso639_1": "qu", "iso639_2": ["que"]}
|
||||
ROMANIAN = {"name": "Romanian", "iso639_1": "ro", "iso639_2": ["ron", "rum"]}
|
||||
ROMANSH = {"name": "Romansh", "iso639_1": "rm", "iso639_2": ["roh"]}
|
||||
RUNDI = {"name": "Rundi", "iso639_1": "rn", "iso639_2": ["run"]}
|
||||
RUSSIAN = {"name": "Russian", "iso639_1": "ru", "iso639_2": ["rus"]}
|
||||
NORTHERN_SAMI = {"name": "Northern Sami", "iso639_1": "se", "iso639_2": ["sme"]}
|
||||
SAMOAN = {"name": "Samoan", "iso639_1": "sm", "iso639_2": ["smo"]}
|
||||
SANGO = {"name": "Sango", "iso639_1": "sg", "iso639_2": ["sag"]}
|
||||
SANSKRIT = {"name": "Sanskrit", "iso639_1": "sa", "iso639_2": ["san"]}
|
||||
SARDINIAN = {"name": "Sardinian", "iso639_1": "sc", "iso639_2": ["srd"]}
|
||||
SCOTTISH_GAELIC = {"name": "Scottish Gaelic", "iso639_1": "gd", "iso639_2": ["gla"]}
|
||||
SERBIAN = {"name": "Serbian", "iso639_1": "sr", "iso639_2": ["srp"]}
|
||||
SHONA = {"name": "Shona", "iso639_1": "sn", "iso639_2": ["sna"]}
|
||||
SICHUAN_YI = {"name": "Sichuan Yi", "iso639_1": "ii", "iso639_2": ["iii"]}
|
||||
SINDHI = {"name": "Sindhi", "iso639_1": "sd", "iso639_2": ["snd"]}
|
||||
SINHALA = {"name": "Sinhala", "iso639_1": "si", "iso639_2": ["sin"]}
|
||||
SLOVAK = {"name": "Slovak", "iso639_1": "sk", "iso639_2": ["slk"]}
|
||||
SLOVAK = {"name": "Slovak", "iso639_1": "sk", "iso639_2": ["slk", "slo"]}
|
||||
SLOVENIAN = {"name": "Slovenian", "iso639_1": "sl", "iso639_2": ["slv"]}
|
||||
SOMALI = {"name": "Somali", "iso639_1": "so", "iso639_2": ["som"]}
|
||||
SOUTH_NDEBELE = {"name": "South Ndebele", "iso639_1": "nr", "iso639_2": ["nbl"]}
|
||||
SOUTHERN_SOTHO = {"name": "Southern Sotho", "iso639_1": "st", "iso639_2": ["sot"]}
|
||||
SPANISH = {"name": "Spanish", "iso639_1": "es", "iso639_2": ["spa"]}
|
||||
SUNDANESE = {"name": "Sundanese", "iso639_1": "su", "iso639_2": ["sun"]}
|
||||
@@ -70,14 +157,38 @@ class IsoLanguage(Enum):
|
||||
SWATI = {"name": "Swati", "iso639_1": "ss", "iso639_2": ["ssw"]}
|
||||
SWEDISH = {"name": "Swedish", "iso639_1": "sv", "iso639_2": ["swe"]}
|
||||
TAGALOG = {"name": "Tagalog", "iso639_1": "tl", "iso639_2": ["tgl"]}
|
||||
TAHITIAN = {"name": "Tahitian", "iso639_1": "ty", "iso639_2": ["tah"]}
|
||||
TAJIK = {"name": "Tajik", "iso639_1": "tg", "iso639_2": ["tgk"]}
|
||||
TAMIL = {"name": "Tamil", "iso639_1": "ta", "iso639_2": ["tam"]}
|
||||
TATAR = {"name": "Tatar", "iso639_1": "tt", "iso639_2": ["tat"]}
|
||||
TELUGU = {"name": "Telugu", "iso639_1": "te", "iso639_2": ["tel"]}
|
||||
THAI = {"name": "Thai", "iso639_1": "th", "iso639_2": ["tha"]}
|
||||
TIBETAN = {"name": "Tibetan", "iso639_1": "bo", "iso639_2": ["bod", "tib"]}
|
||||
TIGRINYA = {"name": "Tigrinya", "iso639_1": "ti", "iso639_2": ["tir"]}
|
||||
TONGA = {"name": "Tonga", "iso639_1": "to", "iso639_2": ["ton"]}
|
||||
TSONGA = {"name": "Tsonga", "iso639_1": "ts", "iso639_2": ["tso"]}
|
||||
TSWANA = {"name": "Tswana", "iso639_1": "tn", "iso639_2": ["tsn"]}
|
||||
TURKISH = {"name": "Turkish", "iso639_1": "tr", "iso639_2": ["tur"]}
|
||||
TURKMEN = {"name": "Turkmen", "iso639_1": "tk", "iso639_2": ["tuk"]}
|
||||
TWI = {"name": "Twi", "iso639_1": "tw", "iso639_2": ["twi"]}
|
||||
UIGHUR = {"name": "Uighur", "iso639_1": "ug", "iso639_2": ["uig"]}
|
||||
UKRAINIAN = {"name": "Ukrainian", "iso639_1": "uk", "iso639_2": ["ukr"]}
|
||||
URDU = {"name": "Urdu", "iso639_1": "ur", "iso639_2": ["urd"]}
|
||||
VIETNAMESE = {"name": "Vietnamese", "iso639_1": "vi", "iso639_2":[ "vie"]}
|
||||
WELSH = {"name": "Welsh", "iso639_1": "cy", "iso639_2": ["wel"]}
|
||||
UZBEK = {"name": "Uzbek", "iso639_1": "uz", "iso639_2": ["uzb"]}
|
||||
VENDA = {"name": "Venda", "iso639_1": "ve", "iso639_2": ["ven"]}
|
||||
VIETNAMESE = {"name": "Vietnamese", "iso639_1": "vi", "iso639_2": ["vie"]}
|
||||
VOLAPUK = {"name": "Volapük", "iso639_1": "vo", "iso639_2": ["vol"]}
|
||||
WALLOON = {"name": "Walloon", "iso639_1": "wa", "iso639_2": ["wln"]}
|
||||
WELSH = {"name": "Welsh", "iso639_1": "cy", "iso639_2": ["cym", "wel"]}
|
||||
WESTERN_FRISIAN = {"name": "Western Frisian", "iso639_1": "fy", "iso639_2": ["fry"]}
|
||||
WOLOF = {"name": "Wolof", "iso639_1": "wo", "iso639_2": ["wol"]}
|
||||
XHOSA = {"name": "Xhosa", "iso639_1": "xh", "iso639_2": ["xho"]}
|
||||
YIDDISH = {"name": "Yiddish", "iso639_1": "yi", "iso639_2": ["yid"]}
|
||||
YORUBA = {"name": "Yoruba", "iso639_1": "yo", "iso639_2": ["yor"]}
|
||||
ZHUANG = {"name": "Zhuang", "iso639_1": "za", "iso639_2": ["zha"]}
|
||||
ZULU = {"name": "Zulu", "iso639_1": "zu", "iso639_2": ["zul"]}
|
||||
|
||||
FILIPINO = {"name": "Filipino", "iso639_1": "tl", "iso639_2": ["fil"]}
|
||||
|
||||
UNDEFINED = {"name": "undefined", "iso639_1": "xx", "iso639_2": ["und"]}
|
||||
|
||||
@@ -88,24 +199,22 @@ class IsoLanguage(Enum):
|
||||
closestMatches = difflib.get_close_matches(label, [l.value["name"] for l in IsoLanguage], n=1)
|
||||
|
||||
if closestMatches:
|
||||
foundLangs = [l for l in IsoLanguage if l.value['name'] == closestMatches[0]]
|
||||
foundLangs = [l for l in IsoLanguage if l.value["name"] == closestMatches[0]]
|
||||
return foundLangs[0] if foundLangs else IsoLanguage.UNDEFINED
|
||||
else:
|
||||
return IsoLanguage.UNDEFINED
|
||||
|
||||
@staticmethod
|
||||
def findThreeLetter(theeLetter : str):
|
||||
foundLangs = [l for l in IsoLanguage if str(theeLetter) in l.value['iso639_2']]
|
||||
foundLangs = [l for l in IsoLanguage if str(theeLetter) in l.value["iso639_2"]]
|
||||
return foundLangs[0] if foundLangs else IsoLanguage.UNDEFINED
|
||||
|
||||
|
||||
def label(self):
|
||||
return str(self.value['name'])
|
||||
return str(self.value["name"])
|
||||
|
||||
def twoLetter(self):
|
||||
return str(self.value['iso639_1'])
|
||||
return str(self.value["iso639_1"])
|
||||
|
||||
def threeLetter(self):
|
||||
return str(self.value['iso639_2'][0])
|
||||
|
||||
|
||||
return str(self.value["iso639_2"][0])
|
||||
|
||||
68
src/ffx/logging_utils.py
Normal file
68
src/ffx/logging_utils.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
|
||||
FFX_LOGGER_NAME = "FFX"
|
||||
CONSOLE_HANDLER_NAME = "ffx-console"
|
||||
FILE_HANDLER_NAME = "ffx-file"
|
||||
|
||||
|
||||
def get_ffx_logger(name: str = FFX_LOGGER_NAME) -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
if not logger.handlers:
|
||||
logger.addHandler(logging.NullHandler())
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def configure_ffx_logger(
|
||||
log_file_path: str,
|
||||
file_level: int,
|
||||
console_level: int,
|
||||
name: str = FFX_LOGGER_NAME,
|
||||
) -> logging.Logger:
|
||||
logger = get_ffx_logger(name)
|
||||
logger.propagate = False
|
||||
|
||||
for handler in list(logger.handlers):
|
||||
if isinstance(handler, logging.NullHandler):
|
||||
logger.removeHandler(handler)
|
||||
|
||||
console_handler = next(
|
||||
(handler for handler in logger.handlers if handler.get_name() == CONSOLE_HANDLER_NAME),
|
||||
None,
|
||||
)
|
||||
if console_handler is None:
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.set_name(CONSOLE_HANDLER_NAME)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
console_handler.setLevel(console_level)
|
||||
console_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
|
||||
normalized_log_path = os.path.abspath(log_file_path)
|
||||
file_handler = next(
|
||||
(handler for handler in logger.handlers if handler.get_name() == FILE_HANDLER_NAME),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
file_handler is not None
|
||||
and os.path.abspath(file_handler.baseFilename) != normalized_log_path
|
||||
):
|
||||
logger.removeHandler(file_handler)
|
||||
file_handler.close()
|
||||
file_handler = None
|
||||
|
||||
if file_handler is None:
|
||||
file_handler = logging.FileHandler(normalized_log_path)
|
||||
file_handler.set_name(FILE_HANDLER_NAME)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
file_handler.setLevel(file_level)
|
||||
file_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
)
|
||||
|
||||
return logger
|
||||
@@ -25,10 +25,9 @@ class MediaController():
|
||||
pid = int(patternId)
|
||||
|
||||
s = self.Session()
|
||||
q = s.query(Pattern).filter(Pattern.id == pid)
|
||||
pattern = s.query(Pattern).filter(Pattern.id == pid).first()
|
||||
|
||||
if q.count():
|
||||
pattern = q.first
|
||||
if pattern is not None:
|
||||
|
||||
for mediaTagKey, mediaTagValue in mediaDescriptor.getTags():
|
||||
self.__tac.updateMediaTag(pid, mediaTagKey, mediaTagValue)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import os, re, click, logging
|
||||
import os, re, click
|
||||
|
||||
from typing import List, Self
|
||||
|
||||
@@ -9,6 +9,7 @@ from ffx.track_disposition import TrackDisposition
|
||||
from ffx.track_codec import TrackCodec
|
||||
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
from ffx.logging_utils import get_ffx_logger
|
||||
|
||||
|
||||
class MediaDescriptor:
|
||||
@@ -46,8 +47,7 @@ class MediaDescriptor:
|
||||
self.__logger = self.__context['logger']
|
||||
else:
|
||||
self.__context = {}
|
||||
self.__logger = logging.getLogger('FFX')
|
||||
self.__logger.addHandler(logging.NullHandler())
|
||||
self.__logger = get_ffx_logger()
|
||||
|
||||
if MediaDescriptor.TAGS_KEY in kwargs.keys():
|
||||
if type(kwargs[MediaDescriptor.TAGS_KEY]) is not dict:
|
||||
@@ -207,7 +207,7 @@ class MediaDescriptor:
|
||||
def rearrangeTrackDescriptors(self, newOrder: List[int]):
|
||||
if len(newOrder) != len(self.__trackDescriptors):
|
||||
raise ValueError('Length of list with reordered indices does not match number of track descriptors')
|
||||
reorderedTrackDescriptors = {}
|
||||
reorderedTrackDescriptors = []
|
||||
for oldIndex in newOrder:
|
||||
reorderedTrackDescriptors.append(self.__trackDescriptors[oldIndex])
|
||||
self.__trackDescriptors = reorderedTrackDescriptors
|
||||
@@ -362,6 +362,14 @@ class MediaDescriptor:
|
||||
inputMappingTokens = []
|
||||
|
||||
sortedTrackDescriptors = sorted(self.__trackDescriptors, key=lambda d: d.getIndex())
|
||||
sourceTrackDescriptorsByIndex = {
|
||||
td.getIndex(): td
|
||||
for td in (
|
||||
sourceMediaDescriptor.getTrackDescriptors()
|
||||
if sourceMediaDescriptor is not None
|
||||
else sortedTrackDescriptors
|
||||
)
|
||||
}
|
||||
|
||||
# raise click.ClickException(' '.join([f"\nindex={td.getIndex()} subIndex={td.getSubIndex()} srcIndex={td.getSourceIndex()} type={td.getType().label()}" for td in self.__trackDescriptors]))
|
||||
|
||||
@@ -373,8 +381,12 @@ class MediaDescriptor:
|
||||
#HINT: Attached thumbnails are not supported by .webm container format
|
||||
if td.getCodec() != TrackCodec.PNG:
|
||||
|
||||
stdi = sortedTrackDescriptors[td.getSourceIndex()].getIndex()
|
||||
stdsi = sortedTrackDescriptors[td.getSourceIndex()].getSubIndex()
|
||||
sourceTrackDescriptor = sourceTrackDescriptorsByIndex.get(td.getSourceIndex())
|
||||
if sourceTrackDescriptor is None:
|
||||
raise ValueError(f"No source track descriptor found for source index {td.getSourceIndex()}")
|
||||
|
||||
stdi = sourceTrackDescriptor.getIndex()
|
||||
stdsi = sourceTrackDescriptor.getSubIndex()
|
||||
|
||||
trackType = td.getType()
|
||||
trackCodec = td.getCodec()
|
||||
@@ -488,7 +500,14 @@ class MediaDescriptor:
|
||||
return subtitleFileDescriptors
|
||||
|
||||
|
||||
def importSubtitles(self, searchDirectory, prefix, season: int = -1, episode: int = -1):
|
||||
def importSubtitles(
|
||||
self,
|
||||
searchDirectory,
|
||||
prefix,
|
||||
season: int = -1,
|
||||
episode: int = -1,
|
||||
preserve_dispositions: bool = False,
|
||||
):
|
||||
|
||||
# click.echo(f"Season: {season} Episode: {episode}")
|
||||
self.__logger.debug(f"importSubtitles(): Season: {season} Episode: {episode}")
|
||||
@@ -507,7 +526,10 @@ class MediaDescriptor:
|
||||
d
|
||||
for d in availableFileSubtitleDescriptors
|
||||
if ((season == -1 and episode == -1)
|
||||
or (d["season"] == int(season) and d["episode"] == int(episode)))
|
||||
or (
|
||||
d.get("season") == int(season)
|
||||
and d.get("episode") == int(episode)
|
||||
))
|
||||
],
|
||||
key=lambda d: d["index"],
|
||||
)
|
||||
@@ -522,10 +544,14 @@ class MediaDescriptor:
|
||||
if matchingSubtitleTrackDescriptor:
|
||||
# click.echo(f"Found matching subtitle file {msfd["path"]}\n")
|
||||
self.__logger.debug(f"importSubtitles(): Found matching subtitle file {msfd['path']}")
|
||||
matchingSubtitleTrackDescriptor[0].setExternalSourceFilePath(msfd["path"])
|
||||
matchingTrack = matchingSubtitleTrackDescriptor[0]
|
||||
matchingTrack.setExternalSourceFilePath(msfd["path"])
|
||||
|
||||
# TODO: Check if useful
|
||||
# matchingSubtitleTrackDescriptor[0].setDispositionSet(msfd["disposition_set"])
|
||||
# Prefer metadata coming from the external single-track source when
|
||||
# it is provided explicitly by the filename contract.
|
||||
matchingTrack.getTags()["language"] = msfd["language"]
|
||||
if msfd["disposition_set"] and not preserve_dispositions:
|
||||
matchingTrack.setDispositionSet(msfd["disposition_set"])
|
||||
|
||||
|
||||
def getConfiguration(self, label: str = ''):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import click
|
||||
|
||||
from ffx.iso_language import IsoLanguage
|
||||
from ffx.media_descriptor import MediaDescriptor
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
|
||||
@@ -42,6 +43,14 @@ class MediaDescriptorChangeSet():
|
||||
|
||||
self.__targetTrackDescriptors = targetMediaDescriptor.getTrackDescriptors() if targetMediaDescriptor is not None else []
|
||||
self.__sourceTrackDescriptors = sourceMediaDescriptor.getTrackDescriptors() if sourceMediaDescriptor is not None else []
|
||||
self.__targetTrackDescriptorsByIndex = {
|
||||
trackDescriptor.getIndex(): trackDescriptor
|
||||
for trackDescriptor in self.__targetTrackDescriptors
|
||||
}
|
||||
self.__sourceTrackDescriptorsByIndex = {
|
||||
trackDescriptor.getIndex(): trackDescriptor
|
||||
for trackDescriptor in self.__sourceTrackDescriptors
|
||||
}
|
||||
|
||||
targetMediaTags = targetMediaDescriptor.getTags() if targetMediaDescriptor is not None else {}
|
||||
sourceMediaTags = sourceMediaDescriptor.getTags() if sourceMediaDescriptor is not None else {}
|
||||
@@ -70,51 +79,34 @@ class MediaDescriptorChangeSet():
|
||||
|
||||
self.__numSourceTracks = len(self.__sourceTrackDescriptors)
|
||||
|
||||
maxNumOfTracks = max(self.__numSourceTracks, self.__numTargetTracks)
|
||||
|
||||
trackCompareResult = {}
|
||||
|
||||
for targetTrackDescriptor in self.__targetTrackDescriptors:
|
||||
sourceTrackDescriptor = self.__sourceTrackDescriptorsByIndex.get(
|
||||
targetTrackDescriptor.getSourceIndex()
|
||||
)
|
||||
|
||||
for trackIndex in range(maxNumOfTracks):
|
||||
|
||||
correspondingSourceTrackDescriptors = [st for st in self.__sourceTrackDescriptors if st.getIndex() == trackIndex]
|
||||
correspondingTargetTrackDescriptors = [tt for tt in self.__targetTrackDescriptors if tt.getIndex() == trackIndex]
|
||||
|
||||
# Track present in target but not in source
|
||||
if (not correspondingSourceTrackDescriptors
|
||||
and correspondingTargetTrackDescriptors):
|
||||
|
||||
if sourceTrackDescriptor is None:
|
||||
if DIFF_ADDED_KEY not in trackCompareResult.keys():
|
||||
trackCompareResult[DIFF_ADDED_KEY] = {}
|
||||
|
||||
trackCompareResult[DIFF_ADDED_KEY][trackIndex] = correspondingTargetTrackDescriptors[0]
|
||||
trackCompareResult[DIFF_ADDED_KEY][targetTrackDescriptor.getIndex()] = targetTrackDescriptor
|
||||
continue
|
||||
|
||||
# Track present in target but not in source
|
||||
if (correspondingSourceTrackDescriptors
|
||||
and not correspondingTargetTrackDescriptors):
|
||||
|
||||
if DIFF_REMOVED_KEY not in trackCompareResult.keys():
|
||||
trackCompareResult[DIFF_REMOVED_KEY] = {}
|
||||
|
||||
trackCompareResult[DIFF_REMOVED_KEY][trackIndex] = correspondingSourceTrackDescriptors[0]
|
||||
continue
|
||||
|
||||
if (correspondingSourceTrackDescriptors
|
||||
and correspondingTargetTrackDescriptors):
|
||||
|
||||
# if correspondingTargetTrackDescriptors[0].getIndex() == 3:
|
||||
# raise click.ClickException(f"{correspondingSourceTrackDescriptors[0].getDispositionSet()} {correspondingTargetTrackDescriptors[0].getDispositionSet()}")
|
||||
|
||||
|
||||
trackDiff = self.compareTracks(correspondingTargetTrackDescriptors[0],
|
||||
correspondingSourceTrackDescriptors[0])
|
||||
|
||||
trackDiff = self.compareTracks(targetTrackDescriptor, sourceTrackDescriptor)
|
||||
if trackDiff:
|
||||
if DIFF_CHANGED_KEY not in trackCompareResult.keys():
|
||||
trackCompareResult[DIFF_CHANGED_KEY] = {}
|
||||
trackCompareResult[DIFF_CHANGED_KEY][targetTrackDescriptor.getIndex()] = trackDiff
|
||||
|
||||
trackCompareResult[DIFF_CHANGED_KEY][trackIndex] = trackDiff
|
||||
targetSourceIndices = {
|
||||
targetTrackDescriptor.getSourceIndex()
|
||||
for targetTrackDescriptor in self.__targetTrackDescriptors
|
||||
}
|
||||
for sourceTrackDescriptor in self.__sourceTrackDescriptors:
|
||||
if sourceTrackDescriptor.getIndex() not in targetSourceIndices:
|
||||
if DIFF_REMOVED_KEY not in trackCompareResult.keys():
|
||||
trackCompareResult[DIFF_REMOVED_KEY] = {}
|
||||
trackCompareResult[DIFF_REMOVED_KEY][sourceTrackDescriptor.getIndex()] = sourceTrackDescriptor
|
||||
|
||||
|
||||
if trackCompareResult:
|
||||
@@ -126,7 +118,11 @@ class MediaDescriptorChangeSet():
|
||||
sourceTrackDescriptor: TrackDescriptor = None):
|
||||
|
||||
sourceTrackTags = sourceTrackDescriptor.getTags() if sourceTrackDescriptor is not None else {}
|
||||
targetTrackTags = targetTrackDescriptor.getTags() if targetTrackDescriptor is not None else {}
|
||||
targetTrackTags = (
|
||||
self.normalizeTrackTags(targetTrackDescriptor.getTags())
|
||||
if targetTrackDescriptor is not None
|
||||
else {}
|
||||
)
|
||||
|
||||
trackCompareResult = {}
|
||||
|
||||
@@ -151,6 +147,25 @@ class MediaDescriptorChangeSet():
|
||||
|
||||
return trackCompareResult
|
||||
|
||||
def normalizeTrackTagValue(self, tagKey, tagValue):
|
||||
if tagKey != "language":
|
||||
return tagValue
|
||||
|
||||
if isinstance(tagValue, IsoLanguage):
|
||||
return tagValue.threeLetter()
|
||||
|
||||
trackLanguage = IsoLanguage.findThreeLetter(str(tagValue))
|
||||
if trackLanguage != IsoLanguage.UNDEFINED:
|
||||
return trackLanguage.threeLetter()
|
||||
|
||||
return tagValue
|
||||
|
||||
def normalizeTrackTags(self, trackTags: dict):
|
||||
return {
|
||||
tagKey: self.normalizeTrackTagValue(tagKey, tagValue)
|
||||
for tagKey, tagValue in trackTags.items()
|
||||
}
|
||||
|
||||
|
||||
def generateDispositionTokens(self):
|
||||
"""
|
||||
@@ -252,7 +267,7 @@ class MediaDescriptorChangeSet():
|
||||
addedTracks: dict = self.__changeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_ADDED_KEY]
|
||||
trackDescriptor: TrackDescriptor
|
||||
for trackDescriptor in addedTracks.values():
|
||||
for tagKey, tagValue in trackDescriptor.getTags().items():
|
||||
for tagKey, tagValue in self.normalizeTrackTags(trackDescriptor.getTags()).items():
|
||||
if not tagKey in self.__removeTrackKeys:
|
||||
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
|
||||
+ f":{trackDescriptor.getSubIndex()}",
|
||||
@@ -274,29 +289,58 @@ class MediaDescriptorChangeSet():
|
||||
|
||||
outputTrackTags = addedTrackTags | changedTrackTags
|
||||
|
||||
trackDescriptor = self.__targetTrackDescriptors[trackIndex]
|
||||
trackDescriptor = self.__targetTrackDescriptorsByIndex[trackIndex]
|
||||
|
||||
for tagKey, tagValue in outputTrackTags.items():
|
||||
for tagKey, tagValue in self.normalizeTrackTags(outputTrackTags).items():
|
||||
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
|
||||
+ f":{trackDescriptor.getSubIndex()}",
|
||||
f"{tagKey}={tagValue}"]
|
||||
|
||||
if trackDescriptor.getExternalSourceFilePath():
|
||||
# When a single-track external file substitutes the
|
||||
# media payload, keep metadata from the regular
|
||||
# source track unless the external/target side
|
||||
# overrides it explicitly.
|
||||
preservedTrackTags = (
|
||||
{
|
||||
tagKey: tagValue
|
||||
for tagKey, tagValue in removedTrackTags.items()
|
||||
if tagKey not in self.__removeTrackKeys
|
||||
}
|
||||
| unchangedTrackTags
|
||||
)
|
||||
for tagKey, tagValue in self.normalizeTrackTags(preservedTrackTags).items():
|
||||
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
|
||||
+ f":{trackDescriptor.getSubIndex()}",
|
||||
f"{tagKey}={tagValue}"]
|
||||
else:
|
||||
for removeKey in removedTrackTags.keys():
|
||||
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
|
||||
+ f":{trackDescriptor.getSubIndex()}",
|
||||
f"{removeKey}="]
|
||||
|
||||
#HINT: In case of loading a track from an external file
|
||||
# no tags from source are present for the track so
|
||||
# the unchanged tracks are passed to the output file as well
|
||||
if trackDescriptor.getExternalSourceFilePath():
|
||||
for tagKey, tagValue in unchangedTrackTags.items():
|
||||
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
|
||||
+ f":{trackDescriptor.getSubIndex()}",
|
||||
f"{tagKey}={tagValue}"]
|
||||
for tagKey, tagValue in self.__context.get('encoding_metadata_tags', {}).items():
|
||||
metadataTokens += [f"-metadata:g", f"{tagKey}={tagValue}"]
|
||||
|
||||
metadataTokens += self.generateConfiguredRemovalMetadataTokens()
|
||||
|
||||
return metadataTokens
|
||||
|
||||
|
||||
def getChangeSetObj(self):
|
||||
return self.__changeSetObj
|
||||
|
||||
def generateConfiguredRemovalMetadataTokens(self):
|
||||
metadataTokens = []
|
||||
|
||||
for removeKey in self.__removeGlobalKeys:
|
||||
metadataTokens += ["-metadata:g", f"{removeKey}="]
|
||||
|
||||
for trackDescriptor in self.__targetTrackDescriptors:
|
||||
for removeKey in self.__removeTrackKeys:
|
||||
metadataTokens += [
|
||||
f"-metadata:s:{trackDescriptor.getType().indicator()}:{trackDescriptor.getSubIndex()}",
|
||||
f"{removeKey}=",
|
||||
]
|
||||
|
||||
return metadataTokens
|
||||
|
||||
@@ -6,13 +6,9 @@ from textual.containers import Grid
|
||||
|
||||
from ffx.audio_layout import AudioLayout
|
||||
|
||||
from .pattern_controller import PatternController
|
||||
from .show_controller import ShowController
|
||||
from .track_controller import TrackController
|
||||
from .tag_controller import TagController
|
||||
|
||||
from .show_details_screen import ShowDetailsScreen
|
||||
from .pattern_details_screen import PatternDetailsScreen
|
||||
from .screen_support import build_screen_bootstrap, build_screen_controllers
|
||||
|
||||
from ffx.track_type import TrackType
|
||||
from ffx.track_codec import TrackCodec
|
||||
@@ -135,29 +131,23 @@ class MediaDetailsScreen(Screen):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.context = self.app.getContext()
|
||||
self.Session = self.context['database']['session'] # convenience
|
||||
bootstrap = build_screen_bootstrap(self.app.getContext())
|
||||
self.context = bootstrap.context
|
||||
|
||||
self.__removeGlobalKeys = bootstrap.remove_global_keys
|
||||
self.__ignoreGlobalKeys = bootstrap.ignore_global_keys
|
||||
|
||||
self.__configurationData = self.context['config'].getData()
|
||||
|
||||
metadataConfiguration = self.__configurationData['metadata'] if 'metadata' in self.__configurationData.keys() else {}
|
||||
|
||||
self.__signatureTags = metadataConfiguration['signature'] if 'signature' in metadataConfiguration.keys() else {}
|
||||
self.__removeGlobalKeys = metadataConfiguration['remove'] if 'remove' in metadataConfiguration.keys() else []
|
||||
self.__ignoreGlobalKeys = metadataConfiguration['ignore'] if 'ignore' in metadataConfiguration.keys() else []
|
||||
self.__removeTrackKeys = (metadataConfiguration['streams']['remove']
|
||||
if 'streams' in metadataConfiguration.keys()
|
||||
and 'remove' in metadataConfiguration['streams'].keys() else [])
|
||||
self.__ignoreTrackKeys = (metadataConfiguration['streams']['ignore']
|
||||
if 'streams' in metadataConfiguration.keys()
|
||||
and 'ignore' in metadataConfiguration['streams'].keys() else [])
|
||||
|
||||
|
||||
self.__pc = PatternController(context = self.context)
|
||||
self.__sc = ShowController(context = self.context)
|
||||
self.__tc = TrackController(context = self.context)
|
||||
self.__tac = TagController(context = self.context)
|
||||
controllers = build_screen_controllers(
|
||||
self.context,
|
||||
pattern=True,
|
||||
show=True,
|
||||
track=True,
|
||||
tag=True,
|
||||
)
|
||||
self.__pc = controllers['pattern']
|
||||
self.__sc = controllers['show']
|
||||
self.__tc = controllers['track']
|
||||
self.__tac = controllers['tag']
|
||||
|
||||
if not 'command' in self.context.keys() or self.context['command'] != 'inspect':
|
||||
raise click.ClickException(f"MediaDetailsScreen.__init__(): Can only perform command 'inspect'")
|
||||
@@ -569,6 +559,7 @@ class MediaDetailsScreen(Screen):
|
||||
try:
|
||||
kwargs = {}
|
||||
|
||||
kwargs[ShowDescriptor.CONTEXT_KEY] = self.context
|
||||
kwargs[ShowDescriptor.ID_KEY] = int(selected_row_data[0])
|
||||
kwargs[ShowDescriptor.NAME_KEY] = str(selected_row_data[1])
|
||||
kwargs[ShowDescriptor.YEAR_KEY] = int(selected_row_data[2])
|
||||
@@ -602,19 +593,20 @@ class MediaDetailsScreen(Screen):
|
||||
patternObj = self.getPatternObjFromInput()
|
||||
|
||||
if patternObj:
|
||||
patternId = self.__pc.addPattern(patternObj)
|
||||
if patternId:
|
||||
self.highlightPattern(False)
|
||||
|
||||
mediaTags = {}
|
||||
for tagKey, tagValue in self.__sourceMediaDescriptor.getTags().items():
|
||||
|
||||
# Filter tags that make no sense to preserve
|
||||
if tagKey not in self.__ignoreGlobalKeys and not tagKey in self.__removeGlobalKeys:
|
||||
self.__tac.updateMediaTag(patternId, tagKey, tagValue)
|
||||
mediaTags[tagKey] = tagValue
|
||||
|
||||
# for trackDescriptor in self.__sourceMediaDescriptor.getAllTrackDescriptors():
|
||||
for trackDescriptor in self.__sourceMediaDescriptor.getTrackDescriptors():
|
||||
self.__tc.addTrack(trackDescriptor, patternId = patternId)
|
||||
patternId = self.__pc.savePatternSchema(
|
||||
patternObj,
|
||||
trackDescriptors=self.__sourceMediaDescriptor.getTrackDescriptors(),
|
||||
mediaTags=mediaTags,
|
||||
)
|
||||
if patternId:
|
||||
self.highlightPattern(False)
|
||||
|
||||
|
||||
def action_new_pattern(self):
|
||||
@@ -754,4 +746,3 @@ class MediaDetailsScreen(Screen):
|
||||
def handle_edit_pattern(self, screenResult):
|
||||
self.query_one("#pattern_input", Input).value = screenResult['pattern']
|
||||
self.updateDifferences()
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Load ORM model modules so SQLAlchemy relationship strings can resolve."""
|
||||
|
||||
from .show import Base, Show
|
||||
from .pattern import Pattern
|
||||
from .track import Track
|
||||
from .track_tag import TrackTag
|
||||
from .media_tag import MediaTag
|
||||
from .shifted_season import ShiftedSeason
|
||||
from .property import Property
|
||||
|
||||
__all__ = [
|
||||
'Base',
|
||||
'Show',
|
||||
'Pattern',
|
||||
'Track',
|
||||
'TrackTag',
|
||||
'MediaTag',
|
||||
'ShiftedSeason',
|
||||
'Property',
|
||||
]
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import os, sys, importlib, inspect, glob, re
|
||||
|
||||
from ffx.configuration_controller import ConfigurationController
|
||||
from ffx.database import databaseContext
|
||||
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
class Conversion():
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self._context = {}
|
||||
self._context['config'] = ConfigurationController()
|
||||
|
||||
self._context['database'] = databaseContext(databasePath=self._context['config'].getDatabaseFilePath())
|
||||
|
||||
self.__databaseSession: sessionmaker = self._context['database']['session']
|
||||
self.__databaseEngine: Engine = self._context['database']['engine']
|
||||
|
||||
|
||||
@staticmethod
|
||||
def list():
|
||||
|
||||
basePath = os.path.dirname(__file__)
|
||||
|
||||
filenamePattern = re.compile("conversion_([0-9]+)_([0-9]+)\\.py")
|
||||
|
||||
filenameList = [os.path.basename(fp) for fp in glob.glob(f"{ basePath }/*.py") if fp != __file__]
|
||||
|
||||
versionTupleList = [(fm.group(1), fm.group(2)) for fn in filenameList if (fm := filenamePattern.search(fn))]
|
||||
|
||||
return versionTupleList
|
||||
|
||||
|
||||
@staticmethod
|
||||
def getClassReference(versionFrom, versionTo):
|
||||
importlib.import_module(f"ffx.model.conversions.conversion_{ versionFrom }_{ versionTo }")
|
||||
for name, obj in inspect.getmembers(sys.modules[f"ffx.model.conversions.conversion_{ versionFrom }_{ versionTo }"]):
|
||||
#HINT: Excluding DispositionCombination as it seems to be included by import (?)
|
||||
if inspect.isclass(obj) and name != 'Conversion' and name.startswith('Conversion'):
|
||||
return obj
|
||||
|
||||
@staticmethod
|
||||
def getAllClassReferences():
|
||||
return [Conversion.getClassReference(verFrom, verTo) for verFrom, verTo in Conversion.list()]
|
||||
@@ -1,17 +0,0 @@
|
||||
import os, sys, importlib, inspect, glob, re
|
||||
|
||||
from .conversion import Conversion
|
||||
|
||||
|
||||
class Conversion_2_3(Conversion):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def applyConversion(self):
|
||||
|
||||
s = self.__databaseSession()
|
||||
e = self.__databaseEngine
|
||||
|
||||
with e.connect() as c:
|
||||
c.execute("ALTER TABLE user ADD COLUMN email VARCHAR(255)")
|
||||
@@ -1,7 +0,0 @@
|
||||
import os, sys, importlib, inspect, glob, re
|
||||
|
||||
from .conversion import Conversion
|
||||
|
||||
|
||||
class Conversion_3_4(Conversion):
|
||||
pass
|
||||
82
src/ffx/model/migration/__init__.py
Normal file
82
src/ffx/model/migration/__init__.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import importlib
|
||||
import importlib.util
|
||||
|
||||
|
||||
class DatabaseVersionException(Exception):
|
||||
def __init__(self, errorMessage):
|
||||
super().__init__(errorMessage)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MigrationStep:
|
||||
versionFrom: int
|
||||
versionTo: int
|
||||
moduleName: str
|
||||
modulePresent: bool
|
||||
|
||||
|
||||
def getMigrationStepModuleName(versionFrom: int, versionTo: int) -> str:
|
||||
return f"ffx.model.migration.step_{int(versionFrom)}_{int(versionTo)}"
|
||||
|
||||
|
||||
def migrationStepModuleExists(versionFrom: int, versionTo: int) -> bool:
|
||||
moduleName = getMigrationStepModuleName(versionFrom, versionTo)
|
||||
|
||||
try:
|
||||
return importlib.util.find_spec(moduleName) is not None
|
||||
except ModuleNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def getMigrationPlan(currentVersion: int, targetVersion: int) -> list[MigrationStep]:
|
||||
version = int(currentVersion)
|
||||
target = int(targetVersion)
|
||||
migrationPlan = []
|
||||
|
||||
while version < target:
|
||||
nextVersion = version + 1
|
||||
migrationPlan.append(
|
||||
MigrationStep(
|
||||
versionFrom=version,
|
||||
versionTo=nextVersion,
|
||||
moduleName=getMigrationStepModuleName(version, nextVersion),
|
||||
modulePresent=migrationStepModuleExists(version, nextVersion),
|
||||
)
|
||||
)
|
||||
version = nextVersion
|
||||
|
||||
return migrationPlan
|
||||
|
||||
|
||||
def loadMigrationStep(versionFrom: int, versionTo: int):
|
||||
moduleName = getMigrationStepModuleName(versionFrom, versionTo)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(moduleName)
|
||||
except ModuleNotFoundError as ex:
|
||||
if ex.name == moduleName:
|
||||
raise DatabaseVersionException(
|
||||
f"No migration path from database version {versionFrom} to {versionTo}"
|
||||
) from ex
|
||||
raise
|
||||
|
||||
migrationStep = getattr(module, "applyMigration", None)
|
||||
if migrationStep is None:
|
||||
raise DatabaseVersionException(
|
||||
f"Migration module {moduleName} does not define applyMigration()"
|
||||
)
|
||||
|
||||
return migrationStep
|
||||
|
||||
|
||||
def migrateDatabase(databaseContext, currentVersion: int, targetVersion: int, setDatabaseVersion):
|
||||
for migrationStepInfo in getMigrationPlan(currentVersion, targetVersion):
|
||||
migrationStep = loadMigrationStep(
|
||||
migrationStepInfo.versionFrom,
|
||||
migrationStepInfo.versionTo,
|
||||
)
|
||||
migrationStep(databaseContext)
|
||||
setDatabaseVersion(databaseContext, migrationStepInfo.versionTo)
|
||||
84
src/ffx/model/migration/step_2_3.py
Normal file
84
src/ffx/model/migration/step_2_3.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
|
||||
def applyMigration(databaseContext):
|
||||
engine = databaseContext['engine']
|
||||
inspector = inspect(engine)
|
||||
shiftedSeasonColumns = {
|
||||
column['name']
|
||||
for column in inspector.get_columns('shifted_seasons')
|
||||
}
|
||||
showColumns = {
|
||||
column['name']
|
||||
for column in inspector.get_columns('shows')
|
||||
}
|
||||
|
||||
with engine.begin() as connection:
|
||||
if 'pattern_id' not in shiftedSeasonColumns:
|
||||
connection.execute(text("PRAGMA foreign_keys=OFF"))
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE shifted_seasons_v3 (
|
||||
id INTEGER PRIMARY KEY,
|
||||
show_id INTEGER,
|
||||
pattern_id INTEGER,
|
||||
original_season INTEGER,
|
||||
first_episode INTEGER DEFAULT -1,
|
||||
last_episode INTEGER DEFAULT -1,
|
||||
season_offset INTEGER DEFAULT 0,
|
||||
episode_offset INTEGER DEFAULT 0,
|
||||
FOREIGN KEY(show_id) REFERENCES shows(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(pattern_id) REFERENCES patterns(id) ON DELETE CASCADE,
|
||||
CHECK (
|
||||
(show_id IS NOT NULL AND pattern_id IS NULL)
|
||||
OR (show_id IS NULL AND pattern_id IS NOT NULL)
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO shifted_seasons_v3 (
|
||||
id,
|
||||
show_id,
|
||||
pattern_id,
|
||||
original_season,
|
||||
first_episode,
|
||||
last_episode,
|
||||
season_offset,
|
||||
episode_offset
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
show_id,
|
||||
NULL,
|
||||
original_season,
|
||||
first_episode,
|
||||
last_episode,
|
||||
season_offset,
|
||||
episode_offset
|
||||
FROM shifted_seasons
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(text("DROP TABLE shifted_seasons"))
|
||||
connection.execute(text("ALTER TABLE shifted_seasons_v3 RENAME TO shifted_seasons"))
|
||||
connection.execute(
|
||||
text("CREATE INDEX ix_shifted_seasons_show_id ON shifted_seasons(show_id)")
|
||||
)
|
||||
connection.execute(
|
||||
text("CREATE INDEX ix_shifted_seasons_pattern_id ON shifted_seasons(pattern_id)")
|
||||
)
|
||||
connection.execute(text("PRAGMA foreign_keys=ON"))
|
||||
|
||||
if 'quality' not in showColumns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE shows ADD COLUMN quality INTEGER DEFAULT 0")
|
||||
)
|
||||
if 'notes' not in showColumns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE shows ADD COLUMN notes TEXT DEFAULT ''")
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
import click
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .show import Base, Show
|
||||
@@ -12,6 +12,9 @@ from ffx.show_descriptor import ShowDescriptor
|
||||
class Pattern(Base):
|
||||
|
||||
__tablename__ = 'patterns'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('show_id', 'pattern', name='uq_patterns_show_id_pattern'),
|
||||
)
|
||||
|
||||
# v1.x
|
||||
id = Column(Integer, primary_key=True)
|
||||
@@ -32,6 +35,7 @@ class Pattern(Base):
|
||||
tracks = relationship('Track', back_populates='pattern', cascade="all, delete", lazy='joined')
|
||||
|
||||
media_tags = relationship('MediaTag', back_populates='pattern', cascade="all, delete", lazy='joined')
|
||||
shifted_seasons = relationship('ShiftedSeason', back_populates='pattern', cascade="all, delete", lazy='joined')
|
||||
|
||||
quality = Column(Integer, default=0)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import click
|
||||
|
||||
from sqlalchemy import Column, Integer, ForeignKey
|
||||
from sqlalchemy import CheckConstraint, Column, ForeignKey, Index, Integer
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .show import Base, Show
|
||||
@@ -9,6 +9,14 @@ from .show import Base, Show
|
||||
class ShiftedSeason(Base):
|
||||
|
||||
__tablename__ = 'shifted_seasons'
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(show_id IS NOT NULL AND pattern_id IS NULL) OR (show_id IS NULL AND pattern_id IS NOT NULL)",
|
||||
name="ck_shifted_seasons_single_owner",
|
||||
),
|
||||
Index("ix_shifted_seasons_show_id", "show_id"),
|
||||
Index("ix_shifted_seasons_pattern_id", "pattern_id"),
|
||||
)
|
||||
|
||||
# v1.x
|
||||
id = Column(Integer, primary_key=True)
|
||||
@@ -19,9 +27,12 @@ class ShiftedSeason(Base):
|
||||
# pattern: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
# v1.x
|
||||
show_id = Column(Integer, ForeignKey('shows.id', ondelete="CASCADE"))
|
||||
show_id = Column(Integer, ForeignKey('shows.id', ondelete="CASCADE"), nullable=True)
|
||||
show = relationship(Show, back_populates='shifted_seasons', lazy='joined')
|
||||
|
||||
pattern_id = Column(Integer, ForeignKey('patterns.id', ondelete="CASCADE"), nullable=True)
|
||||
pattern = relationship('Pattern', back_populates='shifted_seasons', lazy='joined')
|
||||
|
||||
# v2.0
|
||||
# show_id: Mapped[int] = mapped_column(ForeignKey("shows.id", ondelete="CASCADE"))
|
||||
# show: Mapped["Show"] = relationship(back_populates="patterns")
|
||||
@@ -39,6 +50,12 @@ class ShiftedSeason(Base):
|
||||
def getId(self):
|
||||
return self.id
|
||||
|
||||
def getShowId(self):
|
||||
return self.show_id
|
||||
|
||||
def getPatternId(self):
|
||||
return self.pattern_id
|
||||
|
||||
|
||||
def getOriginalSeason(self):
|
||||
return self.original_season
|
||||
@@ -61,6 +78,8 @@ class ShiftedSeason(Base):
|
||||
|
||||
shiftedSeasonObj = {}
|
||||
|
||||
shiftedSeasonObj['show_id'] = self.getShowId()
|
||||
shiftedSeasonObj['pattern_id'] = self.getPatternId()
|
||||
shiftedSeasonObj['original_season'] = self.getOriginalSeason()
|
||||
shiftedSeasonObj['first_episode'] = self.getFirstEpisode()
|
||||
shiftedSeasonObj['last_episode'] = self.getLastEpisode()
|
||||
@@ -68,4 +87,3 @@ class ShiftedSeason(Base):
|
||||
shiftedSeasonObj['episode_offset'] = self.getEpisodeOffset()
|
||||
|
||||
return shiftedSeasonObj
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# from typing import List
|
||||
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship, declarative_base, sessionmaker
|
||||
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
@@ -45,6 +45,8 @@ class Show(Base):
|
||||
index_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS)
|
||||
indicator_season_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS)
|
||||
indicator_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS)
|
||||
quality = Column(Integer, default=0)
|
||||
notes = Column(Text, default='')
|
||||
|
||||
|
||||
def getDescriptor(self, context):
|
||||
@@ -58,5 +60,7 @@ class Show(Base):
|
||||
kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY] = int(self.index_episode_digits)
|
||||
kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY] = int(self.indicator_season_digits)
|
||||
kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.indicator_episode_digits)
|
||||
kwargs[ShowDescriptor.QUALITY_KEY] = int(self.quality or 0)
|
||||
kwargs[ShowDescriptor.NOTES_KEY] = str(self.notes or '')
|
||||
|
||||
return ShowDescriptor(**kwargs)
|
||||
|
||||
@@ -1,161 +1,411 @@
|
||||
import click, re
|
||||
import re
|
||||
|
||||
import click
|
||||
|
||||
from ffx.model.media_tag import MediaTag
|
||||
from ffx.model.pattern import Pattern
|
||||
from ffx.model.track import Track
|
||||
from ffx.model.track_tag import TrackTag
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
|
||||
|
||||
class PatternController():
|
||||
class DuplicatePatternMatchError(click.ClickException):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidPatternSchemaError(click.ClickException):
|
||||
pass
|
||||
|
||||
|
||||
class PatternController:
|
||||
_compiled_regex_cache: dict[str, re.Pattern] = {}
|
||||
|
||||
def __init__(self, context):
|
||||
|
||||
self.context = context
|
||||
self.Session = self.context['database']['session'] # convenience
|
||||
self.Session = self.context["database"]["session"]
|
||||
|
||||
self.__configurationData = self.context["config"].getData()
|
||||
|
||||
def addPattern(self, patternObj):
|
||||
"""Adds pattern to database from obj
|
||||
metadataConfiguration = (
|
||||
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:
|
||||
session = self.Session()
|
||||
self._ensure_unique_pattern_definition(
|
||||
session,
|
||||
fields["show_id"],
|
||||
fields["pattern"],
|
||||
exclude_pattern_id=patternId,
|
||||
)
|
||||
|
||||
s = self.Session()
|
||||
q = s.query(Pattern).filter(Pattern.show_id == int(patternObj['show_id']),
|
||||
Pattern.pattern == str(patternObj['pattern']))
|
||||
|
||||
if not q.count():
|
||||
pattern = Pattern(show_id = int(patternObj['show_id']),
|
||||
pattern = str(patternObj['pattern']))
|
||||
s.add(pattern)
|
||||
s.commit()
|
||||
return pattern.getId()
|
||||
if patternId is None:
|
||||
pattern = Pattern(
|
||||
show_id=fields["show_id"],
|
||||
pattern=fields["pattern"],
|
||||
quality=fields["quality"],
|
||||
notes=fields["notes"],
|
||||
)
|
||||
session.add(pattern)
|
||||
session.flush()
|
||||
else:
|
||||
return 0
|
||||
pattern = session.query(Pattern).filter(Pattern.id == int(patternId)).first()
|
||||
if pattern is None:
|
||||
raise click.ClickException(
|
||||
f"PatternController.savePatternSchema(): Pattern #{patternId} not found"
|
||||
)
|
||||
pattern.show_id = fields["show_id"]
|
||||
pattern.pattern = fields["pattern"]
|
||||
pattern.quality = fields["quality"]
|
||||
pattern.notes = fields["notes"]
|
||||
|
||||
self._replace_pattern_schema(
|
||||
session,
|
||||
pattern,
|
||||
normalized_tags,
|
||||
normalized_tracks,
|
||||
)
|
||||
|
||||
session.commit()
|
||||
self._clear_regex_cache()
|
||||
return pattern.getId()
|
||||
|
||||
except click.ClickException:
|
||||
raise
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"PatternController.addPattern(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"PatternController.savePatternSchema(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def addPattern(self, patternObj, trackDescriptors=None, mediaTags=None):
|
||||
return self.savePatternSchema(
|
||||
patternObj,
|
||||
trackDescriptors=trackDescriptors,
|
||||
mediaTags=mediaTags,
|
||||
)
|
||||
|
||||
def updatePattern(self, patternId, patternObj):
|
||||
|
||||
fields = self._coerce_pattern_fields(patternObj)
|
||||
session = None
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Pattern).filter(Pattern.id == int(patternId))
|
||||
session = self.Session()
|
||||
pattern = session.query(Pattern).filter(Pattern.id == int(patternId)).first()
|
||||
|
||||
if q.count():
|
||||
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: Pattern = q.first()
|
||||
pattern.show_id = fields["show_id"]
|
||||
pattern.pattern = fields["pattern"]
|
||||
pattern.quality = fields["quality"]
|
||||
pattern.notes = fields["notes"]
|
||||
|
||||
pattern.show_id = int(patternObj['show_id'])
|
||||
pattern.pattern = str(patternObj['pattern'])
|
||||
pattern.quality = str(patternObj['quality'])
|
||||
pattern.notes = str(patternObj['notes'])
|
||||
|
||||
s.commit()
|
||||
session.commit()
|
||||
self._clear_regex_cache()
|
||||
return True
|
||||
|
||||
else:
|
||||
return False
|
||||
|
||||
except click.ClickException:
|
||||
raise
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"PatternController.updatePattern(): {repr(ex)}")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def findPattern(self, patternObj):
|
||||
session = None
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Pattern).filter(Pattern.show_id == int(patternObj['show_id']), Pattern.pattern == str(patternObj['pattern']))
|
||||
session = self.Session()
|
||||
pattern = (
|
||||
session.query(Pattern)
|
||||
.filter(
|
||||
Pattern.show_id == int(patternObj["show_id"]),
|
||||
Pattern.pattern == str(patternObj["pattern"]),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if q.count():
|
||||
pattern = q.first()
|
||||
if pattern is not None:
|
||||
return int(pattern.id)
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"PatternController.findPattern(): {repr(ex)}")
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def getPatternsForShow(self, showId: int) -> list[Pattern]:
|
||||
|
||||
def getPattern(self, patternId : int):
|
||||
if type(showId) is not int:
|
||||
raise ValueError(
|
||||
"PatternController.getPatternsForShow(): Argument showId is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
session = self.Session()
|
||||
return (
|
||||
session.query(Pattern)
|
||||
.filter(Pattern.show_id == int(showId))
|
||||
.order_by(Pattern.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"PatternController.getPatternsForShow(): {repr(ex)}")
|
||||
finally:
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def getPattern(self, patternId: int):
|
||||
|
||||
if type(patternId) is not int:
|
||||
raise ValueError(f"PatternController.getPattern(): Argument patternId is required to be of type int")
|
||||
raise ValueError(
|
||||
"PatternController.getPattern(): Argument patternId is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Pattern).filter(Pattern.id == int(patternId))
|
||||
|
||||
return q.first() if q.count() else None
|
||||
session = self.Session()
|
||||
return session.query(Pattern).filter(Pattern.id == int(patternId)).first()
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"PatternController.getPattern(): {repr(ex)}")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def deletePattern(self, patternId):
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Pattern).filter(Pattern.id == int(patternId))
|
||||
session = self.Session()
|
||||
pattern = session.query(Pattern).filter(Pattern.id == int(patternId)).first()
|
||||
|
||||
if q.count():
|
||||
|
||||
#DAFUQ: https://stackoverflow.com/a/19245058
|
||||
# q.delete()
|
||||
pattern = q.first()
|
||||
s.delete(pattern)
|
||||
|
||||
s.commit()
|
||||
if pattern is not None:
|
||||
session.delete(pattern)
|
||||
session.commit()
|
||||
self._clear_regex_cache()
|
||||
return True
|
||||
return False
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"PatternController.deletePattern(): {repr(ex)}")
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
|
||||
def matchFilename(self, filename : str) -> dict:
|
||||
"""Returns dict {'match': <a regex match obj>, 'pattern': <ffx pattern obj>} or empty dict of no pattern was found"""
|
||||
def matchFilename(self, filename: str) -> dict:
|
||||
"""Return {'match': regex match, 'pattern': Pattern} or {} when unmatched."""
|
||||
session = None
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Pattern)
|
||||
session = self.Session()
|
||||
matches = []
|
||||
query = session.query(Pattern).order_by(Pattern.show_id, Pattern.id)
|
||||
|
||||
matchResult = {}
|
||||
for pattern in 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():
|
||||
patternMatch = re.search(str(pattern.pattern), str(filename))
|
||||
if patternMatch is not None:
|
||||
matchResult['match'] = patternMatch
|
||||
matchResult['pattern'] = pattern
|
||||
self._validate_persisted_pattern(pattern)
|
||||
matches.append({"match": patternMatch, "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:
|
||||
raise click.ClickException(f"PatternController.matchFilename(): {repr(ex)}")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
# def getMediaDescriptor(self, context, patternId):
|
||||
#
|
||||
# try:
|
||||
# s = self.Session()
|
||||
# q = s.query(Pattern).filter(Pattern.id == int(patternId))
|
||||
#
|
||||
# if q.count():
|
||||
# return q.first().getMediaDescriptor(context)
|
||||
# else:
|
||||
# return None
|
||||
#
|
||||
# except Exception as ex:
|
||||
# raise click.ClickException(f"PatternController.getMediaDescriptor(): {repr(ex)}")
|
||||
# finally:
|
||||
# s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
@@ -6,18 +6,15 @@ from textual.widgets import Header, Footer, Static, Button, Input, DataTable, Te
|
||||
from textual.containers import Grid
|
||||
|
||||
from ffx.model.pattern import Pattern
|
||||
from ffx.model.track import Track
|
||||
|
||||
from .pattern_controller import PatternController
|
||||
from .show_controller import ShowController
|
||||
from .track_controller import TrackController
|
||||
from .tag_controller import TagController
|
||||
|
||||
from .track_details_screen import TrackDetailsScreen
|
||||
from .track_delete_screen import TrackDeleteScreen
|
||||
from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen
|
||||
from .shifted_season_details_screen import ShiftedSeasonDetailsScreen
|
||||
|
||||
from .tag_details_screen import TagDetailsScreen
|
||||
from .tag_delete_screen import TagDeleteScreen
|
||||
from .screen_support import build_screen_bootstrap, build_screen_controllers
|
||||
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
@@ -29,6 +26,7 @@ from textual.widgets._data_table import CellDoesNotExist
|
||||
from ffx.file_properties import FileProperties
|
||||
from ffx.iso_language import IsoLanguage
|
||||
from ffx.audio_layout import AudioLayout
|
||||
from ffx.model.shifted_season import ShiftedSeason
|
||||
|
||||
from ffx.helper import formatRichColor, removeRichColor
|
||||
|
||||
@@ -39,8 +37,8 @@ class PatternDetailsScreen(Screen):
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 7 17;
|
||||
grid-rows: 2 2 2 2 2 2 6 2 2 8 2 2 8 2 2 2 2;
|
||||
grid-size: 7 20;
|
||||
grid-rows: 2 2 2 2 2 2 6 2 2 8 2 2 8 2 2 8 2 2 2 2;
|
||||
grid-columns: 25 25 25 25 25 25 25;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@@ -108,66 +106,42 @@ class PatternDetailsScreen(Screen):
|
||||
def __init__(self, patternId = None, showId = None):
|
||||
super().__init__()
|
||||
|
||||
self.context = self.app.getContext()
|
||||
self.Session = self.context['database']['session'] # convenience
|
||||
bootstrap = build_screen_bootstrap(self.app.getContext())
|
||||
self.context = bootstrap.context
|
||||
|
||||
self.__configurationData = self.context['config'].getData()
|
||||
self.__removeGlobalKeys = bootstrap.remove_global_keys
|
||||
self.__ignoreGlobalKeys = bootstrap.ignore_global_keys
|
||||
|
||||
metadataConfiguration = self.__configurationData['metadata'] if 'metadata' in self.__configurationData.keys() else {}
|
||||
|
||||
self.__signatureTags = metadataConfiguration['signature'] if 'signature' in metadataConfiguration.keys() else {}
|
||||
self.__removeGlobalKeys = metadataConfiguration['remove'] if 'remove' in metadataConfiguration.keys() else []
|
||||
self.__ignoreGlobalKeys = metadataConfiguration['ignore'] if 'ignore' in metadataConfiguration.keys() else []
|
||||
self.__removeTrackKeys = (metadataConfiguration['streams']['remove']
|
||||
if 'streams' in metadataConfiguration.keys()
|
||||
and 'remove' in metadataConfiguration['streams'].keys() else [])
|
||||
self.__ignoreTrackKeys = (metadataConfiguration['streams']['ignore']
|
||||
if 'streams' in metadataConfiguration.keys()
|
||||
and 'ignore' in metadataConfiguration['streams'].keys() else [])
|
||||
|
||||
self.__pc = PatternController(context = self.context)
|
||||
self.__sc = ShowController(context = self.context)
|
||||
self.__tc = TrackController(context = self.context)
|
||||
self.__tac = TagController(context = self.context)
|
||||
controllers = build_screen_controllers(
|
||||
self.context,
|
||||
pattern=True,
|
||||
show=True,
|
||||
track=True,
|
||||
tag=True,
|
||||
shifted_season=True,
|
||||
)
|
||||
self.__pc = controllers['pattern']
|
||||
self.__sc = controllers['show']
|
||||
self.__tc = controllers['track']
|
||||
self.__tac = controllers['tag']
|
||||
self.__ssc = controllers['shifted_season']
|
||||
|
||||
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
|
||||
|
||||
|
||||
#TODO: per controller
|
||||
def loadTracks(self, show_id):
|
||||
|
||||
try:
|
||||
|
||||
tracks = {}
|
||||
tracks['audio'] = {}
|
||||
tracks['subtitle'] = {}
|
||||
|
||||
s = self.Session()
|
||||
q = s.query(Pattern).filter(Pattern.show_id == int(show_id))
|
||||
|
||||
return [{'id': int(p.id), 'pattern': p.pattern} for p in q.all()]
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"loadTracks(): {repr(ex)}")
|
||||
finally:
|
||||
s.close()
|
||||
self.__draftTracks : List[TrackDescriptor] = []
|
||||
self.__draftTags : dict[str, str] = {}
|
||||
|
||||
|
||||
def updateTracks(self):
|
||||
|
||||
self.tracksTable.clear()
|
||||
|
||||
if self.__pattern is not None:
|
||||
|
||||
tracks = self.__tc.findTracks(self.__pattern.getId())
|
||||
tracks = self.getCurrentTrackDescriptors()
|
||||
|
||||
typeCounter = {}
|
||||
|
||||
tr: Track
|
||||
for tr in tracks:
|
||||
|
||||
td : TrackDescriptor = tr.getDescriptor(self.context)
|
||||
td: TrackDescriptor
|
||||
for td in tracks:
|
||||
|
||||
if (trackType := td.getType()) != TrackType.ATTACHMENT:
|
||||
|
||||
@@ -196,11 +170,47 @@ class PatternDetailsScreen(Screen):
|
||||
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):
|
||||
|
||||
ti1 = int(trackIndex1)
|
||||
ti2 = int(trackIndex2)
|
||||
|
||||
if self.__pattern is None:
|
||||
numSiblings = len(self.__draftTracks)
|
||||
|
||||
if ti1 < 0 or ti1 >= numSiblings:
|
||||
raise ValueError(f"PatternDetailsScreen.swapTracks(): trackIndex1 ({ti1}) is out of range ({numSiblings})")
|
||||
|
||||
if ti2 < 0 or ti2 >= numSiblings:
|
||||
raise ValueError(f"PatternDetailsScreen.swapTracks(): trackIndex2 ({ti2}) is out of range ({numSiblings})")
|
||||
|
||||
self.__draftTracks[ti1], self.__draftTracks[ti2] = self.__draftTracks[ti2], self.__draftTracks[ti1]
|
||||
self.normalizeDraftTracks()
|
||||
self.updateTracks()
|
||||
return
|
||||
|
||||
siblingDescriptors: List[TrackDescriptor] = self.__tc.findSiblingDescriptors(self.__pattern.getId())
|
||||
|
||||
numSiblings = len(siblingDescriptors)
|
||||
@@ -236,9 +246,11 @@ class PatternDetailsScreen(Screen):
|
||||
|
||||
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():
|
||||
|
||||
@@ -248,10 +260,75 @@ class PatternDetailsScreen(Screen):
|
||||
if tagKey in self.__removeGlobalKeys:
|
||||
textColor = 'red'
|
||||
|
||||
# if tagKey not in self.__ignoreTrackKeys:
|
||||
row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor))
|
||||
self.tagsTable.add_row(*map(str, row))
|
||||
|
||||
def updateShiftedSeasons(self):
|
||||
|
||||
self.shiftedSeasonsTable.clear()
|
||||
|
||||
if self.__pattern is None:
|
||||
return
|
||||
|
||||
shiftedSeason: ShiftedSeason
|
||||
for shiftedSeason in self.__ssc.getShiftedSeasonSiblings(patternId=self.__pattern.getId()):
|
||||
shiftedSeasonObj = shiftedSeason.getObj()
|
||||
|
||||
firstEpisode = shiftedSeasonObj['first_episode']
|
||||
firstEpisodeStr = str(firstEpisode) if firstEpisode != -1 else ''
|
||||
|
||||
lastEpisode = shiftedSeasonObj['last_episode']
|
||||
lastEpisodeStr = str(lastEpisode) if lastEpisode != -1 else ''
|
||||
|
||||
row = (
|
||||
shiftedSeasonObj['original_season'],
|
||||
firstEpisodeStr,
|
||||
lastEpisodeStr,
|
||||
shiftedSeasonObj['season_offset'],
|
||||
shiftedSeasonObj['episode_offset'],
|
||||
)
|
||||
|
||||
self.shiftedSeasonsTable.add_row(*map(str, row))
|
||||
|
||||
def getSelectedShiftedSeasonObjFromInput(self):
|
||||
|
||||
shiftedSeasonObj = {}
|
||||
|
||||
try:
|
||||
row_key, col_key = self.shiftedSeasonsTable.coordinate_to_cell_key(
|
||||
self.shiftedSeasonsTable.cursor_coordinate
|
||||
)
|
||||
|
||||
if row_key is not None:
|
||||
selected_row_data = self.shiftedSeasonsTable.get_row(row_key)
|
||||
|
||||
def parse_int_or_default(value: str, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
shiftedSeasonObj['original_season'] = int(selected_row_data[0])
|
||||
shiftedSeasonObj['first_episode'] = parse_int_or_default(selected_row_data[1], -1)
|
||||
shiftedSeasonObj['last_episode'] = parse_int_or_default(selected_row_data[2], -1)
|
||||
shiftedSeasonObj['season_offset'] = parse_int_or_default(selected_row_data[3], 0)
|
||||
shiftedSeasonObj['episode_offset'] = parse_int_or_default(selected_row_data[4], 0)
|
||||
|
||||
if self.__pattern is not None:
|
||||
shiftedSeasonId = self.__ssc.findShiftedSeason(
|
||||
patternId=self.__pattern.getId(),
|
||||
originalSeason=shiftedSeasonObj['original_season'],
|
||||
firstEpisode=shiftedSeasonObj['first_episode'],
|
||||
lastEpisode=shiftedSeasonObj['last_episode'],
|
||||
)
|
||||
if shiftedSeasonId is not None:
|
||||
shiftedSeasonObj['id'] = shiftedSeasonId
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
|
||||
return shiftedSeasonObj
|
||||
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
@@ -270,6 +347,7 @@ class PatternDetailsScreen(Screen):
|
||||
|
||||
self.updateTags()
|
||||
self.updateTracks()
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
def compose(self):
|
||||
|
||||
@@ -298,6 +376,16 @@ class PatternDetailsScreen(Screen):
|
||||
|
||||
self.tracksTable.cursor_type = 'row'
|
||||
|
||||
self.shiftedSeasonsTable = DataTable(classes="seven")
|
||||
|
||||
self.column_key_original_season = self.shiftedSeasonsTable.add_column("Source Season", width=18)
|
||||
self.column_key_first_episode = self.shiftedSeasonsTable.add_column("First Episode", width=18)
|
||||
self.column_key_last_episode = self.shiftedSeasonsTable.add_column("Last Episode", width=18)
|
||||
self.column_key_season_offset = self.shiftedSeasonsTable.add_column("Season Offset", width=18)
|
||||
self.column_key_episode_offset = self.shiftedSeasonsTable.add_column("Episode Offset", width=18)
|
||||
|
||||
self.shiftedSeasonsTable.cursor_type = 'row'
|
||||
|
||||
|
||||
yield Header()
|
||||
|
||||
@@ -339,13 +427,11 @@ class PatternDetailsScreen(Screen):
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 9
|
||||
yield Static("Media Tags")
|
||||
|
||||
|
||||
yield Static("Shifted Seasons")
|
||||
if self.__pattern is not None:
|
||||
yield Button("Add", id="button_add_tag")
|
||||
yield Button("Edit", id="button_edit_tag")
|
||||
yield Button("Delete", id="button_delete_tag")
|
||||
yield Button("Add", id="button_add_shifted_season")
|
||||
yield Button("Edit", id="button_edit_shifted_season")
|
||||
yield Button("Delete", id="button_delete_shifted_season")
|
||||
else:
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
@@ -356,43 +442,52 @@ class PatternDetailsScreen(Screen):
|
||||
yield Static(" ")
|
||||
|
||||
# 10
|
||||
yield self.tagsTable
|
||||
yield self.shiftedSeasonsTable
|
||||
|
||||
# 11
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 12
|
||||
yield Static("Streams")
|
||||
|
||||
|
||||
if self.__pattern is not None:
|
||||
yield Button("Add", id="button_add_track")
|
||||
yield Button("Edit", id="button_edit_track")
|
||||
yield Button("Delete", id="button_delete_track")
|
||||
else:
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
yield Static("Media Tags")
|
||||
yield Button("Add", id="button_add_tag")
|
||||
yield Button("Edit", id="button_edit_tag")
|
||||
yield Button("Delete", id="button_delete_tag")
|
||||
|
||||
yield Static(" ")
|
||||
yield Button("Up", id="button_track_up")
|
||||
yield Button("Down", id="button_track_down")
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
|
||||
# 13
|
||||
yield self.tracksTable
|
||||
yield self.tagsTable
|
||||
|
||||
# 14
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 15
|
||||
yield Static(" ", classes="seven")
|
||||
yield Static("Streams")
|
||||
yield Button("Add", id="button_add_track")
|
||||
yield Button("Edit", id="button_edit_track")
|
||||
yield Button("Delete", id="button_delete_track")
|
||||
|
||||
yield Static(" ")
|
||||
yield Button("Up", id="button_track_up")
|
||||
yield Button("Down", id="button_track_down")
|
||||
|
||||
# 16
|
||||
yield self.tracksTable
|
||||
|
||||
# 17
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 18
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 19
|
||||
yield Button("Save", id="save_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 17
|
||||
# 20
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
yield Footer()
|
||||
@@ -413,13 +508,8 @@ class PatternDetailsScreen(Screen):
|
||||
|
||||
def getSelectedTrackDescriptor(self):
|
||||
|
||||
if not self.__pattern:
|
||||
return None
|
||||
|
||||
try:
|
||||
|
||||
# Fetch the currently selected row when 'Enter' is pressed
|
||||
#selected_row_index = self.table.cursor_row
|
||||
row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
@@ -428,9 +518,11 @@ class PatternDetailsScreen(Screen):
|
||||
trackIndex = int(selected_track_data[0])
|
||||
trackSubIndex = int(selected_track_data[2])
|
||||
|
||||
return self.__tc.getTrack(self.__pattern.getId(), trackIndex).getDescriptor(self.context, subIndex=trackSubIndex)
|
||||
for trackDescriptor in self.getCurrentTrackDescriptors():
|
||||
if (trackDescriptor.getIndex() == trackIndex
|
||||
and trackDescriptor.getSubIndex() == trackSubIndex):
|
||||
return trackDescriptor
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
except CellDoesNotExist:
|
||||
@@ -482,7 +574,11 @@ class PatternDetailsScreen(Screen):
|
||||
self.app.pop_screen()
|
||||
|
||||
else:
|
||||
patternId = self.__pc.addPattern(patternDescriptor)
|
||||
patternId = self.__pc.savePatternSchema(
|
||||
patternDescriptor,
|
||||
trackDescriptors=self.__draftTracks,
|
||||
mediaTags=self.__draftTags,
|
||||
)
|
||||
if patternId:
|
||||
self.dismiss(patternDescriptor)
|
||||
else:
|
||||
@@ -493,33 +589,81 @@ class PatternDetailsScreen(Screen):
|
||||
if event.button.id == "cancel_button":
|
||||
self.app.pop_screen()
|
||||
|
||||
|
||||
# Save pattern when just created before adding streams
|
||||
if event.button.id == "button_add_shifted_season":
|
||||
if self.__pattern is not None:
|
||||
self.app.push_screen(
|
||||
ShiftedSeasonDetailsScreen(patternId=self.__pattern.getId()),
|
||||
self.handle_update_shifted_season,
|
||||
)
|
||||
|
||||
numTracks = len(self.tracksTable.rows)
|
||||
if event.button.id == "button_edit_shifted_season":
|
||||
selectedShiftedSeasonObj = self.getSelectedShiftedSeasonObjFromInput()
|
||||
if 'id' in selectedShiftedSeasonObj.keys():
|
||||
self.app.push_screen(
|
||||
ShiftedSeasonDetailsScreen(
|
||||
patternId=self.__pattern.getId(),
|
||||
shiftedSeasonId=selectedShiftedSeasonObj['id'],
|
||||
),
|
||||
self.handle_update_shifted_season,
|
||||
)
|
||||
|
||||
if event.button.id == "button_delete_shifted_season":
|
||||
selectedShiftedSeasonObj = self.getSelectedShiftedSeasonObjFromInput()
|
||||
if 'id' in selectedShiftedSeasonObj.keys():
|
||||
self.app.push_screen(
|
||||
ShiftedSeasonDeleteScreen(
|
||||
patternId=self.__pattern.getId(),
|
||||
shiftedSeasonId=selectedShiftedSeasonObj['id'],
|
||||
),
|
||||
self.handle_delete_shifted_season,
|
||||
)
|
||||
|
||||
|
||||
numTracks = len(self.getCurrentTrackDescriptors())
|
||||
|
||||
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()
|
||||
if selectedTrack is not None:
|
||||
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":
|
||||
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 self.__pattern is not None:
|
||||
self.app.push_screen(TagDetailsScreen(), self.handle_update_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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -537,6 +681,7 @@ class PatternDetailsScreen(Screen):
|
||||
if event.button.id == "button_track_up":
|
||||
|
||||
selectedTrackDescriptor = self.getSelectedTrackDescriptor()
|
||||
if selectedTrackDescriptor is not None:
|
||||
selectedTrackIndex = selectedTrackDescriptor.getIndex()
|
||||
|
||||
if selectedTrackIndex > 0 and selectedTrackIndex < self.tracksTable.row_count:
|
||||
@@ -547,6 +692,7 @@ class PatternDetailsScreen(Screen):
|
||||
if event.button.id == "button_track_down":
|
||||
|
||||
selectedTrackDescriptor = self.getSelectedTrackDescriptor()
|
||||
if selectedTrackDescriptor is not None:
|
||||
selectedTrackIndex = selectedTrackDescriptor.getIndex()
|
||||
|
||||
if selectedTrackIndex >= 0 and selectedTrackIndex < (self.tracksTable.row_count - 1):
|
||||
@@ -555,67 +701,94 @@ class PatternDetailsScreen(Screen):
|
||||
|
||||
|
||||
def handle_add_track(self, trackDescriptor : TrackDescriptor):
|
||||
if trackDescriptor is None:
|
||||
return
|
||||
|
||||
dispoSet = trackDescriptor.getDispositionSet()
|
||||
trackType = trackDescriptor.getType()
|
||||
index = trackDescriptor.getIndex()
|
||||
subIndex = trackDescriptor.getSubIndex()
|
||||
codec = trackDescriptor.getCodec()
|
||||
language = trackDescriptor.getLanguage()
|
||||
title = trackDescriptor.getTitle()
|
||||
if self.__pattern is not None:
|
||||
self.__tc.addTrack(trackDescriptor, patternId=self.__pattern.getId())
|
||||
else:
|
||||
self.__draftTracks.append(trackDescriptor)
|
||||
self.normalizeDraftTracks()
|
||||
|
||||
row = (index,
|
||||
trackType.label(),
|
||||
subIndex,
|
||||
codec.label(),
|
||||
language.label(),
|
||||
title,
|
||||
'Yes' if TrackDisposition.DEFAULT in dispoSet else 'No',
|
||||
'Yes' if TrackDisposition.FORCED in dispoSet else 'No')
|
||||
|
||||
self.tracksTable.add_row(*map(str, row))
|
||||
self.updateTracks()
|
||||
|
||||
|
||||
def handle_edit_track(self, trackDescriptor : TrackDescriptor):
|
||||
if trackDescriptor is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if self.__pattern is not None:
|
||||
if not self.__tc.updateTrack(trackDescriptor.getId(), trackDescriptor):
|
||||
raise click.ClickException("PatternDetailsScreen.handle_edit_track(): track update failed")
|
||||
else:
|
||||
selectedTrack = self.getSelectedTrackDescriptor()
|
||||
for index, currentTrack in enumerate(self.__draftTracks):
|
||||
if (selectedTrack is not None
|
||||
and currentTrack.getIndex() == selectedTrack.getIndex()
|
||||
and currentTrack.getSubIndex() == selectedTrack.getSubIndex()):
|
||||
self.__draftTracks[index] = trackDescriptor
|
||||
break
|
||||
self.normalizeDraftTracks()
|
||||
|
||||
row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate)
|
||||
|
||||
self.tracksTable.update_cell(row_key, self.column_key_track_audio_layout,
|
||||
trackDescriptor.getAudioLayout().label()
|
||||
if trackDescriptor.getType() == TrackType.AUDIO else ' ')
|
||||
|
||||
self.tracksTable.update_cell(row_key, self.column_key_track_language, trackDescriptor.getLanguage().label())
|
||||
self.tracksTable.update_cell(row_key, self.column_key_track_title, trackDescriptor.getTitle())
|
||||
self.tracksTable.update_cell(row_key, self.column_key_track_default,
|
||||
'Yes' if TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet() else 'No')
|
||||
self.tracksTable.update_cell(row_key, self.column_key_track_forced,
|
||||
'Yes' if TrackDisposition.FORCED in trackDescriptor.getDispositionSet() else 'No')
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
self.updateTracks()
|
||||
|
||||
|
||||
def handle_delete_track(self, trackDescriptor : TrackDescriptor):
|
||||
if trackDescriptor is None:
|
||||
return
|
||||
|
||||
if self.__pattern is not None:
|
||||
track = self.__tc.getTrack(trackDescriptor.getPatternId(), trackDescriptor.getIndex())
|
||||
|
||||
if track is None:
|
||||
raise click.ClickException(
|
||||
f"Track is none: patternId={trackDescriptor.getPatternId()} type={trackDescriptor.getType()} subIndex={trackDescriptor.getSubIndex()}"
|
||||
)
|
||||
|
||||
self.__tc.deleteTrack(track.getId())
|
||||
else:
|
||||
self.__draftTracks = [
|
||||
currentTrack
|
||||
for currentTrack in self.__draftTracks
|
||||
if not (
|
||||
currentTrack.getIndex() == trackDescriptor.getIndex()
|
||||
and currentTrack.getSubIndex() == trackDescriptor.getSubIndex()
|
||||
)
|
||||
]
|
||||
self.normalizeDraftTracks()
|
||||
|
||||
self.updateTracks()
|
||||
|
||||
|
||||
|
||||
def handle_update_tag(self, tag):
|
||||
if tag is None:
|
||||
return
|
||||
|
||||
if self.__pattern is None:
|
||||
raise click.ClickException(f"PatternDetailsScreen.handle_update_tag: pattern not set")
|
||||
self.__draftTags[str(tag[0])] = str(tag[1])
|
||||
else:
|
||||
if self.__tac.updateMediaTag(self.__pattern.getId(), tag[0], tag[1]) is None:
|
||||
raise click.ClickException("PatternDetailsScreen.handle_update_tag(): tag update failed")
|
||||
|
||||
if self.__tac.updateMediaTag(self.__pattern.getId(), tag[0], tag[1]) is not None:
|
||||
self.updateTags()
|
||||
|
||||
def handle_delete_tag(self, tag):
|
||||
if tag is None:
|
||||
return
|
||||
|
||||
if self.__pattern is None:
|
||||
raise click.ClickException(f"PatternDetailsScreen.handle_delete_tag: pattern not set")
|
||||
self.__draftTags.pop(str(tag[0]), None)
|
||||
self.updateTags()
|
||||
return
|
||||
|
||||
if self.__tac.deleteMediaTagByKey(self.__pattern.getId(), tag[0]):
|
||||
self.updateTags()
|
||||
else:
|
||||
raise click.ClickException('tag delete failed')
|
||||
|
||||
def handle_update_shifted_season(self, screenResult):
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
def handle_delete_shifted_season(self, screenResult):
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
@@ -1,33 +1,169 @@
|
||||
import subprocess, logging
|
||||
from typing import List
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
from typing import Iterable, List
|
||||
|
||||
def executeProcess(commandSequence: List[str], directory: str = None, context: dict = None):
|
||||
from .logging_utils import get_ffx_logger
|
||||
|
||||
COMMAND_TIMED_OUT_RETURN_CODE = 124
|
||||
COMMAND_NOT_FOUND_RETURN_CODE = 127
|
||||
MIN_NICENESS = -20
|
||||
MAX_NICENESS = 19
|
||||
DISABLED_NICENESS_SENTINEL = 99
|
||||
DISABLED_CPU_PERCENT_SENTINEL = 0
|
||||
MIN_CPU_PERCENT = 1
|
||||
MAX_CPU_PERCENT = 100
|
||||
|
||||
|
||||
def formatCommandSequence(commandSequence: Iterable[str]) -> str:
|
||||
return shlex.join([str(token) for token in commandSequence])
|
||||
|
||||
|
||||
def normalizeNiceness(niceness) -> int | None:
|
||||
if niceness is None:
|
||||
return None
|
||||
|
||||
niceness = int(niceness)
|
||||
if niceness == DISABLED_NICENESS_SENTINEL:
|
||||
return None
|
||||
|
||||
if niceness < MIN_NICENESS or niceness > MAX_NICENESS:
|
||||
raise ValueError(
|
||||
f"Niceness must be between {MIN_NICENESS} and {MAX_NICENESS}, "
|
||||
+ f"or {DISABLED_NICENESS_SENTINEL} to disable."
|
||||
)
|
||||
|
||||
return niceness
|
||||
|
||||
|
||||
def getPresentCpuCount() -> int:
|
||||
if hasattr(os, 'sched_getaffinity'):
|
||||
affinity = os.sched_getaffinity(0)
|
||||
if affinity:
|
||||
return len(affinity)
|
||||
|
||||
cpuCount = os.cpu_count()
|
||||
return cpuCount if cpuCount and cpuCount > 0 else 1
|
||||
|
||||
|
||||
def normalizeCpuPercent(cpuPercent) -> int | None:
|
||||
if cpuPercent is None:
|
||||
return None
|
||||
|
||||
cpuPercent = str(cpuPercent).strip()
|
||||
if cpuPercent.endswith('%'):
|
||||
percentValue = int(cpuPercent[:-1].strip())
|
||||
if percentValue == DISABLED_CPU_PERCENT_SENTINEL:
|
||||
return None
|
||||
|
||||
if percentValue < MIN_CPU_PERCENT or percentValue > MAX_CPU_PERCENT:
|
||||
raise ValueError(
|
||||
f"CPU percentage must be between {MIN_CPU_PERCENT}% and {MAX_CPU_PERCENT}%, "
|
||||
+ f"or {DISABLED_CPU_PERCENT_SENTINEL} to disable."
|
||||
)
|
||||
|
||||
return percentValue * getPresentCpuCount()
|
||||
|
||||
cpuPercent = int(cpuPercent)
|
||||
if cpuPercent == DISABLED_CPU_PERCENT_SENTINEL:
|
||||
return None
|
||||
|
||||
if cpuPercent < MIN_CPU_PERCENT:
|
||||
raise ValueError(
|
||||
"CPU limit must be a positive absolute value such as 200, "
|
||||
+ f"a percentage such as 25%, or {DISABLED_CPU_PERCENT_SENTINEL} to disable."
|
||||
)
|
||||
|
||||
return cpuPercent
|
||||
|
||||
|
||||
def getWrappedCommandSequence(commandSequence: List[str], context: dict = None) -> List[str]:
|
||||
"""
|
||||
niceness -20 bis +19
|
||||
cpu_percent: 1 bis 99
|
||||
niceness: -20 to 19, disabled when unset
|
||||
cpu limit: positive absolute cpulimit value, or a machine-wide percentage
|
||||
|
||||
When both limits are configured, cpulimit wraps a nice-adjusted command:
|
||||
cpulimit -l <cpu> -- nice -n <niceness> <command>
|
||||
"""
|
||||
|
||||
if context is None:
|
||||
logger = logging.getLogger('FFX')
|
||||
logger.addHandler(logging.NullHandler())
|
||||
else:
|
||||
logger = context['logger']
|
||||
resourceLimits = (context or {}).get('resource_limits', {})
|
||||
niceness = normalizeNiceness(resourceLimits.get('niceness'))
|
||||
cpu_percent = normalizeCpuPercent(
|
||||
resourceLimits.get('cpu_limit', resourceLimits.get('cpu_percent'))
|
||||
)
|
||||
wrappedCommandSequence = [str(token) for token in commandSequence]
|
||||
|
||||
niceSequence = []
|
||||
if niceness is not None:
|
||||
wrappedCommandSequence = ['nice', '-n', str(niceness)] + wrappedCommandSequence
|
||||
if cpu_percent is not None:
|
||||
wrappedCommandSequence = ['cpulimit', '-l', str(cpu_percent), '--'] + wrappedCommandSequence
|
||||
|
||||
niceness = int((context or {}).get('resource_limits', {}).get('niceness', 99))
|
||||
cpu_percent = int((context or {}).get('resource_limits', {}).get('cpu_percent', 0))
|
||||
return wrappedCommandSequence
|
||||
|
||||
if niceness >= -20 and niceness <= 19:
|
||||
niceSequence += ['nice', '-n', str(niceness)]
|
||||
if cpu_percent >= 1:
|
||||
niceSequence += ['cpulimit', '-l', str(cpu_percent), '--']
|
||||
|
||||
niceCommand = niceSequence + commandSequence
|
||||
def getProcessTimeoutSeconds(context: dict = None, timeoutSeconds: float = None):
|
||||
if timeoutSeconds is None:
|
||||
timeoutSeconds = (context or {}).get('resource_limits', {}).get('timeout_seconds')
|
||||
|
||||
logger.debug(f"executeProcess() command sequence: {' '.join(niceCommand)}")
|
||||
if timeoutSeconds is None:
|
||||
return None
|
||||
|
||||
process = subprocess.Popen(niceCommand, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8', cwd = directory)
|
||||
output, error = process.communicate()
|
||||
timeoutSeconds = float(timeoutSeconds)
|
||||
|
||||
return output, error, process.returncode
|
||||
return timeoutSeconds if timeoutSeconds > 0 else None
|
||||
|
||||
|
||||
def executeProcess(
|
||||
commandSequence: List[str],
|
||||
directory: str = None,
|
||||
context: dict = None,
|
||||
timeoutSeconds: float = None,
|
||||
):
|
||||
|
||||
logger = context['logger'] if context is not None and 'logger' in context else get_ffx_logger()
|
||||
wrappedCommandSequence = getWrappedCommandSequence(commandSequence, context=context)
|
||||
timeoutSeconds = getProcessTimeoutSeconds(context=context, timeoutSeconds=timeoutSeconds)
|
||||
|
||||
logger.debug(
|
||||
"executeProcess() cwd=%s timeout=%s command=%s",
|
||||
directory or '.',
|
||||
timeoutSeconds if timeoutSeconds is not None else 'none',
|
||||
formatCommandSequence(wrappedCommandSequence),
|
||||
)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
wrappedCommandSequence,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=directory,
|
||||
timeout=timeoutSeconds,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError as ex:
|
||||
error = (
|
||||
"Command not found while running "
|
||||
+ f"{formatCommandSequence(wrappedCommandSequence)}: {ex.filename or ex}"
|
||||
)
|
||||
logger.error(error)
|
||||
return '', error, COMMAND_NOT_FOUND_RETURN_CODE
|
||||
except subprocess.TimeoutExpired as ex:
|
||||
stdout = ex.stdout or ''
|
||||
stderr = ex.stderr or ''
|
||||
error = (
|
||||
f"Command timed out after {timeoutSeconds} seconds while running "
|
||||
+ formatCommandSequence(wrappedCommandSequence)
|
||||
)
|
||||
if stderr:
|
||||
error = f"{error}\n{stderr}"
|
||||
logger.error(error)
|
||||
return stdout, error, COMMAND_TIMED_OUT_RETURN_CODE
|
||||
|
||||
if completed.returncode != 0:
|
||||
logger.warning(
|
||||
"executeProcess() rc=%s command=%s",
|
||||
completed.returncode,
|
||||
formatCommandSequence(wrappedCommandSequence),
|
||||
)
|
||||
|
||||
return completed.stdout, completed.stderr, completed.returncode
|
||||
|
||||
65
src/ffx/screen_support.py
Normal file
65
src/ffx/screen_support.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .pattern_controller import PatternController
|
||||
from .show_controller import ShowController
|
||||
from .shifted_season_controller import ShiftedSeasonController
|
||||
from .tag_controller import TagController
|
||||
from .tmdb_controller import TmdbController
|
||||
from .track_controller import TrackController
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScreenBootstrap:
|
||||
context: dict
|
||||
configuration_data: dict
|
||||
signature_tags: dict
|
||||
remove_global_keys: list
|
||||
ignore_global_keys: list
|
||||
remove_track_keys: list
|
||||
ignore_track_keys: list
|
||||
|
||||
|
||||
def build_screen_bootstrap(context: dict) -> ScreenBootstrap:
|
||||
configurationData = context['config'].getData()
|
||||
metadataConfiguration = configurationData.get('metadata', {})
|
||||
streamMetadataConfiguration = metadataConfiguration.get('streams', {})
|
||||
|
||||
return ScreenBootstrap(
|
||||
context=context,
|
||||
configuration_data=configurationData,
|
||||
signature_tags=metadataConfiguration.get('signature', {}),
|
||||
remove_global_keys=metadataConfiguration.get('remove', []),
|
||||
ignore_global_keys=metadataConfiguration.get('ignore', []),
|
||||
remove_track_keys=streamMetadataConfiguration.get('remove', []),
|
||||
ignore_track_keys=streamMetadataConfiguration.get('ignore', []),
|
||||
)
|
||||
|
||||
|
||||
def build_screen_controllers(
|
||||
context: dict,
|
||||
*,
|
||||
pattern: bool = False,
|
||||
show: bool = False,
|
||||
track: bool = False,
|
||||
tag: bool = False,
|
||||
tmdb: bool = False,
|
||||
shifted_season: bool = False,
|
||||
) -> dict[str, object]:
|
||||
controllers = {}
|
||||
|
||||
if pattern:
|
||||
controllers['pattern'] = PatternController(context=context)
|
||||
if show:
|
||||
controllers['show'] = ShowController(context=context)
|
||||
if track:
|
||||
controllers['track'] = TrackController(context=context)
|
||||
if tag:
|
||||
controllers['tag'] = TagController(context=context)
|
||||
if tmdb:
|
||||
controllers['tmdb'] = TmdbController()
|
||||
if shifted_season:
|
||||
controllers['shifted_season'] = ShiftedSeasonController(context=context)
|
||||
|
||||
return controllers
|
||||
@@ -6,228 +6,433 @@ from ffx.model.shifted_season import ShiftedSeason
|
||||
class EpisodeOrderException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RangeOverlapException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ShiftedSeasonController():
|
||||
class ShiftedSeasonOwnerException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ShiftedSeasonController:
|
||||
|
||||
def __init__(self, context):
|
||||
|
||||
self.context = context
|
||||
self.Session = self.context['database']['session'] # convenience
|
||||
|
||||
def checkShiftedSeason(self, showId: int, shiftedSeasonObj: dict, shiftedSeasonId: int = 0):
|
||||
"""
|
||||
Check if for a particula season
|
||||
def _resolve_owner(self, showId=None, patternId=None):
|
||||
hasShow = showId is not None
|
||||
hasPattern = patternId is not None
|
||||
|
||||
shiftedSeasonId
|
||||
if hasShow == hasPattern:
|
||||
raise ShiftedSeasonOwnerException(
|
||||
"ShiftedSeason rules require exactly one owner: either showId or patternId."
|
||||
)
|
||||
|
||||
if hasShow:
|
||||
if type(showId) is not int:
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController: Argument showId is required to be of type int"
|
||||
)
|
||||
return {
|
||||
'show_id': int(showId),
|
||||
'pattern_id': None,
|
||||
'label': f"show #{int(showId)}",
|
||||
}
|
||||
|
||||
if type(patternId) is not int:
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController: Argument patternId is required to be of type int"
|
||||
)
|
||||
return {
|
||||
'show_id': None,
|
||||
'pattern_id': int(patternId),
|
||||
'label': f"pattern #{int(patternId)}",
|
||||
}
|
||||
|
||||
def _apply_owner_filter(self, query, owner):
|
||||
if owner['pattern_id'] is not None:
|
||||
return query.filter(ShiftedSeason.pattern_id == owner['pattern_id'])
|
||||
return query.filter(ShiftedSeason.show_id == owner['show_id'])
|
||||
|
||||
def _normalize_shifted_season_fields(self, shiftedSeasonObj: dict):
|
||||
if type(shiftedSeasonObj) is not dict:
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController: Argument shiftedSeasonObj is required to be of type dict"
|
||||
)
|
||||
|
||||
fields = {
|
||||
'original_season': int(shiftedSeasonObj['original_season']),
|
||||
'first_episode': int(shiftedSeasonObj['first_episode']),
|
||||
'last_episode': int(shiftedSeasonObj['last_episode']),
|
||||
'season_offset': int(shiftedSeasonObj['season_offset']),
|
||||
'episode_offset': int(shiftedSeasonObj['episode_offset']),
|
||||
}
|
||||
|
||||
firstEpisode = fields['first_episode']
|
||||
lastEpisode = fields['last_episode']
|
||||
if firstEpisode != -1 and lastEpisode != -1 and lastEpisode < firstEpisode:
|
||||
raise EpisodeOrderException(
|
||||
"ShiftedSeason last_episode must be greater than or equal to first_episode."
|
||||
)
|
||||
|
||||
return fields
|
||||
|
||||
def _ranges_overlap(self, firstEpisodeA, lastEpisodeA, firstEpisodeB, lastEpisodeB):
|
||||
startA = float('-inf') if int(firstEpisodeA) == -1 else int(firstEpisodeA)
|
||||
endA = float('inf') if int(lastEpisodeA) == -1 else int(lastEpisodeA)
|
||||
startB = float('-inf') if int(firstEpisodeB) == -1 else int(firstEpisodeB)
|
||||
endB = float('inf') if int(lastEpisodeB) == -1 else int(lastEpisodeB)
|
||||
return startA <= endB and startB <= endA
|
||||
|
||||
def _ordered_query(self, session, owner):
|
||||
q = self._apply_owner_filter(session.query(ShiftedSeason), owner)
|
||||
return q.order_by(
|
||||
ShiftedSeason.original_season.asc(),
|
||||
ShiftedSeason.first_episode.asc(),
|
||||
ShiftedSeason.last_episode.asc(),
|
||||
ShiftedSeason.id.asc(),
|
||||
)
|
||||
|
||||
def _find_matching_rule(self, session, owner, season: int, episode: int):
|
||||
for shiftedSeasonEntry in self._ordered_query(session, owner).all():
|
||||
if (
|
||||
season == shiftedSeasonEntry.getOriginalSeason()
|
||||
and (
|
||||
shiftedSeasonEntry.getFirstEpisode() == -1
|
||||
or episode >= shiftedSeasonEntry.getFirstEpisode()
|
||||
)
|
||||
and (
|
||||
shiftedSeasonEntry.getLastEpisode() == -1
|
||||
or episode <= shiftedSeasonEntry.getLastEpisode()
|
||||
)
|
||||
):
|
||||
return shiftedSeasonEntry
|
||||
return None
|
||||
|
||||
def checkShiftedSeason(
|
||||
self,
|
||||
showId: int | None = None,
|
||||
shiftedSeasonObj: dict | None = None,
|
||||
shiftedSeasonId: int = 0,
|
||||
patternId: int | None = None,
|
||||
):
|
||||
"""
|
||||
Check whether a shifted-season rule is valid within one owner scope.
|
||||
"""
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
|
||||
session = self.Session()
|
||||
|
||||
originalSeason = shiftedSeasonObj['original_season']
|
||||
firstEpisode = int(shiftedSeasonObj['first_episode'])
|
||||
lastEpisode = int(shiftedSeasonObj['last_episode'])
|
||||
|
||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
||||
q = self._ordered_query(session, owner)
|
||||
if shiftedSeasonId:
|
||||
q = q.filter(ShiftedSeason.id != int(shiftedSeasonId))
|
||||
|
||||
siblingShiftedSeason: ShiftedSeason
|
||||
for siblingShiftedSeason in q.all():
|
||||
if fields['original_season'] != siblingShiftedSeason.getOriginalSeason():
|
||||
continue
|
||||
|
||||
siblingOriginalSeason = siblingShiftedSeason.getOriginalSeason
|
||||
siblingFirstEpisode = siblingShiftedSeason.getFirstEpisode()
|
||||
siblingLastEpisode = siblingShiftedSeason.getLastEpisode()
|
||||
|
||||
if (originalSeason == siblingOriginalSeason
|
||||
and lastEpisode >= siblingFirstEpisode
|
||||
and siblingLastEpisode >= firstEpisode):
|
||||
|
||||
if self._ranges_overlap(
|
||||
fields['first_episode'],
|
||||
fields['last_episode'],
|
||||
siblingShiftedSeason.getFirstEpisode(),
|
||||
siblingShiftedSeason.getLastEpisode(),
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except (EpisodeOrderException, ShiftedSeasonOwnerException) as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.checkShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def addShiftedSeason(
|
||||
self,
|
||||
showId: int | None = None,
|
||||
shiftedSeasonObj: dict | None = None,
|
||||
patternId: int | None = None,
|
||||
):
|
||||
|
||||
def addShiftedSeason(self, showId: int, shiftedSeasonObj: dict):
|
||||
|
||||
if type(showId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.addShiftedSeason(): Argument showId is required to be of type int")
|
||||
|
||||
if type(shiftedSeasonObj) is not dict:
|
||||
raise ValueError(f"ShiftedSeasonController.addShiftedSeason(): Argument shiftedSeasonObj is required to be of type dict")
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
|
||||
|
||||
firstEpisode = int(shiftedSeasonObj['first_episode'])
|
||||
lastEpisode = int(shiftedSeasonObj['last_episode'])
|
||||
if not self.checkShiftedSeason(
|
||||
showId=owner['show_id'],
|
||||
patternId=owner['pattern_id'],
|
||||
shiftedSeasonObj=fields,
|
||||
):
|
||||
raise RangeOverlapException(
|
||||
f"ShiftedSeason rule overlaps with an existing rule for {owner['label']}."
|
||||
)
|
||||
|
||||
if lastEpisode < firstEpisode:
|
||||
raise EpisodeOrderException()
|
||||
|
||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
||||
|
||||
shiftedSeason = ShiftedSeason(show_id = int(showId),
|
||||
original_season = int(shiftedSeasonObj['original_season']),
|
||||
first_episode = firstEpisode,
|
||||
last_episode = lastEpisode,
|
||||
season_offset = int(shiftedSeasonObj['season_offset']),
|
||||
episode_offset = int(shiftedSeasonObj['episode_offset']))
|
||||
s.add(shiftedSeason)
|
||||
s.commit()
|
||||
session = self.Session()
|
||||
shiftedSeason = ShiftedSeason(
|
||||
show_id=owner['show_id'],
|
||||
pattern_id=owner['pattern_id'],
|
||||
original_season=fields['original_season'],
|
||||
first_episode=fields['first_episode'],
|
||||
last_episode=fields['last_episode'],
|
||||
season_offset=fields['season_offset'],
|
||||
episode_offset=fields['episode_offset'],
|
||||
)
|
||||
session.add(shiftedSeason)
|
||||
session.commit()
|
||||
return shiftedSeason.getId()
|
||||
|
||||
except (EpisodeOrderException, RangeOverlapException, ShiftedSeasonOwnerException) as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def updateShiftedSeason(self, shiftedSeasonId: int, shiftedSeasonObj: dict):
|
||||
|
||||
if type(shiftedSeasonId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
||||
|
||||
if type(shiftedSeasonObj) is not dict:
|
||||
raise ValueError(f"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonObj is required to be of type dict")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonId is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
|
||||
session = self.Session()
|
||||
|
||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||
shiftedSeason = (
|
||||
session.query(ShiftedSeason)
|
||||
.filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||
.first()
|
||||
)
|
||||
|
||||
if q.count():
|
||||
|
||||
shiftedSeason = q.first()
|
||||
|
||||
shiftedSeason.original_season = int(shiftedSeasonObj['original_season'])
|
||||
shiftedSeason.first_episode = int(shiftedSeasonObj['first_episode'])
|
||||
shiftedSeason.last_episode = int(shiftedSeasonObj['last_episode'])
|
||||
shiftedSeason.season_offset = int(shiftedSeasonObj['season_offset'])
|
||||
shiftedSeason.episode_offset = int(shiftedSeasonObj['episode_offset'])
|
||||
|
||||
s.commit()
|
||||
return True
|
||||
|
||||
else:
|
||||
if shiftedSeason is None:
|
||||
return False
|
||||
|
||||
owner = self._resolve_owner(
|
||||
showId=shiftedSeason.getShowId(),
|
||||
patternId=shiftedSeason.getPatternId(),
|
||||
)
|
||||
if not self.checkShiftedSeason(
|
||||
showId=owner['show_id'],
|
||||
patternId=owner['pattern_id'],
|
||||
shiftedSeasonObj=fields,
|
||||
shiftedSeasonId=shiftedSeasonId,
|
||||
):
|
||||
raise RangeOverlapException(
|
||||
f"ShiftedSeason rule overlaps with an existing rule for {owner['label']}."
|
||||
)
|
||||
|
||||
shiftedSeason.original_season = fields['original_season']
|
||||
shiftedSeason.first_episode = fields['first_episode']
|
||||
shiftedSeason.last_episode = fields['last_episode']
|
||||
shiftedSeason.season_offset = fields['season_offset']
|
||||
shiftedSeason.episode_offset = fields['episode_offset']
|
||||
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
except (EpisodeOrderException, RangeOverlapException, ShiftedSeasonOwnerException) as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
|
||||
def findShiftedSeason(self, showId: int, originalSeason: int, firstEpisode: int, lastEpisode: int):
|
||||
|
||||
if type(showId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
||||
def findShiftedSeason(
|
||||
self,
|
||||
showId: int | None = None,
|
||||
originalSeason: int | None = None,
|
||||
firstEpisode: int | None = None,
|
||||
lastEpisode: int | None = None,
|
||||
patternId: int | None = None,
|
||||
):
|
||||
|
||||
if type(originalSeason) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument originalSeason is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.findShiftedSeason(): Argument originalSeason is required to be of type int"
|
||||
)
|
||||
|
||||
if type(firstEpisode) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument firstEpisode is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.findShiftedSeason(): Argument firstEpisode is required to be of type int"
|
||||
)
|
||||
|
||||
if type(lastEpisode) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument lastEpisode is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.findShiftedSeason(): Argument lastEpisode is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId),
|
||||
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||
session = self.Session()
|
||||
shiftedSeason = (
|
||||
self._apply_owner_filter(session.query(ShiftedSeason), owner)
|
||||
.filter(
|
||||
ShiftedSeason.original_season == int(originalSeason),
|
||||
ShiftedSeason.first_episode == int(firstEpisode),
|
||||
ShiftedSeason.last_episode == int(lastEpisode))
|
||||
ShiftedSeason.last_episode == int(lastEpisode),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
return q.first().getId() if q.count() else None
|
||||
return shiftedSeason.getId() if shiftedSeason is not None else None
|
||||
|
||||
except ShiftedSeasonOwnerException as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"PatternController.findShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.findShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def getShiftedSeasonSiblings(self, showId: int):
|
||||
|
||||
if type(showId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.getShiftedSeasonSiblings(): Argument shiftedSeasonId is required to be of type int")
|
||||
def getShiftedSeasonSiblings(
|
||||
self,
|
||||
showId: int | None = None,
|
||||
patternId: int | None = None,
|
||||
):
|
||||
session = None
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
||||
|
||||
return q.all()
|
||||
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||
session = self.Session()
|
||||
return self._ordered_query(session, owner).all()
|
||||
|
||||
except ShiftedSeasonOwnerException as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"PatternController.getShiftedSeasonSiblings(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.getShiftedSeasonSiblings(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def getShiftedSeason(self, shiftedSeasonId: int):
|
||||
|
||||
if type(shiftedSeasonId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.getShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.getShiftedSeason(): Argument shiftedSeasonId is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||
|
||||
return q.first() if q.count() else None
|
||||
session = self.Session()
|
||||
return (
|
||||
session.query(ShiftedSeason)
|
||||
.filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||
.first()
|
||||
)
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def deleteShiftedSeason(self, shiftedSeasonId):
|
||||
|
||||
if type(shiftedSeasonId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.deleteShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.deleteShiftedSeason(): Argument shiftedSeasonId is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||
session = self.Session()
|
||||
shiftedSeason = (
|
||||
session.query(ShiftedSeason)
|
||||
.filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||
.first()
|
||||
)
|
||||
|
||||
if q.count():
|
||||
|
||||
#DAFUQ: https://stackoverflow.com/a/19245058
|
||||
# q.delete()
|
||||
shiftedSeason = q.first()
|
||||
s.delete(shiftedSeason)
|
||||
|
||||
s.commit()
|
||||
if shiftedSeason is not None:
|
||||
session.delete(shiftedSeason)
|
||||
session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def shiftSeason(self, showId, season, episode, patternId=None):
|
||||
|
||||
def shiftSeason(self, showId, season, episode):
|
||||
if season == -1 or episode == -1:
|
||||
return season, episode
|
||||
|
||||
shiftedSeasonEntry: ShiftedSeason
|
||||
for shiftedSeasonEntry in self.getShiftedSeasonSiblings(showId):
|
||||
session = None
|
||||
try:
|
||||
session = self.Session()
|
||||
activeShift = None
|
||||
|
||||
if (season == shiftedSeasonEntry.getOriginalSeason()
|
||||
and (shiftedSeasonEntry.getFirstEpisode() == -1 or episode >= shiftedSeasonEntry.getFirstEpisode())
|
||||
and (shiftedSeasonEntry.getLastEpisode() == -1 or episode <= shiftedSeasonEntry.getLastEpisode())):
|
||||
if patternId is not None:
|
||||
activeShift = self._find_matching_rule(
|
||||
session,
|
||||
self._resolve_owner(patternId=patternId),
|
||||
season=int(season),
|
||||
episode=int(episode),
|
||||
)
|
||||
|
||||
shiftedSeason = season + shiftedSeasonEntry.getSeasonOffset()
|
||||
shiftedEpisode = episode + shiftedSeasonEntry.getEpisodeOffset()
|
||||
if activeShift is None and showId is not None and showId != -1:
|
||||
activeShift = self._find_matching_rule(
|
||||
session,
|
||||
self._resolve_owner(showId=showId),
|
||||
season=int(season),
|
||||
episode=int(episode),
|
||||
)
|
||||
|
||||
self.context['logger'].info(f"Shifting season: {season} episode: {episode} "
|
||||
+f"-> season: {shiftedSeason} episode: {shiftedEpisode}")
|
||||
if activeShift is None:
|
||||
shiftedSeason = season
|
||||
shiftedEpisode = episode
|
||||
sourceLabel = "default"
|
||||
else:
|
||||
shiftedSeason = season + activeShift.getSeasonOffset()
|
||||
shiftedEpisode = episode + activeShift.getEpisodeOffset()
|
||||
sourceLabel = (
|
||||
"pattern"
|
||||
if activeShift.getPatternId() is not None
|
||||
else "show"
|
||||
)
|
||||
|
||||
self.context['logger'].info(
|
||||
f"Setting season shift {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}"
|
||||
)
|
||||
|
||||
return shiftedSeason, shiftedEpisode
|
||||
|
||||
return season, episode
|
||||
except ShiftedSeasonOwnerException as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.shiftSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
@@ -43,7 +43,7 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, showId = None, shiftedSeasonId = None):
|
||||
def __init__(self, showId = None, patternId = None, shiftedSeasonId = None):
|
||||
super().__init__()
|
||||
|
||||
self.context = self.app.getContext()
|
||||
@@ -52,6 +52,7 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
self.__ssc = ShiftedSeasonController(context = self.context)
|
||||
|
||||
self._showId = showId
|
||||
self._patternId = patternId
|
||||
self.__shiftedSeasonId = shiftedSeasonId
|
||||
|
||||
|
||||
@@ -59,7 +60,12 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
|
||||
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
|
||||
|
||||
self.query_one("#static_show_id", Static).update(str(self._showId))
|
||||
ownerLabel = (
|
||||
f"pattern #{self._patternId}"
|
||||
if self._patternId is not None
|
||||
else f"show #{self._showId}"
|
||||
)
|
||||
self.query_one("#static_owner", Static).update(ownerLabel)
|
||||
self.query_one("#static_original_season", Static).update(str(shiftedSeason.getOriginalSeason()))
|
||||
self.query_one("#static_first_episode", Static).update(str(shiftedSeason.getFirstEpisode()))
|
||||
self.query_one("#static_last_episode", Static).update(str(shiftedSeason.getLastEpisode()))
|
||||
@@ -77,12 +83,12 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
|
||||
yield Static(" ", classes="two")
|
||||
|
||||
yield Static("from show")
|
||||
yield Static(" ", id="static_show_id")
|
||||
yield Static("from")
|
||||
yield Static(" ", id="static_owner")
|
||||
|
||||
yield Static(" ", classes="two")
|
||||
|
||||
yield Static("Original season")
|
||||
yield Static("Source season")
|
||||
yield Static(" ", id="static_original_season")
|
||||
|
||||
yield Static("First episode")
|
||||
@@ -122,4 +128,3 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
|
||||
if event.button.id == "cancel_button":
|
||||
self.app.pop_screen()
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, showId = None, shiftedSeasonId = None):
|
||||
def __init__(self, showId = None, patternId = None, shiftedSeasonId = None):
|
||||
super().__init__()
|
||||
|
||||
self.context = self.app.getContext()
|
||||
@@ -90,8 +90,14 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
self.__ssc = ShiftedSeasonController(context = self.context)
|
||||
|
||||
self.__showId = showId
|
||||
self.__patternId = patternId
|
||||
self.__shiftedSeasonId = shiftedSeasonId
|
||||
|
||||
def _owner_kwargs(self):
|
||||
if self.__patternId is not None:
|
||||
return {'patternId': self.__patternId}
|
||||
return {'showId': self.__showId}
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if self.__shiftedSeasonId is not None:
|
||||
@@ -126,7 +132,7 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
yield Static(" ", classes="three")
|
||||
|
||||
# 3
|
||||
yield Static("Original season")
|
||||
yield Static("Source season")
|
||||
yield Input(id="input_original_season", classes="two")
|
||||
|
||||
# 4
|
||||
@@ -203,8 +209,11 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
|
||||
if self.__shiftedSeasonId is not None:
|
||||
|
||||
if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj,
|
||||
shiftedSeasonId = self.__shiftedSeasonId):
|
||||
if self.__ssc.checkShiftedSeason(
|
||||
shiftedSeasonObj=shiftedSeasonObj,
|
||||
shiftedSeasonId=self.__shiftedSeasonId,
|
||||
**self._owner_kwargs(),
|
||||
):
|
||||
if self.__ssc.updateShiftedSeason(self.__shiftedSeasonId, shiftedSeasonObj):
|
||||
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
||||
else:
|
||||
@@ -212,8 +221,14 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
self.app.pop_screen()
|
||||
|
||||
else:
|
||||
if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj):
|
||||
self.__shiftedSeasonId = self.__ssc.addShiftedSeason(self.__showId, shiftedSeasonObj)
|
||||
if self.__ssc.checkShiftedSeason(
|
||||
shiftedSeasonObj=shiftedSeasonObj,
|
||||
**self._owner_kwargs(),
|
||||
):
|
||||
self.__shiftedSeasonId = self.__ssc.addShiftedSeason(
|
||||
shiftedSeasonObj=shiftedSeasonObj,
|
||||
**self._owner_kwargs(),
|
||||
)
|
||||
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
||||
|
||||
|
||||
|
||||
@@ -16,10 +16,9 @@ class ShowController():
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Show).filter(Show.id == showId)
|
||||
show = s.query(Show).filter(Show.id == showId).first()
|
||||
|
||||
if q.count():
|
||||
show: Show = q.first()
|
||||
if show is not None:
|
||||
return show.getDescriptor(self.context)
|
||||
|
||||
except Exception as ex:
|
||||
@@ -31,9 +30,7 @@ class ShowController():
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Show).filter(Show.id == showId)
|
||||
|
||||
return q.first() if q.count() else None
|
||||
return s.query(Show).filter(Show.id == showId).first()
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShowController.getShow(): {repr(ex)}")
|
||||
@@ -44,12 +41,7 @@ class ShowController():
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Show)
|
||||
|
||||
if q.count():
|
||||
return q.all()
|
||||
else:
|
||||
return []
|
||||
return s.query(Show).all()
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShowController.getAllShows(): {repr(ex)}")
|
||||
@@ -61,24 +53,23 @@ class ShowController():
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Show).filter(Show.id == showDescriptor.getId())
|
||||
currentShow = s.query(Show).filter(Show.id == showDescriptor.getId()).first()
|
||||
|
||||
if not q.count():
|
||||
if currentShow is None:
|
||||
show = Show(id = int(showDescriptor.getId()),
|
||||
name = str(showDescriptor.getName()),
|
||||
year = int(showDescriptor.getYear()),
|
||||
index_season_digits = showDescriptor.getIndexSeasonDigits(),
|
||||
index_episode_digits = showDescriptor.getIndexEpisodeDigits(),
|
||||
indicator_season_digits = showDescriptor.getIndicatorSeasonDigits(),
|
||||
indicator_episode_digits = showDescriptor.getIndicatorEpisodeDigits())
|
||||
indicator_episode_digits = showDescriptor.getIndicatorEpisodeDigits(),
|
||||
quality = showDescriptor.getQuality(),
|
||||
notes = showDescriptor.getNotes())
|
||||
|
||||
s.add(show)
|
||||
s.commit()
|
||||
return True
|
||||
else:
|
||||
|
||||
currentShow = q.first()
|
||||
|
||||
changed = False
|
||||
if currentShow.name != str(showDescriptor.getName()):
|
||||
currentShow.name = str(showDescriptor.getName())
|
||||
@@ -99,6 +90,12 @@ class ShowController():
|
||||
if currentShow.indicator_episode_digits != int(showDescriptor.getIndicatorEpisodeDigits()):
|
||||
currentShow.indicator_episode_digits = int(showDescriptor.getIndicatorEpisodeDigits())
|
||||
changed = True
|
||||
if int(currentShow.quality or 0) != int(showDescriptor.getQuality()):
|
||||
currentShow.quality = int(showDescriptor.getQuality())
|
||||
changed = True
|
||||
if str(currentShow.notes or '') != str(showDescriptor.getNotes()):
|
||||
currentShow.notes = str(showDescriptor.getNotes())
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
s.commit()
|
||||
@@ -113,14 +110,12 @@ class ShowController():
|
||||
def deleteShow(self, show_id):
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Show).filter(Show.id == int(show_id))
|
||||
show = s.query(Show).filter(Show.id == int(show_id)).first()
|
||||
|
||||
|
||||
if q.count():
|
||||
if show is not None:
|
||||
|
||||
#DAFUQ: https://stackoverflow.com/a/19245058
|
||||
# q.delete()
|
||||
show = q.first()
|
||||
s.delete(show)
|
||||
|
||||
s.commit()
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import logging
|
||||
from .configuration_controller import ConfigurationController
|
||||
from .constants import (
|
||||
DEFAULT_SHOW_INDEX_EPISODE_DIGITS,
|
||||
DEFAULT_SHOW_INDEX_SEASON_DIGITS,
|
||||
DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS,
|
||||
DEFAULT_SHOW_INDICATOR_SEASON_DIGITS,
|
||||
)
|
||||
from .logging_utils import get_ffx_logger
|
||||
|
||||
|
||||
class ShowDescriptor():
|
||||
@@ -14,11 +21,45 @@ class ShowDescriptor():
|
||||
INDEX_EPISODE_DIGITS_KEY = 'index_episode_digits'
|
||||
INDICATOR_SEASON_DIGITS_KEY = 'indicator_season_digits'
|
||||
INDICATOR_EPISODE_DIGITS_KEY = 'indicator_episode_digits'
|
||||
QUALITY_KEY = 'quality'
|
||||
NOTES_KEY = 'notes'
|
||||
|
||||
DEFAULT_INDEX_SEASON_DIGITS = 2
|
||||
DEFAULT_INDEX_EPISODE_DIGITS = 2
|
||||
DEFAULT_INDICATOR_SEASON_DIGITS = 2
|
||||
DEFAULT_INDICATOR_EPISODE_DIGITS = 2
|
||||
DEFAULT_INDEX_SEASON_DIGITS = DEFAULT_SHOW_INDEX_SEASON_DIGITS
|
||||
DEFAULT_INDEX_EPISODE_DIGITS = DEFAULT_SHOW_INDEX_EPISODE_DIGITS
|
||||
DEFAULT_INDICATOR_SEASON_DIGITS = DEFAULT_SHOW_INDICATOR_SEASON_DIGITS
|
||||
DEFAULT_INDICATOR_EPISODE_DIGITS = DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS
|
||||
|
||||
@classmethod
|
||||
def getDefaultDigitLengths(cls, context: dict | None = None) -> dict[str, int]:
|
||||
configurationData = {}
|
||||
|
||||
if context is not None:
|
||||
configController = context.get('config')
|
||||
if configController is not None and hasattr(configController, 'getData'):
|
||||
configurationData = configController.getData()
|
||||
|
||||
return {
|
||||
cls.INDEX_SEASON_DIGITS_KEY: ConfigurationController.getConfiguredIntegerValue(
|
||||
configurationData,
|
||||
ConfigurationController.DEFAULT_INDEX_SEASON_DIGITS_CONFIG_KEY,
|
||||
cls.DEFAULT_INDEX_SEASON_DIGITS,
|
||||
),
|
||||
cls.INDEX_EPISODE_DIGITS_KEY: ConfigurationController.getConfiguredIntegerValue(
|
||||
configurationData,
|
||||
ConfigurationController.DEFAULT_INDEX_EPISODE_DIGITS_CONFIG_KEY,
|
||||
cls.DEFAULT_INDEX_EPISODE_DIGITS,
|
||||
),
|
||||
cls.INDICATOR_SEASON_DIGITS_KEY: ConfigurationController.getConfiguredIntegerValue(
|
||||
configurationData,
|
||||
ConfigurationController.DEFAULT_INDICATOR_SEASON_DIGITS_CONFIG_KEY,
|
||||
cls.DEFAULT_INDICATOR_SEASON_DIGITS,
|
||||
),
|
||||
cls.INDICATOR_EPISODE_DIGITS_KEY: ConfigurationController.getConfiguredIntegerValue(
|
||||
configurationData,
|
||||
ConfigurationController.DEFAULT_INDICATOR_EPISODE_DIGITS_CONFIG_KEY,
|
||||
cls.DEFAULT_INDICATOR_EPISODE_DIGITS,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
@@ -32,8 +73,7 @@ class ShowDescriptor():
|
||||
self.__logger = self.__context['logger']
|
||||
else:
|
||||
self.__context = {}
|
||||
self.__logger = logging.getLogger('FFX')
|
||||
self.__logger.addHandler(logging.NullHandler())
|
||||
self.__logger = get_ffx_logger()
|
||||
|
||||
if ShowDescriptor.ID_KEY in kwargs.keys():
|
||||
if type(kwargs[ShowDescriptor.ID_KEY]) is not int:
|
||||
@@ -56,34 +96,49 @@ class ShowDescriptor():
|
||||
else:
|
||||
self.__showYear = -1
|
||||
|
||||
defaultDigitLengths = self.getDefaultDigitLengths(self.__context)
|
||||
|
||||
if ShowDescriptor.INDEX_SEASON_DIGITS_KEY in kwargs.keys():
|
||||
if type(kwargs[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]) is not int:
|
||||
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.INDEX_SEASON_DIGITS_KEY} is required to be of type int")
|
||||
self.__indexSeasonDigits = kwargs[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]
|
||||
else:
|
||||
self.__indexSeasonDigits = ShowDescriptor.DEFAULT_INDEX_SEASON_DIGITS
|
||||
self.__indexSeasonDigits = defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]
|
||||
|
||||
if ShowDescriptor.INDEX_EPISODE_DIGITS_KEY in kwargs.keys():
|
||||
if type(kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY]) is not int:
|
||||
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.INDEX_EPISODE_DIGITS_KEY} is required to be of type int")
|
||||
self.__indexEpisodeDigits = kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY]
|
||||
else:
|
||||
self.__indexEpisodeDigits = ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS
|
||||
self.__indexEpisodeDigits = defaultDigitLengths[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY]
|
||||
|
||||
if ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY in kwargs.keys():
|
||||
if type(kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY]) is not int:
|
||||
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY} is required to be of type int")
|
||||
self.__indicatorSeasonDigits = kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY]
|
||||
else:
|
||||
self.__indicatorSeasonDigits = ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS
|
||||
self.__indicatorSeasonDigits = defaultDigitLengths[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY]
|
||||
|
||||
if ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY in kwargs.keys():
|
||||
if type(kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]) is not int:
|
||||
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY} is required to be of type int")
|
||||
self.__indicatorEpisodeDigits = kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
|
||||
else:
|
||||
self.__indicatorEpisodeDigits = ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS
|
||||
self.__indicatorEpisodeDigits = defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
|
||||
|
||||
if ShowDescriptor.QUALITY_KEY in kwargs.keys():
|
||||
if type(kwargs[ShowDescriptor.QUALITY_KEY]) is not int:
|
||||
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.QUALITY_KEY} is required to be of type int")
|
||||
self.__quality = kwargs[ShowDescriptor.QUALITY_KEY]
|
||||
else:
|
||||
self.__quality = 0
|
||||
|
||||
if ShowDescriptor.NOTES_KEY in kwargs.keys():
|
||||
if type(kwargs[ShowDescriptor.NOTES_KEY]) is not str:
|
||||
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.NOTES_KEY} is required to be of type str")
|
||||
self.__notes = kwargs[ShowDescriptor.NOTES_KEY]
|
||||
else:
|
||||
self.__notes = ''
|
||||
|
||||
|
||||
def getId(self):
|
||||
@@ -101,6 +156,10 @@ class ShowDescriptor():
|
||||
return self.__indicatorSeasonDigits
|
||||
def getIndicatorEpisodeDigits(self):
|
||||
return self.__indicatorEpisodeDigits
|
||||
def getQuality(self):
|
||||
return self.__quality
|
||||
def getNotes(self):
|
||||
return self.__notes
|
||||
|
||||
def getFilenamePrefix(self):
|
||||
return f"{self.__showName} ({str(self.__showYear)})"
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import click
|
||||
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static, Button, DataTable, Input
|
||||
from textual.widgets import Header, Footer, Static, Button, DataTable, Input, TextArea
|
||||
from textual.containers import Grid
|
||||
from textual.widgets._data_table import CellDoesNotExist
|
||||
|
||||
from ffx.model.pattern import Pattern
|
||||
|
||||
from .pattern_details_screen import PatternDetailsScreen
|
||||
from .pattern_delete_screen import PatternDeleteScreen
|
||||
|
||||
from .show_controller import ShowController
|
||||
from .pattern_controller import PatternController
|
||||
from .tmdb_controller import TmdbController
|
||||
from .shifted_season_controller import ShiftedSeasonController
|
||||
|
||||
from .show_descriptor import ShowDescriptor
|
||||
|
||||
from .shifted_season_details_screen import ShiftedSeasonDetailsScreen
|
||||
@@ -23,6 +16,7 @@ from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen
|
||||
from ffx.model.shifted_season import ShiftedSeason
|
||||
|
||||
from .helper import filterFilename
|
||||
from .screen_support import build_screen_bootstrap, build_screen_controllers
|
||||
|
||||
|
||||
# Screen[dict[int, str, int]]
|
||||
@@ -31,8 +25,8 @@ class ShowDetailsScreen(Screen):
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 5 16;
|
||||
grid-rows: 2 2 2 2 2 2 2 2 2 2 2 9 2 9 2 2;
|
||||
grid-size: 5 18;
|
||||
grid-rows: 2 2 2 2 2 2 6 2 2 2 2 2 2 9 2 9 2 2;
|
||||
grid-columns: 30 30 30 30 30;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@@ -83,6 +77,10 @@ class ShowDetailsScreen(Screen):
|
||||
height: 100%;
|
||||
border: solid green;
|
||||
}
|
||||
|
||||
.note_box {
|
||||
min-height: 6;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
@@ -94,31 +92,24 @@ class ShowDetailsScreen(Screen):
|
||||
def __init__(self, showId = None):
|
||||
super().__init__()
|
||||
|
||||
self.context = self.app.getContext()
|
||||
self.Session = self.context['database']['session'] # convenience
|
||||
bootstrap = build_screen_bootstrap(self.app.getContext())
|
||||
self.context = bootstrap.context
|
||||
|
||||
self.__sc = ShowController(context = self.context)
|
||||
self.__pc = PatternController(context = self.context)
|
||||
self.__tc = TmdbController()
|
||||
self.__ssc = ShiftedSeasonController(context = self.context)
|
||||
controllers = build_screen_controllers(
|
||||
self.context,
|
||||
pattern=True,
|
||||
show=True,
|
||||
tmdb=True,
|
||||
shifted_season=True,
|
||||
)
|
||||
self.__sc = controllers['show']
|
||||
self.__pc = controllers['pattern']
|
||||
self.__tc = controllers['tmdb']
|
||||
self.__ssc = controllers['shifted_season']
|
||||
|
||||
self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None
|
||||
|
||||
|
||||
def loadPatterns(self, show_id : int):
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Pattern).filter(Pattern.show_id == int(show_id))
|
||||
|
||||
return [{'id': int(p.id), 'pattern': str(p.pattern)} for p in q.all()]
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShowDetailsScreen.loadPatterns(): {repr(ex)}")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
|
||||
def updateShiftedSeasons(self):
|
||||
|
||||
@@ -163,23 +154,34 @@ class ShowDetailsScreen(Screen):
|
||||
self.query_one("#index_episode_digits_input", Input).value = str(self.__showDescriptor.getIndexEpisodeDigits())
|
||||
self.query_one("#indicator_season_digits_input", Input).value = str(self.__showDescriptor.getIndicatorSeasonDigits())
|
||||
self.query_one("#indicator_episode_digits_input", Input).value = str(self.__showDescriptor.getIndicatorEpisodeDigits())
|
||||
if self.__showDescriptor.getQuality():
|
||||
self.query_one("#quality_input", Input).value = str(self.__showDescriptor.getQuality())
|
||||
if self.__showDescriptor.getNotes():
|
||||
self.query_one("#notes_textarea", TextArea).text = str(self.__showDescriptor.getNotes())
|
||||
|
||||
|
||||
#raise click.ClickException(f"show_id {showId}")
|
||||
patternList = self.loadPatterns(showId)
|
||||
# raise click.ClickException(f"patternList {patternList}")
|
||||
for pattern in patternList:
|
||||
row = (pattern['pattern'],)
|
||||
for pattern in self.__pc.getPatternsForShow(showId):
|
||||
row = (pattern.getPattern(),)
|
||||
self.patternTable.add_row(*map(str, row))
|
||||
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
else:
|
||||
defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(self.context)
|
||||
|
||||
self.query_one("#index_season_digits_input", Input).value = "2"
|
||||
self.query_one("#index_episode_digits_input", Input).value = "2"
|
||||
self.query_one("#indicator_season_digits_input", Input).value = "2"
|
||||
self.query_one("#indicator_episode_digits_input", Input).value = "2"
|
||||
self.query_one("#index_season_digits_input", Input).value = str(
|
||||
defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]
|
||||
)
|
||||
self.query_one("#index_episode_digits_input", Input).value = str(
|
||||
defaultDigitLengths[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY]
|
||||
)
|
||||
self.query_one("#indicator_season_digits_input", Input).value = str(
|
||||
defaultDigitLengths[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY]
|
||||
)
|
||||
self.query_one("#indicator_episode_digits_input", Input).value = str(
|
||||
defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
|
||||
)
|
||||
|
||||
|
||||
def getSelectedPatternDescriptor(self):
|
||||
@@ -217,11 +219,17 @@ class ShowDetailsScreen(Screen):
|
||||
if row_key is not None:
|
||||
selected_row_data = self.shiftedSeasonsTable.get_row(row_key)
|
||||
|
||||
def parse_int_or_default(value: str, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
shiftedSeasonObj['original_season'] = int(selected_row_data[0])
|
||||
shiftedSeasonObj['first_episode'] = int(selected_row_data[1]) if selected_row_data[1].isnumeric() else -1
|
||||
shiftedSeasonObj['last_episode'] = int(selected_row_data[2]) if selected_row_data[2].isnumeric() else -1
|
||||
shiftedSeasonObj['season_offset'] = int(selected_row_data[3]) if selected_row_data[3].isnumeric() else 0
|
||||
shiftedSeasonObj['episode_offset'] = int(selected_row_data[4]) if selected_row_data[4].isnumeric() else 0
|
||||
shiftedSeasonObj['first_episode'] = parse_int_or_default(selected_row_data[1], -1)
|
||||
shiftedSeasonObj['last_episode'] = parse_int_or_default(selected_row_data[2], -1)
|
||||
shiftedSeasonObj['season_offset'] = parse_int_or_default(selected_row_data[3], 0)
|
||||
shiftedSeasonObj['episode_offset'] = parse_int_or_default(selected_row_data[4], 0)
|
||||
|
||||
|
||||
if self.__showDescriptor is not None:
|
||||
@@ -314,7 +322,7 @@ class ShowDetailsScreen(Screen):
|
||||
|
||||
self.shiftedSeasonsTable = DataTable(classes="five")
|
||||
|
||||
self.column_key_original_season = self.shiftedSeasonsTable.add_column("Original Season", width=30)
|
||||
self.column_key_original_season = self.shiftedSeasonsTable.add_column("Source Season", width=30)
|
||||
self.column_key_first_episode = self.shiftedSeasonsTable.add_column("First Episode", width=30)
|
||||
self.column_key_last_episode = self.shiftedSeasonsTable.add_column("Last Episode", width=30)
|
||||
self.column_key_season_offset = self.shiftedSeasonsTable.add_column("Season Offset", width=30)
|
||||
@@ -348,28 +356,36 @@ class ShowDetailsScreen(Screen):
|
||||
yield Input(type="integer", id="year_input", classes="four")
|
||||
|
||||
#5
|
||||
yield Static(" ", classes="five")
|
||||
yield Static("Quality")
|
||||
yield Input(type="integer", id="quality_input", classes="four")
|
||||
|
||||
#6
|
||||
yield Static("Notes")
|
||||
yield Static(" ", classes="four")
|
||||
|
||||
#7
|
||||
yield TextArea(id="notes_textarea", classes="five note_box")
|
||||
|
||||
#8
|
||||
yield Static("Index Season Digits")
|
||||
yield Input(type="integer", id="index_season_digits_input", classes="four")
|
||||
|
||||
#7
|
||||
#9
|
||||
yield Static("Index Episode Digits")
|
||||
yield Input(type="integer", id="index_episode_digits_input", classes="four")
|
||||
|
||||
#8
|
||||
#10
|
||||
yield Static("Indicator Season Digits")
|
||||
yield Input(type="integer", id="indicator_season_digits_input", classes="four")
|
||||
|
||||
#9
|
||||
#11
|
||||
yield Static("Indicator Edisode Digits")
|
||||
yield Input(type="integer", id="indicator_episode_digits_input", classes="four")
|
||||
|
||||
# 10
|
||||
# 12
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 11
|
||||
# 13
|
||||
yield Static("Shifted seasons", classes="two")
|
||||
|
||||
if self.__showDescriptor is not None:
|
||||
@@ -381,18 +397,18 @@ class ShowDetailsScreen(Screen):
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
|
||||
# 12
|
||||
# 14
|
||||
yield self.shiftedSeasonsTable
|
||||
|
||||
# 13
|
||||
# 15
|
||||
yield Static("File patterns", classes="five")
|
||||
# 14
|
||||
# 16
|
||||
yield self.patternTable
|
||||
|
||||
# 15
|
||||
# 17
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 16
|
||||
# 18
|
||||
yield Button("Save", id="save_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
|
||||
@@ -402,7 +418,7 @@ class ShowDetailsScreen(Screen):
|
||||
|
||||
def getShowDescriptorFromInput(self) -> ShowDescriptor:
|
||||
|
||||
kwargs = {}
|
||||
kwargs = {ShowDescriptor.CONTEXT_KEY: self.context}
|
||||
|
||||
try:
|
||||
if self.__showDescriptor:
|
||||
@@ -438,6 +454,11 @@ class ShowDetailsScreen(Screen):
|
||||
kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.query_one("#indicator_episode_digits_input", Input).value)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
kwargs[ShowDescriptor.QUALITY_KEY] = int(self.query_one("#quality_input", Input).value)
|
||||
except ValueError:
|
||||
pass
|
||||
kwargs[ShowDescriptor.NOTES_KEY] = str(self.query_one("#notes_textarea", TextArea).text)
|
||||
|
||||
return ShowDescriptor(**kwargs)
|
||||
|
||||
|
||||
@@ -67,10 +67,11 @@ class TagController():
|
||||
try:
|
||||
s = self.Session()
|
||||
|
||||
q = s.query(MediaTag).filter(MediaTag.pattern_id == int(patternId),
|
||||
MediaTag.key == str(tagKey))
|
||||
if q.count():
|
||||
tag = q.first()
|
||||
tag = s.query(MediaTag).filter(
|
||||
MediaTag.pattern_id == int(patternId),
|
||||
MediaTag.key == str(tagKey),
|
||||
).first()
|
||||
if tag is not None:
|
||||
s.delete(tag)
|
||||
s.commit()
|
||||
return True
|
||||
@@ -107,12 +108,8 @@ class TagController():
|
||||
try:
|
||||
s = self.Session()
|
||||
|
||||
q = s.query(MediaTag).filter(MediaTag.pattern_id == int(patternId))
|
||||
|
||||
if q.count():
|
||||
return {t.key:t.value for t in q.all()}
|
||||
else:
|
||||
return {}
|
||||
tags = s.query(MediaTag).filter(MediaTag.pattern_id == int(patternId)).all()
|
||||
return {t.key:t.value for t in tags}
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"TagController.findAllMediaTags(): {repr(ex)}")
|
||||
@@ -125,12 +122,8 @@ class TagController():
|
||||
try:
|
||||
s = self.Session()
|
||||
|
||||
q = s.query(TrackTag).filter(TrackTag.track_id == int(trackId))
|
||||
|
||||
if q.count():
|
||||
return {t.key:t.value for t in q.all()}
|
||||
else:
|
||||
return {}
|
||||
tags = s.query(TrackTag).filter(TrackTag.track_id == int(trackId)).all()
|
||||
return {t.key:t.value for t in tags}
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"TagController.findAllTracks(): {repr(ex)}")
|
||||
@@ -142,12 +135,7 @@ class TagController():
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Track).filter(MediaTag.track_id == int(trackId), MediaTag.key == str(trackKey))
|
||||
|
||||
if q.count():
|
||||
return q.first()
|
||||
else:
|
||||
return None
|
||||
return s.query(Track).filter(MediaTag.track_id == int(trackId), MediaTag.key == str(trackKey)).first()
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"TagController.findMediaTag(): {repr(ex)}")
|
||||
@@ -158,12 +146,10 @@ class TagController():
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(TrackTag).filter(TrackTag.track_id == int(trackId), TrackTag.key == str(tagKey))
|
||||
|
||||
if q.count():
|
||||
return q.first()
|
||||
else:
|
||||
return None
|
||||
return s.query(TrackTag).filter(
|
||||
TrackTag.track_id == int(trackId),
|
||||
TrackTag.key == str(tagKey),
|
||||
).first()
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"TagController.findTrackTag(): {repr(ex)}")
|
||||
@@ -175,11 +161,9 @@ class TagController():
|
||||
def deleteMediaTag(self, tagId) -> bool:
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(MediaTag).filter(MediaTag.id == int(tagId))
|
||||
tag = s.query(MediaTag).filter(MediaTag.id == int(tagId)).first()
|
||||
|
||||
if q.count():
|
||||
|
||||
tag = q.first()
|
||||
if tag is not None:
|
||||
|
||||
s.delete(tag)
|
||||
|
||||
@@ -201,11 +185,9 @@ class TagController():
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(TrackTag).filter(TrackTag.id == int(tagId))
|
||||
tag = s.query(TrackTag).filter(TrackTag.id == int(tagId)).first()
|
||||
|
||||
if q.count():
|
||||
|
||||
tag = q.first()
|
||||
if tag is not None:
|
||||
|
||||
s.delete(tag)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import os, requests, time, logging
|
||||
import os, requests, time
|
||||
from datetime import datetime
|
||||
|
||||
from .logging_utils import get_ffx_logger
|
||||
|
||||
|
||||
class TMDB_REQUEST_EXCEPTION(Exception):
|
||||
def __init__(self, statusCode, statusMessage):
|
||||
@@ -27,8 +29,7 @@ class TmdbController():
|
||||
self.__context = context
|
||||
|
||||
if context is None:
|
||||
self.__logger = logging.getLogger('FFX')
|
||||
self.__logger.addHandler(logging.NullHandler())
|
||||
self.__logger = get_ffx_logger()
|
||||
else:
|
||||
self.__logger = context['logger']
|
||||
|
||||
|
||||
@@ -75,11 +75,9 @@ class TrackController():
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Track).filter(Track.id == int(trackId))
|
||||
track = s.query(Track).filter(Track.id == int(trackId)).first()
|
||||
|
||||
if q.count():
|
||||
|
||||
track : Track = q.first()
|
||||
if track is not None:
|
||||
|
||||
track.index = int(trackDescriptor.getIndex())
|
||||
|
||||
@@ -193,12 +191,10 @@ class TrackController():
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Track).filter(Track.pattern_id == int(patternId), Track.index == int(index))
|
||||
|
||||
if q.count():
|
||||
return q.first()
|
||||
else:
|
||||
return None
|
||||
return s.query(Track).filter(
|
||||
Track.pattern_id == int(patternId),
|
||||
Track.index == int(index),
|
||||
).first()
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"TrackController.getTrack(): {repr(ex)}")
|
||||
@@ -218,11 +214,9 @@ class TrackController():
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(Track).filter(Track.pattern_id == patternId, Track.index == index)
|
||||
track = s.query(Track).filter(Track.pattern_id == patternId, Track.index == index).first()
|
||||
|
||||
if q.count():
|
||||
|
||||
track : Track = q.first()
|
||||
if track is not None:
|
||||
|
||||
if state:
|
||||
track.setDisposition(disposition)
|
||||
@@ -244,15 +238,21 @@ class TrackController():
|
||||
try:
|
||||
s = self.Session()
|
||||
|
||||
q = s.query(Track).filter(Track.id == int(trackId))
|
||||
track = s.query(Track).filter(Track.id == int(trackId)).first()
|
||||
|
||||
if q.count():
|
||||
patternId = int(q.first().pattern_id)
|
||||
if track is not None:
|
||||
patternId = int(track.pattern_id)
|
||||
|
||||
q_siblings = s.query(Track).filter(Track.pattern_id == patternId).order_by(Track.index)
|
||||
siblingTracks = q_siblings.all()
|
||||
|
||||
if len(siblingTracks) <= 1:
|
||||
raise click.ClickException(
|
||||
f"Cannot delete the last track from pattern #{patternId}. Patterns must define at least one track."
|
||||
)
|
||||
|
||||
index = 0
|
||||
for track in q_siblings.all():
|
||||
for track in siblingTracks:
|
||||
|
||||
if track.id == int(trackId):
|
||||
s.delete(track)
|
||||
|
||||
@@ -6,8 +6,6 @@ from textual.containers import Grid
|
||||
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
|
||||
from .track_controller import TrackController
|
||||
|
||||
|
||||
# Screen[dict[int, str, int]]
|
||||
class TrackDeleteScreen(Screen):
|
||||
@@ -52,14 +50,9 @@ class TrackDeleteScreen(Screen):
|
||||
def __init__(self, trackDescriptor : TrackDescriptor):
|
||||
super().__init__()
|
||||
|
||||
self.context = self.app.getContext()
|
||||
self.Session = self.context['database']['session'] # convenience
|
||||
|
||||
if type(trackDescriptor) is not TrackDescriptor:
|
||||
raise click.ClickException('TrackDeleteScreen.init(): trackDescriptor is required to be of type TrackDescriptor')
|
||||
|
||||
self.__tc = TrackController(context = self.context)
|
||||
|
||||
self.__trackDescriptor = trackDescriptor
|
||||
|
||||
|
||||
@@ -116,21 +109,7 @@ class TrackDeleteScreen(Screen):
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
|
||||
if event.button.id == "delete_button":
|
||||
|
||||
track = self.__tc.getTrack(self.__trackDescriptor.getPatternId(), self.__trackDescriptor.getIndex())
|
||||
|
||||
if track is None:
|
||||
raise click.ClickException(f"Track is none: patternId={self.__trackDescriptor.getPatternId()} type={self.__trackDescriptor.getType()} subIndex={self.__trackDescriptor.getSubIndex()}")
|
||||
|
||||
if track is not None:
|
||||
|
||||
if self.__tc.deleteTrack(track.getId()):
|
||||
self.dismiss(self.__trackDescriptor)
|
||||
|
||||
else:
|
||||
#TODO: Meldung
|
||||
self.app.pop_screen()
|
||||
|
||||
if event.button.id == "cancel_button":
|
||||
self.app.pop_screen()
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
from typing import Self
|
||||
|
||||
from .iso_language import IsoLanguage
|
||||
@@ -6,6 +5,7 @@ from .track_type import TrackType
|
||||
from .audio_layout import AudioLayout
|
||||
from .track_disposition import TrackDisposition
|
||||
from .track_codec import TrackCodec
|
||||
from .logging_utils import get_ffx_logger
|
||||
|
||||
# from .helper import dictDiff, setDiff
|
||||
|
||||
@@ -46,8 +46,7 @@ class TrackDescriptor:
|
||||
self.__logger = self.__context['logger']
|
||||
else:
|
||||
self.__context = {}
|
||||
self.__logger = logging.getLogger('FFX')
|
||||
self.__logger.addHandler(logging.NullHandler())
|
||||
self.__logger = get_ffx_logger()
|
||||
|
||||
if TrackDescriptor.ID_KEY in kwargs.keys():
|
||||
if type(kwargs[TrackDescriptor.ID_KEY]) is not int:
|
||||
|
||||
@@ -3,31 +3,20 @@ import click
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Header, Footer, Static, Button, SelectionList, Select, DataTable, Input
|
||||
from textual.containers import Grid
|
||||
|
||||
from ffx.model.pattern import Pattern
|
||||
|
||||
from .track_controller import TrackController
|
||||
from .pattern_controller import PatternController
|
||||
from .tag_controller import TagController
|
||||
|
||||
from .track_type import TrackType
|
||||
from .track_codec import TrackCodec
|
||||
|
||||
from .iso_language import IsoLanguage
|
||||
from .track_disposition import TrackDisposition
|
||||
from .audio_layout import AudioLayout
|
||||
|
||||
from .track_descriptor import TrackDescriptor
|
||||
|
||||
from .tag_details_screen import TagDetailsScreen
|
||||
from .tag_delete_screen import TagDeleteScreen
|
||||
|
||||
from textual.widgets._data_table import CellDoesNotExist
|
||||
|
||||
from .audio_layout import AudioLayout
|
||||
from .iso_language import IsoLanguage
|
||||
from .tag_delete_screen import TagDeleteScreen
|
||||
from .tag_details_screen import TagDetailsScreen
|
||||
from .track_codec import TrackCodec
|
||||
from .track_descriptor import TrackDescriptor
|
||||
from .track_disposition import TrackDisposition
|
||||
from .track_type import TrackType
|
||||
|
||||
from ffx.helper import formatRichColor, removeRichColor
|
||||
|
||||
|
||||
# Screen[dict[int, str, int]]
|
||||
class TrackDetailsScreen(Screen):
|
||||
|
||||
CSS = """
|
||||
@@ -97,257 +86,288 @@ class TrackDetailsScreen(Screen):
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, trackDescriptor : TrackDescriptor = None, patternId = None, trackType : TrackType = None, index = None, subIndex = None):
|
||||
def __init__(
|
||||
self,
|
||||
trackDescriptor: TrackDescriptor = None,
|
||||
patternId=None,
|
||||
patternLabel: str = "",
|
||||
siblingTrackDescriptors=None,
|
||||
trackType: TrackType = None,
|
||||
index=None,
|
||||
subIndex=None,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.context = self.app.getContext()
|
||||
self.Session = self.context['database']['session'] # convenience
|
||||
|
||||
self.__configurationData = self.context['config'].getData()
|
||||
self.__configurationData = self.context["config"].getData()
|
||||
|
||||
metadataConfiguration = self.__configurationData['metadata'] if 'metadata' in self.__configurationData.keys() else {}
|
||||
metadataConfiguration = (
|
||||
self.__configurationData["metadata"]
|
||||
if "metadata" in self.__configurationData.keys()
|
||||
else {}
|
||||
)
|
||||
|
||||
self.__signatureTags = metadataConfiguration['signature'] if 'signature' in metadataConfiguration.keys() else {}
|
||||
self.__removeGlobalKeys = metadataConfiguration['remove'] if 'remove' in metadataConfiguration.keys() else []
|
||||
self.__ignoreGlobalKeys = metadataConfiguration['ignore'] if 'ignore' in metadataConfiguration.keys() else []
|
||||
self.__removeTrackKeys = (metadataConfiguration['streams']['remove']
|
||||
if 'streams' in metadataConfiguration.keys()
|
||||
and 'remove' in metadataConfiguration['streams'].keys() else [])
|
||||
self.__ignoreTrackKeys = (metadataConfiguration['streams']['ignore']
|
||||
if 'streams' in metadataConfiguration.keys()
|
||||
and 'ignore' in metadataConfiguration['streams'].keys() else [])
|
||||
|
||||
|
||||
self.__tc = TrackController(context = self.context)
|
||||
self.__pc = PatternController(context = self.context)
|
||||
self.__tac = TagController(context = self.context)
|
||||
self.__removeTrackKeys = (
|
||||
metadataConfiguration["streams"]["remove"]
|
||||
if "streams" in metadataConfiguration.keys()
|
||||
and "remove" in metadataConfiguration["streams"].keys()
|
||||
else []
|
||||
)
|
||||
self.__ignoreTrackKeys = (
|
||||
metadataConfiguration["streams"]["ignore"]
|
||||
if "streams" in metadataConfiguration.keys()
|
||||
and "ignore" in metadataConfiguration["streams"].keys()
|
||||
else []
|
||||
)
|
||||
|
||||
self.__isNew = trackDescriptor is None
|
||||
self.__trackDescriptor = trackDescriptor
|
||||
self.__patternId = (
|
||||
int(patternId)
|
||||
if patternId is not None
|
||||
else (
|
||||
int(trackDescriptor.getPatternId())
|
||||
if trackDescriptor is not None and trackDescriptor.getPatternId() != -1
|
||||
else -1
|
||||
)
|
||||
)
|
||||
self.__patternLabel = str(patternLabel)
|
||||
self.__siblingTrackDescriptors = list(siblingTrackDescriptors or [])
|
||||
|
||||
if self.__isNew:
|
||||
self.__trackType = trackType
|
||||
self.__trackCodec = TrackCodec.UNKNOWN
|
||||
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED
|
||||
self.__index = index
|
||||
self.__subIndex = subIndex
|
||||
self.__trackDescriptor : TrackDescriptor = None
|
||||
self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else {}
|
||||
self.__draftTrackTags = {}
|
||||
else:
|
||||
self.__trackType = trackDescriptor.getType()
|
||||
self.__trackCodec = trackDescriptor.getCodec()
|
||||
self.__audioLayout = trackDescriptor.getAudioLayout()
|
||||
self.__index = trackDescriptor.getIndex()
|
||||
self.__subIndex = trackDescriptor.getSubIndex()
|
||||
self.__trackDescriptor : TrackDescriptor = trackDescriptor
|
||||
self.__pattern : Pattern = self.__pc.getPattern(self.__trackDescriptor.getPatternId())
|
||||
|
||||
self.__draftTrackTags = {
|
||||
key: value
|
||||
for key, value in trackDescriptor.getTags().items()
|
||||
if key not in ("language", "title")
|
||||
}
|
||||
|
||||
def _descriptor_refs_same_track(self, descriptor: TrackDescriptor) -> bool:
|
||||
if self.__trackDescriptor is None:
|
||||
return False
|
||||
if descriptor.getId() != -1 and self.__trackDescriptor.getId() != -1:
|
||||
return descriptor.getId() == self.__trackDescriptor.getId()
|
||||
return (
|
||||
descriptor.getPatternId() == self.__trackDescriptor.getPatternId()
|
||||
and descriptor.getIndex() == self.__trackDescriptor.getIndex()
|
||||
and descriptor.getSubIndex() == self.__trackDescriptor.getSubIndex()
|
||||
)
|
||||
|
||||
def updateTags(self):
|
||||
|
||||
self.trackTagsTable.clear()
|
||||
|
||||
trackId = self.__trackDescriptor.getId()
|
||||
|
||||
if trackId != -1:
|
||||
|
||||
trackTags = self.__tac.findAllTrackTags(trackId)
|
||||
|
||||
for k,v in trackTags.items():
|
||||
|
||||
if k != 'language' and k != 'title':
|
||||
|
||||
for key, value in self.__draftTrackTags.items():
|
||||
textColor = None
|
||||
if k in self.__ignoreTrackKeys:
|
||||
textColor = 'blue'
|
||||
if k in self.__removeTrackKeys:
|
||||
textColor = 'red'
|
||||
if key in self.__ignoreTrackKeys:
|
||||
textColor = "blue"
|
||||
if key in self.__removeTrackKeys:
|
||||
textColor = "red"
|
||||
|
||||
row = (formatRichColor(k, textColor), formatRichColor(v, textColor))
|
||||
row = (formatRichColor(key, textColor), formatRichColor(value, textColor))
|
||||
self.trackTagsTable.add_row(*map(str, row))
|
||||
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
self.query_one("#index_label", Static).update(str(self.__index) if self.__index is not None else '-')
|
||||
self.query_one("#subindex_label", Static).update(str(self.__subIndex)if self.__subIndex is not None else '-')
|
||||
|
||||
if self.__pattern is not None:
|
||||
self.query_one("#pattern_label", Static).update(self.__pattern.getPattern())
|
||||
self.query_one("#index_label", Static).update(
|
||||
str(self.__index) if self.__index is not None else "-"
|
||||
)
|
||||
self.query_one("#subindex_label", Static).update(
|
||||
str(self.__subIndex) if self.__subIndex is not None else "-"
|
||||
)
|
||||
self.query_one("#pattern_label", Static).update(self.__patternLabel)
|
||||
|
||||
if self.__trackType is not None:
|
||||
self.query_one("#type_select", Select).value = self.__trackType.label()
|
||||
if self.__trackType == TrackType.AUDIO:
|
||||
|
||||
self.query_one("#audio_layout_select", Select).value = self.__audioLayout.label()
|
||||
|
||||
for d in TrackDisposition:
|
||||
for disposition in TrackDisposition:
|
||||
|
||||
dispositionIsSet = (self.__trackDescriptor is not None
|
||||
and d in self.__trackDescriptor.getDispositionSet())
|
||||
dispositionIsSet = (
|
||||
self.__trackDescriptor is not None
|
||||
and disposition in self.__trackDescriptor.getDispositionSet()
|
||||
)
|
||||
|
||||
dispositionOption = (d.label(), d.index(), dispositionIsSet)
|
||||
self.query_one("#dispositions_selection_list", SelectionList).add_option(dispositionOption)
|
||||
dispositionOption = (
|
||||
disposition.label(),
|
||||
disposition.index(),
|
||||
dispositionIsSet,
|
||||
)
|
||||
self.query_one("#dispositions_selection_list", SelectionList).add_option(
|
||||
dispositionOption
|
||||
)
|
||||
|
||||
if self.__trackDescriptor is not None:
|
||||
|
||||
self.query_one("#language_select", Select).value = self.__trackDescriptor.getLanguage().label()
|
||||
self.query_one("#language_select", Select).value = (
|
||||
self.__trackDescriptor.getLanguage().label()
|
||||
)
|
||||
self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle()
|
||||
self.updateTags()
|
||||
|
||||
|
||||
def compose(self):
|
||||
|
||||
self.trackTagsTable = DataTable(classes="five")
|
||||
|
||||
# Define the columns with headers
|
||||
self.column_key_track_tag_key = self.trackTagsTable.add_column("Key", width=50)
|
||||
self.column_key_track_tag_value = self.trackTagsTable.add_column("Value", width=100)
|
||||
|
||||
self.trackTagsTable.cursor_type = 'row'
|
||||
self.trackTagsTable.cursor_type = "row"
|
||||
|
||||
|
||||
languages = [l.label() for l in IsoLanguage]
|
||||
languages = [language.label() for language in IsoLanguage]
|
||||
|
||||
yield Header()
|
||||
|
||||
with Grid():
|
||||
|
||||
# 1
|
||||
yield Static(f"New stream" if self.__isNew else f"Edit stream", id="toplabel", classes="five")
|
||||
yield Static(
|
||||
"New stream" if self.__isNew else "Edit stream",
|
||||
id="toplabel",
|
||||
classes="five",
|
||||
)
|
||||
|
||||
# 2
|
||||
yield Static("for pattern")
|
||||
yield Static("", id="pattern_label", classes="four", markup=False)
|
||||
|
||||
# 3
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 4
|
||||
yield Static("Index / Subindex")
|
||||
yield Static("", id="index_label", classes="two")
|
||||
yield Static("", id="subindex_label", classes="two")
|
||||
|
||||
# 5
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 6
|
||||
yield Static("Type")
|
||||
yield Select.from_values([t.label() for t in TrackType], classes="four", id="type_select")
|
||||
yield Select.from_values(
|
||||
[trackType.label() for trackType in TrackType],
|
||||
classes="four",
|
||||
id="type_select",
|
||||
)
|
||||
|
||||
# 7
|
||||
if self.__trackType == TrackType.AUDIO:
|
||||
yield Static("Audio Layout")
|
||||
yield Select.from_values([t.label() for t in AudioLayout], classes="four", id="audio_layout_select")
|
||||
else:
|
||||
yield Select.from_values(
|
||||
[layout.label() for layout in AudioLayout],
|
||||
classes="four",
|
||||
id="audio_layout_select",
|
||||
)
|
||||
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 8
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 9
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 10
|
||||
yield Static("Language")
|
||||
yield Select.from_values(languages, classes="four", id="language_select")
|
||||
# 11
|
||||
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 12
|
||||
yield Static("Title")
|
||||
yield Input(id="title_input", classes="four")
|
||||
|
||||
# 13
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 14
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 15
|
||||
yield Static("Stream tags")
|
||||
yield Static(" ")
|
||||
yield Button("Add", id="button_add_stream_tag")
|
||||
yield Button("Edit", id="button_edit_stream_tag")
|
||||
yield Button("Delete", id="button_delete_stream_tag")
|
||||
# 16
|
||||
|
||||
yield self.trackTagsTable
|
||||
|
||||
# 17
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 18
|
||||
yield Static("Stream dispositions", classes="five")
|
||||
|
||||
# 19
|
||||
yield SelectionList[int](
|
||||
classes="five",
|
||||
id = "dispositions_selection_list"
|
||||
id="dispositions_selection_list",
|
||||
)
|
||||
|
||||
# 20
|
||||
yield Static(" ", classes="five")
|
||||
# 21
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 22
|
||||
yield Button("Save", id="save_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
|
||||
# 23
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 24
|
||||
yield Static(" ", classes="five", id="messagestatic")
|
||||
|
||||
|
||||
yield Footer(id="footer")
|
||||
|
||||
|
||||
def getTrackDescriptorFromInput(self):
|
||||
|
||||
kwargs = {}
|
||||
|
||||
kwargs[TrackDescriptor.CONTEXT_KEY] = self.context
|
||||
|
||||
kwargs[TrackDescriptor.PATTERN_ID_KEY] = int(self.__pattern.getId())
|
||||
if self.__trackDescriptor is not None and self.__trackDescriptor.getId() != -1:
|
||||
kwargs[TrackDescriptor.ID_KEY] = self.__trackDescriptor.getId()
|
||||
|
||||
kwargs[TrackDescriptor.INDEX_KEY] = self.__index
|
||||
kwargs[TrackDescriptor.SUB_INDEX_KEY] = self.__subIndex #!
|
||||
if self.__patternId != -1:
|
||||
kwargs[TrackDescriptor.PATTERN_ID_KEY] = int(self.__patternId)
|
||||
|
||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = TrackType.fromLabel(self.query_one("#type_select", Select).value)
|
||||
kwargs[TrackDescriptor.INDEX_KEY] = int(self.__index)
|
||||
kwargs[TrackDescriptor.SOURCE_INDEX_KEY] = (
|
||||
int(self.__trackDescriptor.getSourceIndex())
|
||||
if self.__trackDescriptor is not None
|
||||
else int(self.__index)
|
||||
)
|
||||
if self.__subIndex is not None and int(self.__subIndex) >= 0:
|
||||
kwargs[TrackDescriptor.SUB_INDEX_KEY] = int(self.__subIndex)
|
||||
|
||||
selectedTrackType = TrackType.fromLabel(
|
||||
self.query_one("#type_select", Select).value
|
||||
)
|
||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = selectedTrackType
|
||||
kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec
|
||||
|
||||
if self.__trackType == TrackType.AUDIO:
|
||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(self.query_one("#audio_layout_select", Select).value)
|
||||
if selectedTrackType == TrackType.AUDIO:
|
||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(
|
||||
self.query_one("#audio_layout_select", Select).value
|
||||
)
|
||||
else:
|
||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_UNDEFINED
|
||||
|
||||
trackTags = {}
|
||||
trackTags = dict(self.__draftTrackTags)
|
||||
|
||||
language = self.query_one("#language_select", Select).value
|
||||
if language:
|
||||
trackTags['language'] = IsoLanguage.find(language).threeLetter()
|
||||
trackTags["language"] = IsoLanguage.find(language).threeLetter()
|
||||
|
||||
title = self.query_one("#title_input", Input).value
|
||||
if title:
|
||||
trackTags['title'] = title
|
||||
trackTags["title"] = title
|
||||
|
||||
tableTags = {row[0]:row[1] for r in self.trackTagsTable.rows if (row := self.trackTagsTable.get_row(r)) and row[0] != 'language' and row[0] != 'title'}
|
||||
kwargs[TrackDescriptor.TAGS_KEY] = trackTags
|
||||
|
||||
kwargs[TrackDescriptor.TAGS_KEY] = trackTags | tableTags
|
||||
|
||||
dispositionFlags = sum([2**f for f in self.query_one("#dispositions_selection_list", SelectionList).selected])
|
||||
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = TrackDisposition.toSet(dispositionFlags)
|
||||
dispositionFlags = sum(
|
||||
[2 ** flag for flag in self.query_one("#dispositions_selection_list", SelectionList).selected]
|
||||
)
|
||||
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = TrackDisposition.toSet(
|
||||
dispositionFlags
|
||||
)
|
||||
|
||||
return TrackDescriptor(**kwargs)
|
||||
|
||||
|
||||
|
||||
def getSelectedTag(self):
|
||||
|
||||
try:
|
||||
|
||||
# Fetch the currently selected row when 'Enter' is pressed
|
||||
#selected_row_index = self.table.cursor_row
|
||||
row_key, col_key = self.trackTagsTable.coordinate_to_cell_key(self.trackTagsTable.cursor_coordinate)
|
||||
row_key, _ = self.trackTagsTable.coordinate_to_cell_key(
|
||||
self.trackTagsTable.cursor_coordinate
|
||||
)
|
||||
|
||||
if row_key is not None:
|
||||
selected_tag_data = self.trackTagsTable.get_row(row_key)
|
||||
@@ -357,101 +377,92 @@ class TrackDetailsScreen(Screen):
|
||||
|
||||
return tagKey, tagValue
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
except CellDoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
# Event handler for button press
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
|
||||
# Check if the button pressed is the one we are interested in
|
||||
if event.button.id == "save_button":
|
||||
|
||||
# Check for multiple default/forced disposition flags
|
||||
|
||||
if self.__trackType == TrackType.VIDEO:
|
||||
trackList = self.__tc.findVideoTracks(self.__pattern.getId())
|
||||
if self.__trackType == TrackType.AUDIO:
|
||||
trackList = self.__tc.findAudioTracks(self.__pattern.getId())
|
||||
elif self.__trackType == TrackType.SUBTITLE:
|
||||
trackList = self.__tc.findSubtitleTracks(self.__pattern.getId())
|
||||
else:
|
||||
trackList = []
|
||||
|
||||
siblingTrackList = [t for t in trackList if t.getType() == self.__trackType and t.getIndex() != self.__index]
|
||||
|
||||
numDefaultTracks = len([t for t in siblingTrackList if TrackDisposition.DEFAULT in t.getDispositionSet()])
|
||||
numForcedTracks = len([t for t in siblingTrackList if TrackDisposition.FORCED in t.getDispositionSet()])
|
||||
|
||||
self.__subIndex = len(trackList)
|
||||
trackDescriptor = self.getTrackDescriptorFromInput()
|
||||
|
||||
if ((TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet() and numDefaultTracks)
|
||||
or (TrackDisposition.FORCED in trackDescriptor.getDispositionSet() and numForcedTracks)):
|
||||
siblingTrackList = [
|
||||
descriptor
|
||||
for descriptor in self.__siblingTrackDescriptors
|
||||
if not self._descriptor_refs_same_track(descriptor)
|
||||
]
|
||||
siblingTrackList = [
|
||||
descriptor
|
||||
for descriptor in siblingTrackList
|
||||
if descriptor.getType() == trackDescriptor.getType()
|
||||
]
|
||||
|
||||
self.query_one("#messagestatic", Static).update("Cannot add another stream with disposition flag 'debug' or 'forced' set")
|
||||
|
||||
else:
|
||||
|
||||
self.query_one("#messagestatic", Static).update(" ")
|
||||
numDefaultTracks = len(
|
||||
[
|
||||
descriptor
|
||||
for descriptor in siblingTrackList
|
||||
if TrackDisposition.DEFAULT in descriptor.getDispositionSet()
|
||||
]
|
||||
)
|
||||
numForcedTracks = len(
|
||||
[
|
||||
descriptor
|
||||
for descriptor in siblingTrackList
|
||||
if TrackDisposition.FORCED in descriptor.getDispositionSet()
|
||||
]
|
||||
)
|
||||
|
||||
if self.__isNew:
|
||||
trackDescriptor.setSubIndex(len(siblingTrackList))
|
||||
elif self.__subIndex is not None and int(self.__subIndex) >= 0:
|
||||
trackDescriptor.setSubIndex(int(self.__subIndex))
|
||||
|
||||
# Track per Screen hinzufügen
|
||||
self.__tc.addTrack(trackDescriptor)
|
||||
self.dismiss(trackDescriptor)
|
||||
if (
|
||||
TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet()
|
||||
and numDefaultTracks
|
||||
) or (
|
||||
TrackDisposition.FORCED in trackDescriptor.getDispositionSet()
|
||||
and numForcedTracks
|
||||
):
|
||||
|
||||
self.query_one("#messagestatic", Static).update(
|
||||
"Cannot add another stream with disposition flag 'default' or 'forced' set"
|
||||
)
|
||||
else:
|
||||
|
||||
track = self.__tc.getTrack(self.__pattern.getId(), self.__index)
|
||||
|
||||
# Track per details screen updaten
|
||||
if self.__tc.updateTrack(track.getId(), trackDescriptor):
|
||||
self.query_one("#messagestatic", Static).update(" ")
|
||||
self.dismiss(trackDescriptor)
|
||||
|
||||
else:
|
||||
self.app.pop_screen()
|
||||
|
||||
if event.button.id == "cancel_button":
|
||||
self.app.pop_screen()
|
||||
|
||||
|
||||
if event.button.id == "button_add_stream_tag":
|
||||
if not self.__isNew:
|
||||
self.app.push_screen(TagDetailsScreen(), self.handle_update_tag)
|
||||
|
||||
if event.button.id == "button_edit_stream_tag":
|
||||
tagKey, tagValue = self.getSelectedTag()
|
||||
self.app.push_screen(TagDetailsScreen(key=tagKey, value=tagValue), self.handle_update_tag)
|
||||
selectedTag = self.getSelectedTag()
|
||||
if selectedTag is not None:
|
||||
self.app.push_screen(
|
||||
TagDetailsScreen(key=selectedTag[0], value=selectedTag[1]),
|
||||
self.handle_update_tag,
|
||||
)
|
||||
|
||||
if event.button.id == "button_delete_stream_tag":
|
||||
tagKey, tagValue = self.getSelectedTag()
|
||||
self.app.push_screen(TagDeleteScreen(key=tagKey, value=tagValue), self.handle_delete_tag)
|
||||
|
||||
selectedTag = self.getSelectedTag()
|
||||
if selectedTag is not None:
|
||||
self.app.push_screen(
|
||||
TagDeleteScreen(key=selectedTag[0], value=selectedTag[1]),
|
||||
self.handle_delete_tag,
|
||||
)
|
||||
|
||||
def handle_update_tag(self, tag):
|
||||
|
||||
trackId = self.__trackDescriptor.getId()
|
||||
|
||||
if trackId == -1:
|
||||
raise click.ClickException(f"TrackDetailsScreen.handle_update_tag: trackId not set (-1) trackDescriptor={self.__trackDescriptor}")
|
||||
|
||||
if self.__tac.updateTrackTag(trackId, tag[0], tag[1]) is not None:
|
||||
if tag is None:
|
||||
return
|
||||
self.__draftTrackTags[str(tag[0])] = str(tag[1])
|
||||
self.updateTags()
|
||||
|
||||
def handle_delete_tag(self, trackTag):
|
||||
|
||||
trackId = self.__trackDescriptor.getId()
|
||||
|
||||
if trackId == -1:
|
||||
raise click.ClickException(f"TrackDetailsScreen.handle_delete_tag: trackId not set (-1) trackDescriptor={self.__trackDescriptor}")
|
||||
|
||||
tag = self.__tac.findTrackTag(trackId, trackTag[0])
|
||||
|
||||
if tag is not None:
|
||||
if self.__tac.deleteTrackTag(tag.id):
|
||||
if trackTag is None:
|
||||
return
|
||||
self.__draftTrackTags.pop(str(trackTag[0]), None)
|
||||
self.updateTags()
|
||||
|
||||
@@ -5,6 +5,7 @@ class VideoEncoder(Enum):
|
||||
AV1 = {'label': 'av1', 'index': 1}
|
||||
VP9 = {'label': 'vp9', 'index': 2}
|
||||
H264 = {'label': 'h264', 'index': 3}
|
||||
COPY = {'label': 'copy', 'index': 4}
|
||||
|
||||
UNDEFINED = {'label': 'undefined', 'index': 0}
|
||||
|
||||
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Repo-root tests package for legacy and future test code.
|
||||
BIN
tests/assets/dball_S01E89_3_und.sup
Normal file
BIN
tests/assets/dball_S01E89_3_und.sup
Normal file
Binary file not shown.
BIN
tests/assets/dball_S01E89_4_und.sup
Normal file
BIN
tests/assets/dball_S01E89_4_und.sup
Normal file
Binary file not shown.
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
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()
|
||||
1
tests/integration/subtrack_mapping/__init__.py
Normal file
1
tests/integration/subtrack_mapping/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
436
tests/integration/subtrack_mapping/test_cli_bundle.py
Normal file
436
tests/integration/subtrack_mapping/test_cli_bundle.py
Normal file
@@ -0,0 +1,436 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from tests.support.ffx_bundle import (
|
||||
PatternTrackSpec,
|
||||
SourceTrackSpec,
|
||||
create_source_fixture,
|
||||
expected_output_path,
|
||||
extract_first_subtitle_text,
|
||||
ffprobe_json,
|
||||
get_tag,
|
||||
prepare_pattern_database,
|
||||
run_ffx_convert,
|
||||
write_vtt,
|
||||
)
|
||||
|
||||
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.subtrack_mapping]
|
||||
|
||||
|
||||
class SubtrackMappingBundleTests(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 write_config(self, data: dict) -> None:
|
||||
config_dir = self.home_dir / ".local" / "etc"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
(config_dir / "ffx.json").write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
def assertCompleted(self, completed):
|
||||
if completed.returncode != 0:
|
||||
self.fail(
|
||||
"FFX convert failed\n"
|
||||
f"STDOUT:\n{completed.stdout}\n"
|
||||
f"STDERR:\n{completed.stderr}"
|
||||
)
|
||||
|
||||
def test_pattern_reorders_and_omits_tracks_preserving_metadata_and_group_order(self):
|
||||
source_filename = "reorder_s01e01.mkv"
|
||||
source_path = create_source_fixture(
|
||||
self.workdir,
|
||||
source_filename,
|
||||
[
|
||||
SourceTrackSpec(TrackType.VIDEO, identity="video-0", title="Video Zero"),
|
||||
SourceTrackSpec(
|
||||
TrackType.SUBTITLE,
|
||||
identity="subtitle-1",
|
||||
language="eng",
|
||||
title="First Subtitle",
|
||||
subtitle_lines=("first embedded subtitle",),
|
||||
),
|
||||
SourceTrackSpec(
|
||||
TrackType.AUDIO,
|
||||
identity="audio-2",
|
||||
language="deu",
|
||||
title="German Audio",
|
||||
),
|
||||
SourceTrackSpec(
|
||||
TrackType.SUBTITLE,
|
||||
identity="subtitle-3",
|
||||
language="fra",
|
||||
title="Second Subtitle",
|
||||
subtitle_lines=("second embedded subtitle",),
|
||||
),
|
||||
SourceTrackSpec(TrackType.ATTACHMENT, attachment_name="ordered.ttf"),
|
||||
],
|
||||
)
|
||||
|
||||
prepare_pattern_database(
|
||||
self.database_path,
|
||||
r"^reorder_(s[0-9]+e[0-9]+)\.mkv$",
|
||||
[
|
||||
PatternTrackSpec(
|
||||
index=0,
|
||||
source_index=0,
|
||||
track_type=TrackType.VIDEO,
|
||||
tags={"THIS_IS": "video-0", "title": "Video Zero"},
|
||||
),
|
||||
PatternTrackSpec(
|
||||
index=1,
|
||||
source_index=2,
|
||||
track_type=TrackType.AUDIO,
|
||||
tags={"THIS_IS": "audio-2", "language": "deu", "title": "German Audio"},
|
||||
),
|
||||
PatternTrackSpec(
|
||||
index=2,
|
||||
source_index=1,
|
||||
track_type=TrackType.SUBTITLE,
|
||||
tags={"THIS_IS": "subtitle-1", "language": "eng", "title": "First Subtitle"},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
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.assertCompleted(completed)
|
||||
|
||||
output_path = expected_output_path(self.workdir, source_filename)
|
||||
self.assertTrue(output_path.is_file(), output_path)
|
||||
|
||||
streams = ffprobe_json(output_path)["streams"]
|
||||
self.assertEqual(
|
||||
[stream["codec_type"] for stream in streams],
|
||||
["video", "audio", "subtitle", "attachment"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[get_tag(streams[index], "THIS_IS") for index in range(3)],
|
||||
["video-0", "audio-2", "subtitle-1"],
|
||||
)
|
||||
self.assertNotIn(
|
||||
"subtitle-3",
|
||||
[get_tag(stream, "THIS_IS") for stream in streams if stream["codec_type"] != "attachment"],
|
||||
)
|
||||
self.assertEqual(streams[-1]["codec_name"], "ttf")
|
||||
extracted_subtitle = extract_first_subtitle_text(self.workdir, output_path)
|
||||
self.assertIn("first embedded subtitle", extracted_subtitle)
|
||||
self.assertNotIn("second embedded subtitle", extracted_subtitle)
|
||||
|
||||
def test_cli_rearrange_streams_reorders_tracks_without_database_pattern(self):
|
||||
source_filename = "cli_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", title="First Audio"),
|
||||
SourceTrackSpec(TrackType.AUDIO, identity="audio-2", language="deu", title="Second Audio"),
|
||||
SourceTrackSpec(TrackType.SUBTITLE, identity="subtitle-3", language="eng", title="Subtitle"),
|
||||
],
|
||||
)
|
||||
|
||||
completed = run_ffx_convert(
|
||||
self.workdir,
|
||||
self.home_dir,
|
||||
self.database_path,
|
||||
"--video-encoder",
|
||||
"copy",
|
||||
"--no-pattern",
|
||||
"--no-tmdb",
|
||||
"--no-prompt",
|
||||
"--no-signature",
|
||||
"--rearrange-streams",
|
||||
"0,2,1,3",
|
||||
str(source_path),
|
||||
)
|
||||
self.assertCompleted(completed)
|
||||
|
||||
output_path = expected_output_path(self.workdir, source_filename)
|
||||
streams = ffprobe_json(output_path)["streams"]
|
||||
|
||||
self.assertEqual(
|
||||
[stream["codec_type"] for stream in streams],
|
||||
["video", "audio", "audio", "subtitle"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[get_tag(stream, "THIS_IS") for stream in streams],
|
||||
["video-0", "audio-2", "audio-1", "subtitle-3"],
|
||||
)
|
||||
|
||||
def test_no_pattern_stream_remove_list_clears_copied_stream_metadata(self):
|
||||
source_filename = "remove_tags_s01e01.mkv"
|
||||
self.write_config(
|
||||
{
|
||||
"metadata": {
|
||||
"streams": {
|
||||
"remove": ["BPS"],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
source_path = create_source_fixture(
|
||||
self.workdir,
|
||||
source_filename,
|
||||
[
|
||||
SourceTrackSpec(
|
||||
TrackType.VIDEO,
|
||||
identity="video-0",
|
||||
extra_tags={"BPS": "remove-me", "KEEP_ME": "video-keep"},
|
||||
),
|
||||
SourceTrackSpec(
|
||||
TrackType.AUDIO,
|
||||
identity="audio-1",
|
||||
language="eng",
|
||||
title="Main Audio",
|
||||
extra_tags={"BPS": "remove-me", "KEEP_ME": "audio-keep"},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
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.assertCompleted(completed)
|
||||
|
||||
output_path = expected_output_path(self.workdir, source_filename)
|
||||
streams = ffprobe_json(output_path)["streams"]
|
||||
|
||||
self.assertEqual(
|
||||
[stream["codec_type"] for stream in streams],
|
||||
["video", "audio"],
|
||||
)
|
||||
self.assertEqual(get_tag(streams[0], "THIS_IS"), "video-0")
|
||||
self.assertEqual(get_tag(streams[0], "KEEP_ME"), "video-keep")
|
||||
self.assertIsNone(get_tag(streams[0], "BPS"))
|
||||
self.assertEqual(get_tag(streams[1], "THIS_IS"), "audio-1")
|
||||
self.assertEqual(get_tag(streams[1], "KEEP_ME"), "audio-keep")
|
||||
self.assertIsNone(get_tag(streams[1], "BPS"))
|
||||
|
||||
def test_pattern_validation_fails_for_nonexistent_source_track_reference(self):
|
||||
source_filename = "invalid_s01e01.mkv"
|
||||
source_path = create_source_fixture(
|
||||
self.workdir,
|
||||
source_filename,
|
||||
[
|
||||
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||
SourceTrackSpec(TrackType.AUDIO, identity="audio-1"),
|
||||
SourceTrackSpec(TrackType.SUBTITLE, identity="subtitle-2"),
|
||||
],
|
||||
)
|
||||
|
||||
prepare_pattern_database(
|
||||
self.database_path,
|
||||
r"^invalid_(s[0-9]+e[0-9]+)\.mkv$",
|
||||
[
|
||||
PatternTrackSpec(index=0, source_index=0, track_type=TrackType.VIDEO),
|
||||
PatternTrackSpec(index=1, source_index=99, track_type=TrackType.SUBTITLE),
|
||||
],
|
||||
)
|
||||
|
||||
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("non-existent source track #99", error_output)
|
||||
self.assertFalse(expected_output_path(self.workdir, source_filename).exists())
|
||||
|
||||
def test_external_subtitle_file_replaces_payload_and_overrides_metadata(self):
|
||||
source_filename = "substitute_s01e01.mkv"
|
||||
self.write_config(
|
||||
{
|
||||
"metadata": {
|
||||
"streams": {
|
||||
"remove": ["BPS"],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
source_path = create_source_fixture(
|
||||
self.workdir,
|
||||
source_filename,
|
||||
[
|
||||
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||
SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng", title="Main Audio"),
|
||||
SourceTrackSpec(
|
||||
TrackType.SUBTITLE,
|
||||
identity="embedded-subtitle",
|
||||
language="eng",
|
||||
title="Embedded Title",
|
||||
extra_tags={"BPS": "remove-me", "EXTERNAL_KEEP": "keep-me"},
|
||||
subtitle_lines=("embedded subtitle payload",),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
write_vtt(
|
||||
self.workdir / "substitute_s01e01_2_deu.vtt",
|
||||
("external subtitle payload",),
|
||||
)
|
||||
|
||||
prepare_pattern_database(
|
||||
self.database_path,
|
||||
r"^substitute_(s[0-9]+e[0-9]+)\.mkv$",
|
||||
[
|
||||
PatternTrackSpec(index=0, source_index=0, track_type=TrackType.VIDEO),
|
||||
PatternTrackSpec(index=1, source_index=1, track_type=TrackType.AUDIO),
|
||||
PatternTrackSpec(index=2, source_index=2, track_type=TrackType.SUBTITLE),
|
||||
],
|
||||
)
|
||||
|
||||
completed = run_ffx_convert(
|
||||
self.workdir,
|
||||
self.home_dir,
|
||||
self.database_path,
|
||||
"--video-encoder",
|
||||
"copy",
|
||||
"--no-tmdb",
|
||||
"--no-prompt",
|
||||
"--no-signature",
|
||||
"--subtitle-directory",
|
||||
str(self.workdir),
|
||||
"--subtitle-prefix",
|
||||
"substitute",
|
||||
str(source_path),
|
||||
)
|
||||
self.assertCompleted(completed)
|
||||
|
||||
output_path = expected_output_path(self.workdir, source_filename)
|
||||
streams = ffprobe_json(output_path)["streams"]
|
||||
subtitle_stream = [stream for stream in streams if stream["codec_type"] == "subtitle"][0]
|
||||
|
||||
self.assertEqual(get_tag(subtitle_stream, "language"), "deu")
|
||||
self.assertEqual(get_tag(subtitle_stream, "title"), "Embedded Title")
|
||||
self.assertEqual(get_tag(subtitle_stream, "THIS_IS"), "embedded-subtitle")
|
||||
self.assertEqual(get_tag(subtitle_stream, "EXTERNAL_KEEP"), "keep-me")
|
||||
self.assertIsNone(get_tag(subtitle_stream, "BPS"))
|
||||
|
||||
extracted_subtitle = extract_first_subtitle_text(self.workdir, output_path)
|
||||
self.assertIn("external subtitle payload", extracted_subtitle)
|
||||
self.assertNotIn("embedded subtitle payload", extracted_subtitle)
|
||||
|
||||
def test_subtitle_prefix_uses_configured_base_directory_when_directory_is_omitted(self):
|
||||
source_filename = "substitute_default_s01e01.mkv"
|
||||
subtitle_prefix = "substitute_default"
|
||||
subtitles_base_dir = self.home_dir / ".local" / "var" / "sync" / "subtitles"
|
||||
resolved_subtitle_dir = subtitles_base_dir / subtitle_prefix
|
||||
resolved_subtitle_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.write_config(
|
||||
{
|
||||
"subtitlesDirectory": "~/.local/var/sync/subtitles",
|
||||
"metadata": {
|
||||
"streams": {
|
||||
"remove": ["BPS"],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
source_path = create_source_fixture(
|
||||
self.workdir,
|
||||
source_filename,
|
||||
[
|
||||
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||
SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng", title="Main Audio"),
|
||||
SourceTrackSpec(
|
||||
TrackType.SUBTITLE,
|
||||
identity="embedded-subtitle",
|
||||
language="eng",
|
||||
title="Embedded Title",
|
||||
extra_tags={"BPS": "remove-me", "EXTERNAL_KEEP": "keep-me"},
|
||||
subtitle_lines=("embedded subtitle payload",),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
write_vtt(
|
||||
resolved_subtitle_dir / f"{subtitle_prefix}_s01e01_2_deu.vtt",
|
||||
("external subtitle payload",),
|
||||
)
|
||||
|
||||
prepare_pattern_database(
|
||||
self.database_path,
|
||||
r"^substitute_default_(s[0-9]+e[0-9]+)\.mkv$",
|
||||
[
|
||||
PatternTrackSpec(index=0, source_index=0, track_type=TrackType.VIDEO),
|
||||
PatternTrackSpec(index=1, source_index=1, track_type=TrackType.AUDIO),
|
||||
PatternTrackSpec(index=2, source_index=2, track_type=TrackType.SUBTITLE),
|
||||
],
|
||||
)
|
||||
|
||||
completed = run_ffx_convert(
|
||||
self.workdir,
|
||||
self.home_dir,
|
||||
self.database_path,
|
||||
"--video-encoder",
|
||||
"copy",
|
||||
"--no-tmdb",
|
||||
"--no-prompt",
|
||||
"--no-signature",
|
||||
"--subtitle-prefix",
|
||||
subtitle_prefix,
|
||||
str(source_path),
|
||||
)
|
||||
self.assertCompleted(completed)
|
||||
|
||||
output_path = expected_output_path(self.workdir, source_filename)
|
||||
streams = ffprobe_json(output_path)["streams"]
|
||||
subtitle_stream = [stream for stream in streams if stream["codec_type"] == "subtitle"][0]
|
||||
|
||||
self.assertEqual(get_tag(subtitle_stream, "language"), "deu")
|
||||
self.assertEqual(get_tag(subtitle_stream, "title"), "Embedded Title")
|
||||
self.assertEqual(get_tag(subtitle_stream, "THIS_IS"), "embedded-subtitle")
|
||||
self.assertEqual(get_tag(subtitle_stream, "EXTERNAL_KEEP"), "keep-me")
|
||||
self.assertIsNone(get_tag(subtitle_stream, "BPS"))
|
||||
|
||||
extracted_subtitle = extract_first_subtitle_text(self.workdir, output_path)
|
||||
self.assertIn("external subtitle payload", extracted_subtitle)
|
||||
self.assertNotIn("embedded subtitle payload", extracted_subtitle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
303
tests/integration/test_cli_unmux.py
Normal file
303
tests/integration/test_cli_unmux.py
Normal file
@@ -0,0 +1,303 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from tests.support.ffx_bundle import (
|
||||
SourceTrackSpec,
|
||||
build_controller_context,
|
||||
create_source_fixture,
|
||||
dispose_controller_context,
|
||||
)
|
||||
|
||||
from ffx.pattern_controller import PatternController
|
||||
from ffx.show_controller import ShowController
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
from ffx.shifted_season_controller import ShiftedSeasonController
|
||||
from ffx.track_codec import TrackCodec
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
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]
|
||||
|
||||
|
||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||
|
||||
|
||||
def run_ffx_unmux(workdir: Path, home_dir: Path, database_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(home_dir)
|
||||
existing_pythonpath = env.get("PYTHONPATH", "")
|
||||
env["PYTHONPATH"] = str(SRC_ROOT) if not existing_pythonpath else f"{SRC_ROOT}{os.pathsep}{existing_pythonpath}"
|
||||
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ffx",
|
||||
"--database-file",
|
||||
str(database_path),
|
||||
"unmux",
|
||||
*args,
|
||||
]
|
||||
return subprocess.run(command, cwd=workdir, env=env, capture_output=True, text=True)
|
||||
|
||||
|
||||
class UnmuxCliTests(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 write_config(self, data: dict) -> None:
|
||||
config_dir = self.home_dir / ".local" / "etc"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
(config_dir / "ffx.json").write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
def assertCompleted(self, completed):
|
||||
if completed.returncode != 0:
|
||||
self.fail(
|
||||
"FFX unmux failed\n"
|
||||
f"STDOUT:\n{completed.stdout}\n"
|
||||
f"STDERR:\n{completed.stderr}"
|
||||
)
|
||||
|
||||
def seed_matching_show(self, pattern_expression: str, *, indicator_season_digits: int, indicator_episode_digits: int) -> None:
|
||||
context = build_controller_context(self.database_path)
|
||||
try:
|
||||
ShowController(context).updateShow(
|
||||
ShowDescriptor(
|
||||
id=1,
|
||||
name="Unmux Test Show",
|
||||
year=2000,
|
||||
indicator_season_digits=indicator_season_digits,
|
||||
indicator_episode_digits=indicator_episode_digits,
|
||||
)
|
||||
)
|
||||
PatternController(context).savePatternSchema(
|
||||
{
|
||||
"show_id": 1,
|
||||
"pattern": pattern_expression,
|
||||
"quality": 0,
|
||||
"notes": "",
|
||||
},
|
||||
trackDescriptors=[
|
||||
TrackDescriptor(
|
||||
index=0,
|
||||
source_index=0,
|
||||
track_type=TrackType.VIDEO,
|
||||
codec_name=TrackCodec.H264,
|
||||
tags={},
|
||||
disposition_set=set(),
|
||||
)
|
||||
],
|
||||
)
|
||||
finally:
|
||||
dispose_controller_context(context)
|
||||
|
||||
def add_show_shift(
|
||||
self,
|
||||
*,
|
||||
show_id: int,
|
||||
original_season: int,
|
||||
first_episode: int,
|
||||
last_episode: int,
|
||||
season_offset: int,
|
||||
episode_offset: int,
|
||||
) -> None:
|
||||
context = build_controller_context(self.database_path)
|
||||
try:
|
||||
ShiftedSeasonController(context).addShiftedSeason(
|
||||
showId=show_id,
|
||||
shiftedSeasonObj={
|
||||
"original_season": original_season,
|
||||
"first_episode": first_episode,
|
||||
"last_episode": last_episode,
|
||||
"season_offset": season_offset,
|
||||
"episode_offset": episode_offset,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
dispose_controller_context(context)
|
||||
|
||||
def test_subtitles_only_without_output_directory_uses_configured_base_plus_label(self):
|
||||
self.write_config(
|
||||
{
|
||||
"subtitlesDirectory": "~/.local/var/sync/subtitles",
|
||||
}
|
||||
)
|
||||
source_filename = "unmux_s01e01.mkv"
|
||||
source_path = create_source_fixture(
|
||||
self.workdir,
|
||||
source_filename,
|
||||
[
|
||||
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||
SourceTrackSpec(
|
||||
TrackType.SUBTITLE,
|
||||
identity="subtitle-1",
|
||||
language="eng",
|
||||
subtitle_lines=("subtitle payload",),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
completed = run_ffx_unmux(
|
||||
self.workdir,
|
||||
self.home_dir,
|
||||
self.database_path,
|
||||
"--subtitles-only",
|
||||
"--label",
|
||||
"dball",
|
||||
str(source_path),
|
||||
)
|
||||
self.assertCompleted(completed)
|
||||
|
||||
expected_directory = self.home_dir / ".local" / "var" / "sync" / "subtitles" / "dball"
|
||||
self.assertTrue(expected_directory.is_dir(), expected_directory)
|
||||
|
||||
def test_unmux_uses_configured_indicator_digits_in_output_filenames(self):
|
||||
self.write_config(
|
||||
{
|
||||
"defaultIndicatorSeasonDigits": 3,
|
||||
"defaultIndicatorEpisodeDigits": 4,
|
||||
}
|
||||
)
|
||||
source_filename = "unmux_s01e01.mkv"
|
||||
output_directory = self.workdir / "unmux-output"
|
||||
output_directory.mkdir()
|
||||
source_path = create_source_fixture(
|
||||
self.workdir,
|
||||
source_filename,
|
||||
[
|
||||
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||
],
|
||||
)
|
||||
|
||||
completed = run_ffx_unmux(
|
||||
self.workdir,
|
||||
self.home_dir,
|
||||
self.database_path,
|
||||
"--label",
|
||||
"dball",
|
||||
"--output-directory",
|
||||
str(output_directory),
|
||||
str(source_path),
|
||||
)
|
||||
self.assertCompleted(completed)
|
||||
|
||||
output_filenames = sorted(path.name for path in output_directory.iterdir())
|
||||
self.assertEqual(1, len(output_filenames), output_filenames)
|
||||
self.assertTrue(
|
||||
output_filenames[0].startswith("dball_S001E0001_"),
|
||||
output_filenames,
|
||||
)
|
||||
|
||||
def test_unmux_prefers_matched_show_indicator_digits_over_config_defaults(self):
|
||||
self.write_config(
|
||||
{
|
||||
"defaultIndicatorSeasonDigits": 4,
|
||||
"defaultIndicatorEpisodeDigits": 4,
|
||||
}
|
||||
)
|
||||
self.seed_matching_show(
|
||||
r"^unmux_([sS][0-9]+[eE][0-9]+)\.mkv$",
|
||||
indicator_season_digits=1,
|
||||
indicator_episode_digits=3,
|
||||
)
|
||||
source_filename = "unmux_s01e01.mkv"
|
||||
output_directory = self.workdir / "unmux-output"
|
||||
output_directory.mkdir()
|
||||
source_path = create_source_fixture(
|
||||
self.workdir,
|
||||
source_filename,
|
||||
[
|
||||
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||
],
|
||||
)
|
||||
|
||||
completed = run_ffx_unmux(
|
||||
self.workdir,
|
||||
self.home_dir,
|
||||
self.database_path,
|
||||
"--label",
|
||||
"dball",
|
||||
"--output-directory",
|
||||
str(output_directory),
|
||||
str(source_path),
|
||||
)
|
||||
self.assertCompleted(completed)
|
||||
|
||||
output_filenames = sorted(path.name for path in output_directory.iterdir())
|
||||
self.assertEqual(1, len(output_filenames), output_filenames)
|
||||
self.assertTrue(
|
||||
output_filenames[0].startswith("dball_S1E001_"),
|
||||
output_filenames,
|
||||
)
|
||||
|
||||
def test_unmux_applies_shifted_season_mapping_to_output_filenames(self):
|
||||
self.seed_matching_show(
|
||||
r"^unmux_([sS][0-9]+[eE][0-9]+)\.mkv$",
|
||||
indicator_season_digits=2,
|
||||
indicator_episode_digits=2,
|
||||
)
|
||||
self.add_show_shift(
|
||||
show_id=1,
|
||||
original_season=1,
|
||||
first_episode=1,
|
||||
last_episode=99,
|
||||
season_offset=1,
|
||||
episode_offset=-88,
|
||||
)
|
||||
source_filename = "unmux_s01e89.mkv"
|
||||
output_directory = self.workdir / "unmux-output"
|
||||
output_directory.mkdir()
|
||||
source_path = create_source_fixture(
|
||||
self.workdir,
|
||||
source_filename,
|
||||
[
|
||||
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||
SourceTrackSpec(
|
||||
TrackType.SUBTITLE,
|
||||
identity="subtitle-1",
|
||||
language="eng",
|
||||
subtitle_lines=("subtitle payload",),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
completed = run_ffx_unmux(
|
||||
self.workdir,
|
||||
self.home_dir,
|
||||
self.database_path,
|
||||
"--label",
|
||||
"dball",
|
||||
"--output-directory",
|
||||
str(output_directory),
|
||||
"--subtitles-only",
|
||||
str(source_path),
|
||||
)
|
||||
self.assertCompleted(completed)
|
||||
|
||||
self.assertIn(
|
||||
"Unmuxing stream 1 into file dball_S02E01_1_eng",
|
||||
completed.stderr,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1
tests/legacy/__init__.py
Normal file
1
tests/legacy/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Legacy custom FFX test harness modules.
|
||||
@@ -24,8 +24,9 @@ class BasenameCombinator():
|
||||
|
||||
@staticmethod
|
||||
def getClassReference(identifier):
|
||||
importlib.import_module(f"ffx.test.basename_combinator_{ identifier }")
|
||||
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.basename_combinator_{ identifier }"]):
|
||||
module_name = f"tests.legacy.basename_combinator_{ identifier }"
|
||||
importlib.import_module(module_name)
|
||||
for name, obj in inspect.getmembers(sys.modules[module_name]):
|
||||
#HINT: Excluding MediaCombinator as it seems to be included by import (?)
|
||||
if inspect.isclass(obj) and name != 'BasenameCombinator' and name.startswith('BasenameCombinator'):
|
||||
return obj
|
||||
@@ -24,8 +24,9 @@ class DispositionCombinator2():
|
||||
|
||||
@staticmethod
|
||||
def getClassReference(identifier):
|
||||
importlib.import_module(f"ffx.test.disposition_combinator_2_{ identifier }")
|
||||
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.disposition_combinator_2_{ identifier }"]):
|
||||
module_name = f"tests.legacy.disposition_combinator_2_{ identifier }"
|
||||
importlib.import_module(module_name)
|
||||
for name, obj in inspect.getmembers(sys.modules[module_name]):
|
||||
#HINT: Excluding DispositionCombination as it seems to be included by import (?)
|
||||
if inspect.isclass(obj) and name != 'DispositionCombinator2' and name.startswith('DispositionCombinator2'):
|
||||
return obj
|
||||
@@ -23,8 +23,9 @@ class DispositionCombinator3():
|
||||
|
||||
@staticmethod
|
||||
def getClassReference(identifier):
|
||||
importlib.import_module(f"ffx.test.disposition_combinator_3_{ identifier }")
|
||||
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.disposition_combinator_3_{ identifier }"]):
|
||||
module_name = f"tests.legacy.disposition_combinator_3_{ identifier }"
|
||||
importlib.import_module(module_name)
|
||||
for name, obj in inspect.getmembers(sys.modules[module_name]):
|
||||
#HINT: Excluding DispositionCombination as it seems to be included by import (?)
|
||||
if inspect.isclass(obj) and name != 'DispositionCombinator3' and name.startswith('DispositionCombinator3'):
|
||||
return obj
|
||||
@@ -1,11 +1,9 @@
|
||||
import os, math, tempfile, click
|
||||
|
||||
|
||||
from ffx.ffx_controller import FfxController
|
||||
|
||||
from ffx.process import executeProcess
|
||||
|
||||
from ffx.media_descriptor import MediaDescriptor
|
||||
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
|
||||
from ffx.track_type import TrackType
|
||||
|
||||
from ffx.helper import dictCache
|
||||
@@ -149,7 +147,6 @@ def createMediaTestFile(mediaDescriptor: MediaDescriptor,
|
||||
|
||||
# subtitleFilePath = createVttFile(SHORT_SUBTITLE_SEQUENCE)
|
||||
|
||||
# commandTokens = FfxController.COMMAND_TOKENS
|
||||
commandTokens = ['ffmpeg', '-y']
|
||||
|
||||
generatorCache = []
|
||||
@@ -232,15 +229,14 @@ def createMediaTestFile(mediaDescriptor: MediaDescriptor,
|
||||
f"{mediaTagKey}={mediaTagValue}"]
|
||||
subIndexCounter[trackType] += 1
|
||||
|
||||
#TODO: Optimize too many runs
|
||||
ffxContext = {'config': ConfigurationController(), 'logger': logger}
|
||||
fc = FfxController(ffxContext, mediaDescriptor)
|
||||
mdcs = MediaDescriptorChangeSet(ffxContext, mediaDescriptor)
|
||||
|
||||
commandTokens += (generatorTokens
|
||||
+ importTokens
|
||||
+ mappingTokens
|
||||
+ metadataTokens
|
||||
+ fc.generateDispositionTokens())
|
||||
+ mdcs.generateDispositionTokens())
|
||||
|
||||
|
||||
commandTokens += ['-t', str(length)]
|
||||
@@ -25,8 +25,9 @@ class LabelCombinator():
|
||||
|
||||
@staticmethod
|
||||
def getClassReference(identifier):
|
||||
importlib.import_module(f"ffx.test.{LabelCombinator.PREFIX}{ identifier }")
|
||||
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.{LabelCombinator.PREFIX}{ identifier }"]):
|
||||
module_name = f"tests.legacy.{LabelCombinator.PREFIX}{ identifier }"
|
||||
importlib.import_module(module_name)
|
||||
for name, obj in inspect.getmembers(sys.modules[module_name]):
|
||||
#HINT: Excluding MediaCombinator as it seems to be included by import (?)
|
||||
if inspect.isclass(obj) and name != 'LabelCombinator' and name.startswith('LabelCombinator'):
|
||||
return obj
|
||||
@@ -22,8 +22,9 @@ class MediaCombinator():
|
||||
|
||||
@staticmethod
|
||||
def getClassReference(identifier):
|
||||
importlib.import_module(f"ffx.test.media_combinator_{ identifier }")
|
||||
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.media_combinator_{ identifier }"]):
|
||||
module_name = f"tests.legacy.media_combinator_{ identifier }"
|
||||
importlib.import_module(module_name)
|
||||
for name, obj in inspect.getmembers(sys.modules[module_name]):
|
||||
#HINT: Excluding MediaCombinator as it seems to be included by import (?)
|
||||
if inspect.isclass(obj) and name != 'MediaCombinator' and name.startswith('MediaCombinator'):
|
||||
return obj
|
||||
@@ -22,8 +22,9 @@ class MediaTagCombinator():
|
||||
|
||||
@staticmethod
|
||||
def getClassReference(identifier):
|
||||
importlib.import_module(f"ffx.test.media_tag_combinator_{ identifier }")
|
||||
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.media_tag_combinator_{ identifier }"]):
|
||||
module_name = f"tests.legacy.media_tag_combinator_{ identifier }"
|
||||
importlib.import_module(module_name)
|
||||
for name, obj in inspect.getmembers(sys.modules[module_name]):
|
||||
#HINT: Excluding MediaCombinator as it seems to be included by import (?)
|
||||
if inspect.isclass(obj) and name != 'MediaTagCombinator' and name.startswith('MediaTagCombinator'):
|
||||
return obj
|
||||
@@ -4,7 +4,7 @@ from ffx.show_controller import ShowController
|
||||
from ffx.pattern_controller import PatternController
|
||||
from ffx.media_controller import MediaController
|
||||
|
||||
from ffx.test.helper import createEmptyDirectory
|
||||
from .helper import createEmptyDirectory
|
||||
from ffx.database import databaseContext
|
||||
|
||||
class Scenario():
|
||||
@@ -90,11 +90,7 @@ class Scenario():
|
||||
def __init__(self, context = None):
|
||||
self._context = context
|
||||
self._testDirectory = createEmptyDirectory()
|
||||
self._ffxExecutablePath = os.path.join(
|
||||
os.path.dirname(
|
||||
os.path.dirname(
|
||||
os.path.dirname(__file__))),
|
||||
'ffx.py')
|
||||
self._ffxModuleName = 'ffx'
|
||||
|
||||
self._logger = context['logger']
|
||||
self._reportLogger = context['report_logger']
|
||||
@@ -146,8 +142,9 @@ class Scenario():
|
||||
|
||||
@staticmethod
|
||||
def getClassReference(identifier):
|
||||
importlib.import_module(f"ffx.test.scenario_{ identifier }")
|
||||
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.scenario_{ identifier }"]):
|
||||
module_name = f"tests.legacy.scenario_{ identifier }"
|
||||
importlib.import_module(module_name)
|
||||
for name, obj in inspect.getmembers(sys.modules[module_name]):
|
||||
#HINT: Excluding Scenario as it seems to be included by import (?)
|
||||
if inspect.isclass(obj) and name != 'Scenario' and name.startswith('Scenario'):
|
||||
return obj
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user