49 Commits

Author SHA1 Message Date
Javanaut
2593c95b5c Release v0.2.5 2026-04-12 19:58:30 +02:00
Javanaut
8a8c43ecdf v0.2.5 2026-04-12 19:57:46 +02:00
Javanaut
6170ac641c ff 2026-04-12 19:35:03 +02:00
Javanaut
497c0e500b ff 2026-04-12 19:34:51 +02:00
Javanaut
008c643272 change disposition order for sidecar files 2026-04-12 19:31:49 +02:00
Javanaut
c302b30e63 ff 2026-04-12 19:19:08 +02:00
Javanaut
7926407534 ff 2026-04-12 19:09:26 +02:00
Javanaut
0894ac2fab ff 2026-04-12 18:50:41 +02:00
Javanaut
353759b983 ff 2026-04-12 18:47:54 +02:00
Javanaut
454f5f0656 ff 2026-04-12 18:46:54 +02:00
Javanaut
0e51d6337f ff 2026-04-12 18:35:13 +02:00
Javanaut
a24b6dedaa ff 2026-04-12 18:26:39 +02:00
Javanaut
8361fc536b ff 2026-04-12 17:53:56 +02:00
Javanaut
4d4272e5e8 ff 2026-04-12 17:47:06 +02:00
Javanaut
559869ca68 iteration1 2026-04-12 17:12:32 +02:00
Javanaut
0e4fae538b prep season shift 2026-04-12 16:52:12 +02:00
Javanaut
12509cd4e2 Release v0.2.4 2026-04-12 12:28:37 +02:00
Javanaut
2595bfe4f4 prep 0.2.4 2026-04-12 12:28:23 +02:00
Javanaut
3df11be5e9 upd .gitignore 2026-04-12 12:24:19 +02:00
Javanaut
fc9d94aeee prep 0.2.4 2026-04-12 12:21:26 +02:00
Javanaut
111df11199 ff 2026-04-12 12:20:01 +02:00
Javanaut
f0d4c36bc3 Adds release script and bumps 0.2.4 2026-04-12 12:12:41 +02:00
Javanaut
ef0d6e9274 Extd rename/unmux to pad with zeroes 2026-04-12 11:44:32 +02:00
Javanaut
d05b01cfb2 Adds rename command 2026-04-12 10:38:36 +02:00
Javanaut
9dc08d48e9 ff 2026-04-12 10:06:19 +02:00
Javanaut
20bdfc0dd7 Fix pri lang for rename mode 2026-04-12 10:06:01 +02:00
Javanaut
4365e083dc Adapt unmux command to changes in convert command 2026-04-11 22:31:04 +02:00
Javanaut
528915a235 Adds subtitle default dir 2026-04-11 21:17:21 +02:00
Javanaut
9a980b5766 Fix streamtags remove list 2026-04-11 20:50:09 +02:00
Javanaut
5eee7e1161 Extd cut parameter 2026-04-11 20:27:58 +02:00
Javanaut
0a41998e29 Adds Q/P values to output file metadata 2026-04-11 17:46:16 +02:00
Javanaut
ebdc23c3ce Fixes remove stream tags per list 2026-04-11 17:31:10 +02:00
Javanaut
9611930949 Misc Opts 2026-04-11 16:52:58 +02:00
Javanaut
609f93b783 Fix cpu percentage interpretations 2026-04-11 16:30:41 +02:00
Javanaut
52c6462fa8 Optimizes niceness and cpulimit usage 2026-04-11 16:21:17 +02:00
Javanaut
358ef18f77 Fix regex issues 2026-04-11 16:10:41 +02:00
Javanaut
fc729a2414 Opt database bootstrapping 2026-04-11 16:04:54 +02:00
Javanaut
0939a0c6c2 Optimizes ffprobe usage 2026-04-11 16:00:01 +02:00
Javanaut
c384d54c12 Impr upgrade 2026-04-11 15:08:08 +02:00
Javanaut
71553aad32 Streamlines imports and app start 2026-04-11 14:57:01 +02:00
Javanaut
d19e69990a Opt pattern matching 2026-04-09 16:11:51 +02:00
Javanaut
be0f4b4c4e Optimize database queries 2026-04-09 13:49:14 +02:00
Javanaut
01b5fdb289 Refine tests, CLI 2026-04-09 13:34:38 +02:00
Javanaut
60ae58500a Tidy up logging and rework tests from scratch 2026-04-09 12:46:24 +02:00
Javanaut
f9c8b8ac5e ffn2 2026-04-09 01:13:06 +02:00
Javanaut
72c735c3ee ffn 2026-04-09 01:06:32 +02:00
Javanaut
5871ae30ad ffn 2026-04-09 01:06:09 +02:00
Javanaut
381a62046b nightly 2026-04-09 01:04:47 +02:00
Javanaut
52724ecc5b ff 2026-04-09 01:03:41 +02:00
143 changed files with 8191 additions and 2192 deletions

11
.gitignore vendored
View File

@@ -1,4 +1,5 @@
__pycache__ __pycache__/
*.py[cod]
junk/ junk/
.vscode .vscode
.ipynb_checkpoints/ .ipynb_checkpoints/
@@ -12,4 +13,12 @@ bin/conversiontest.py
build/ build/
dist/ dist/
*.egg-info/ *.egg-info/
.venv/
venv/
.codex .codex
*.mkv
*.webm
ffmpeg2pass-0.log
*.sup

376
AGENTS.md
View File

@@ -1,376 +0,0 @@
# AGENTS.md
This file is the entry point for agent guidance in this repository.
It is intentionally generic and reusable across projects. Keep this file focused on non-project-specific constraints, working style, and the structure used to link more detailed guidance.
# Purpose
- Provide a small default rule set for agents working in this repository.
- Keep the base guidance modular and easy to extend.
- Separate reusable agent behavior from project-specific requirements.
# Comment Syntax
- A segment wrapped in `<!--` and `-->` is a comment and must be ignored by agents.
- Use HTML comments for optional guidance that should stay inactive until enabled.
- To enable an optional segment, remove the surrounding `<!--` and `-->` markers.
# Core Principles
- Prefer the simplest solution that satisfies the current goal.
- Keep guidance lightweight: only add detail when it meaningfully improves outcomes.
- Reuse modular guideline files instead of expanding this file indefinitely.
- Treat project-specific documents as the source of truth for project behavior.
- When guidance conflicts, use the most specific applicable document.
# Rule Terms
- A `rule` is the general term for any constraint, requirement, definition, or similar guidance item.
- A `rule set` addresses all rules inside one file that share the same rule set ID.
- Any rule inside a rule set shall use an ID following the schema `RULESET-0001`, `RULESET-0002`, and so on.
- Rules without a rule set ID are also valid, but they are not addressable by rule ID.
# Scope Of This File
This file should contain:
- Generic agent behavior and constraints.
- Rules that are reusable across multiple projects.
- Links to optional guideline modules.
- Links to project-specific requirements.
- Commented optional templates for released-product documentation and agent-output locations.
This file should not contain:
- Project business requirements.
- Project architecture decisions.
- Stack-specific implementation details unless they are universally applicable.
- Task-specific runbooks that belong in dedicated modules.
# Default Agent Behavior
- Read the relevant context before making changes.
- Prefer small, understandable edits over broad refactors.
- Preserve existing patterns unless there is a clear reason to change them.
- Document assumptions when context is missing.
- Ignore HTML comment segments.
- If a more specific enabled guideline exists for the current task, follow it.
# Guideline Structure
Use the following structure for reusable guidance files and project-specific documentation as needed:
```text
/
|-- AGENTS.md
|-- guidance/
| |-- stacks/
| |-- conventions/
| `-- workflows/
|-- prompts/
`-- requirements/
Optional files and directories
|-- SCRATCHPAD.md
|-- docs/
| |-- readme.md
| |-- installation.md
| `-- history.md
|-- process/
| |-- log.md
| `-- coding-handbook.md
```
# Optional Reusable Modules
Add files under `guidance/` only when they are needed.
# Optional Scratchpad
- `SCRATCHPAD.md` is an optional repo-root scratchpad for temporary
information aimed at the next iteration.
- Developers may create or delete `SCRATCHPAD.md` at any time.
- Developers may refer to `SCRATCHPAD.md` as `scratchpad` when giving agents a
source or target for information.
- Agents may read, update, create, or remove the scratchpad when the task
explicitly calls for it.
- Treat the scratchpad as low-formality working context rather than canonical
project truth.
- Use the scratchpad for short-lived notes, open questions, sketches, and
temporary decisions that should be resolved away.
- Move durable outcomes into `requirements/`, `guidance/`, code, tests, or
another long-lived location.
- If `SCRATCHPAD.md` is absent, agents should continue normally.
# Optional Rule Sets
- Optional rule sets may be stored in `guidance/optional/` or in `guidance/{section}/optional/`.
- Optional rule sets are inactive by default and shall only be applied when a prompt explicitly requests them, for example by phrases such as `Apply rules for lean interface iteration in the following steps.` or `Apply LII rules.`
- An optional rule set may be requested by its descriptive name, by its rule set ID, or by another equally clear explicit reference.
- Agents shall never infer or auto-enable optional rule sets from general intent alone.
- If an optional rule or rule set cannot be identified and addressed clearly, agents shall stop and ask before proceeding.
# Prepared Orders
- An `order` is a prepared prompt for one isolated operation rather than a general workflow or standing rule set.
- Orders shall be stored under `prompts/`.
- Order files shall use the naming schema `ORDER-0001-<slug>.md`, `ORDER-0002-<slug>.md`, and so on.
- The canonical order identifier is the `ORDER-0001` style prefix. The trailing slug is descriptive only.
- Recommended internal order file structure is: prompt ID, prompt name, purpose, trigger examples, scope, operation, and expected output.
- Orders shall only be executed when they are explicitly requested by a prompt such as `Execute ORDER-0007.` or `Execute ORDER 7.`
- Agents may accept an unambiguous short numeric reference such as `ORDER 7` as an alias for `ORDER-0007`.
- If an order cannot be identified uniquely and clearly, agents shall stop and ask before proceeding.
# Toolstack Guides
Location:
```text
guidance/stacks/
```
Examples:
- `guidance/stacks/python.md`
- `guidance/stacks/typescript.md`
- `guidance/stacks/docker.md`
- `guidance/stacks/terraform.md`
Use for:
- Language or framework expectations.
- Tooling and environment conventions.
- Build, test, and runtime guidance tied to a specific stack.
# Coding Conventions
Location:
```text
guidance/conventions/
```
Examples:
- `guidance/conventions/naming.md`
- `guidance/conventions/testing.md`
- `guidance/conventions/review.md`
Use for:
- Naming and structure conventions.
- Testing expectations.
- Code review and quality rules.
# Recurring Workflows
Location:
```text
guidance/workflows/
```
Examples:
- `guidance/workflows/feature-delivery.md`
- `guidance/workflows/bugfix.md`
- `guidance/workflows/release.md`
- `guidance/workflows/incident-response.md`
Use for:
- Repeatable task flows.
- Checklists for common delivery work.
- Operational or maintenance procedures.
<!-- Enable this optional section by removing the outer HTML comment markers from this segment
when you want agents to create, update, and consult released-product
documentation in `docs/`.
# Released Product Documentation
Released-product documentation should live outside the generic sections above.
Recommended location:
```text
docs/
```
Examples:
- `docs/readme.md`
- `docs/installation.md`
- `docs/history.md`
Agent rules for docs output:
- Keep content compact but comprehensive.
- Write for end users, operators, or other consumers of the released product.
- Prefer shipped behavior, supported workflows, and stable terminology over
internal implementation detail.
- Keep documentation synchronized with released behavior.
- Update release history when user-visible changes are shipped.
Recommended topics:
- Product overview and intended use.
- Installation, configuration, and upgrade guidance.
- Usage patterns, operational instructions, and support boundaries.
- Compatibility notes, migration notes, and release history.
- Troubleshooting and common pitfalls when relevant. -->
<!-- Enable this optional section by removing the outer HTML comment markers from this
segment when you want agents to produce and consult workflow output in `process/`.
# Agent Output In `process/`
The `process/` directory is primarily for agent output created during
delivery, maintenance, and review work.
Recommended location:
```text
process/
```
Agent rules for process output:
- Use `process/` for agent-produced artifacts rather than released-product
documentation.
- Keep entries concise, traceable, and tied to resulting changes.
- Treat `process/` as workflow output, not as the primary source of product
truth.
- Prefer summaries and rationale over raw transcript dumps unless a workflow
explicitly requires full prompt history.
# Agent Change Log
Location:
```text
process/log.md
```
Use for:
- Capturing prompts given to agents.
- Recording concise explanations of the resulting changes made by agents.
- Preserving task-by-task rationale, decisions, and implementation notes.
# Coding Handbook
Location:
```text
process/coding-handbook.md
```
Use for:
- A tutorial-style handbook that explains the programming components used in
the project.
- Compact but comprehensive technical onboarding material for future
contributors.
- Written explanations that connect code structure, concepts, and
implementation patterns. -->
# Project-Specific Requirements
Project-specific material should live outside the generic sections above.
Recommended location:
```text
requirements/
```
Examples:
- `requirements/project.md`
- `requirements/architecture.md`
- `requirements/decisions.md`
- `requirements/domain.md`
Use for:
- Product and business requirements.
- Project goals and constraints.
- Architecture and design decisions.
- Domain knowledge that is specific to this repository.
# Agent-Level Variables
When present, `requirements/identifiers.yml` is an optional project-specific
input that defines agent-level variables for use inside `requirements/` and
`guidance/`.
Variable schema:
- Use `@{VARIABLE_NAME}` for agent-level variables.
- Prefer uppercase snake case names such as `@{PROJECT_ID}` or `@{VENDOR_ID}`.
- Do not treat `${...}` as an agent-level variable form; that syntax may appear
in Bash or other code and should not be interpreted as agent metadata.
Scope:
- The effective scope of `requirements/identifiers.yml` is limited to
`requirements/` and `guidance/`.
- Definitions from `requirements/identifiers.yml` must not leak into product code.
Defaults:
- Default `@{VENDOR_ID}` is `osgw`.
- Default `@{PROJECT_ID}` is the current repository directory name.
Resolution rules:
- Treat `requirements/identifiers.yml` as optional; when it is absent, agents
may still resolve the defaults defined above.
- If a variable is used in `requirements/` or `guidance/` and it is not
defined in `requirements/identifiers.yml` and does not have a default in this
file, agents may stop and report the undefined variable.
- Prefer updating duplicated identifier values in `requirements/` and
`guidance/` to use the variable schema when that improves consistency.
# Precedence
Some precedence levels may be absent because optional levels can remain inside
HTML comments. The smaller numeric index wins.
Apply guidance in this order:
1. Direct user or task instructions.
2. Project-specific documents in `requirements/`.
<!-- 3. Released-product documentation in `docs/` when shipped behavior or
user-facing expectations are relevant. -->
4. Relevant modular guides in `guidance/stacks/`, `guidance/conventions/`, or `guidance/workflows/`.
<!-- 5. Agent output in `process/` when prior prompts, rationale, or
implementation notes are relevant. -->
6. This `AGENTS.md`.
# Maintenance
- Keep this file short and stable.
- Move detail into dedicated modules when a section becomes too specific or too long.
- Add new guideline files only when they solve a recurring need.
- Remove outdated references when the repository structure changes.
# Current Status
This repository defines the base `AGENTS.md` structure plus project-specific
requirements and modular guidance.
Future project work can add:
- Reusable modules under `guidance/`
- Project-specific documentation under `requirements/`
- Optional temporary iteration context in `SCRATCHPAD.md`
- Optional released-product documentation under `docs/` by uncommenting its segment
- Optional agent output under `process/` by uncommenting its segment
- Cross-references from this file once those documents exist

151
README.md
View File

@@ -1,48 +1,157 @@
# FFX # 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 ## 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 ```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 ```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.5
- show-level quality and notes fields
- pattern-over-show-over-default season-shift resolution with dynamic DB migration loading
- migration prompt now reports the upgrade path and creates an in-place DB backup before applying schema changes
- `upgrade --branch <name>` now fetches remote-only branches before switching
- `unmux` now applies season shifting to subtitle output filenames
- convert now keeps DB-defined target subtitle dispositions authoritative over sidecar filename disposition flags when a pattern definition exists
- focused modern tests added around migrations, unmux, upgrade, and subtitle-disposition import precedence
### 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 ### 0.2.3
PyPi packaging - PyPI packaging
Templating output filename - output filename templating
Season shiftung - season shifting
DB-Versionierung - 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

View File

@@ -1,62 +0,0 @@
<!--
# 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.
## 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.
## 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.
## 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.
## 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.
## 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.
## 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.
-->

36
assets/ffx.json.j2 Normal file
View 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"
]
}
}
}

View File

@@ -1,28 +0,0 @@
# Lean Interface Iteration
Rule set name: `lean-interface-iteration`
Rule set ID: `LII`
Status: optional, prompt-activated only
Trigger examples:
- `Apply the lean-interface-iteration rules.`
- `Apply LII rules.`
LII-0001: Apply this rule set only when it is explicitly requested in the prompt.
LII-0002: The target of work under this rule set is the iterated product state for the addressed iteration only.
LII-0003: Optimize the addressed interface toward the leanest and least complex model that still satisfies the iteration order.
LII-0004: Backward compatibility, legacy aliases, and compatibility shims are not required unless the prompt explicitly asks to preserve them.
LII-0005: Prefer one authoritative interface over multiple overlapping parameters, flags, or naming variants.
LII-0006: Remove or avoid transitional interface layers when they are not required by the addressed iteration order.
LII-0007: Update affected tests, guidance, requirements, and documentation so they describe the simplified interface model rather than a mixed legacy-and-new model.
LII-0008: Never change behavior, interfaces, or surrounding areas that are not addressed by the current iteration order.

View File

@@ -1,56 +0,0 @@
# Preparation Script Design
Rule set name: `preparation-script-design`
Rule set ID: `PSD`
Status: optional, prompt-activated only
Trigger examples:
- `Apply the preparation-script-design rules.`
- `Apply PSD rules.`
PSD-0001: Apply this rule set only when it is explicitly requested in the prompt.
PSD-0002: Use this rule set for scripts whose purpose is to prepare, verify, or expose a local development or automation environment rather than to perform product runtime behavior.
PSD-0003: Keep a preparation script focused on environment readiness, dependency installation, local helper exposure, and clear verification output; do not mix unrelated product logic into the script.
PSD-0004: Design the script to be idempotent so repeated runs converge on the same prepared state without unnecessary reinstallation or destructive side effects.
PSD-0005: Provide a verification-only mode such as `--check` that reports readiness without installing, modifying, or creating dependencies.
PSD-0006: Separate component checks from installation steps so the script can report what is missing before or after attempted remediation.
PSD-0007: Group required capabilities into clear purpose-oriented sections such as support toolchains, local package bundles, generated environment helpers, or other relevant readiness areas instead of presenting one undifferentiated dependency list.
PSD-0008: Prefer explicit per-component check helpers over opaque one-shot checks so failures remain traceable and easy to extend.
PSD-0009: Generate or update environment helper files only when they provide a stable, reusable way to expose repo-local or workspace-local tools, paths, or environment variables.
PSD-0010: Generated environment helper files shall be safe to source multiple times and should avoid duplicating path entries or clobbering unrelated user environment state.
PSD-0011: When a preparation flow seeds optional user-owned files such as config templates, do so non-destructively by creating them only when absent unless the prompt explicitly requests overwrite behavior.
PSD-0012: Report status in a concise scan-friendly line format of the shape `[status] Label: detail`, where the label names the checked component and the detail string stays short and specific.
PSD-0013: Prefer a small canonical status vocabulary in those report lines, with `ok` for satisfied checks, `warn` for non-blocking gaps, and a failure status such as `failed` for blocking or unsuccessful states.
PSD-0014: When a preparation script uses terminal colors in its status output, apply a consistent severity mapping so `ok` is green, `warn` is yellow, and all other status levels are red.
PSD-0015: In bracketed status markers such as `[ok]` or `[warn]`, keep the square brackets uncolored and apply the severity color only to the inner status text.
PSD-0016: Colorized status output shall degrade safely in non-terminal or non-color contexts so the script remains readable and automation-friendly without ANSI support.
PSD-0017: End with an explicit readiness conclusion that distinguishes between successful preparation, incomplete prerequisites, and failed installation attempts.
PSD-0018: Installation logic should use the narrowest supported platform-specific package-manager actions necessary for the declared scope and should fail clearly when no supported installation path is available.
PSD-0019: Treat repo-local helper tooling and local package installation boundaries explicitly rather than assuming global installs, especially when the prepared environment is intended to be reproducible.
PSD-0020: Keep the script suitable for both interactive local developer use and non-interactive automation checks by avoiding prompts during normal execution unless the prompt explicitly requires interactivity.
PSD-0021: When a script depends on generated helper files or adjacent validation helpers, update those supporting files only as needed to keep the preparation flow coherent and usable.
PSD-0022: Verify shell syntax after changes and, when feasible, run a dry readiness check so the resulting preparation flow is validated rather than only written.

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "ffx" name = "ffx"
description = "FFX recoding and metadata managing tool" description = "FFX recoding and metadata managing tool"
version = "0.2.3" version = "0.2.5"
license = {file = "LICENSE.md"} license = {file = "LICENSE.md"}
dependencies = [ dependencies = [
"requests", "requests",
@@ -27,6 +27,11 @@ Homepage = "https://gitea.maveno.de/Javanaut/ffx"
Repository = "https://gitea.maveno.de/Javanaut/ffx.git" Repository = "https://gitea.maveno.de/Javanaut/ffx.git"
Issues = "https://gitea.maveno.de/Javanaut/ffx/issues" Issues = "https://gitea.maveno.de/Javanaut/ffx/issues"
[project.optional-dependencies]
test = [
"pytest",
]
[build-system] [build-system]
requires = [ requires = [
"setuptools", "setuptools",
@@ -35,4 +40,15 @@ requires = [
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project.scripts] [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",
]

View File

@@ -1,97 +0,0 @@
# Architecture
## Architecture Goals
- Keep the tool small, local, and easy to reason about.
- Separate media inspection, stored normalization rules, and conversion execution clearly enough that users can inspect and adjust behavior.
- Favor explicit local state and deterministic rule application over opaque automation.
- Make external runtime dependencies and platform assumptions visible.
## System Context
- Primary actors:
- Local operator running the CLI.
- Local operator using the Textual TUI to inspect files and maintain rules.
- External systems:
- `ffprobe` for media introspection.
- `ffmpeg` for conversion and extraction.
- TMDB API for optional show and episode metadata.
- Local filesystem for source media, generated outputs, subtitles, logs, config, and database files.
- Data entering the system:
- Media container and stream metadata from source files.
- Regex patterns and per-show normalization rules entered in the TUI.
- Optional config values from `~/.local/etc/ffx.json`.
- Optional TMDB identifiers and CLI overrides.
- Optional external subtitle files.
- Data leaving the system:
- Normalized output media files.
- Extracted stream files from unmux operations.
- SQLite rows representing shows, patterns, tracks, tags, shifted seasons, and properties.
- Local log output and console messages.
## 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 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.
- Storage:
- SQLite via SQLAlchemy ORM, with schema rooted in shows, patterns, tracks, media tags, track tags, shifted seasons, and generic properties.
- A configuration JSON file supplies optional path, metadata-filtering, and filename-template settings.
- Integration adapters:
- Process execution wrapper for `ffmpeg`, `ffprobe`, `nice`, and `cpulimit`.
- 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.
- `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.
- `MediaTag`: persisted container-level metadata for a pattern.
- `ShiftedSeason`: mapping from source numbering ranges to adjusted season and episode numbers.
- `Property`: internal key-value storage currently used for database versioning.
- External interfaces:
- CLI commands for conversion, inspection, extraction, and crop detection.
- TUI workflows for rule authoring and rule maintenance.
- Environment variable `TMDB_API_KEY` for TMDB access.
- 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.
- 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.
- 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.
- Ambiguous default and forced stream states trigger prompts unless `--no-prompt` is set, in which case the command fails fast.
- External-process failures and invalid media are surfaced through logs and command errors rather than retries, except for TMDB rate-limit retries.
## Deployment And Operations
- Runtime environment:
- Local Python environment with the package installed and `ffmpeg`, `ffprobe`, `nice`, and `cpulimit` available on `PATH`.
- Deployment shape:
- Single-process command execution on demand; no daemon, queue, or network service of its own.
- Secrets and configuration handling:
- TMDB secret is read from `TMDB_API_KEY`.
- User config is read from `~/.local/etc/ffx.json`.
- Database path may also be overridden per command via `--database-file`.
- Logging and monitoring approach:
- File and console logging configured per invocation.
- Default log file path is `~/.local/var/log/ffx.log`.
- No dedicated monitoring integration is present.
## Open Technical Questions
- Question: Should Linux-specific assumptions such as `/dev/null`, `nice`, `cpulimit`, and `~/.local` remain part of the supported-platform contract?
- Risk: Portability and operational behavior are underspecified for non-Linux environments.
- Next decision needed: Either document Linux-like systems as the official support boundary or refactor the process and path handling for broader portability.
- Question: Should placeholder TUI surfaces such as settings and help become part of the required product surface or stay explicitly out of scope?
- Risk: The UI appears broader than the actually finished feature set.
- Next decision needed: Either remove or complete placeholder screens and update requirements accordingly.

View File

@@ -1,101 +0,0 @@
## Purpose And Scope
- Project name: FFX
- User problem: TV episode files from mixed sources arrive with inconsistent codecs, stream metadata, subtitle layouts, season and episode numbering, and output filenames, which makes them awkward to archive and use in media-player applications.
- Target users: Individual operators curating a local TV media library on a workstation, especially users willing to define normalization rules per show.
- Success outcome: A user can inspect source files, define reusable show and pattern rules, and produce output files whose streams, metadata, and filenames follow a predictable schema for web playback and library import.
- Out of scope:
- Multi-user or hosted service workflows.
- General movie-library management.
- Distributed transcoding or remote job orchestration.
- Broad media-server administration beyond file preparation.
## Required Product
- Deliverable type: Installable Python command-line application with a Textual terminal UI for inspection and rule editing.
- Core capabilities:
- Maintain an SQLite-backed database of shows, filename-matching patterns, per-pattern stream layouts and metadata tags, and optional season-shift rules.
- 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.
- 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`.
- Requires `ffmpeg`, `ffprobe`, and `cpulimit` on `PATH`.
- Operational owner: The local user running the tool and maintaining its config, database, and external tooling.
## Suggested User Stories
- As a library maintainer, I want to define show-specific matching rules once so that future source files can be normalized automatically.
- 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.
## 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 persist reusable normalization rules in SQLite for:
- shows and show formatting digits,
- regex-based filename patterns,
- per-pattern media tags,
- per-pattern stream definitions,
- shifted-season mappings,
- internal database version properties.
- 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.
- The system shall convert supported input files (`mkv`, `mp4`, `avi`, `flv`, `webm`) with `ffmpeg`, supporting at least:
- VP9, AV1, and H.264 video encoding,
- Opus audio encoding with bitrate selection based on channel layout,
- metadata and disposition rewriting,
- optional crop detection and crop application,
- optional deinterlacing and denoising,
- optional subtitle import from external files,
- rename-only copy 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 support extracting streams into separate files via `unmux` and reporting suggested crop parameters via `cropdetect`.
- 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
- 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 expose enough logging to diagnose failed probes, failed conversions, and rule mismatches without requiring a debugger.
## Constraints And Assumptions
- Technology constraints:
- Python package built with setuptools.
- Primary libraries: `click`, `textual`, `sqlalchemy`, `jinja2`, `requests`.
- Conversion and inspection rely on external executables rather than pure-Python media libraries.
- Hosting or infrastructure constraints:
- 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`.
- 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.
## Acceptance Scope
- First release boundary:
- Local installation through `pip`.
- Working SQLite-backed rule storage.
- Functional CLI conversion and inspection workflows.
- Textual CRUD flows for shows, patterns, tags, tracks, and shifted seasons.
- TMDB-assisted filename generation, subtitle import, season shifting, database versioning, and configurable output filename templating.
- Excluded follow-up ideas:
- Completing placeholder screens such as settings and help.
- Hardening platform portability beyond Linux-like systems.
- Broader media types, richer release packaging, and production-grade background processing.
- Demonstration scenario:
- Inspect a TV episode file, define or update the matching show and pattern in the TUI, then run `ffx convert` so the result uses the stored stream schema, optional TMDB episode naming, and a normalized output filename.

9
src/ffx/__main__.py Normal file
View File

@@ -0,0 +1,9 @@
from .cli import ffx
def main():
ffx()
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,12 @@
import os, json 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(): class ConfigurationController():
CONFIG_FILENAME = 'ffx.json' CONFIG_FILENAME = 'ffx.json'
@@ -8,7 +15,12 @@ class ConfigurationController():
DATABASE_PATH_CONFIG_KEY = 'databasePath' DATABASE_PATH_CONFIG_KEY = 'databasePath'
LOG_DIRECTORY_CONFIG_KEY = 'logDirectory' LOG_DIRECTORY_CONFIG_KEY = 'logDirectory'
SUBTITLES_DIRECTORY_CONFIG_KEY = 'subtitlesDirectory'
OUTPUT_FILENAME_TEMPLATE_KEY = 'outputFilenameTemplate' 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): def __init__(self):
@@ -49,6 +61,48 @@ class ConfigurationController():
def getDatabaseFilePath(self): def getDatabaseFilePath(self):
return self.__databaseFilePath 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): def getData(self):
return self.__configurationData return self.__configurationData

View File

@@ -1,15 +1,30 @@
VERSION='0.2.3' VERSION='0.2.5'
DATABASE_VERSION = 2 DATABASE_VERSION = 3
DEFAULT_QUALITY = 32 DEFAULT_QUALITY = 32
DEFAULT_AV1_PRESET = 5 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_STEREO_BANDWIDTH = "112"
DEFAULT_AC3_BANDWIDTH = "256" DEFAULT_AC3_BANDWIDTH = "256"
DEFAULT_DTS_BANDWIDTH = "320" DEFAULT_DTS_BANDWIDTH = "320"
DEFAULT_7_1_BANDWIDTH = "384" DEFAULT_7_1_BANDWIDTH = "384"
DEFAULT_CROPDETECT_SEEK_SECONDS = 60
DEFAULT_CROPDETECT_DURATION_SECONDS = 180
DEFAULT_cut_start = 60 DEFAULT_cut_start = 60
DEFAULT_cut_length = 180 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 }}' DEFAULT_OUTPUT_FILENAME_TEMPLATE = '{{ ffx_show_name }} - {{ ffx_index }}{{ ffx_index_separator }}{{ ffx_episode_name }}{{ ffx_indicator_separator }}{{ ffx_indicator }}'

View File

@@ -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 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.show import Base
from ffx.model.property import Property from ffx.model.property import Property
from ffx.model.migration import (
DatabaseVersionException,
getMigrationPlan,
migrateDatabase,
)
from ffx.constants import DATABASE_VERSION from ffx.constants import DATABASE_VERSION
DATABASE_VERSION_KEY = 'database_version' DATABASE_VERSION_KEY = 'database_version'
EXPECTED_TABLE_NAMES = set(Base.metadata.tables.keys())
class DatabaseVersionException(Exception):
def __init__(self, errorMessage):
super().__init__(errorMessage)
def databaseContext(databasePath: str = ''): def databaseContext(databasePath: str = ''):
@@ -29,12 +34,18 @@ def databaseContext(databasePath: str = ''):
if not os.path.exists(ffxVarDir): if not os.path.exists(ffxVarDir):
os.makedirs(ffxVarDir) os.makedirs(ffxVarDir)
databasePath = os.path.join(ffxVarDir, 'ffx.db') 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['url'] = f"sqlite:///{databasePath}"
databaseContext['engine'] = create_engine(databaseContext['url']) databaseContext['engine'] = create_engine(databaseContext['url'])
databaseContext['session'] = sessionmaker(bind=databaseContext['engine']) databaseContext['session'] = sessionmaker(bind=databaseContext['engine'])
Base.metadata.create_all(databaseContext['engine']) bootstrapDatabaseIfNeeded(databaseContext)
# isSyncronuous = False # isSyncronuous = False
# while not isSyncronuous: # while not isSyncronuous:
@@ -51,14 +62,126 @@ def databaseContext(databasePath: str = ''):
return databaseContext 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): def ensureDatabaseVersion(databaseContext):
currentDatabaseVersion = getDatabaseVersion(databaseContext) currentDatabaseVersion = getDatabaseVersion(databaseContext)
if currentDatabaseVersion: if not currentDatabaseVersion:
if currentDatabaseVersion != DATABASE_VERSION:
raise DatabaseVersionException(f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})")
else:
setDatabaseVersion(databaseContext, DATABASE_VERSION) 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): def getDatabaseVersion(databaseContext):
@@ -67,9 +190,9 @@ def getDatabaseVersion(databaseContext):
Session = databaseContext['session'] Session = databaseContext['session']
s = 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: except Exception as ex:
raise click.ClickException(f"getDatabaseVersion(): {repr(ex)}") raise click.ClickException(f"getDatabaseVersion(): {repr(ex)}")

View File

@@ -10,7 +10,16 @@ from ffx.track_codec import TrackCodec
from ffx.video_encoder import VideoEncoder from ffx.video_encoder import VideoEncoder
from ffx.process import executeProcess 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.quality_filter import QualityFilter
from ffx.filter.preset_filter import PresetFilter from ffx.filter.preset_filter import PresetFilter
@@ -21,17 +30,17 @@ from ffx.model.pattern import Pattern
class FfxController(): class FfxController():
COMMAND_TOKENS = ['ffmpeg', '-y'] COMMAND_TOKENS = list(FFMPEG_COMMAND_TOKENS)
NULL_TOKENS = ['-f', 'null', '/dev/null'] # -f null /dev/null NULL_TOKENS = list(FFMPEG_NULL_OUTPUT_TOKENS) # -f null /dev/null
TEMP_FILE_NAME = "ffmpeg2pass-0.log" 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_FORMAT = DEFAULT_CONTAINER_FORMAT
DEFAULT_FILE_EXTENSION = 'webm' 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' 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'] 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): def generateAV1Tokens(self, quality, preset, subIndex : int = 0):
return [f"-c:v:{int(subIndex)}", 'libsvtav1', return [f"-c:v:{int(subIndex)}", 'libsvtav1',
@@ -100,6 +116,37 @@ class FfxController():
return [f"-c:v:{int(subIndex)}", return [f"-c:v:{int(subIndex)}",
'copy'] '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): def generateCropTokens(self):
@@ -124,6 +171,18 @@ class FfxController():
return [outputFilePath] 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): def generateAudioEncodingTokens(self):
"""Generates ffmpeg options audio streams including channel remapping, codec and bitrate""" """Generates ffmpeg options audio streams including channel remapping, codec and bitrate"""
@@ -186,7 +245,8 @@ class FfxController():
targetFormat: str = '', targetFormat: str = '',
chainIteration: list = [], chainIteration: list = [],
cropArguments: dict = {}, cropArguments: dict = {},
currentPattern: Pattern = None): currentPattern: Pattern = None,
currentShowDescriptor = None):
# quality: int = DEFAULT_QUALITY, # quality: int = DEFAULT_QUALITY,
# preset: int = DEFAULT_AV1_PRESET): # preset: int = DEFAULT_AV1_PRESET):
@@ -203,9 +263,11 @@ class FfxController():
if qualityFilters and (quality := qualityFilters[0]['parameters']['quality']): if qualityFilters and (quality := qualityFilters[0]['parameters']['quality']):
self.__logger.info(f"Setting quality {quality} from command line parameter") self.__logger.info(f"Setting quality {quality} from command line")
elif (quality := currentPattern.quality): elif currentPattern is not None and (quality := currentPattern.quality):
self.__logger.info(f"Setting quality {quality} from pattern default") 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: else:
quality = (QualityFilter.DEFAULT_H264_QUALITY quality = (QualityFilter.DEFAULT_H264_QUALITY
if (videoEncoder == VideoEncoder.H264) if (videoEncoder == VideoEncoder.H264)
@@ -214,6 +276,11 @@ class FfxController():
preset = presetFilters[0]['parameters']['preset'] if presetFilters else PresetFilter.DEFAULT_PRESET preset = presetFilters[0]['parameters']['preset'] if presetFilters else PresetFilter.DEFAULT_PRESET
self.__context['encoding_metadata_tags'] = self.generateEncodingMetadataTags(
videoEncoder,
quality,
preset,
)
filterParamTokens = [] filterParamTokens = []
@@ -238,6 +305,28 @@ class FfxController():
commandTokens = FfxController.COMMAND_TOKENS + ['-i', sourcePath] 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: if videoEncoder == VideoEncoder.AV1:
commandSequence = (commandTokens commandSequence = (commandTokens
@@ -265,7 +354,7 @@ class FfxController():
self.__logger.debug(f"FfxController.runJob(): Running command sequence") self.__logger.debug(f"FfxController.runJob(): Running command sequence")
if not self.__context['dry_run']: if not self.__context['dry_run']:
executeProcess(commandSequence, context = self.__context) self.executeCommandSequence(commandSequence)
if videoEncoder == VideoEncoder.H264: if videoEncoder == VideoEncoder.H264:
@@ -295,7 +384,7 @@ class FfxController():
self.__logger.debug(f"FfxController.runJob(): Running command sequence") self.__logger.debug(f"FfxController.runJob(): Running command sequence")
if not self.__context['dry_run']: 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") self.__logger.debug(f"FfxController.runJob(): Running command sequence 1")
if not self.__context['dry_run']: if not self.__context['dry_run']:
executeProcess(commandSequence1, context = self.__context) self.executeCommandSequence(commandSequence1)
commandSequence2 = (commandTokens commandSequence2 = (commandTokens
+ self.__targetMediaDescriptor.getImportFileTokens() + self.__targetMediaDescriptor.getImportFileTokens()
@@ -354,9 +443,7 @@ class FfxController():
self.__logger.debug(f"FfxController.runJob(): Running command sequence 2") self.__logger.debug(f"FfxController.runJob(): Running command sequence 2")
if not self.__context['dry_run']: if not self.__context['dry_run']:
out, err, rc = executeProcess(commandSequence2, context = self.__context) self.executeCommandSequence(commandSequence2)
if rc:
raise click.ClickException(f"Command resulted in error: rc={rc} error={err}")
@@ -381,4 +468,4 @@ class FfxController():
str(length), str(length),
path] path]
out, err, rc = executeProcess(commandTokens, context = self.__context) self.executeCommandSequence(commandTokens)

View File

@@ -1,5 +1,11 @@
import os, re, json 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 .media_descriptor import MediaDescriptor
from .pattern_controller import PatternController from .pattern_controller import PatternController
@@ -11,8 +17,10 @@ from ffx.model.pattern import Pattern
class FileProperties(): class FileProperties():
_cropdetect_cache: dict[tuple[str, int, int, int, int], dict[str, str]] = {}
FILE_EXTENSIONS = ['mkv', 'mp4', 'avi', 'flv', 'webm'] 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]+)' SE_INDICATOR_PATTERN = '([sS][0-9]+[eE][0-9]+)'
SEASON_EPISODE_INDICATOR_MATCH = '[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 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): def __init__(self, context, sourcePath):
self.context = context self.context = context
@@ -44,9 +64,10 @@ class FileProperties():
self.__sourceFilenameExtension = '' self.__sourceFilenameExtension = ''
self.__pc = PatternController(context) self.__pc = PatternController(context)
self.__usePattern = bool(self.context.get('use_pattern', True))
# Checking if database contains matching pattern # Checking if database contains matching pattern
matchResult = self.__pc.matchFilename(self.__sourceFilename) matchResult = self.__pc.matchFilename(self.__sourceFilename) if self.__usePattern else {}
self.__logger.debug(f"FileProperties.__init__(): Match result: {matchResult}") self.__logger.debug(f"FileProperties.__init__(): Match result: {matchResult}")
@@ -56,26 +77,67 @@ class FileProperties():
databaseMatchedGroups = matchResult['match'].groups() databaseMatchedGroups = matchResult['match'].groups()
self.__logger.debug(f"FileProperties.__init__(): Matched groups: {databaseMatchedGroups}") self.__logger.debug(f"FileProperties.__init__(): Matched groups: {databaseMatchedGroups}")
seIndicator = databaseMatchedGroups[0] indicatorSource = databaseMatchedGroups[0]
se_match = re.search(FileProperties.SEASON_EPISODE_INDICATOR_MATCH, seIndicator)
e_match = re.search(FileProperties.EPISODE_INDICATOR_MATCH, seIndicator)
else: else:
self.__logger.debug(f"FileProperties.__init__(): Checking file name for indicator {self.__sourceFilename}") 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) seasonEpisodeValues = self.extractSeasonEpisodeValues(indicatorSource)
e_match = re.search(FileProperties.EPISODE_INDICATOR_MATCH, self.__sourceFilename) if seasonEpisodeValues is None:
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:
self.__season = -1 self.__season = -1
self.__episode = -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): def getFormatData(self):
@@ -98,22 +160,7 @@ class FileProperties():
} }
} }
""" """
return self._getFfprobeData()['format']
# 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']
def getStreamData(self): def getStreamData(self):
@@ -158,40 +205,32 @@ class FileProperties():
} }
} }
""" """
return self._getFfprobeData()['streams']
# 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']
def findCropArguments(self): def findCropArguments(self):
"""""" """"""
# ffmpeg -i <input.file> -vf cropdetect -f null - cacheKey = self._getCropdetectCacheKey()
ffprobeOutput, ffprobeError, returnCode = executeProcess(["ffmpeg", "-i", cachedCropArguments = FileProperties._cropdetect_cache.get(cacheKey)
if cachedCropArguments is not None:
self.__logger.debug(
"FileProperties.findCropArguments(): Reusing cached cropdetect result for %s",
self.__sourcePath, self.__sourcePath,
"-vf", "cropdetect", )
"-ss", "60", return dict(cachedCropArguments)
"-t", "180",
"-f", "null", "-"
])
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 = {} crops = {}
for el in errorLines: for el in errorLines:
@@ -204,20 +243,25 @@ class FileProperties():
crops[cropParam] = crops.get(cropParam, 0) + 1 crops[cropParam] = crops.get(cropParam, 0) + 1
if crops: if crops:
cropHistogram = sorted(crops, reverse=True) cropString = max(crops.items(), key=lambda item: (item[1], item[0]))[0]
cropString = cropHistogram[0]
cropTokens = cropString.split('=') cropTokens = cropString.split('=')
cropValueTokens = cropTokens[1] cropValueTokens = cropTokens[1]
cropValues = cropValueTokens.split(':') cropValues = cropValueTokens.split(':')
return { cropArguments = {
CropFilter.OUTPUT_WIDTH_KEY: cropValues[0], CropFilter.OUTPUT_WIDTH_KEY: cropValues[0],
CropFilter.OUTPUT_HEIGHT_KEY: cropValues[1], CropFilter.OUTPUT_HEIGHT_KEY: cropValues[1],
CropFilter.OFFSET_X_KEY: cropValues[2], CropFilter.OFFSET_X_KEY: cropValues[2],
CropFilter.OFFSET_Y_KEY: cropValues[3] 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 {} return {}

View File

@@ -1,8 +1,10 @@
import re, logging import re
from jinja2 import Environment, Undefined from jinja2 import Environment, Undefined
from .constants import DEFAULT_OUTPUT_FILENAME_TEMPLATE from .constants import DEFAULT_OUTPUT_FILENAME_TEMPLATE
from .configuration_controller import ConfigurationController from .configuration_controller import ConfigurationController
from .logging_utils import get_ffx_logger
from .show_descriptor import ShowDescriptor
class EmptyStringUndefined(Undefined): class EmptyStringUndefined(Undefined):
@@ -15,7 +17,21 @@ DIFF_REMOVED_KEY = 'removed'
DIFF_CHANGED_KEY = 'changed' DIFF_CHANGED_KEY = 'changed'
DIFF_UNCHANGED_KEY = 'unchanged' 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 = []): 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 """This filter replaces charactes from TMDB responses with characters
less problemating when using in filenames or removes them""" less problemating when using in filenames or removes them"""
fileName = str(fileName).replace('/', '-') return str(fileName).translate(FILENAME_FILTER_TRANSLATION).strip()
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()
def substituteTmdbFilename(fileName: str) -> str: def substituteTmdbFilename(fileName: str) -> str:
"""If chaining this method with filterFilename use this one first as the latter will destroy some patterns""" """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 normalizedFileName = str(fileName)
fileName = str(fileName).replace(' (*)', '')
fileName = str(fileName).replace('(*)', '')
# This indicates the index of multi-episode files for fillerMarker in TMDB_FILLER_MARKERS:
episodePartMatch = re.search("\\(([0-9]+)\\)$", fileName) 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: if episodePartMatch is not None:
partSuffix = str(episodePartMatch.group(0)) partIndex = episodePartMatch.group(1)
partIndex = episodePartMatch.groups()[0] return TMDB_EPISODE_PART_SUFFIX_REGEX.sub(
fileName = str(fileName).replace(partSuffix, f"Teil {partIndex}") f"Teil {partIndex}",
normalizedFileName,
count=1,
)
# Also multi-episodes with first and last episode index return normalizedFileName
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
def getEpisodeFileBasename(showName, def getEpisodeFileBasename(showName,
episodeName, episodeName,
season, season,
episode, episode,
indexSeasonDigits = 2, indexSeasonDigits = None,
indexEpisodeDigits = 2, indexEpisodeDigits = None,
indicatorSeasonDigits = 2, indicatorSeasonDigits = None,
indicatorEpisodeDigits = 2, indicatorEpisodeDigits = None,
context = None): context = None):
""" """
One Piece: One Piece:
@@ -188,12 +200,21 @@ def getEpisodeFileBasename(showName,
configData = cc.getData() if cc is not None else {} configData = cc.getData() if cc is not None else {}
outputFilenameTemplate = configData.get(ConfigurationController.OUTPUT_FILENAME_TEMPLATE_KEY, outputFilenameTemplate = configData.get(ConfigurationController.OUTPUT_FILENAME_TEMPLATE_KEY,
DEFAULT_OUTPUT_FILENAME_TEMPLATE) 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(): if context is not None and 'logger' in context.keys():
logger = context['logger'] logger = context['logger']
else: else:
logger = logging.getLogger('FFX') logger = get_ffx_logger()
logger.addHandler(logging.NullHandler())
indexSeparator = ' ' if indexSeasonDigits or indexEpisodeDigits else '' indexSeparator = ' ' if indexSeasonDigits or indexEpisodeDigits else ''
@@ -231,9 +252,8 @@ def formatRichColor(text: str, color: str = None):
return f"[{color}]{text}[/{color}]" return f"[{color}]{text}[/{color}]"
def removeRichColor(text: str): def removeRichColor(text: str):
richColorMatch = re.search(RICH_COLOR_PATTERN, text) richColorMatch = RICH_COLOR_REGEX.search(str(text))
if richColorMatch is None: if richColorMatch is None:
return text return text
else: else:
return str(richColorMatch.group(1)) return str(richColorMatch.group(1))

View File

@@ -1,68 +1,155 @@
from enum import Enum from enum import Enum
import difflib import difflib
class IsoLanguage(Enum): 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"]} 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"]} 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"]} 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"]} 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"]} 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"]} 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"]} 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"]} 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"]} 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"]} 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"]} 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"]} 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"]} FINNISH = {"name": "Finnish", "iso639_1": "fi", "iso639_2": ["fin"]}
FRENCH = {"name": "French", "iso639_1": "fr", "iso639_2": ["fra", "fre"]} 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"]} 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"]} 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"]} 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"]} 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"]} 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"]} 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"]} IRISH = {"name": "Irish", "iso639_1": "ga", "iso639_2": ["gle"]}
ITALIAN = {"name": "Italian", "iso639_1": "it", "iso639_2": ["ita"]} ITALIAN = {"name": "Italian", "iso639_1": "it", "iso639_2": ["ita"]}
JAPANESE = {"name": "Japanese", "iso639_1": "ja", "iso639_2": ["jpn"]} 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"]} 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"]} 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"]} 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"]} LATIN = {"name": "Latin", "iso639_1": "la", "iso639_2": ["lat"]}
LATVIAN = {"name": "Latvian", "iso639_1": "lv", "iso639_2": ["lav"]} 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"]} LITHUANIAN = {"name": "Lithuanian", "iso639_1": "lt", "iso639_2": ["lit"]}
MACEDONIAN = {"name": "Macedonian", "iso639_1": "mk", "iso639_2": ["mac"]} LUBA_KATANGA = {"name": "Luba-Katanga", "iso639_1": "lu", "iso639_2": ["lub"]}
MALAY = {"name": "Malay", "iso639_1": "ms", "iso639_2": ["may"]} 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"]} MALAYALAM = {"name": "Malayalam", "iso639_1": "ml", "iso639_2": ["mal"]}
MALTESE = {"name": "Maltese", "iso639_1": "mt", "iso639_2": ["mlt"]} 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"]} 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"]} POLISH = {"name": "Polish", "iso639_1": "pl", "iso639_2": ["pol"]}
PORTUGUESE = {"name": "Portuguese", "iso639_1": "pt", "iso639_2": ["por"]} 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"]} 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"]} SAMOAN = {"name": "Samoan", "iso639_1": "sm", "iso639_2": ["smo"]}
SANGO = {"name": "Sango", "iso639_1": "sg", "iso639_2": ["sag"]} SANGO = {"name": "Sango", "iso639_1": "sg", "iso639_2": ["sag"]}
SANSKRIT = {"name": "Sanskrit", "iso639_1": "sa", "iso639_2": ["san"]} SANSKRIT = {"name": "Sanskrit", "iso639_1": "sa", "iso639_2": ["san"]}
SARDINIAN = {"name": "Sardinian", "iso639_1": "sc", "iso639_2": ["srd"]} 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"]} SERBIAN = {"name": "Serbian", "iso639_1": "sr", "iso639_2": ["srp"]}
SHONA = {"name": "Shona", "iso639_1": "sn", "iso639_2": ["sna"]} 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"]} SINDHI = {"name": "Sindhi", "iso639_1": "sd", "iso639_2": ["snd"]}
SINHALA = {"name": "Sinhala", "iso639_1": "si", "iso639_2": ["sin"]} 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"]} SLOVENIAN = {"name": "Slovenian", "iso639_1": "sl", "iso639_2": ["slv"]}
SOMALI = {"name": "Somali", "iso639_1": "so", "iso639_2": ["som"]} 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"]} SOUTHERN_SOTHO = {"name": "Southern Sotho", "iso639_1": "st", "iso639_2": ["sot"]}
SPANISH = {"name": "Spanish", "iso639_1": "es", "iso639_2": ["spa"]} SPANISH = {"name": "Spanish", "iso639_1": "es", "iso639_2": ["spa"]}
SUNDANESE = {"name": "Sundanese", "iso639_1": "su", "iso639_2": ["sun"]} 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"]} SWATI = {"name": "Swati", "iso639_1": "ss", "iso639_2": ["ssw"]}
SWEDISH = {"name": "Swedish", "iso639_1": "sv", "iso639_2": ["swe"]} SWEDISH = {"name": "Swedish", "iso639_1": "sv", "iso639_2": ["swe"]}
TAGALOG = {"name": "Tagalog", "iso639_1": "tl", "iso639_2": ["tgl"]} 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"]} 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"]} TELUGU = {"name": "Telugu", "iso639_1": "te", "iso639_2": ["tel"]}
THAI = {"name": "Thai", "iso639_1": "th", "iso639_2": ["tha"]} 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"]} 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"]} UKRAINIAN = {"name": "Ukrainian", "iso639_1": "uk", "iso639_2": ["ukr"]}
URDU = {"name": "Urdu", "iso639_1": "ur", "iso639_2": ["urd"]} URDU = {"name": "Urdu", "iso639_1": "ur", "iso639_2": ["urd"]}
VIETNAMESE = {"name": "Vietnamese", "iso639_1": "vi", "iso639_2":[ "vie"]} UZBEK = {"name": "Uzbek", "iso639_1": "uz", "iso639_2": ["uzb"]}
WELSH = {"name": "Welsh", "iso639_1": "cy", "iso639_2": ["wel"]} 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"]} 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) closestMatches = difflib.get_close_matches(label, [l.value["name"] for l in IsoLanguage], n=1)
if closestMatches: 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 return foundLangs[0] if foundLangs else IsoLanguage.UNDEFINED
else: else:
return IsoLanguage.UNDEFINED return IsoLanguage.UNDEFINED
@staticmethod @staticmethod
def findThreeLetter(theeLetter : str): 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 return foundLangs[0] if foundLangs else IsoLanguage.UNDEFINED
def label(self): def label(self):
return str(self.value['name']) return str(self.value["name"])
def twoLetter(self): def twoLetter(self):
return str(self.value['iso639_1']) return str(self.value["iso639_1"])
def threeLetter(self): 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
View 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

View File

@@ -25,10 +25,9 @@ class MediaController():
pid = int(patternId) pid = int(patternId)
s = self.Session() s = self.Session()
q = s.query(Pattern).filter(Pattern.id == pid) pattern = s.query(Pattern).filter(Pattern.id == pid).first()
if q.count(): if pattern is not None:
pattern = q.first
for mediaTagKey, mediaTagValue in mediaDescriptor.getTags(): for mediaTagKey, mediaTagValue in mediaDescriptor.getTags():
self.__tac.updateMediaTag(pid, mediaTagKey, mediaTagValue) self.__tac.updateMediaTag(pid, mediaTagKey, mediaTagValue)

View File

@@ -1,4 +1,4 @@
import os, re, click, logging import os, re, click
from typing import List, Self from typing import List, Self
@@ -9,6 +9,7 @@ from ffx.track_disposition import TrackDisposition
from ffx.track_codec import TrackCodec from ffx.track_codec import TrackCodec
from ffx.track_descriptor import TrackDescriptor from ffx.track_descriptor import TrackDescriptor
from ffx.logging_utils import get_ffx_logger
class MediaDescriptor: class MediaDescriptor:
@@ -46,8 +47,7 @@ class MediaDescriptor:
self.__logger = self.__context['logger'] self.__logger = self.__context['logger']
else: else:
self.__context = {} self.__context = {}
self.__logger = logging.getLogger('FFX') self.__logger = get_ffx_logger()
self.__logger.addHandler(logging.NullHandler())
if MediaDescriptor.TAGS_KEY in kwargs.keys(): if MediaDescriptor.TAGS_KEY in kwargs.keys():
if type(kwargs[MediaDescriptor.TAGS_KEY]) is not dict: if type(kwargs[MediaDescriptor.TAGS_KEY]) is not dict:
@@ -207,7 +207,7 @@ class MediaDescriptor:
def rearrangeTrackDescriptors(self, newOrder: List[int]): def rearrangeTrackDescriptors(self, newOrder: List[int]):
if len(newOrder) != len(self.__trackDescriptors): if len(newOrder) != len(self.__trackDescriptors):
raise ValueError('Length of list with reordered indices does not match number of track descriptors') raise ValueError('Length of list with reordered indices does not match number of track descriptors')
reorderedTrackDescriptors = {} reorderedTrackDescriptors = []
for oldIndex in newOrder: for oldIndex in newOrder:
reorderedTrackDescriptors.append(self.__trackDescriptors[oldIndex]) reorderedTrackDescriptors.append(self.__trackDescriptors[oldIndex])
self.__trackDescriptors = reorderedTrackDescriptors self.__trackDescriptors = reorderedTrackDescriptors
@@ -362,6 +362,14 @@ class MediaDescriptor:
inputMappingTokens = [] inputMappingTokens = []
sortedTrackDescriptors = sorted(self.__trackDescriptors, key=lambda d: d.getIndex()) 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])) # 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 #HINT: Attached thumbnails are not supported by .webm container format
if td.getCodec() != TrackCodec.PNG: if td.getCodec() != TrackCodec.PNG:
stdi = sortedTrackDescriptors[td.getSourceIndex()].getIndex() sourceTrackDescriptor = sourceTrackDescriptorsByIndex.get(td.getSourceIndex())
stdsi = sortedTrackDescriptors[td.getSourceIndex()].getSubIndex() 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() trackType = td.getType()
trackCodec = td.getCodec() trackCodec = td.getCodec()
@@ -488,7 +500,14 @@ class MediaDescriptor:
return subtitleFileDescriptors 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}") # click.echo(f"Season: {season} Episode: {episode}")
self.__logger.debug(f"importSubtitles(): Season: {season} Episode: {episode}") self.__logger.debug(f"importSubtitles(): Season: {season} Episode: {episode}")
@@ -507,7 +526,10 @@ class MediaDescriptor:
d d
for d in availableFileSubtitleDescriptors for d in availableFileSubtitleDescriptors
if ((season == -1 and episode == -1) 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"], key=lambda d: d["index"],
) )
@@ -522,10 +544,14 @@ class MediaDescriptor:
if matchingSubtitleTrackDescriptor: if matchingSubtitleTrackDescriptor:
# click.echo(f"Found matching subtitle file {msfd["path"]}\n") # click.echo(f"Found matching subtitle file {msfd["path"]}\n")
self.__logger.debug(f"importSubtitles(): Found matching subtitle file {msfd['path']}") 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 # Prefer metadata coming from the external single-track source when
# matchingSubtitleTrackDescriptor[0].setDispositionSet(msfd["disposition_set"]) # 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 = ''): def getConfiguration(self, label: str = ''):

View File

@@ -1,5 +1,6 @@
import click import click
from ffx.iso_language import IsoLanguage
from ffx.media_descriptor import MediaDescriptor from ffx.media_descriptor import MediaDescriptor
from ffx.track_descriptor import TrackDescriptor from ffx.track_descriptor import TrackDescriptor
@@ -42,6 +43,14 @@ class MediaDescriptorChangeSet():
self.__targetTrackDescriptors = targetMediaDescriptor.getTrackDescriptors() if targetMediaDescriptor is not None else [] self.__targetTrackDescriptors = targetMediaDescriptor.getTrackDescriptors() if targetMediaDescriptor is not None else []
self.__sourceTrackDescriptors = sourceMediaDescriptor.getTrackDescriptors() if sourceMediaDescriptor 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 {} targetMediaTags = targetMediaDescriptor.getTags() if targetMediaDescriptor is not None else {}
sourceMediaTags = sourceMediaDescriptor.getTags() if sourceMediaDescriptor is not None else {} sourceMediaTags = sourceMediaDescriptor.getTags() if sourceMediaDescriptor is not None else {}
@@ -70,51 +79,34 @@ class MediaDescriptorChangeSet():
self.__numSourceTracks = len(self.__sourceTrackDescriptors) self.__numSourceTracks = len(self.__sourceTrackDescriptors)
maxNumOfTracks = max(self.__numSourceTracks, self.__numTargetTracks)
trackCompareResult = {} trackCompareResult = {}
for targetTrackDescriptor in self.__targetTrackDescriptors:
sourceTrackDescriptor = self.__sourceTrackDescriptorsByIndex.get(
targetTrackDescriptor.getSourceIndex()
)
for trackIndex in range(maxNumOfTracks): if sourceTrackDescriptor is None:
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 DIFF_ADDED_KEY not in trackCompareResult.keys(): if DIFF_ADDED_KEY not in trackCompareResult.keys():
trackCompareResult[DIFF_ADDED_KEY] = {} trackCompareResult[DIFF_ADDED_KEY] = {}
trackCompareResult[DIFF_ADDED_KEY][targetTrackDescriptor.getIndex()] = targetTrackDescriptor
trackCompareResult[DIFF_ADDED_KEY][trackIndex] = correspondingTargetTrackDescriptors[0]
continue continue
# Track present in target but not in source trackDiff = self.compareTracks(targetTrackDescriptor, sourceTrackDescriptor)
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])
if trackDiff: if trackDiff:
if DIFF_CHANGED_KEY not in trackCompareResult.keys(): if DIFF_CHANGED_KEY not in trackCompareResult.keys():
trackCompareResult[DIFF_CHANGED_KEY] = {} 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: if trackCompareResult:
@@ -126,7 +118,11 @@ class MediaDescriptorChangeSet():
sourceTrackDescriptor: TrackDescriptor = None): sourceTrackDescriptor: TrackDescriptor = None):
sourceTrackTags = sourceTrackDescriptor.getTags() if sourceTrackDescriptor is not None else {} 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 = {} trackCompareResult = {}
@@ -151,6 +147,25 @@ class MediaDescriptorChangeSet():
return trackCompareResult 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): def generateDispositionTokens(self):
""" """
@@ -252,7 +267,7 @@ class MediaDescriptorChangeSet():
addedTracks: dict = self.__changeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_ADDED_KEY] addedTracks: dict = self.__changeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_ADDED_KEY]
trackDescriptor: TrackDescriptor trackDescriptor: TrackDescriptor
for trackDescriptor in addedTracks.values(): 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: if not tagKey in self.__removeTrackKeys:
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}" metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
+ f":{trackDescriptor.getSubIndex()}", + f":{trackDescriptor.getSubIndex()}",
@@ -274,29 +289,58 @@ class MediaDescriptorChangeSet():
outputTrackTags = addedTrackTags | changedTrackTags 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()}" metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
+ f":{trackDescriptor.getSubIndex()}", + f":{trackDescriptor.getSubIndex()}",
f"{tagKey}={tagValue}"] 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(): for removeKey in removedTrackTags.keys():
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}" metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
+ f":{trackDescriptor.getSubIndex()}", + f":{trackDescriptor.getSubIndex()}",
f"{removeKey}="] f"{removeKey}="]
#HINT: In case of loading a track from an external file for tagKey, tagValue in self.__context.get('encoding_metadata_tags', {}).items():
# no tags from source are present for the track so metadataTokens += [f"-metadata:g", f"{tagKey}={tagValue}"]
# the unchanged tracks are passed to the output file as well
if trackDescriptor.getExternalSourceFilePath(): metadataTokens += self.generateConfiguredRemovalMetadataTokens()
for tagKey, tagValue in unchangedTrackTags.items():
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
+ f":{trackDescriptor.getSubIndex()}",
f"{tagKey}={tagValue}"]
return metadataTokens return metadataTokens
def getChangeSetObj(self): def getChangeSetObj(self):
return self.__changeSetObj 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

View File

@@ -6,13 +6,9 @@ from textual.containers import Grid
from ffx.audio_layout import AudioLayout 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 .show_details_screen import ShowDetailsScreen
from .pattern_details_screen import PatternDetailsScreen 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_type import TrackType
from ffx.track_codec import TrackCodec from ffx.track_codec import TrackCodec
@@ -135,29 +131,23 @@ class MediaDetailsScreen(Screen):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.context = self.app.getContext() bootstrap = build_screen_bootstrap(self.app.getContext())
self.Session = self.context['database']['session'] # convenience self.context = bootstrap.context
self.__removeGlobalKeys = bootstrap.remove_global_keys
self.__ignoreGlobalKeys = bootstrap.ignore_global_keys
self.__configurationData = self.context['config'].getData() controllers = build_screen_controllers(
self.context,
metadataConfiguration = self.__configurationData['metadata'] if 'metadata' in self.__configurationData.keys() else {} pattern=True,
show=True,
self.__signatureTags = metadataConfiguration['signature'] if 'signature' in metadataConfiguration.keys() else {} track=True,
self.__removeGlobalKeys = metadataConfiguration['remove'] if 'remove' in metadataConfiguration.keys() else [] tag=True,
self.__ignoreGlobalKeys = metadataConfiguration['ignore'] if 'ignore' in metadataConfiguration.keys() else [] )
self.__removeTrackKeys = (metadataConfiguration['streams']['remove'] self.__pc = controllers['pattern']
if 'streams' in metadataConfiguration.keys() self.__sc = controllers['show']
and 'remove' in metadataConfiguration['streams'].keys() else []) self.__tc = controllers['track']
self.__ignoreTrackKeys = (metadataConfiguration['streams']['ignore'] self.__tac = controllers['tag']
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)
if not 'command' in self.context.keys() or self.context['command'] != 'inspect': if not 'command' in self.context.keys() or self.context['command'] != 'inspect':
raise click.ClickException(f"MediaDetailsScreen.__init__(): Can only perform command 'inspect'") raise click.ClickException(f"MediaDetailsScreen.__init__(): Can only perform command 'inspect'")
@@ -569,6 +559,7 @@ class MediaDetailsScreen(Screen):
try: try:
kwargs = {} kwargs = {}
kwargs[ShowDescriptor.CONTEXT_KEY] = self.context
kwargs[ShowDescriptor.ID_KEY] = int(selected_row_data[0]) kwargs[ShowDescriptor.ID_KEY] = int(selected_row_data[0])
kwargs[ShowDescriptor.NAME_KEY] = str(selected_row_data[1]) kwargs[ShowDescriptor.NAME_KEY] = str(selected_row_data[1])
kwargs[ShowDescriptor.YEAR_KEY] = int(selected_row_data[2]) kwargs[ShowDescriptor.YEAR_KEY] = int(selected_row_data[2])
@@ -602,19 +593,20 @@ class MediaDetailsScreen(Screen):
patternObj = self.getPatternObjFromInput() patternObj = self.getPatternObjFromInput()
if patternObj: if patternObj:
patternId = self.__pc.addPattern(patternObj) mediaTags = {}
if patternId:
self.highlightPattern(False)
for tagKey, tagValue in self.__sourceMediaDescriptor.getTags().items(): for tagKey, tagValue in self.__sourceMediaDescriptor.getTags().items():
# Filter tags that make no sense to preserve # Filter tags that make no sense to preserve
if tagKey not in self.__ignoreGlobalKeys and not tagKey in self.__removeGlobalKeys: if tagKey not in self.__ignoreGlobalKeys and not tagKey in self.__removeGlobalKeys:
self.__tac.updateMediaTag(patternId, tagKey, tagValue) mediaTags[tagKey] = tagValue
# for trackDescriptor in self.__sourceMediaDescriptor.getAllTrackDescriptors(): patternId = self.__pc.savePatternSchema(
for trackDescriptor in self.__sourceMediaDescriptor.getTrackDescriptors(): patternObj,
self.__tc.addTrack(trackDescriptor, patternId = patternId) trackDescriptors=self.__sourceMediaDescriptor.getTrackDescriptors(),
mediaTags=mediaTags,
)
if patternId:
self.highlightPattern(False)
def action_new_pattern(self): def action_new_pattern(self):
@@ -754,4 +746,3 @@ class MediaDetailsScreen(Screen):
def handle_edit_pattern(self, screenResult): def handle_edit_pattern(self, screenResult):
self.query_one("#pattern_input", Input).value = screenResult['pattern'] self.query_one("#pattern_input", Input).value = screenResult['pattern']
self.updateDifferences() self.updateDifferences()

View File

@@ -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',
]

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
import os, sys, importlib, inspect, glob, re
from .conversion import Conversion
class Conversion_3_4(Conversion):
pass

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

View 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 ''")
)

View File

@@ -1,6 +1,6 @@
import click import click
from sqlalchemy import Column, Integer, String, Text, ForeignKey from sqlalchemy import Column, Integer, String, Text, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from .show import Base, Show from .show import Base, Show
@@ -12,6 +12,9 @@ from ffx.show_descriptor import ShowDescriptor
class Pattern(Base): class Pattern(Base):
__tablename__ = 'patterns' __tablename__ = 'patterns'
__table_args__ = (
UniqueConstraint('show_id', 'pattern', name='uq_patterns_show_id_pattern'),
)
# v1.x # v1.x
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
@@ -32,6 +35,7 @@ class Pattern(Base):
tracks = relationship('Track', back_populates='pattern', cascade="all, delete", lazy='joined') tracks = relationship('Track', back_populates='pattern', cascade="all, delete", lazy='joined')
media_tags = relationship('MediaTag', 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) quality = Column(Integer, default=0)

View File

@@ -1,6 +1,6 @@
import click import click
from sqlalchemy import Column, Integer, ForeignKey from sqlalchemy import CheckConstraint, Column, ForeignKey, Index, Integer
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from .show import Base, Show from .show import Base, Show
@@ -9,6 +9,14 @@ from .show import Base, Show
class ShiftedSeason(Base): class ShiftedSeason(Base):
__tablename__ = 'shifted_seasons' __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 # v1.x
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
@@ -19,9 +27,12 @@ class ShiftedSeason(Base):
# pattern: Mapped[str] = mapped_column(String, nullable=False) # pattern: Mapped[str] = mapped_column(String, nullable=False)
# v1.x # 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') 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 # v2.0
# show_id: Mapped[int] = mapped_column(ForeignKey("shows.id", ondelete="CASCADE")) # show_id: Mapped[int] = mapped_column(ForeignKey("shows.id", ondelete="CASCADE"))
# show: Mapped["Show"] = relationship(back_populates="patterns") # show: Mapped["Show"] = relationship(back_populates="patterns")
@@ -39,6 +50,12 @@ class ShiftedSeason(Base):
def getId(self): def getId(self):
return self.id return self.id
def getShowId(self):
return self.show_id
def getPatternId(self):
return self.pattern_id
def getOriginalSeason(self): def getOriginalSeason(self):
return self.original_season return self.original_season
@@ -61,6 +78,8 @@ class ShiftedSeason(Base):
shiftedSeasonObj = {} shiftedSeasonObj = {}
shiftedSeasonObj['show_id'] = self.getShowId()
shiftedSeasonObj['pattern_id'] = self.getPatternId()
shiftedSeasonObj['original_season'] = self.getOriginalSeason() shiftedSeasonObj['original_season'] = self.getOriginalSeason()
shiftedSeasonObj['first_episode'] = self.getFirstEpisode() shiftedSeasonObj['first_episode'] = self.getFirstEpisode()
shiftedSeasonObj['last_episode'] = self.getLastEpisode() shiftedSeasonObj['last_episode'] = self.getLastEpisode()
@@ -68,4 +87,3 @@ class ShiftedSeason(Base):
shiftedSeasonObj['episode_offset'] = self.getEpisodeOffset() shiftedSeasonObj['episode_offset'] = self.getEpisodeOffset()
return shiftedSeasonObj return shiftedSeasonObj

View File

@@ -1,5 +1,5 @@
# from typing import List # 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 sqlalchemy.orm import relationship, declarative_base, sessionmaker
from ffx.show_descriptor import ShowDescriptor from ffx.show_descriptor import ShowDescriptor
@@ -45,6 +45,8 @@ class Show(Base):
index_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS) index_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS)
indicator_season_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS) indicator_season_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS)
indicator_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_EPISODE_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): def getDescriptor(self, context):
@@ -58,5 +60,7 @@ class Show(Base):
kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY] = int(self.index_episode_digits) 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_SEASON_DIGITS_KEY] = int(self.indicator_season_digits)
kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.indicator_episode_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) return ShowDescriptor(**kwargs)

View File

@@ -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.pattern import Pattern
from ffx.model.track import Track
from ffx.model.track_tag import TrackTag
from ffx.track_descriptor import TrackDescriptor
from ffx.track_disposition import TrackDisposition
class PatternController(): class DuplicatePatternMatchError(click.ClickException):
pass
class InvalidPatternSchemaError(click.ClickException):
pass
class PatternController:
_compiled_regex_cache: dict[str, re.Pattern] = {}
def __init__(self, context): def __init__(self, context):
self.context = context self.context = context
self.Session = self.context['database']['session'] # convenience self.Session = self.context["database"]["session"]
self.__configurationData = self.context["config"].getData()
def addPattern(self, patternObj): metadataConfiguration = (
"""Adds pattern to database from obj self.__configurationData["metadata"]
if "metadata" in self.__configurationData.keys()
else {}
)
Returns database id or 0 if pattern already exists""" self.__removeTrackKeys = (
metadataConfiguration["streams"]["remove"]
if "streams" in metadataConfiguration.keys()
and "remove" in metadataConfiguration["streams"].keys()
else []
)
self.__ignoreTrackKeys = (
metadataConfiguration["streams"]["ignore"]
if "streams" in metadataConfiguration.keys()
and "ignore" in metadataConfiguration["streams"].keys()
else []
)
@classmethod
def _clear_regex_cache(cls):
cls._compiled_regex_cache.clear()
@classmethod
def _compile_pattern_expression(cls, pattern_id: int, expression: str) -> re.Pattern:
expression_text = str(expression)
compiled = cls._compiled_regex_cache.get(expression_text)
if compiled is None:
try:
compiled = re.compile(expression_text)
except re.error as ex:
raise click.ClickException(
f"Pattern #{pattern_id} contains an invalid regex {expression_text!r}: {ex}"
)
cls._compiled_regex_cache[expression_text] = compiled
return compiled
def _coerce_pattern_fields(self, patternObj):
return {
"show_id": int(patternObj["show_id"]),
"pattern": str(patternObj["pattern"]),
"quality": int(patternObj.get("quality", 0) or 0),
"notes": str(patternObj.get("notes", "")),
}
def _coerce_media_tags(self, mediaTags):
return {
str(tagKey): str(tagValue)
for tagKey, tagValue in (mediaTags or {}).items()
}
def _normalize_track_descriptors(self, trackDescriptors):
if trackDescriptors is None:
raise InvalidPatternSchemaError(
"Patterns must define at least one track before they can be stored."
)
normalized_descriptors = []
for trackDescriptor in trackDescriptors:
if type(trackDescriptor) is not TrackDescriptor:
raise TypeError(
"PatternController: All track descriptors are required to be of type TrackDescriptor"
)
normalized_descriptors.append(trackDescriptor)
if not normalized_descriptors:
raise InvalidPatternSchemaError(
"Patterns must define at least one track before they can be stored."
)
normalized_descriptors = sorted(
normalized_descriptors, key=lambda descriptor: descriptor.getIndex()
)
index_set = {descriptor.getIndex() for descriptor in normalized_descriptors}
expected_indexes = set(range(len(normalized_descriptors)))
if index_set != expected_indexes:
raise click.ClickException(
"Pattern tracks must use a contiguous zero-based index order."
)
return normalized_descriptors
def _ensure_unique_pattern_definition(
self,
session,
show_id: int,
pattern_expression: str,
exclude_pattern_id: int | None = None,
):
query = session.query(Pattern).filter(
Pattern.show_id == show_id,
Pattern.pattern == pattern_expression,
)
if exclude_pattern_id is not None:
query = query.filter(Pattern.id != int(exclude_pattern_id))
existing_pattern = query.first()
if existing_pattern is not None:
raise click.ClickException(
f"Pattern {pattern_expression!r} already exists for show #{show_id}."
)
def _build_track_row(self, trackDescriptor: TrackDescriptor) -> Track:
track = Track(
track_type=int(trackDescriptor.getType().index()),
codec_name=str(trackDescriptor.getCodec().identifier()),
index=int(trackDescriptor.getIndex()),
source_index=int(trackDescriptor.getSourceIndex()),
disposition_flags=int(
TrackDisposition.toFlags(trackDescriptor.getDispositionSet())
),
audio_layout=trackDescriptor.getAudioLayout().index(),
)
for tagKey, tagValue in trackDescriptor.getTags().items():
if tagKey in self.__ignoreTrackKeys or tagKey in self.__removeTrackKeys:
continue
track.track_tags.append(TrackTag(key=str(tagKey), value=str(tagValue)))
return track
def _replace_pattern_schema(
self,
session,
pattern: Pattern,
mediaTags: dict[str, str],
trackDescriptors: list[TrackDescriptor],
):
for mediaTag in list(pattern.media_tags):
session.delete(mediaTag)
for track in list(pattern.tracks):
session.delete(track)
session.flush()
for tagKey, tagValue in mediaTags.items():
pattern.media_tags.append(MediaTag(key=str(tagKey), value=str(tagValue)))
for trackDescriptor in trackDescriptors:
pattern.tracks.append(self._build_track_row(trackDescriptor))
def _validate_persisted_pattern(self, pattern: Pattern):
if not pattern.tracks:
raise InvalidPatternSchemaError(
f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no tracks."
)
def savePatternSchema(
self,
patternObj,
trackDescriptors,
mediaTags=None,
patternId: int | None = None,
) -> int:
fields = self._coerce_pattern_fields(patternObj)
normalized_tracks = self._normalize_track_descriptors(trackDescriptors)
normalized_tags = self._coerce_media_tags(mediaTags)
session = None
try: try:
session = self.Session()
self._ensure_unique_pattern_definition(
session,
fields["show_id"],
fields["pattern"],
exclude_pattern_id=patternId,
)
s = self.Session() if patternId is None:
q = s.query(Pattern).filter(Pattern.show_id == int(patternObj['show_id']), pattern = Pattern(
Pattern.pattern == str(patternObj['pattern'])) show_id=fields["show_id"],
pattern=fields["pattern"],
if not q.count(): quality=fields["quality"],
pattern = Pattern(show_id = int(patternObj['show_id']), notes=fields["notes"],
pattern = str(patternObj['pattern'])) )
s.add(pattern) session.add(pattern)
s.commit() session.flush()
return pattern.getId()
else: else:
return 0 pattern = session.query(Pattern).filter(Pattern.id == int(patternId)).first()
if pattern is None:
raise click.ClickException(
f"PatternController.savePatternSchema(): Pattern #{patternId} not found"
)
pattern.show_id = fields["show_id"]
pattern.pattern = fields["pattern"]
pattern.quality = fields["quality"]
pattern.notes = fields["notes"]
self._replace_pattern_schema(
session,
pattern,
normalized_tags,
normalized_tracks,
)
session.commit()
self._clear_regex_cache()
return pattern.getId()
except click.ClickException:
raise
except Exception as ex: except Exception as ex:
raise click.ClickException(f"PatternController.addPattern(): {repr(ex)}") raise click.ClickException(
f"PatternController.savePatternSchema(): {repr(ex)}"
)
finally: finally:
s.close() if session is not None:
session.close()
def addPattern(self, patternObj, trackDescriptors=None, mediaTags=None):
return self.savePatternSchema(
patternObj,
trackDescriptors=trackDescriptors,
mediaTags=mediaTags,
)
def updatePattern(self, patternId, patternObj): def updatePattern(self, patternId, patternObj):
fields = self._coerce_pattern_fields(patternObj)
session = None
try: try:
s = self.Session() session = self.Session()
q = s.query(Pattern).filter(Pattern.id == int(patternId)) 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']) session.commit()
pattern.pattern = str(patternObj['pattern']) self._clear_regex_cache()
pattern.quality = str(patternObj['quality'])
pattern.notes = str(patternObj['notes'])
s.commit()
return True return True
else:
return False return False
except click.ClickException:
raise
except Exception as ex: except Exception as ex:
raise click.ClickException(f"PatternController.updatePattern(): {repr(ex)}") raise click.ClickException(f"PatternController.updatePattern(): {repr(ex)}")
finally: finally:
s.close() if session is not None:
session.close()
def findPattern(self, patternObj): def findPattern(self, patternObj):
session = None
try: try:
s = self.Session() session = self.Session()
q = s.query(Pattern).filter(Pattern.show_id == int(patternObj['show_id']), Pattern.pattern == str(patternObj['pattern'])) pattern = (
session.query(Pattern)
.filter(
Pattern.show_id == int(patternObj["show_id"]),
Pattern.pattern == str(patternObj["pattern"]),
)
.first()
)
if q.count(): if pattern is not None:
pattern = q.first()
return int(pattern.id) return int(pattern.id)
else:
return None return None
except Exception as ex: except Exception as ex:
raise click.ClickException(f"PatternController.findPattern(): {repr(ex)}") raise click.ClickException(f"PatternController.findPattern(): {repr(ex)}")
finally: finally:
s.close() if session is not None:
session.close()
def 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: if type(patternId) is not int:
raise ValueError(f"PatternController.getPattern(): Argument patternId is required to be of type int") raise ValueError(
"PatternController.getPattern(): Argument patternId is required to be of type int"
)
session = None
try: try:
s = self.Session() session = self.Session()
q = s.query(Pattern).filter(Pattern.id == int(patternId)) return session.query(Pattern).filter(Pattern.id == int(patternId)).first()
return q.first() if q.count() else None
except Exception as ex: except Exception as ex:
raise click.ClickException(f"PatternController.getPattern(): {repr(ex)}") raise click.ClickException(f"PatternController.getPattern(): {repr(ex)}")
finally: finally:
s.close() if session is not None:
session.close()
def deletePattern(self, patternId): def deletePattern(self, patternId):
session = None
try: try:
s = self.Session() session = self.Session()
q = s.query(Pattern).filter(Pattern.id == int(patternId)) pattern = session.query(Pattern).filter(Pattern.id == int(patternId)).first()
if q.count(): if pattern is not None:
session.delete(pattern)
#DAFUQ: https://stackoverflow.com/a/19245058 session.commit()
# q.delete() self._clear_regex_cache()
pattern = q.first()
s.delete(pattern)
s.commit()
return True return True
return False return False
except Exception as ex: except Exception as ex:
raise click.ClickException(f"PatternController.deletePattern(): {repr(ex)}") raise click.ClickException(f"PatternController.deletePattern(): {repr(ex)}")
finally: finally:
s.close() if session is not None:
session.close()
def matchFilename(self, filename: str) -> dict:
def matchFilename(self, filename : str) -> dict: """Return {'match': regex match, 'pattern': Pattern} or {} when unmatched."""
"""Returns dict {'match': <a regex match obj>, 'pattern': <ffx pattern obj>} or empty dict of no pattern was found""" session = None
try: try:
s = self.Session() session = self.Session()
q = s.query(Pattern) matches = []
query = session.query(Pattern).order_by(Pattern.show_id, Pattern.id)
matchResult = {} for pattern in query.all():
compiled = self._compile_pattern_expression(
pattern.getId(),
pattern.getPattern(),
)
patternMatch = compiled.search(str(filename))
if patternMatch is None:
continue
for pattern in q.all(): self._validate_persisted_pattern(pattern)
patternMatch = re.search(str(pattern.pattern), str(filename)) matches.append({"match": patternMatch, "pattern": pattern})
if patternMatch is not None:
matchResult['match'] = patternMatch
matchResult['pattern'] = pattern
return matchResult if not matches:
return {}
if len(matches) > 1:
duplicateDescriptions = ", ".join(
[
f"show #{match['pattern'].getShowId()} pattern #{match['pattern'].getId()} {match['pattern'].getPattern()!r}"
for match in matches
]
)
raise DuplicatePatternMatchError(
f"Filename {filename!r} matched more than one pattern: {duplicateDescriptions}"
)
return matches[0]
except click.ClickException:
raise
except Exception as ex: except Exception as ex:
raise click.ClickException(f"PatternController.matchFilename(): {repr(ex)}") raise click.ClickException(f"PatternController.matchFilename(): {repr(ex)}")
finally: finally:
s.close() if session is not None:
session.close()
# def getMediaDescriptor(self, context, patternId):
#
# try:
# s = self.Session()
# q = s.query(Pattern).filter(Pattern.id == int(patternId))
#
# if q.count():
# return q.first().getMediaDescriptor(context)
# else:
# return None
#
# except Exception as ex:
# raise click.ClickException(f"PatternController.getMediaDescriptor(): {repr(ex)}")
# finally:
# s.close()

View File

@@ -6,18 +6,15 @@ from textual.widgets import Header, Footer, Static, Button, Input, DataTable, Te
from textual.containers import Grid from textual.containers import Grid
from ffx.model.pattern import Pattern from ffx.model.pattern import Pattern
from ffx.model.track import Track
from .pattern_controller import PatternController
from .show_controller import ShowController
from .track_controller import TrackController
from .tag_controller import TagController
from .track_details_screen import TrackDetailsScreen from .track_details_screen import TrackDetailsScreen
from .track_delete_screen import TrackDeleteScreen 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_details_screen import TagDetailsScreen
from .tag_delete_screen import TagDeleteScreen from .tag_delete_screen import TagDeleteScreen
from .screen_support import build_screen_bootstrap, build_screen_controllers
from ffx.track_type import TrackType 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.file_properties import FileProperties
from ffx.iso_language import IsoLanguage from ffx.iso_language import IsoLanguage
from ffx.audio_layout import AudioLayout from ffx.audio_layout import AudioLayout
from ffx.model.shifted_season import ShiftedSeason
from ffx.helper import formatRichColor, removeRichColor from ffx.helper import formatRichColor, removeRichColor
@@ -39,8 +37,8 @@ class PatternDetailsScreen(Screen):
CSS = """ CSS = """
Grid { Grid {
grid-size: 7 17; grid-size: 7 20;
grid-rows: 2 2 2 2 2 2 6 2 2 8 2 2 8 2 2 2 2; 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; grid-columns: 25 25 25 25 25 25 25;
height: 100%; height: 100%;
width: 100%; width: 100%;
@@ -108,66 +106,42 @@ class PatternDetailsScreen(Screen):
def __init__(self, patternId = None, showId = None): def __init__(self, patternId = None, showId = None):
super().__init__() super().__init__()
self.context = self.app.getContext() bootstrap = build_screen_bootstrap(self.app.getContext())
self.Session = self.context['database']['session'] # convenience 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 {} controllers = build_screen_controllers(
self.context,
self.__signatureTags = metadataConfiguration['signature'] if 'signature' in metadataConfiguration.keys() else {} pattern=True,
self.__removeGlobalKeys = metadataConfiguration['remove'] if 'remove' in metadataConfiguration.keys() else [] show=True,
self.__ignoreGlobalKeys = metadataConfiguration['ignore'] if 'ignore' in metadataConfiguration.keys() else [] track=True,
self.__removeTrackKeys = (metadataConfiguration['streams']['remove'] tag=True,
if 'streams' in metadataConfiguration.keys() shifted_season=True,
and 'remove' in metadataConfiguration['streams'].keys() else []) )
self.__ignoreTrackKeys = (metadataConfiguration['streams']['ignore'] self.__pc = controllers['pattern']
if 'streams' in metadataConfiguration.keys() self.__sc = controllers['show']
and 'ignore' in metadataConfiguration['streams'].keys() else []) self.__tc = controllers['track']
self.__tac = controllers['tag']
self.__pc = PatternController(context = self.context) self.__ssc = controllers['shifted_season']
self.__sc = ShowController(context = self.context)
self.__tc = TrackController(context = self.context)
self.__tac = TagController(context = self.context)
self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else None self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else None
self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None
self.__draftTracks : List[TrackDescriptor] = []
self.__draftTags : dict[str, str] = {}
#TODO: per controller
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()
def updateTracks(self): def updateTracks(self):
self.tracksTable.clear() self.tracksTable.clear()
if self.__pattern is not None: tracks = self.getCurrentTrackDescriptors()
tracks = self.__tc.findTracks(self.__pattern.getId())
typeCounter = {} typeCounter = {}
tr: Track td: TrackDescriptor
for tr in tracks: for td in tracks:
td : TrackDescriptor = tr.getDescriptor(self.context)
if (trackType := td.getType()) != TrackType.ATTACHMENT: if (trackType := td.getType()) != TrackType.ATTACHMENT:
@@ -196,11 +170,47 @@ class PatternDetailsScreen(Screen):
typeCounter[trackType] += 1 typeCounter[trackType] += 1
def getCurrentTrackDescriptors(self) -> List[TrackDescriptor]:
if self.__pattern is not None:
return self.__tc.findSiblingDescriptors(self.__pattern.getId())
return list(self.__draftTracks)
def normalizeDraftTracks(self):
typeCounter = {}
for index, trackDescriptor in enumerate(self.__draftTracks):
trackDescriptor.setIndex(index)
trackType = trackDescriptor.getType()
subIndex = typeCounter.get(trackType, 0)
trackDescriptor.setSubIndex(subIndex)
typeCounter[trackType] = subIndex + 1
if trackDescriptor.getSourceIndex() < 0:
trackDescriptor.setSourceIndex(index)
def swapTracks(self, trackIndex1: int, trackIndex2: int): def swapTracks(self, trackIndex1: int, trackIndex2: int):
ti1 = int(trackIndex1) ti1 = int(trackIndex1)
ti2 = int(trackIndex2) ti2 = int(trackIndex2)
if self.__pattern is None:
numSiblings = len(self.__draftTracks)
if ti1 < 0 or ti1 >= numSiblings:
raise ValueError(f"PatternDetailsScreen.swapTracks(): trackIndex1 ({ti1}) is out of range ({numSiblings})")
if ti2 < 0 or ti2 >= numSiblings:
raise ValueError(f"PatternDetailsScreen.swapTracks(): trackIndex2 ({ti2}) is out of range ({numSiblings})")
self.__draftTracks[ti1], self.__draftTracks[ti2] = self.__draftTracks[ti2], self.__draftTracks[ti1]
self.normalizeDraftTracks()
self.updateTracks()
return
siblingDescriptors: List[TrackDescriptor] = self.__tc.findSiblingDescriptors(self.__pattern.getId()) siblingDescriptors: List[TrackDescriptor] = self.__tc.findSiblingDescriptors(self.__pattern.getId())
numSiblings = len(siblingDescriptors) numSiblings = len(siblingDescriptors)
@@ -236,9 +246,11 @@ class PatternDetailsScreen(Screen):
self.tagsTable.clear() self.tagsTable.clear()
if self.__pattern is not None: tags = (
self.__tac.findAllMediaTags(self.__pattern.getId())
tags = self.__tac.findAllMediaTags(self.__pattern.getId()) if self.__pattern is not None
else self.__draftTags
)
for tagKey, tagValue in tags.items(): for tagKey, tagValue in tags.items():
@@ -248,10 +260,75 @@ class PatternDetailsScreen(Screen):
if tagKey in self.__removeGlobalKeys: if tagKey in self.__removeGlobalKeys:
textColor = 'red' textColor = 'red'
# if tagKey not in self.__ignoreTrackKeys:
row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor)) row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor))
self.tagsTable.add_row(*map(str, row)) self.tagsTable.add_row(*map(str, row))
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): def on_mount(self):
@@ -270,6 +347,7 @@ class PatternDetailsScreen(Screen):
self.updateTags() self.updateTags()
self.updateTracks() self.updateTracks()
self.updateShiftedSeasons()
def compose(self): def compose(self):
@@ -298,6 +376,16 @@ class PatternDetailsScreen(Screen):
self.tracksTable.cursor_type = 'row' 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() yield Header()
@@ -339,13 +427,11 @@ class PatternDetailsScreen(Screen):
yield Static(" ", classes="seven") yield Static(" ", classes="seven")
# 9 # 9
yield Static("Media Tags") yield Static("Shifted Seasons")
if self.__pattern is not None: if self.__pattern is not None:
yield Button("Add", id="button_add_tag") yield Button("Add", id="button_add_shifted_season")
yield Button("Edit", id="button_edit_tag") yield Button("Edit", id="button_edit_shifted_season")
yield Button("Delete", id="button_delete_tag") yield Button("Delete", id="button_delete_shifted_season")
else: else:
yield Static(" ") yield Static(" ")
yield Static(" ") yield Static(" ")
@@ -356,43 +442,52 @@ class PatternDetailsScreen(Screen):
yield Static(" ") yield Static(" ")
# 10 # 10
yield self.tagsTable yield self.shiftedSeasonsTable
# 11 # 11
yield Static(" ", classes="seven") yield Static(" ", classes="seven")
# 12 # 12
yield Static("Streams") yield Static("Media Tags")
yield Button("Add", id="button_add_tag")
yield Button("Edit", id="button_edit_tag")
if self.__pattern is not None: yield Button("Delete", id="button_delete_tag")
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(" ") yield Static(" ")
yield Button("Up", id="button_track_up") yield Static(" ")
yield Button("Down", id="button_track_down") yield Static(" ")
# 13 # 13
yield self.tracksTable yield self.tagsTable
# 14 # 14
yield Static(" ", classes="seven") yield Static(" ", classes="seven")
# 15 # 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 # 16
yield self.tracksTable
# 17
yield Static(" ", classes="seven")
# 18
yield Static(" ", classes="seven")
# 19
yield Button("Save", id="save_button") yield Button("Save", id="save_button")
yield Button("Cancel", id="cancel_button") yield Button("Cancel", id="cancel_button")
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 17 # 20
yield Static(" ", classes="seven") yield Static(" ", classes="seven")
yield Footer() yield Footer()
@@ -413,13 +508,8 @@ class PatternDetailsScreen(Screen):
def getSelectedTrackDescriptor(self): def getSelectedTrackDescriptor(self):
if not self.__pattern:
return None
try: try:
# Fetch the currently selected row when 'Enter' is pressed
#selected_row_index = self.table.cursor_row
row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate) row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
@@ -428,9 +518,11 @@ class PatternDetailsScreen(Screen):
trackIndex = int(selected_track_data[0]) trackIndex = int(selected_track_data[0])
trackSubIndex = int(selected_track_data[2]) trackSubIndex = int(selected_track_data[2])
return self.__tc.getTrack(self.__pattern.getId(), trackIndex).getDescriptor(self.context, subIndex=trackSubIndex) for trackDescriptor in self.getCurrentTrackDescriptors():
if (trackDescriptor.getIndex() == trackIndex
and trackDescriptor.getSubIndex() == trackSubIndex):
return trackDescriptor
else:
return None return None
except CellDoesNotExist: except CellDoesNotExist:
@@ -482,7 +574,11 @@ class PatternDetailsScreen(Screen):
self.app.pop_screen() self.app.pop_screen()
else: else:
patternId = self.__pc.addPattern(patternDescriptor) patternId = self.__pc.savePatternSchema(
patternDescriptor,
trackDescriptors=self.__draftTracks,
mediaTags=self.__draftTags,
)
if patternId: if patternId:
self.dismiss(patternDescriptor) self.dismiss(patternDescriptor)
else: else:
@@ -493,33 +589,81 @@ class PatternDetailsScreen(Screen):
if event.button.id == "cancel_button": if event.button.id == "cancel_button":
self.app.pop_screen() self.app.pop_screen()
if event.button.id == "button_add_shifted_season":
# Save pattern when just created before adding streams
if self.__pattern is not None: 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": if event.button.id == "button_add_track":
self.app.push_screen(TrackDetailsScreen(patternId = self.__pattern.getId(), index = numTracks), self.handle_add_track) self.app.push_screen(
TrackDetailsScreen(
patternId=self.__pattern.getId() if self.__pattern is not None else None,
patternLabel=self.getPatternFromInput(),
siblingTrackDescriptors=self.getCurrentTrackDescriptors(),
index=numTracks,
),
self.handle_add_track,
)
selectedTrack = self.getSelectedTrackDescriptor() selectedTrack = self.getSelectedTrackDescriptor()
if selectedTrack is not None: if selectedTrack is not None:
if event.button.id == "button_edit_track": if event.button.id == "button_edit_track":
self.app.push_screen(TrackDetailsScreen(trackDescriptor = selectedTrack), self.handle_edit_track) self.app.push_screen(
TrackDetailsScreen(
trackDescriptor=selectedTrack,
patternId=self.__pattern.getId() if self.__pattern is not None else None,
patternLabel=self.getPatternFromInput(),
siblingTrackDescriptors=self.getCurrentTrackDescriptors(),
),
self.handle_edit_track,
)
if event.button.id == "button_delete_track": if event.button.id == "button_delete_track":
self.app.push_screen(TrackDeleteScreen(trackDescriptor = selectedTrack), self.handle_delete_track) self.app.push_screen(
TrackDeleteScreen(trackDescriptor = selectedTrack),
self.handle_delete_track,
)
if event.button.id == "button_add_tag": if event.button.id == "button_add_tag":
if self.__pattern is not None:
self.app.push_screen(TagDetailsScreen(), self.handle_update_tag) self.app.push_screen(TagDetailsScreen(), self.handle_update_tag)
if event.button.id == "button_edit_tag": if event.button.id == "button_edit_tag":
tagKey, tagValue = self.getSelectedTag() selectedTag = self.getSelectedTag()
if selectedTag is not None:
tagKey, tagValue = selectedTag
self.app.push_screen(TagDetailsScreen(key=tagKey, value=tagValue), self.handle_update_tag) self.app.push_screen(TagDetailsScreen(key=tagKey, value=tagValue), self.handle_update_tag)
if event.button.id == "button_delete_tag": if event.button.id == "button_delete_tag":
tagKey, tagValue = self.getSelectedTag() selectedTag = self.getSelectedTag()
if selectedTag is not None:
tagKey, tagValue = selectedTag
self.app.push_screen(TagDeleteScreen(key=tagKey, value=tagValue), self.handle_delete_tag) self.app.push_screen(TagDeleteScreen(key=tagKey, value=tagValue), self.handle_delete_tag)
@@ -537,6 +681,7 @@ class PatternDetailsScreen(Screen):
if event.button.id == "button_track_up": if event.button.id == "button_track_up":
selectedTrackDescriptor = self.getSelectedTrackDescriptor() selectedTrackDescriptor = self.getSelectedTrackDescriptor()
if selectedTrackDescriptor is not None:
selectedTrackIndex = selectedTrackDescriptor.getIndex() selectedTrackIndex = selectedTrackDescriptor.getIndex()
if selectedTrackIndex > 0 and selectedTrackIndex < self.tracksTable.row_count: if selectedTrackIndex > 0 and selectedTrackIndex < self.tracksTable.row_count:
@@ -547,6 +692,7 @@ class PatternDetailsScreen(Screen):
if event.button.id == "button_track_down": if event.button.id == "button_track_down":
selectedTrackDescriptor = self.getSelectedTrackDescriptor() selectedTrackDescriptor = self.getSelectedTrackDescriptor()
if selectedTrackDescriptor is not None:
selectedTrackIndex = selectedTrackDescriptor.getIndex() selectedTrackIndex = selectedTrackDescriptor.getIndex()
if selectedTrackIndex >= 0 and selectedTrackIndex < (self.tracksTable.row_count - 1): if selectedTrackIndex >= 0 and selectedTrackIndex < (self.tracksTable.row_count - 1):
@@ -555,67 +701,94 @@ class PatternDetailsScreen(Screen):
def handle_add_track(self, trackDescriptor : TrackDescriptor): def handle_add_track(self, trackDescriptor : TrackDescriptor):
if trackDescriptor is None:
return
dispoSet = trackDescriptor.getDispositionSet() if self.__pattern is not None:
trackType = trackDescriptor.getType() self.__tc.addTrack(trackDescriptor, patternId=self.__pattern.getId())
index = trackDescriptor.getIndex() else:
subIndex = trackDescriptor.getSubIndex() self.__draftTracks.append(trackDescriptor)
codec = trackDescriptor.getCodec() self.normalizeDraftTracks()
language = trackDescriptor.getLanguage()
title = trackDescriptor.getTitle()
row = (index, self.updateTracks()
trackType.label(),
subIndex,
codec.label(),
language.label(),
title,
'Yes' if TrackDisposition.DEFAULT in dispoSet else 'No',
'Yes' if TrackDisposition.FORCED in dispoSet else 'No')
self.tracksTable.add_row(*map(str, row))
def handle_edit_track(self, trackDescriptor : TrackDescriptor): def handle_edit_track(self, trackDescriptor : TrackDescriptor):
if trackDescriptor is None:
return
try: if self.__pattern is not None:
if not self.__tc.updateTrack(trackDescriptor.getId(), trackDescriptor):
raise click.ClickException("PatternDetailsScreen.handle_edit_track(): track update failed")
else:
selectedTrack = self.getSelectedTrackDescriptor()
for index, currentTrack in enumerate(self.__draftTracks):
if (selectedTrack is not None
and currentTrack.getIndex() == selectedTrack.getIndex()
and currentTrack.getSubIndex() == selectedTrack.getSubIndex()):
self.__draftTracks[index] = trackDescriptor
break
self.normalizeDraftTracks()
row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate) self.updateTracks()
self.tracksTable.update_cell(row_key, self.column_key_track_audio_layout,
trackDescriptor.getAudioLayout().label()
if trackDescriptor.getType() == TrackType.AUDIO else ' ')
self.tracksTable.update_cell(row_key, self.column_key_track_language, trackDescriptor.getLanguage().label())
self.tracksTable.update_cell(row_key, self.column_key_track_title, trackDescriptor.getTitle())
self.tracksTable.update_cell(row_key, self.column_key_track_default,
'Yes' if TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet() else 'No')
self.tracksTable.update_cell(row_key, self.column_key_track_forced,
'Yes' if TrackDisposition.FORCED in trackDescriptor.getDispositionSet() else 'No')
except CellDoesNotExist:
pass
def handle_delete_track(self, trackDescriptor : TrackDescriptor): def handle_delete_track(self, trackDescriptor : TrackDescriptor):
if trackDescriptor is None:
return
if self.__pattern is not None:
track = self.__tc.getTrack(trackDescriptor.getPatternId(), trackDescriptor.getIndex())
if track is None:
raise click.ClickException(
f"Track is none: patternId={trackDescriptor.getPatternId()} type={trackDescriptor.getType()} subIndex={trackDescriptor.getSubIndex()}"
)
self.__tc.deleteTrack(track.getId())
else:
self.__draftTracks = [
currentTrack
for currentTrack in self.__draftTracks
if not (
currentTrack.getIndex() == trackDescriptor.getIndex()
and currentTrack.getSubIndex() == trackDescriptor.getSubIndex()
)
]
self.normalizeDraftTracks()
self.updateTracks() self.updateTracks()
def handle_update_tag(self, tag): def handle_update_tag(self, tag):
if tag is None:
return
if self.__pattern is None: if self.__pattern is None:
raise click.ClickException(f"PatternDetailsScreen.handle_update_tag: pattern not set") self.__draftTags[str(tag[0])] = str(tag[1])
else:
if self.__tac.updateMediaTag(self.__pattern.getId(), tag[0], tag[1]) is None:
raise click.ClickException("PatternDetailsScreen.handle_update_tag(): tag update failed")
if self.__tac.updateMediaTag(self.__pattern.getId(), tag[0], tag[1]) is not None:
self.updateTags() self.updateTags()
def handle_delete_tag(self, tag): def handle_delete_tag(self, tag):
if tag is None:
return
if self.__pattern is None: if self.__pattern is None:
raise click.ClickException(f"PatternDetailsScreen.handle_delete_tag: pattern not set") self.__draftTags.pop(str(tag[0]), None)
self.updateTags()
return
if self.__tac.deleteMediaTagByKey(self.__pattern.getId(), tag[0]): if self.__tac.deleteMediaTagByKey(self.__pattern.getId(), tag[0]):
self.updateTags() self.updateTags()
else: else:
raise click.ClickException('tag delete failed') raise click.ClickException('tag delete failed')
def handle_update_shifted_season(self, screenResult):
self.updateShiftedSeasons()
def handle_delete_shifted_season(self, screenResult):
self.updateShiftedSeasons()

View File

@@ -1,33 +1,169 @@
import subprocess, logging import os
from typing import List 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 niceness: -20 to 19, disabled when unset
cpu_percent: 1 bis 99 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: resourceLimits = (context or {}).get('resource_limits', {})
logger = logging.getLogger('FFX') niceness = normalizeNiceness(resourceLimits.get('niceness'))
logger.addHandler(logging.NullHandler()) cpu_percent = normalizeCpuPercent(
else: resourceLimits.get('cpu_limit', resourceLimits.get('cpu_percent'))
logger = context['logger'] )
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)) return wrappedCommandSequence
cpu_percent = int((context or {}).get('resource_limits', {}).get('cpu_percent', 0))
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) timeoutSeconds = float(timeoutSeconds)
output, error = process.communicate()
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
View 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

View File

@@ -6,228 +6,433 @@ from ffx.model.shifted_season import ShiftedSeason
class EpisodeOrderException(Exception): class EpisodeOrderException(Exception):
pass pass
class RangeOverlapException(Exception): class RangeOverlapException(Exception):
pass pass
class ShiftedSeasonController(): class ShiftedSeasonOwnerException(Exception):
pass
class ShiftedSeasonController:
def __init__(self, context): def __init__(self, context):
self.context = context self.context = context
self.Session = self.context['database']['session'] # convenience self.Session = self.context['database']['session'] # convenience
def checkShiftedSeason(self, showId: int, shiftedSeasonObj: dict, shiftedSeasonId: int = 0): def _resolve_owner(self, showId=None, patternId=None):
""" hasShow = showId is not None
Check if for a particula season 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: 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'] q = self._ordered_query(session, owner)
firstEpisode = int(shiftedSeasonObj['first_episode'])
lastEpisode = int(shiftedSeasonObj['last_episode'])
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
if shiftedSeasonId: if shiftedSeasonId:
q = q.filter(ShiftedSeason.id != int(shiftedSeasonId)) q = q.filter(ShiftedSeason.id != int(shiftedSeasonId))
siblingShiftedSeason: ShiftedSeason
for siblingShiftedSeason in q.all(): for siblingShiftedSeason in q.all():
if fields['original_season'] != siblingShiftedSeason.getOriginalSeason():
continue
siblingOriginalSeason = siblingShiftedSeason.getOriginalSeason if self._ranges_overlap(
siblingFirstEpisode = siblingShiftedSeason.getFirstEpisode() fields['first_episode'],
siblingLastEpisode = siblingShiftedSeason.getLastEpisode() fields['last_episode'],
siblingShiftedSeason.getFirstEpisode(),
if (originalSeason == siblingOriginalSeason siblingShiftedSeason.getLastEpisode(),
and lastEpisode >= siblingFirstEpisode ):
and siblingLastEpisode >= firstEpisode):
return False return False
return True return True
except (EpisodeOrderException, ShiftedSeasonOwnerException) as ex:
raise click.ClickException(str(ex))
except Exception as ex: except Exception as ex:
raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}") raise click.ClickException(
f"ShiftedSeasonController.checkShiftedSeason(): {repr(ex)}"
)
finally: 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): session = None
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")
try: try:
s = self.Session() owner = self._resolve_owner(showId=showId, patternId=patternId)
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
firstEpisode = int(shiftedSeasonObj['first_episode']) if not self.checkShiftedSeason(
lastEpisode = int(shiftedSeasonObj['last_episode']) 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: session = self.Session()
raise EpisodeOrderException() shiftedSeason = ShiftedSeason(
show_id=owner['show_id'],
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId)) pattern_id=owner['pattern_id'],
original_season=fields['original_season'],
shiftedSeason = ShiftedSeason(show_id = int(showId), first_episode=fields['first_episode'],
original_season = int(shiftedSeasonObj['original_season']), last_episode=fields['last_episode'],
first_episode = firstEpisode, season_offset=fields['season_offset'],
last_episode = lastEpisode, episode_offset=fields['episode_offset'],
season_offset = int(shiftedSeasonObj['season_offset']), )
episode_offset = int(shiftedSeasonObj['episode_offset'])) session.add(shiftedSeason)
s.add(shiftedSeason) session.commit()
s.commit()
return shiftedSeason.getId() return shiftedSeason.getId()
except (EpisodeOrderException, RangeOverlapException, ShiftedSeasonOwnerException) as ex:
raise click.ClickException(str(ex))
except Exception as ex: except Exception as ex:
raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}") raise click.ClickException(
f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}"
)
finally: finally:
s.close() if session is not None:
session.close()
def updateShiftedSeason(self, shiftedSeasonId: int, shiftedSeasonObj: dict): def updateShiftedSeason(self, shiftedSeasonId: int, shiftedSeasonObj: dict):
if type(shiftedSeasonId) is not int: if type(shiftedSeasonId) is not int:
raise ValueError(f"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonId is required to be of type int") raise ValueError(
"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")
session = None
try: 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(): if shiftedSeason is None:
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:
return False 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: except Exception as ex:
raise click.ClickException(f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}") raise click.ClickException(
f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}"
)
finally: finally:
s.close() if session is not None:
session.close()
def findShiftedSeason(
def findShiftedSeason(self, showId: int, originalSeason: int, firstEpisode: int, lastEpisode: int): self,
showId: int | None = None,
if type(showId) is not int: originalSeason: int | None = None,
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument shiftedSeasonId is required to be of type int") firstEpisode: int | None = None,
lastEpisode: int | None = None,
patternId: int | None = None,
):
if type(originalSeason) is not int: 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: 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: 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: try:
s = self.Session() owner = self._resolve_owner(showId=showId, patternId=patternId)
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId), session = self.Session()
shiftedSeason = (
self._apply_owner_filter(session.query(ShiftedSeason), owner)
.filter(
ShiftedSeason.original_season == int(originalSeason), ShiftedSeason.original_season == int(originalSeason),
ShiftedSeason.first_episode == int(firstEpisode), 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: except Exception as ex:
raise click.ClickException(f"PatternController.findShiftedSeason(): {repr(ex)}") raise click.ClickException(
f"ShiftedSeasonController.findShiftedSeason(): {repr(ex)}"
)
finally: finally:
s.close() if session is not None:
session.close()
def getShiftedSeasonSiblings(self, showId: int): def getShiftedSeasonSiblings(
self,
if type(showId) is not int: showId: int | None = None,
raise ValueError(f"ShiftedSeasonController.getShiftedSeasonSiblings(): Argument shiftedSeasonId is required to be of type int") patternId: int | None = None,
):
session = None
try: try:
s = self.Session() owner = self._resolve_owner(showId=showId, patternId=patternId)
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId)) session = self.Session()
return self._ordered_query(session, owner).all()
return q.all()
except ShiftedSeasonOwnerException as ex:
raise click.ClickException(str(ex))
except Exception as ex: except Exception as ex:
raise click.ClickException(f"PatternController.getShiftedSeasonSiblings(): {repr(ex)}") raise click.ClickException(
f"ShiftedSeasonController.getShiftedSeasonSiblings(): {repr(ex)}"
)
finally: finally:
s.close() if session is not None:
session.close()
def getShiftedSeason(self, shiftedSeasonId: int): def getShiftedSeason(self, shiftedSeasonId: int):
if type(shiftedSeasonId) is not 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: try:
s = self.Session() session = self.Session()
q = s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)) return (
session.query(ShiftedSeason)
return q.first() if q.count() else None .filter(ShiftedSeason.id == int(shiftedSeasonId))
.first()
)
except Exception as ex: except Exception as ex:
raise click.ClickException(f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}") raise click.ClickException(
f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}"
)
finally: finally:
s.close() if session is not None:
session.close()
def deleteShiftedSeason(self, shiftedSeasonId): def deleteShiftedSeason(self, shiftedSeasonId):
if type(shiftedSeasonId) is not int: 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: try:
s = self.Session() 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(): if shiftedSeason is not None:
session.delete(shiftedSeason)
#DAFUQ: https://stackoverflow.com/a/19245058 session.commit()
# q.delete()
shiftedSeason = q.first()
s.delete(shiftedSeason)
s.commit()
return True return True
return False return False
except Exception as ex: except Exception as ex:
raise click.ClickException(f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}") raise click.ClickException(
f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}"
)
finally: 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 session = None
for shiftedSeasonEntry in self.getShiftedSeasonSiblings(showId): try:
session = self.Session()
activeShift = None
if (season == shiftedSeasonEntry.getOriginalSeason() if patternId is not None:
and (shiftedSeasonEntry.getFirstEpisode() == -1 or episode >= shiftedSeasonEntry.getFirstEpisode()) activeShift = self._find_matching_rule(
and (shiftedSeasonEntry.getLastEpisode() == -1 or episode <= shiftedSeasonEntry.getLastEpisode())): session,
self._resolve_owner(patternId=patternId),
season=int(season),
episode=int(episode),
)
shiftedSeason = season + shiftedSeasonEntry.getSeasonOffset() if activeShift is None and showId is not None and showId != -1:
shiftedEpisode = episode + shiftedSeasonEntry.getEpisodeOffset() 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} " if activeShift is None:
+f"-> season: {shiftedSeason} episode: {shiftedEpisode}") 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 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()

View File

@@ -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__() super().__init__()
self.context = self.app.getContext() self.context = self.app.getContext()
@@ -52,6 +52,7 @@ class ShiftedSeasonDeleteScreen(Screen):
self.__ssc = ShiftedSeasonController(context = self.context) self.__ssc = ShiftedSeasonController(context = self.context)
self._showId = showId self._showId = showId
self._patternId = patternId
self.__shiftedSeasonId = shiftedSeasonId self.__shiftedSeasonId = shiftedSeasonId
@@ -59,7 +60,12 @@ class ShiftedSeasonDeleteScreen(Screen):
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId) 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_original_season", Static).update(str(shiftedSeason.getOriginalSeason()))
self.query_one("#static_first_episode", Static).update(str(shiftedSeason.getFirstEpisode())) self.query_one("#static_first_episode", Static).update(str(shiftedSeason.getFirstEpisode()))
self.query_one("#static_last_episode", Static).update(str(shiftedSeason.getLastEpisode())) self.query_one("#static_last_episode", Static).update(str(shiftedSeason.getLastEpisode()))
@@ -77,12 +83,12 @@ class ShiftedSeasonDeleteScreen(Screen):
yield Static(" ", classes="two") yield Static(" ", classes="two")
yield Static("from show") yield Static("from")
yield Static(" ", id="static_show_id") yield Static(" ", id="static_owner")
yield Static(" ", classes="two") yield Static(" ", classes="two")
yield Static("Original season") yield Static("Source season")
yield Static(" ", id="static_original_season") yield Static(" ", id="static_original_season")
yield Static("First episode") yield Static("First episode")
@@ -122,4 +128,3 @@ class ShiftedSeasonDeleteScreen(Screen):
if event.button.id == "cancel_button": if event.button.id == "cancel_button":
self.app.pop_screen() self.app.pop_screen()

View File

@@ -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__() super().__init__()
self.context = self.app.getContext() self.context = self.app.getContext()
@@ -90,8 +90,14 @@ class ShiftedSeasonDetailsScreen(Screen):
self.__ssc = ShiftedSeasonController(context = self.context) self.__ssc = ShiftedSeasonController(context = self.context)
self.__showId = showId self.__showId = showId
self.__patternId = patternId
self.__shiftedSeasonId = shiftedSeasonId 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): def on_mount(self):
if self.__shiftedSeasonId is not None: if self.__shiftedSeasonId is not None:
@@ -126,7 +132,7 @@ class ShiftedSeasonDetailsScreen(Screen):
yield Static(" ", classes="three") yield Static(" ", classes="three")
# 3 # 3
yield Static("Original season") yield Static("Source season")
yield Input(id="input_original_season", classes="two") yield Input(id="input_original_season", classes="two")
# 4 # 4
@@ -203,8 +209,11 @@ class ShiftedSeasonDetailsScreen(Screen):
if self.__shiftedSeasonId is not None: if self.__shiftedSeasonId is not None:
if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj, if self.__ssc.checkShiftedSeason(
shiftedSeasonId = self.__shiftedSeasonId): shiftedSeasonObj=shiftedSeasonObj,
shiftedSeasonId=self.__shiftedSeasonId,
**self._owner_kwargs(),
):
if self.__ssc.updateShiftedSeason(self.__shiftedSeasonId, shiftedSeasonObj): if self.__ssc.updateShiftedSeason(self.__shiftedSeasonId, shiftedSeasonObj):
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj)) self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
else: else:
@@ -212,8 +221,14 @@ class ShiftedSeasonDetailsScreen(Screen):
self.app.pop_screen() self.app.pop_screen()
else: else:
if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj): if self.__ssc.checkShiftedSeason(
self.__shiftedSeasonId = self.__ssc.addShiftedSeason(self.__showId, shiftedSeasonObj) shiftedSeasonObj=shiftedSeasonObj,
**self._owner_kwargs(),
):
self.__shiftedSeasonId = self.__ssc.addShiftedSeason(
shiftedSeasonObj=shiftedSeasonObj,
**self._owner_kwargs(),
)
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj)) self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))

View File

@@ -16,10 +16,9 @@ class ShowController():
try: try:
s = self.Session() s = self.Session()
q = s.query(Show).filter(Show.id == showId) show = s.query(Show).filter(Show.id == showId).first()
if q.count(): if show is not None:
show: Show = q.first()
return show.getDescriptor(self.context) return show.getDescriptor(self.context)
except Exception as ex: except Exception as ex:
@@ -31,9 +30,7 @@ class ShowController():
try: try:
s = self.Session() s = self.Session()
q = s.query(Show).filter(Show.id == showId) return s.query(Show).filter(Show.id == showId).first()
return q.first() if q.count() else None
except Exception as ex: except Exception as ex:
raise click.ClickException(f"ShowController.getShow(): {repr(ex)}") raise click.ClickException(f"ShowController.getShow(): {repr(ex)}")
@@ -44,12 +41,7 @@ class ShowController():
try: try:
s = self.Session() s = self.Session()
q = s.query(Show) return s.query(Show).all()
if q.count():
return q.all()
else:
return []
except Exception as ex: except Exception as ex:
raise click.ClickException(f"ShowController.getAllShows(): {repr(ex)}") raise click.ClickException(f"ShowController.getAllShows(): {repr(ex)}")
@@ -61,24 +53,23 @@ class ShowController():
try: try:
s = self.Session() 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()), show = Show(id = int(showDescriptor.getId()),
name = str(showDescriptor.getName()), name = str(showDescriptor.getName()),
year = int(showDescriptor.getYear()), year = int(showDescriptor.getYear()),
index_season_digits = showDescriptor.getIndexSeasonDigits(), index_season_digits = showDescriptor.getIndexSeasonDigits(),
index_episode_digits = showDescriptor.getIndexEpisodeDigits(), index_episode_digits = showDescriptor.getIndexEpisodeDigits(),
indicator_season_digits = showDescriptor.getIndicatorSeasonDigits(), 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.add(show)
s.commit() s.commit()
return True return True
else: else:
currentShow = q.first()
changed = False changed = False
if currentShow.name != str(showDescriptor.getName()): if currentShow.name != str(showDescriptor.getName()):
currentShow.name = str(showDescriptor.getName()) currentShow.name = str(showDescriptor.getName())
@@ -99,6 +90,12 @@ class ShowController():
if currentShow.indicator_episode_digits != int(showDescriptor.getIndicatorEpisodeDigits()): if currentShow.indicator_episode_digits != int(showDescriptor.getIndicatorEpisodeDigits()):
currentShow.indicator_episode_digits = int(showDescriptor.getIndicatorEpisodeDigits()) currentShow.indicator_episode_digits = int(showDescriptor.getIndicatorEpisodeDigits())
changed = True 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: if changed:
s.commit() s.commit()
@@ -113,14 +110,12 @@ class ShowController():
def deleteShow(self, show_id): def deleteShow(self, show_id):
try: try:
s = self.Session() 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 show is not None:
if q.count():
#DAFUQ: https://stackoverflow.com/a/19245058 #DAFUQ: https://stackoverflow.com/a/19245058
# q.delete() # q.delete()
show = q.first()
s.delete(show) s.delete(show)
s.commit() s.commit()

View File

@@ -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(): class ShowDescriptor():
@@ -14,11 +21,45 @@ class ShowDescriptor():
INDEX_EPISODE_DIGITS_KEY = 'index_episode_digits' INDEX_EPISODE_DIGITS_KEY = 'index_episode_digits'
INDICATOR_SEASON_DIGITS_KEY = 'indicator_season_digits' INDICATOR_SEASON_DIGITS_KEY = 'indicator_season_digits'
INDICATOR_EPISODE_DIGITS_KEY = 'indicator_episode_digits' INDICATOR_EPISODE_DIGITS_KEY = 'indicator_episode_digits'
QUALITY_KEY = 'quality'
NOTES_KEY = 'notes'
DEFAULT_INDEX_SEASON_DIGITS = 2 DEFAULT_INDEX_SEASON_DIGITS = DEFAULT_SHOW_INDEX_SEASON_DIGITS
DEFAULT_INDEX_EPISODE_DIGITS = 2 DEFAULT_INDEX_EPISODE_DIGITS = DEFAULT_SHOW_INDEX_EPISODE_DIGITS
DEFAULT_INDICATOR_SEASON_DIGITS = 2 DEFAULT_INDICATOR_SEASON_DIGITS = DEFAULT_SHOW_INDICATOR_SEASON_DIGITS
DEFAULT_INDICATOR_EPISODE_DIGITS = 2 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): def __init__(self, **kwargs):
@@ -32,8 +73,7 @@ class ShowDescriptor():
self.__logger = self.__context['logger'] self.__logger = self.__context['logger']
else: else:
self.__context = {} self.__context = {}
self.__logger = logging.getLogger('FFX') self.__logger = get_ffx_logger()
self.__logger.addHandler(logging.NullHandler())
if ShowDescriptor.ID_KEY in kwargs.keys(): if ShowDescriptor.ID_KEY in kwargs.keys():
if type(kwargs[ShowDescriptor.ID_KEY]) is not int: if type(kwargs[ShowDescriptor.ID_KEY]) is not int:
@@ -56,34 +96,49 @@ class ShowDescriptor():
else: else:
self.__showYear = -1 self.__showYear = -1
defaultDigitLengths = self.getDefaultDigitLengths(self.__context)
if ShowDescriptor.INDEX_SEASON_DIGITS_KEY in kwargs.keys(): if ShowDescriptor.INDEX_SEASON_DIGITS_KEY in kwargs.keys():
if type(kwargs[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]) is not int: 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") 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] self.__indexSeasonDigits = kwargs[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]
else: 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 ShowDescriptor.INDEX_EPISODE_DIGITS_KEY in kwargs.keys():
if type(kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY]) is not int: 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") 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] self.__indexEpisodeDigits = kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY]
else: 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 ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY in kwargs.keys():
if type(kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY]) is not int: 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") 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] self.__indicatorSeasonDigits = kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY]
else: 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 ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY in kwargs.keys():
if type(kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]) is not int: 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") 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] self.__indicatorEpisodeDigits = kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
else: 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): def getId(self):
@@ -101,6 +156,10 @@ class ShowDescriptor():
return self.__indicatorSeasonDigits return self.__indicatorSeasonDigits
def getIndicatorEpisodeDigits(self): def getIndicatorEpisodeDigits(self):
return self.__indicatorEpisodeDigits return self.__indicatorEpisodeDigits
def getQuality(self):
return self.__quality
def getNotes(self):
return self.__notes
def getFilenamePrefix(self): def getFilenamePrefix(self):
return f"{self.__showName} ({str(self.__showYear)})" return f"{self.__showName} ({str(self.__showYear)})"

View File

@@ -1,20 +1,13 @@
import click import click
from textual.screen import Screen 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.containers import Grid
from textual.widgets._data_table import CellDoesNotExist from textual.widgets._data_table import CellDoesNotExist
from ffx.model.pattern import Pattern
from .pattern_details_screen import PatternDetailsScreen from .pattern_details_screen import PatternDetailsScreen
from .pattern_delete_screen import PatternDeleteScreen 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 .show_descriptor import ShowDescriptor
from .shifted_season_details_screen import ShiftedSeasonDetailsScreen 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 ffx.model.shifted_season import ShiftedSeason
from .helper import filterFilename from .helper import filterFilename
from .screen_support import build_screen_bootstrap, build_screen_controllers
# Screen[dict[int, str, int]] # Screen[dict[int, str, int]]
@@ -31,8 +25,8 @@ class ShowDetailsScreen(Screen):
CSS = """ CSS = """
Grid { Grid {
grid-size: 5 16; grid-size: 5 18;
grid-rows: 2 2 2 2 2 2 2 2 2 2 2 9 2 9 2 2; 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; grid-columns: 30 30 30 30 30;
height: 100%; height: 100%;
width: 100%; width: 100%;
@@ -83,6 +77,10 @@ class ShowDetailsScreen(Screen):
height: 100%; height: 100%;
border: solid green; border: solid green;
} }
.note_box {
min-height: 6;
}
""" """
BINDINGS = [ BINDINGS = [
@@ -94,31 +92,24 @@ class ShowDetailsScreen(Screen):
def __init__(self, showId = None): def __init__(self, showId = None):
super().__init__() super().__init__()
self.context = self.app.getContext() bootstrap = build_screen_bootstrap(self.app.getContext())
self.Session = self.context['database']['session'] # convenience self.context = bootstrap.context
self.__sc = ShowController(context = self.context) controllers = build_screen_controllers(
self.__pc = PatternController(context = self.context) self.context,
self.__tc = TmdbController() pattern=True,
self.__ssc = ShiftedSeasonController(context = self.context) 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 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): 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("#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_season_digits_input", Input).value = str(self.__showDescriptor.getIndicatorSeasonDigits())
self.query_one("#indicator_episode_digits_input", Input).value = str(self.__showDescriptor.getIndicatorEpisodeDigits()) 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}") #raise click.ClickException(f"show_id {showId}")
patternList = self.loadPatterns(showId) for pattern in self.__pc.getPatternsForShow(showId):
# raise click.ClickException(f"patternList {patternList}") row = (pattern.getPattern(),)
for pattern in patternList:
row = (pattern['pattern'],)
self.patternTable.add_row(*map(str, row)) self.patternTable.add_row(*map(str, row))
self.updateShiftedSeasons() self.updateShiftedSeasons()
else: else:
defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(self.context)
self.query_one("#index_season_digits_input", Input).value = "2" self.query_one("#index_season_digits_input", Input).value = str(
self.query_one("#index_episode_digits_input", Input).value = "2" defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]
self.query_one("#indicator_season_digits_input", Input).value = "2" )
self.query_one("#indicator_episode_digits_input", Input).value = "2" 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): def getSelectedPatternDescriptor(self):
@@ -217,11 +219,17 @@ class ShowDetailsScreen(Screen):
if row_key is not None: if row_key is not None:
selected_row_data = self.shiftedSeasonsTable.get_row(row_key) 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['original_season'] = int(selected_row_data[0])
shiftedSeasonObj['first_episode'] = int(selected_row_data[1]) if selected_row_data[1].isnumeric() else -1 shiftedSeasonObj['first_episode'] = parse_int_or_default(selected_row_data[1], -1)
shiftedSeasonObj['last_episode'] = int(selected_row_data[2]) if selected_row_data[2].isnumeric() else -1 shiftedSeasonObj['last_episode'] = parse_int_or_default(selected_row_data[2], -1)
shiftedSeasonObj['season_offset'] = int(selected_row_data[3]) if selected_row_data[3].isnumeric() else 0 shiftedSeasonObj['season_offset'] = parse_int_or_default(selected_row_data[3], 0)
shiftedSeasonObj['episode_offset'] = int(selected_row_data[4]) if selected_row_data[4].isnumeric() else 0 shiftedSeasonObj['episode_offset'] = parse_int_or_default(selected_row_data[4], 0)
if self.__showDescriptor is not None: if self.__showDescriptor is not None:
@@ -314,7 +322,7 @@ class ShowDetailsScreen(Screen):
self.shiftedSeasonsTable = DataTable(classes="five") 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_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_last_episode = self.shiftedSeasonsTable.add_column("Last Episode", width=30)
self.column_key_season_offset = self.shiftedSeasonsTable.add_column("Season Offset", 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") yield Input(type="integer", id="year_input", classes="four")
#5 #5
yield Static(" ", classes="five") yield Static("Quality")
yield Input(type="integer", id="quality_input", classes="four")
#6 #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 Static("Index Season Digits")
yield Input(type="integer", id="index_season_digits_input", classes="four") yield Input(type="integer", id="index_season_digits_input", classes="four")
#7 #9
yield Static("Index Episode Digits") yield Static("Index Episode Digits")
yield Input(type="integer", id="index_episode_digits_input", classes="four") yield Input(type="integer", id="index_episode_digits_input", classes="four")
#8 #10
yield Static("Indicator Season Digits") yield Static("Indicator Season Digits")
yield Input(type="integer", id="indicator_season_digits_input", classes="four") yield Input(type="integer", id="indicator_season_digits_input", classes="four")
#9 #11
yield Static("Indicator Edisode Digits") yield Static("Indicator Edisode Digits")
yield Input(type="integer", id="indicator_episode_digits_input", classes="four") yield Input(type="integer", id="indicator_episode_digits_input", classes="four")
# 10 # 12
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 11 # 13
yield Static("Shifted seasons", classes="two") yield Static("Shifted seasons", classes="two")
if self.__showDescriptor is not None: if self.__showDescriptor is not None:
@@ -381,18 +397,18 @@ class ShowDetailsScreen(Screen):
yield Static(" ") yield Static(" ")
yield Static(" ") yield Static(" ")
# 12 # 14
yield self.shiftedSeasonsTable yield self.shiftedSeasonsTable
# 13 # 15
yield Static("File patterns", classes="five") yield Static("File patterns", classes="five")
# 14 # 16
yield self.patternTable yield self.patternTable
# 15 # 17
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 16 # 18
yield Button("Save", id="save_button") yield Button("Save", id="save_button")
yield Button("Cancel", id="cancel_button") yield Button("Cancel", id="cancel_button")
@@ -402,7 +418,7 @@ class ShowDetailsScreen(Screen):
def getShowDescriptorFromInput(self) -> ShowDescriptor: def getShowDescriptorFromInput(self) -> ShowDescriptor:
kwargs = {} kwargs = {ShowDescriptor.CONTEXT_KEY: self.context}
try: try:
if self.__showDescriptor: 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) kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.query_one("#indicator_episode_digits_input", Input).value)
except ValueError: except ValueError:
pass 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) return ShowDescriptor(**kwargs)

View File

@@ -67,10 +67,11 @@ class TagController():
try: try:
s = self.Session() s = self.Session()
q = s.query(MediaTag).filter(MediaTag.pattern_id == int(patternId), tag = s.query(MediaTag).filter(
MediaTag.key == str(tagKey)) MediaTag.pattern_id == int(patternId),
if q.count(): MediaTag.key == str(tagKey),
tag = q.first() ).first()
if tag is not None:
s.delete(tag) s.delete(tag)
s.commit() s.commit()
return True return True
@@ -107,12 +108,8 @@ class TagController():
try: try:
s = self.Session() s = self.Session()
q = s.query(MediaTag).filter(MediaTag.pattern_id == int(patternId)) tags = s.query(MediaTag).filter(MediaTag.pattern_id == int(patternId)).all()
return {t.key:t.value for t in tags}
if q.count():
return {t.key:t.value for t in q.all()}
else:
return {}
except Exception as ex: except Exception as ex:
raise click.ClickException(f"TagController.findAllMediaTags(): {repr(ex)}") raise click.ClickException(f"TagController.findAllMediaTags(): {repr(ex)}")
@@ -125,12 +122,8 @@ class TagController():
try: try:
s = self.Session() s = self.Session()
q = s.query(TrackTag).filter(TrackTag.track_id == int(trackId)) tags = s.query(TrackTag).filter(TrackTag.track_id == int(trackId)).all()
return {t.key:t.value for t in tags}
if q.count():
return {t.key:t.value for t in q.all()}
else:
return {}
except Exception as ex: except Exception as ex:
raise click.ClickException(f"TagController.findAllTracks(): {repr(ex)}") raise click.ClickException(f"TagController.findAllTracks(): {repr(ex)}")
@@ -142,12 +135,7 @@ class TagController():
try: try:
s = self.Session() s = self.Session()
q = s.query(Track).filter(MediaTag.track_id == int(trackId), MediaTag.key == str(trackKey)) return s.query(Track).filter(MediaTag.track_id == int(trackId), MediaTag.key == str(trackKey)).first()
if q.count():
return q.first()
else:
return None
except Exception as ex: except Exception as ex:
raise click.ClickException(f"TagController.findMediaTag(): {repr(ex)}") raise click.ClickException(f"TagController.findMediaTag(): {repr(ex)}")
@@ -158,12 +146,10 @@ class TagController():
try: try:
s = self.Session() s = self.Session()
q = s.query(TrackTag).filter(TrackTag.track_id == int(trackId), TrackTag.key == str(tagKey)) return s.query(TrackTag).filter(
TrackTag.track_id == int(trackId),
if q.count(): TrackTag.key == str(tagKey),
return q.first() ).first()
else:
return None
except Exception as ex: except Exception as ex:
raise click.ClickException(f"TagController.findTrackTag(): {repr(ex)}") raise click.ClickException(f"TagController.findTrackTag(): {repr(ex)}")
@@ -175,11 +161,9 @@ class TagController():
def deleteMediaTag(self, tagId) -> bool: def deleteMediaTag(self, tagId) -> bool:
try: try:
s = self.Session() 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(): if tag is not None:
tag = q.first()
s.delete(tag) s.delete(tag)
@@ -201,11 +185,9 @@ class TagController():
try: try:
s = self.Session() 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(): if tag is not None:
tag = q.first()
s.delete(tag) s.delete(tag)

View File

@@ -1,6 +1,8 @@
import os, requests, time, logging import os, requests, time
from datetime import datetime from datetime import datetime
from .logging_utils import get_ffx_logger
class TMDB_REQUEST_EXCEPTION(Exception): class TMDB_REQUEST_EXCEPTION(Exception):
def __init__(self, statusCode, statusMessage): def __init__(self, statusCode, statusMessage):
@@ -27,8 +29,7 @@ class TmdbController():
self.__context = context self.__context = context
if context is None: if context is None:
self.__logger = logging.getLogger('FFX') self.__logger = get_ffx_logger()
self.__logger.addHandler(logging.NullHandler())
else: else:
self.__logger = context['logger'] self.__logger = context['logger']

View File

@@ -75,11 +75,9 @@ class TrackController():
try: try:
s = self.Session() 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(): if track is not None:
track : Track = q.first()
track.index = int(trackDescriptor.getIndex()) track.index = int(trackDescriptor.getIndex())
@@ -193,12 +191,10 @@ class TrackController():
try: try:
s = self.Session() s = self.Session()
q = s.query(Track).filter(Track.pattern_id == int(patternId), Track.index == int(index)) return s.query(Track).filter(
Track.pattern_id == int(patternId),
if q.count(): Track.index == int(index),
return q.first() ).first()
else:
return None
except Exception as ex: except Exception as ex:
raise click.ClickException(f"TrackController.getTrack(): {repr(ex)}") raise click.ClickException(f"TrackController.getTrack(): {repr(ex)}")
@@ -218,11 +214,9 @@ class TrackController():
try: try:
s = self.Session() 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(): if track is not None:
track : Track = q.first()
if state: if state:
track.setDisposition(disposition) track.setDisposition(disposition)
@@ -244,15 +238,21 @@ class TrackController():
try: try:
s = self.Session() 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(): if track is not None:
patternId = int(q.first().pattern_id) patternId = int(track.pattern_id)
q_siblings = s.query(Track).filter(Track.pattern_id == patternId).order_by(Track.index) q_siblings = s.query(Track).filter(Track.pattern_id == patternId).order_by(Track.index)
siblingTracks = q_siblings.all()
if len(siblingTracks) <= 1:
raise click.ClickException(
f"Cannot delete the last track from pattern #{patternId}. Patterns must define at least one track."
)
index = 0 index = 0
for track in q_siblings.all(): for track in siblingTracks:
if track.id == int(trackId): if track.id == int(trackId):
s.delete(track) s.delete(track)

View File

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

View File

@@ -1,4 +1,3 @@
import logging
from typing import Self from typing import Self
from .iso_language import IsoLanguage from .iso_language import IsoLanguage
@@ -6,6 +5,7 @@ from .track_type import TrackType
from .audio_layout import AudioLayout from .audio_layout import AudioLayout
from .track_disposition import TrackDisposition from .track_disposition import TrackDisposition
from .track_codec import TrackCodec from .track_codec import TrackCodec
from .logging_utils import get_ffx_logger
# from .helper import dictDiff, setDiff # from .helper import dictDiff, setDiff
@@ -46,8 +46,7 @@ class TrackDescriptor:
self.__logger = self.__context['logger'] self.__logger = self.__context['logger']
else: else:
self.__context = {} self.__context = {}
self.__logger = logging.getLogger('FFX') self.__logger = get_ffx_logger()
self.__logger.addHandler(logging.NullHandler())
if TrackDescriptor.ID_KEY in kwargs.keys(): if TrackDescriptor.ID_KEY in kwargs.keys():
if type(kwargs[TrackDescriptor.ID_KEY]) is not int: if type(kwargs[TrackDescriptor.ID_KEY]) is not int:

View File

@@ -3,31 +3,20 @@ import click
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, SelectionList, Select, DataTable, Input from textual.widgets import Header, Footer, Static, Button, SelectionList, Select, DataTable, Input
from textual.containers import Grid from textual.containers import Grid
from ffx.model.pattern import Pattern
from .track_controller import TrackController
from .pattern_controller import PatternController
from .tag_controller import TagController
from .track_type import TrackType
from .track_codec import TrackCodec
from .iso_language import IsoLanguage
from .track_disposition import TrackDisposition
from .audio_layout import AudioLayout
from .track_descriptor import TrackDescriptor
from .tag_details_screen import TagDetailsScreen
from .tag_delete_screen import TagDeleteScreen
from textual.widgets._data_table import CellDoesNotExist from textual.widgets._data_table import CellDoesNotExist
from .audio_layout import AudioLayout
from .iso_language import IsoLanguage
from .tag_delete_screen import TagDeleteScreen
from .tag_details_screen import TagDetailsScreen
from .track_codec import TrackCodec
from .track_descriptor import TrackDescriptor
from .track_disposition import TrackDisposition
from .track_type import TrackType
from ffx.helper import formatRichColor, removeRichColor from ffx.helper import formatRichColor, removeRichColor
# Screen[dict[int, str, int]]
class TrackDetailsScreen(Screen): class TrackDetailsScreen(Screen):
CSS = """ CSS = """
@@ -97,257 +86,288 @@ class TrackDetailsScreen(Screen):
} }
""" """
def __init__(self, trackDescriptor : TrackDescriptor = None, patternId = None, trackType : TrackType = None, index = None, subIndex = None): def __init__(
self,
trackDescriptor: TrackDescriptor = None,
patternId=None,
patternLabel: str = "",
siblingTrackDescriptors=None,
trackType: TrackType = None,
index=None,
subIndex=None,
):
super().__init__() super().__init__()
self.context = self.app.getContext() self.context = self.app.getContext()
self.Session = self.context['database']['session'] # convenience
self.__configurationData = self.context['config'].getData() self.__configurationData = self.context["config"].getData()
metadataConfiguration = self.__configurationData['metadata'] if 'metadata' in self.__configurationData.keys() else {} metadataConfiguration = (
self.__configurationData["metadata"]
if "metadata" in self.__configurationData.keys()
else {}
)
self.__signatureTags = metadataConfiguration['signature'] if 'signature' in metadataConfiguration.keys() else {} self.__removeTrackKeys = (
self.__removeGlobalKeys = metadataConfiguration['remove'] if 'remove' in metadataConfiguration.keys() else [] metadataConfiguration["streams"]["remove"]
self.__ignoreGlobalKeys = metadataConfiguration['ignore'] if 'ignore' in metadataConfiguration.keys() else [] if "streams" in metadataConfiguration.keys()
self.__removeTrackKeys = (metadataConfiguration['streams']['remove'] and "remove" in metadataConfiguration["streams"].keys()
if 'streams' in metadataConfiguration.keys() else []
and 'remove' in metadataConfiguration['streams'].keys() else []) )
self.__ignoreTrackKeys = (metadataConfiguration['streams']['ignore'] self.__ignoreTrackKeys = (
if 'streams' in metadataConfiguration.keys() metadataConfiguration["streams"]["ignore"]
and 'ignore' in metadataConfiguration['streams'].keys() else []) if "streams" in metadataConfiguration.keys()
and "ignore" in metadataConfiguration["streams"].keys()
else []
self.__tc = TrackController(context = self.context) )
self.__pc = PatternController(context = self.context)
self.__tac = TagController(context = self.context)
self.__isNew = trackDescriptor is None self.__isNew = trackDescriptor is None
self.__trackDescriptor = trackDescriptor
self.__patternId = (
int(patternId)
if patternId is not None
else (
int(trackDescriptor.getPatternId())
if trackDescriptor is not None and trackDescriptor.getPatternId() != -1
else -1
)
)
self.__patternLabel = str(patternLabel)
self.__siblingTrackDescriptors = list(siblingTrackDescriptors or [])
if self.__isNew: if self.__isNew:
self.__trackType = trackType self.__trackType = trackType
self.__trackCodec = TrackCodec.UNKNOWN self.__trackCodec = TrackCodec.UNKNOWN
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED
self.__index = index self.__index = index
self.__subIndex = subIndex self.__subIndex = subIndex
self.__trackDescriptor : TrackDescriptor = None self.__draftTrackTags = {}
self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else {}
else: else:
self.__trackType = trackDescriptor.getType() self.__trackType = trackDescriptor.getType()
self.__trackCodec = trackDescriptor.getCodec() self.__trackCodec = trackDescriptor.getCodec()
self.__audioLayout = trackDescriptor.getAudioLayout() self.__audioLayout = trackDescriptor.getAudioLayout()
self.__index = trackDescriptor.getIndex() self.__index = trackDescriptor.getIndex()
self.__subIndex = trackDescriptor.getSubIndex() self.__subIndex = trackDescriptor.getSubIndex()
self.__trackDescriptor : TrackDescriptor = trackDescriptor self.__draftTrackTags = {
self.__pattern : Pattern = self.__pc.getPattern(self.__trackDescriptor.getPatternId()) key: value
for key, value in trackDescriptor.getTags().items()
if key not in ("language", "title")
}
def _descriptor_refs_same_track(self, descriptor: TrackDescriptor) -> bool:
if self.__trackDescriptor is None:
return False
if descriptor.getId() != -1 and self.__trackDescriptor.getId() != -1:
return descriptor.getId() == self.__trackDescriptor.getId()
return (
descriptor.getPatternId() == self.__trackDescriptor.getPatternId()
and descriptor.getIndex() == self.__trackDescriptor.getIndex()
and descriptor.getSubIndex() == self.__trackDescriptor.getSubIndex()
)
def updateTags(self): def updateTags(self):
self.trackTagsTable.clear() self.trackTagsTable.clear()
trackId = self.__trackDescriptor.getId() for key, value in self.__draftTrackTags.items():
if trackId != -1:
trackTags = self.__tac.findAllTrackTags(trackId)
for k,v in trackTags.items():
if k != 'language' and k != 'title':
textColor = None textColor = None
if k in self.__ignoreTrackKeys: if key in self.__ignoreTrackKeys:
textColor = 'blue' textColor = "blue"
if k in self.__removeTrackKeys: if key in self.__removeTrackKeys:
textColor = 'red' textColor = "red"
row = (formatRichColor(k, textColor), formatRichColor(v, textColor)) row = (formatRichColor(key, textColor), formatRichColor(value, textColor))
self.trackTagsTable.add_row(*map(str, row)) self.trackTagsTable.add_row(*map(str, row))
def on_mount(self): def on_mount(self):
self.query_one("#index_label", Static).update(str(self.__index) if self.__index is not None else '-') self.query_one("#index_label", Static).update(
self.query_one("#subindex_label", Static).update(str(self.__subIndex)if self.__subIndex is not None else '-') str(self.__index) if self.__index is not None else "-"
)
if self.__pattern is not None: self.query_one("#subindex_label", Static).update(
self.query_one("#pattern_label", Static).update(self.__pattern.getPattern()) str(self.__subIndex) if self.__subIndex is not None else "-"
)
self.query_one("#pattern_label", Static).update(self.__patternLabel)
if self.__trackType is not None: if self.__trackType is not None:
self.query_one("#type_select", Select).value = self.__trackType.label() self.query_one("#type_select", Select).value = self.__trackType.label()
if self.__trackType == TrackType.AUDIO:
self.query_one("#audio_layout_select", Select).value = self.__audioLayout.label() self.query_one("#audio_layout_select", Select).value = self.__audioLayout.label()
for d in TrackDisposition: for disposition in TrackDisposition:
dispositionIsSet = (self.__trackDescriptor is not None dispositionIsSet = (
and d in self.__trackDescriptor.getDispositionSet()) self.__trackDescriptor is not None
and disposition in self.__trackDescriptor.getDispositionSet()
)
dispositionOption = (d.label(), d.index(), dispositionIsSet) dispositionOption = (
self.query_one("#dispositions_selection_list", SelectionList).add_option(dispositionOption) disposition.label(),
disposition.index(),
dispositionIsSet,
)
self.query_one("#dispositions_selection_list", SelectionList).add_option(
dispositionOption
)
if self.__trackDescriptor is not None: if self.__trackDescriptor is not None:
self.query_one("#language_select", Select).value = (
self.query_one("#language_select", Select).value = self.__trackDescriptor.getLanguage().label() self.__trackDescriptor.getLanguage().label()
)
self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle() self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle()
self.updateTags() self.updateTags()
def compose(self): def compose(self):
self.trackTagsTable = DataTable(classes="five") self.trackTagsTable = DataTable(classes="five")
# Define the columns with headers
self.column_key_track_tag_key = self.trackTagsTable.add_column("Key", width=50) self.column_key_track_tag_key = self.trackTagsTable.add_column("Key", width=50)
self.column_key_track_tag_value = self.trackTagsTable.add_column("Value", width=100) self.column_key_track_tag_value = self.trackTagsTable.add_column("Value", width=100)
self.trackTagsTable.cursor_type = 'row' self.trackTagsTable.cursor_type = "row"
languages = [language.label() for language in IsoLanguage]
languages = [l.label() for l in IsoLanguage]
yield Header() yield Header()
with Grid(): with Grid():
# 1 yield Static(
yield Static(f"New stream" if self.__isNew else f"Edit stream", id="toplabel", classes="five") "New stream" if self.__isNew else "Edit stream",
id="toplabel",
classes="five",
)
# 2
yield Static("for pattern") yield Static("for pattern")
yield Static("", id="pattern_label", classes="four", markup=False) yield Static("", id="pattern_label", classes="four", markup=False)
# 3
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 4
yield Static("Index / Subindex") yield Static("Index / Subindex")
yield Static("", id="index_label", classes="two") yield Static("", id="index_label", classes="two")
yield Static("", id="subindex_label", classes="two") yield Static("", id="subindex_label", classes="two")
# 5
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 6
yield Static("Type") yield Static("Type")
yield Select.from_values([t.label() for t in TrackType], classes="four", id="type_select") yield Select.from_values(
[trackType.label() for trackType in TrackType],
classes="four",
id="type_select",
)
# 7
if self.__trackType == TrackType.AUDIO:
yield Static("Audio Layout") yield Static("Audio Layout")
yield Select.from_values([t.label() for t in AudioLayout], classes="four", id="audio_layout_select") yield Select.from_values(
else: [layout.label() for layout in AudioLayout],
classes="four",
id="audio_layout_select",
)
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 8
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 9
yield Static(" ", classes="five")
# 10
yield Static("Language") yield Static("Language")
yield Select.from_values(languages, classes="four", id="language_select") yield Select.from_values(languages, classes="four", id="language_select")
# 11
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 12
yield Static("Title") yield Static("Title")
yield Input(id="title_input", classes="four") yield Input(id="title_input", classes="four")
# 13
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 14
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 15
yield Static("Stream tags") yield Static("Stream tags")
yield Static(" ") yield Static(" ")
yield Button("Add", id="button_add_stream_tag") yield Button("Add", id="button_add_stream_tag")
yield Button("Edit", id="button_edit_stream_tag") yield Button("Edit", id="button_edit_stream_tag")
yield Button("Delete", id="button_delete_stream_tag") yield Button("Delete", id="button_delete_stream_tag")
# 16
yield self.trackTagsTable yield self.trackTagsTable
# 17
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 18
yield Static("Stream dispositions", classes="five") yield Static("Stream dispositions", classes="five")
# 19
yield SelectionList[int]( yield SelectionList[int](
classes="five", classes="five",
id = "dispositions_selection_list" id="dispositions_selection_list",
) )
# 20
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 21
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 22
yield Button("Save", id="save_button") yield Button("Save", id="save_button")
yield Button("Cancel", id="cancel_button") yield Button("Cancel", id="cancel_button")
# 23
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 24
yield Static(" ", classes="five", id="messagestatic") yield Static(" ", classes="five", id="messagestatic")
yield Footer(id="footer") yield Footer(id="footer")
def getTrackDescriptorFromInput(self): def getTrackDescriptorFromInput(self):
kwargs = {} kwargs = {}
kwargs[TrackDescriptor.CONTEXT_KEY] = self.context kwargs[TrackDescriptor.CONTEXT_KEY] = self.context
kwargs[TrackDescriptor.PATTERN_ID_KEY] = int(self.__pattern.getId()) if self.__trackDescriptor is not None and self.__trackDescriptor.getId() != -1:
kwargs[TrackDescriptor.ID_KEY] = self.__trackDescriptor.getId()
kwargs[TrackDescriptor.INDEX_KEY] = self.__index if self.__patternId != -1:
kwargs[TrackDescriptor.SUB_INDEX_KEY] = self.__subIndex #! kwargs[TrackDescriptor.PATTERN_ID_KEY] = int(self.__patternId)
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = TrackType.fromLabel(self.query_one("#type_select", Select).value) kwargs[TrackDescriptor.INDEX_KEY] = int(self.__index)
kwargs[TrackDescriptor.SOURCE_INDEX_KEY] = (
int(self.__trackDescriptor.getSourceIndex())
if self.__trackDescriptor is not None
else int(self.__index)
)
if self.__subIndex is not None and int(self.__subIndex) >= 0:
kwargs[TrackDescriptor.SUB_INDEX_KEY] = int(self.__subIndex)
selectedTrackType = TrackType.fromLabel(
self.query_one("#type_select", Select).value
)
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = selectedTrackType
kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec
if self.__trackType == TrackType.AUDIO: if selectedTrackType == TrackType.AUDIO:
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(self.query_one("#audio_layout_select", Select).value) kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(
self.query_one("#audio_layout_select", Select).value
)
else: else:
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_UNDEFINED kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_UNDEFINED
trackTags = {} trackTags = dict(self.__draftTrackTags)
language = self.query_one("#language_select", Select).value language = self.query_one("#language_select", Select).value
if language: if language:
trackTags['language'] = IsoLanguage.find(language).threeLetter() trackTags["language"] = IsoLanguage.find(language).threeLetter()
title = self.query_one("#title_input", Input).value title = self.query_one("#title_input", Input).value
if title: if title:
trackTags['title'] = title trackTags["title"] = title
tableTags = {row[0]:row[1] for r in self.trackTagsTable.rows if (row := self.trackTagsTable.get_row(r)) and row[0] != 'language' and row[0] != 'title'} kwargs[TrackDescriptor.TAGS_KEY] = trackTags
kwargs[TrackDescriptor.TAGS_KEY] = trackTags | tableTags dispositionFlags = sum(
[2 ** flag for flag in self.query_one("#dispositions_selection_list", SelectionList).selected]
dispositionFlags = sum([2**f for f in self.query_one("#dispositions_selection_list", SelectionList).selected]) )
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = TrackDisposition.toSet(dispositionFlags) kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = TrackDisposition.toSet(
dispositionFlags
)
return TrackDescriptor(**kwargs) return TrackDescriptor(**kwargs)
def getSelectedTag(self): def getSelectedTag(self):
try: try:
row_key, _ = self.trackTagsTable.coordinate_to_cell_key(
# Fetch the currently selected row when 'Enter' is pressed self.trackTagsTable.cursor_coordinate
#selected_row_index = self.table.cursor_row )
row_key, col_key = self.trackTagsTable.coordinate_to_cell_key(self.trackTagsTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
selected_tag_data = self.trackTagsTable.get_row(row_key) selected_tag_data = self.trackTagsTable.get_row(row_key)
@@ -357,101 +377,92 @@ class TrackDetailsScreen(Screen):
return tagKey, tagValue return tagKey, tagValue
else:
return None return None
except CellDoesNotExist: except CellDoesNotExist:
return None return None
# Event handler for button press
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
# Check if the button pressed is the one we are interested in
if event.button.id == "save_button": if event.button.id == "save_button":
# Check for multiple default/forced disposition flags
if self.__trackType == TrackType.VIDEO:
trackList = self.__tc.findVideoTracks(self.__pattern.getId())
if self.__trackType == TrackType.AUDIO:
trackList = self.__tc.findAudioTracks(self.__pattern.getId())
elif self.__trackType == TrackType.SUBTITLE:
trackList = self.__tc.findSubtitleTracks(self.__pattern.getId())
else:
trackList = []
siblingTrackList = [t for t in trackList if t.getType() == self.__trackType and t.getIndex() != self.__index]
numDefaultTracks = len([t for t in siblingTrackList if TrackDisposition.DEFAULT in t.getDispositionSet()])
numForcedTracks = len([t for t in siblingTrackList if TrackDisposition.FORCED in t.getDispositionSet()])
self.__subIndex = len(trackList)
trackDescriptor = self.getTrackDescriptorFromInput() trackDescriptor = self.getTrackDescriptorFromInput()
if ((TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet() and numDefaultTracks) siblingTrackList = [
or (TrackDisposition.FORCED in trackDescriptor.getDispositionSet() and numForcedTracks)): descriptor
for descriptor in self.__siblingTrackDescriptors
if not self._descriptor_refs_same_track(descriptor)
]
siblingTrackList = [
descriptor
for descriptor in siblingTrackList
if descriptor.getType() == trackDescriptor.getType()
]
self.query_one("#messagestatic", Static).update("Cannot add another stream with disposition flag 'debug' or 'forced' set") numDefaultTracks = len(
[
else: descriptor
for descriptor in siblingTrackList
self.query_one("#messagestatic", Static).update(" ") if TrackDisposition.DEFAULT in descriptor.getDispositionSet()
]
)
numForcedTracks = len(
[
descriptor
for descriptor in siblingTrackList
if TrackDisposition.FORCED in descriptor.getDispositionSet()
]
)
if self.__isNew: if self.__isNew:
trackDescriptor.setSubIndex(len(siblingTrackList))
elif self.__subIndex is not None and int(self.__subIndex) >= 0:
trackDescriptor.setSubIndex(int(self.__subIndex))
# Track per Screen hinzufügen if (
self.__tc.addTrack(trackDescriptor) TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet()
self.dismiss(trackDescriptor) and numDefaultTracks
) or (
TrackDisposition.FORCED in trackDescriptor.getDispositionSet()
and numForcedTracks
):
self.query_one("#messagestatic", Static).update(
"Cannot add another stream with disposition flag 'default' or 'forced' set"
)
else: else:
self.query_one("#messagestatic", Static).update(" ")
track = self.__tc.getTrack(self.__pattern.getId(), self.__index)
# Track per details screen updaten
if self.__tc.updateTrack(track.getId(), trackDescriptor):
self.dismiss(trackDescriptor) self.dismiss(trackDescriptor)
else:
self.app.pop_screen()
if event.button.id == "cancel_button": if event.button.id == "cancel_button":
self.app.pop_screen() self.app.pop_screen()
if event.button.id == "button_add_stream_tag": if event.button.id == "button_add_stream_tag":
if not self.__isNew:
self.app.push_screen(TagDetailsScreen(), self.handle_update_tag) self.app.push_screen(TagDetailsScreen(), self.handle_update_tag)
if event.button.id == "button_edit_stream_tag": if event.button.id == "button_edit_stream_tag":
tagKey, tagValue = self.getSelectedTag() selectedTag = self.getSelectedTag()
self.app.push_screen(TagDetailsScreen(key=tagKey, value=tagValue), self.handle_update_tag) if selectedTag is not None:
self.app.push_screen(
TagDetailsScreen(key=selectedTag[0], value=selectedTag[1]),
self.handle_update_tag,
)
if event.button.id == "button_delete_stream_tag": if event.button.id == "button_delete_stream_tag":
tagKey, tagValue = self.getSelectedTag() selectedTag = self.getSelectedTag()
self.app.push_screen(TagDeleteScreen(key=tagKey, value=tagValue), self.handle_delete_tag) if selectedTag is not None:
self.app.push_screen(
TagDeleteScreen(key=selectedTag[0], value=selectedTag[1]),
self.handle_delete_tag,
)
def handle_update_tag(self, tag): def handle_update_tag(self, tag):
if tag is None:
trackId = self.__trackDescriptor.getId() return
self.__draftTrackTags[str(tag[0])] = str(tag[1])
if trackId == -1:
raise click.ClickException(f"TrackDetailsScreen.handle_update_tag: trackId not set (-1) trackDescriptor={self.__trackDescriptor}")
if self.__tac.updateTrackTag(trackId, tag[0], tag[1]) is not None:
self.updateTags() self.updateTags()
def handle_delete_tag(self, trackTag): def handle_delete_tag(self, trackTag):
if trackTag is None:
trackId = self.__trackDescriptor.getId() return
self.__draftTrackTags.pop(str(trackTag[0]), None)
if trackId == -1:
raise click.ClickException(f"TrackDetailsScreen.handle_delete_tag: trackId not set (-1) trackDescriptor={self.__trackDescriptor}")
tag = self.__tac.findTrackTag(trackId, trackTag[0])
if tag is not None:
if self.__tac.deleteTrackTag(tag.id):
self.updateTags() self.updateTags()

View File

@@ -5,6 +5,7 @@ class VideoEncoder(Enum):
AV1 = {'label': 'av1', 'index': 1} AV1 = {'label': 'av1', 'index': 1}
VP9 = {'label': 'vp9', 'index': 2} VP9 = {'label': 'vp9', 'index': 2}
H264 = {'label': 'h264', 'index': 3} H264 = {'label': 'h264', 'index': 3}
COPY = {'label': 'copy', 'index': 4}
UNDEFINED = {'label': 'undefined', 'index': 0} UNDEFINED = {'label': 'undefined', 'index': 0}

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Repo-root tests package for legacy and future test code.

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,138 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from tests.support.ffx_bundle import (
PatternTrackSpec,
SourceTrackSpec,
add_show,
build_controller_context,
create_source_fixture,
dispose_controller_context,
expected_output_path,
run_ffx_convert,
)
from ffx.pattern_controller import PatternController
from ffx.track_type import TrackType
try:
import pytest
except ImportError: # pragma: no cover - unittest-only environments
pytest = None
if pytest is not None:
pytestmark = [pytest.mark.integration, pytest.mark.pattern_management]
class PatternManagementCliTests(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory()
self.workdir = Path(self.tempdir.name)
self.home_dir = self.workdir / "home"
self.home_dir.mkdir()
self.database_path = self.workdir / "test.db"
def tearDown(self):
self.tempdir.cleanup()
def prepare_duplicate_matching_patterns(self):
context = build_controller_context(self.database_path)
try:
add_show(context, show_id=1)
add_show(context, show_id=2)
controller = PatternController(context)
track_descriptors = [
PatternTrackSpec(index=0, source_index=0, track_type=TrackType.VIDEO)
]
def to_track_descriptor(spec: PatternTrackSpec):
from ffx.track_descriptor import TrackDescriptor
kwargs = {
TrackDescriptor.INDEX_KEY: spec.index,
TrackDescriptor.SOURCE_INDEX_KEY: spec.source_index,
TrackDescriptor.TRACK_TYPE_KEY: spec.track_type,
TrackDescriptor.TAGS_KEY: dict(spec.tags),
TrackDescriptor.DISPOSITION_SET_KEY: set(spec.dispositions),
}
return TrackDescriptor(**kwargs)
controller.savePatternSchema(
{"show_id": 1, "pattern": r"^dup_(s[0-9]+e[0-9]+)\.mkv$"},
[to_track_descriptor(track_descriptors[0])],
)
controller.savePatternSchema(
{"show_id": 2, "pattern": r"^dup_.*$"},
[to_track_descriptor(track_descriptors[0])],
)
finally:
dispose_controller_context(context)
def test_convert_fails_when_filename_matches_more_than_one_pattern(self):
self.prepare_duplicate_matching_patterns()
source_filename = "dup_s01e01.mkv"
source_path = create_source_fixture(
self.workdir,
source_filename,
[
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng"),
],
)
completed = run_ffx_convert(
self.workdir,
self.home_dir,
self.database_path,
"--video-encoder",
"copy",
"--no-tmdb",
"--no-prompt",
"--no-signature",
str(source_path),
)
self.assertNotEqual(completed.returncode, 0)
error_output = f"{completed.stdout}\n{completed.stderr}"
self.assertIn("matched more than one pattern", error_output)
self.assertFalse(expected_output_path(self.workdir, source_filename).exists())
def test_convert_can_ignore_duplicate_matches_when_no_pattern_is_requested(self):
self.prepare_duplicate_matching_patterns()
source_filename = "dup_s01e01.mkv"
source_path = create_source_fixture(
self.workdir,
source_filename,
[
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng"),
],
)
completed = run_ffx_convert(
self.workdir,
self.home_dir,
self.database_path,
"--video-encoder",
"copy",
"--no-pattern",
"--no-tmdb",
"--no-prompt",
"--no-signature",
str(source_path),
)
self.assertEqual(
0,
completed.returncode,
f"STDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}",
)
self.assertTrue(expected_output_path(self.workdir, source_filename).exists())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1 @@

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

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

@@ -0,0 +1 @@
# Legacy custom FFX test harness modules.

View File

@@ -24,8 +24,9 @@ class BasenameCombinator():
@staticmethod @staticmethod
def getClassReference(identifier): def getClassReference(identifier):
importlib.import_module(f"ffx.test.basename_combinator_{ identifier }") module_name = f"tests.legacy.basename_combinator_{ identifier }"
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.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 (?) #HINT: Excluding MediaCombinator as it seems to be included by import (?)
if inspect.isclass(obj) and name != 'BasenameCombinator' and name.startswith('BasenameCombinator'): if inspect.isclass(obj) and name != 'BasenameCombinator' and name.startswith('BasenameCombinator'):
return obj return obj

View File

@@ -24,8 +24,9 @@ class DispositionCombinator2():
@staticmethod @staticmethod
def getClassReference(identifier): def getClassReference(identifier):
importlib.import_module(f"ffx.test.disposition_combinator_2_{ identifier }") module_name = f"tests.legacy.disposition_combinator_2_{ identifier }"
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.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 (?) #HINT: Excluding DispositionCombination as it seems to be included by import (?)
if inspect.isclass(obj) and name != 'DispositionCombinator2' and name.startswith('DispositionCombinator2'): if inspect.isclass(obj) and name != 'DispositionCombinator2' and name.startswith('DispositionCombinator2'):
return obj return obj

View File

@@ -23,8 +23,9 @@ class DispositionCombinator3():
@staticmethod @staticmethod
def getClassReference(identifier): def getClassReference(identifier):
importlib.import_module(f"ffx.test.disposition_combinator_3_{ identifier }") module_name = f"tests.legacy.disposition_combinator_3_{ identifier }"
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.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 (?) #HINT: Excluding DispositionCombination as it seems to be included by import (?)
if inspect.isclass(obj) and name != 'DispositionCombinator3' and name.startswith('DispositionCombinator3'): if inspect.isclass(obj) and name != 'DispositionCombinator3' and name.startswith('DispositionCombinator3'):
return obj return obj

View File

@@ -1,11 +1,9 @@
import os, math, tempfile, click import os, math, tempfile, click
from ffx.ffx_controller import FfxController
from ffx.process import executeProcess from ffx.process import executeProcess
from ffx.media_descriptor import MediaDescriptor from ffx.media_descriptor import MediaDescriptor
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
from ffx.track_type import TrackType from ffx.track_type import TrackType
from ffx.helper import dictCache from ffx.helper import dictCache
@@ -149,7 +147,6 @@ def createMediaTestFile(mediaDescriptor: MediaDescriptor,
# subtitleFilePath = createVttFile(SHORT_SUBTITLE_SEQUENCE) # subtitleFilePath = createVttFile(SHORT_SUBTITLE_SEQUENCE)
# commandTokens = FfxController.COMMAND_TOKENS
commandTokens = ['ffmpeg', '-y'] commandTokens = ['ffmpeg', '-y']
generatorCache = [] generatorCache = []
@@ -232,15 +229,14 @@ def createMediaTestFile(mediaDescriptor: MediaDescriptor,
f"{mediaTagKey}={mediaTagValue}"] f"{mediaTagKey}={mediaTagValue}"]
subIndexCounter[trackType] += 1 subIndexCounter[trackType] += 1
#TODO: Optimize too many runs
ffxContext = {'config': ConfigurationController(), 'logger': logger} ffxContext = {'config': ConfigurationController(), 'logger': logger}
fc = FfxController(ffxContext, mediaDescriptor) mdcs = MediaDescriptorChangeSet(ffxContext, mediaDescriptor)
commandTokens += (generatorTokens commandTokens += (generatorTokens
+ importTokens + importTokens
+ mappingTokens + mappingTokens
+ metadataTokens + metadataTokens
+ fc.generateDispositionTokens()) + mdcs.generateDispositionTokens())
commandTokens += ['-t', str(length)] commandTokens += ['-t', str(length)]

View File

@@ -25,8 +25,9 @@ class LabelCombinator():
@staticmethod @staticmethod
def getClassReference(identifier): def getClassReference(identifier):
importlib.import_module(f"ffx.test.{LabelCombinator.PREFIX}{ identifier }") module_name = f"tests.legacy.{LabelCombinator.PREFIX}{ identifier }"
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.{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 (?) #HINT: Excluding MediaCombinator as it seems to be included by import (?)
if inspect.isclass(obj) and name != 'LabelCombinator' and name.startswith('LabelCombinator'): if inspect.isclass(obj) and name != 'LabelCombinator' and name.startswith('LabelCombinator'):
return obj return obj

View File

@@ -22,8 +22,9 @@ class MediaCombinator():
@staticmethod @staticmethod
def getClassReference(identifier): def getClassReference(identifier):
importlib.import_module(f"ffx.test.media_combinator_{ identifier }") module_name = f"tests.legacy.media_combinator_{ identifier }"
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.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 (?) #HINT: Excluding MediaCombinator as it seems to be included by import (?)
if inspect.isclass(obj) and name != 'MediaCombinator' and name.startswith('MediaCombinator'): if inspect.isclass(obj) and name != 'MediaCombinator' and name.startswith('MediaCombinator'):
return obj return obj

View File

@@ -22,8 +22,9 @@ class MediaTagCombinator():
@staticmethod @staticmethod
def getClassReference(identifier): def getClassReference(identifier):
importlib.import_module(f"ffx.test.media_tag_combinator_{ identifier }") module_name = f"tests.legacy.media_tag_combinator_{ identifier }"
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.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 (?) #HINT: Excluding MediaCombinator as it seems to be included by import (?)
if inspect.isclass(obj) and name != 'MediaTagCombinator' and name.startswith('MediaTagCombinator'): if inspect.isclass(obj) and name != 'MediaTagCombinator' and name.startswith('MediaTagCombinator'):
return obj return obj

View File

@@ -4,7 +4,7 @@ from ffx.show_controller import ShowController
from ffx.pattern_controller import PatternController from ffx.pattern_controller import PatternController
from ffx.media_controller import MediaController from ffx.media_controller import MediaController
from ffx.test.helper import createEmptyDirectory from .helper import createEmptyDirectory
from ffx.database import databaseContext from ffx.database import databaseContext
class Scenario(): class Scenario():
@@ -90,11 +90,7 @@ class Scenario():
def __init__(self, context = None): def __init__(self, context = None):
self._context = context self._context = context
self._testDirectory = createEmptyDirectory() self._testDirectory = createEmptyDirectory()
self._ffxExecutablePath = os.path.join( self._ffxModuleName = 'ffx'
os.path.dirname(
os.path.dirname(
os.path.dirname(__file__))),
'ffx.py')
self._logger = context['logger'] self._logger = context['logger']
self._reportLogger = context['report_logger'] self._reportLogger = context['report_logger']
@@ -146,8 +142,9 @@ class Scenario():
@staticmethod @staticmethod
def getClassReference(identifier): def getClassReference(identifier):
importlib.import_module(f"ffx.test.scenario_{ identifier }") module_name = f"tests.legacy.scenario_{ identifier }"
for name, obj in inspect.getmembers(sys.modules[f"ffx.test.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 (?) #HINT: Excluding Scenario as it seems to be included by import (?)
if inspect.isclass(obj) and name != 'Scenario' and name.startswith('Scenario'): if inspect.isclass(obj) and name != 'Scenario' and name.startswith('Scenario'):
return obj return obj

View File

@@ -2,7 +2,7 @@ import os, sys, click, glob
from .scenario import Scenario from .scenario import Scenario
from ffx.test.helper import createMediaTestFile from .helper import createMediaTestFile
from ffx.process import executeProcess from ffx.process import executeProcess
from ffx.file_properties import FileProperties from ffx.file_properties import FileProperties
@@ -13,9 +13,9 @@ from ffx.track_descriptor import TrackDescriptor
from ffx.track_type import TrackType from ffx.track_type import TrackType
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
from ffx.test.media_combinator_0 import MediaCombinator0 from .media_combinator_0 import MediaCombinator0
from ffx.test.basename_combinator import BasenameCombinator from .basename_combinator import BasenameCombinator
class Scenario1(Scenario): class Scenario1(Scenario):
@@ -92,8 +92,7 @@ class Scenario1(Scenario):
# Phase 2: Run ffx # Phase 2: Run ffx
commandSequence = [sys.executable, commandSequence = [sys.executable, '-m', self._ffxModuleName]
self._ffxExecutablePath]
if self._context['verbosity']: if self._context['verbosity']:
commandSequence += ['--verbose', commandSequence += ['--verbose',

View File

@@ -2,7 +2,7 @@ import os, sys, click
from .scenario import Scenario from .scenario import Scenario
from ffx.test.helper import createMediaTestFile from .helper import createMediaTestFile
from ffx.process import executeProcess from ffx.process import executeProcess
from ffx.file_properties import FileProperties from ffx.file_properties import FileProperties
@@ -13,7 +13,7 @@ from ffx.track_descriptor import TrackDescriptor
from ffx.track_type import TrackType from ffx.track_type import TrackType
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
from ffx.test.media_combinator import MediaCombinator from .media_combinator import MediaCombinator
class Scenario2(Scenario): class Scenario2(Scenario):
@@ -77,8 +77,7 @@ class Scenario2(Scenario):
# Phase 2: Run ffx # Phase 2: Run ffx
commandSequence = [sys.executable, commandSequence = [sys.executable, '-m', self._ffxModuleName]
self._ffxExecutablePath]
if self._context['verbosity']: if self._context['verbosity']:
commandSequence += ['--verbose', commandSequence += ['--verbose',

View File

@@ -2,11 +2,11 @@ import os, sys, click
from .scenario import Scenario from .scenario import Scenario
from ffx.test.helper import createMediaTestFile from .helper import createMediaTestFile
from ffx.process import executeProcess from ffx.process import executeProcess
from ffx.database import databaseContext from ffx.database import databaseContext
from ffx.test.helper import createEmptyDirectory from .helper import createEmptyDirectory
from ffx.helper import getEpisodeFileBasename from ffx.helper import getEpisodeFileBasename
from ffx.file_properties import FileProperties from ffx.file_properties import FileProperties
@@ -17,8 +17,8 @@ from ffx.track_descriptor import TrackDescriptor
from ffx.track_type import TrackType from ffx.track_type import TrackType
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
from ffx.test.media_combinator import MediaCombinator from .media_combinator import MediaCombinator
from ffx.test.indicator_combinator import IndicatorCombinator from .indicator_combinator import IndicatorCombinator
from ffx.show_descriptor import ShowDescriptor from ffx.show_descriptor import ShowDescriptor
@@ -163,8 +163,7 @@ class Scenario4(Scenario):
# Phase 3: Run ffx # Phase 3: Run ffx
commandSequence = [sys.executable, commandSequence = [sys.executable, '-m', self._ffxModuleName]
self._ffxExecutablePath]
if self._context['verbosity']: if self._context['verbosity']:
commandSequence += ['--verbose', commandSequence += ['--verbose',

Some files were not shown because too many files have changed in this diff Show More