Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaa3fc35a3 | ||
|
|
f9432ec564 | ||
|
|
8f5873fbab | ||
|
|
12e5438d22 | ||
|
|
6f09c80aaf | ||
|
|
f49d6f4838 | ||
|
|
2aeeaac204 | ||
|
|
8c2a4cae96 | ||
|
|
cdc01062d3 | ||
|
|
aa864dfd4d | ||
|
|
697645be25 | ||
|
|
c0056b6c3c | ||
|
|
dd810ed41f | ||
|
|
f9d9db5871 | ||
|
|
56f801631f | ||
|
|
89409907f9 | ||
|
|
68c4b23b2a | ||
|
|
0415087e75 | ||
|
|
20a9bb36b8 | ||
|
|
3ac139a2dc | ||
|
|
912db3c39a | ||
|
|
8a375ccce1 | ||
|
|
176cfa06eb | ||
|
|
f794f822f2 | ||
|
|
1a11710df7 | ||
|
|
ac6e3020b2 | ||
|
|
93d19629dc | ||
|
|
db43501ce2 | ||
|
|
87568989fe | ||
|
|
20ab08626b | ||
|
|
2e2c94f539 | ||
|
|
12be6e985a | ||
|
|
12310942ae | ||
|
|
f913cb4fe3 | ||
|
|
0a153280e3 | ||
|
|
6ca0cd54b0 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,7 +1,6 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
junk/
|
junk/
|
||||||
.vscode
|
|
||||||
.ipynb_checkpoints/
|
.ipynb_checkpoints/
|
||||||
tools/ansible/inventory/hawaii.yml
|
tools/ansible/inventory/hawaii.yml
|
||||||
tools/ansible/inventory/peppermint.yml
|
tools/ansible/inventory/peppermint.yml
|
||||||
@@ -17,6 +16,7 @@ dist/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
docs/_build/
|
||||||
.codex
|
.codex
|
||||||
|
|
||||||
|
|
||||||
@@ -24,4 +24,4 @@ venv/
|
|||||||
*.webm
|
*.webm
|
||||||
*.mp4
|
*.mp4
|
||||||
ffmpeg2pass-0.log
|
ffmpeg2pass-0.log
|
||||||
*.sup
|
*.sup
|
||||||
|
|||||||
11
.vscode/extensions.json
vendored
Normal file
11
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"swyddfa.esbonio",
|
||||||
|
"ms-python.python",
|
||||||
|
"ms-python.vscode-pylance",
|
||||||
|
"ms-python.debugpy",
|
||||||
|
"tamasfe.even-better-toml",
|
||||||
|
"redhat.vscode-yaml",
|
||||||
|
"DavidAnson.vscode-markdownlint"
|
||||||
|
]
|
||||||
|
}
|
||||||
18
.vscode/settings.json
vendored
Normal file
18
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"esbonio.sphinx.pythonCommand": "${venv:.venv}/bin/python",
|
||||||
|
"esbonio.sphinx.buildCommand": [
|
||||||
|
"sphinx-build",
|
||||||
|
"-b",
|
||||||
|
"html",
|
||||||
|
"docs",
|
||||||
|
"docs/_build/html"
|
||||||
|
],
|
||||||
|
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||||
|
"python.testing.pytestEnabled": true,
|
||||||
|
"python.testing.pytestArgs": [
|
||||||
|
"--ignore=tests/legacy",
|
||||||
|
"--ignore=tests/support",
|
||||||
|
"tests"
|
||||||
|
],
|
||||||
|
"restructuredtext.confPath": "${workspaceFolder}/docs"
|
||||||
|
}
|
||||||
87
README.md
87
README.md
@@ -99,89 +99,4 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
|
|||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
### 0.4.1
|
See the [version history](docs/history.rst) for release notes.
|
||||||
|
|
||||||
- `convert` now supports `--copy-video` and `--copy-audio` to keep the selected stream type in copy mode without applying the corresponding reencode flags, filters, or formatting options
|
|
||||||
- ffmpeg conversions now monitor diagnostics while the process is running, retry unset AVI packet timestamps once with `-fflags +genpts`, and stop early when a file should be skipped instead of waiting for the full job to finish
|
|
||||||
- end-of-run convert summaries now list only ffmpeg findings that still require review, including named remedy identifiers such as `warn-corrupt-mpeg-audio`
|
|
||||||
- `upgrade` now finishes by reporting the installed FFX version together with the active bundle branch
|
|
||||||
|
|
||||||
### 0.3.1
|
|
||||||
|
|
||||||
- debug mode screen titles now append the active Textual screen class name, making screen-specific troubleshooting easier during inspect and edit flows
|
|
||||||
- `--cut` again works as a combined flag/option: omitted disables cutting, bare `--cut` applies the default `60,180`, and explicit duration or `START,DURATION` values stay supported
|
|
||||||
- H.265 unmux commands no longer force an invalid `-f h265` output format, keeping ffmpeg copy extraction aligned with the required Annex B bitstream filter
|
|
||||||
- H.264 encoding now falls back from `libx264` to `libopenh264` with a warning when needed, and the test fixtures use the same encoder fallback so the suite remains portable across ffmpeg builds
|
|
||||||
|
|
||||||
### 0.3.0
|
|
||||||
|
|
||||||
- inspect and edit screens now refresh nested track and pattern changes more reliably, with inspect-mode tables aligned to the target pattern view shown in the differences pane
|
|
||||||
- metadata editing got a follow-up polish pass with clearer ffmpeg notifications, a shared in-screen log pane, safer apply/reload handling, and expanded cleanup and normalization coverage
|
|
||||||
- track and asset probing recognize additional codecs, and the modern test suite now covers more metadata-editor, change-set, screen-state, and asset-probe behavior
|
|
||||||
- Textual now requires version `8.0` or newer to match the UI APIs used by the current screens
|
|
||||||
|
|
||||||
### 0.2.6
|
|
||||||
|
|
||||||
- DB-free `ffx edit` workflow for in-place metadata editing via temporary-file rewrite
|
|
||||||
- inspect and edit workflows split into dedicated Textual screens with shared media-workflow support
|
|
||||||
- Textual tables and row actions now separate raw data from rendered labels to avoid markup leaking into stored metadata
|
|
||||||
- responsive screen layout pass, `Esc` back handling, sortable show/inspect tables, and improved edit-screen notifications/toggles
|
|
||||||
- application-wide UTF-8 i18n catalogs with language precedence from CLI over config over system over German default
|
|
||||||
- metadata normalization extended for localized subtitle titles, ISO language cleanup, and smarter track editor language/title helpers
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
- PyPI packaging
|
|
||||||
- output filename templating
|
|
||||||
- season shifting
|
|
||||||
- DB versioning
|
|
||||||
|
|
||||||
### 0.2.2
|
|
||||||
|
|
||||||
- CLI overrides
|
|
||||||
|
|
||||||
### 0.2.1
|
|
||||||
|
|
||||||
- signature handling
|
|
||||||
- tag cleanup
|
|
||||||
- bugfixes and refactoring
|
|
||||||
|
|
||||||
### 0.2.0
|
|
||||||
|
|
||||||
- tests
|
|
||||||
- config file
|
|
||||||
|
|
||||||
### 0.1.3
|
|
||||||
|
|
||||||
- subtitle file imports
|
|
||||||
|
|
||||||
### 0.1.2
|
|
||||||
|
|
||||||
- bugfixes
|
|
||||||
|
|
||||||
### 0.1.1
|
|
||||||
|
|
||||||
- bugfixes
|
|
||||||
- TMDB show identification
|
|
||||||
|
|||||||
21
docs/Makefile
Normal file
21
docs/Makefile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
SPHINXOPTS ?=
|
||||||
|
VENV_SPHINXBUILD = ../.venv/bin/sphinx-build
|
||||||
|
SPHINXBUILD ?= $(if $(wildcard $(VENV_SPHINXBUILD)),$(VENV_SPHINXBUILD),sphinx-build)
|
||||||
|
SOURCEDIR = .
|
||||||
|
BUILDDIR = _build
|
||||||
|
|
||||||
|
.PHONY: help clean html linkcheck
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Please use 'make <target>' where <target> is one of"
|
||||||
|
@echo " html to make standalone HTML files"
|
||||||
|
@echo " linkcheck to check all external links for integrity"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf "$(BUILDDIR)"
|
||||||
|
|
||||||
|
html:
|
||||||
|
@$(SPHINXBUILD) -b html "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS)
|
||||||
|
|
||||||
|
linkcheck:
|
||||||
|
@$(SPHINXBUILD) -b linkcheck "$(SOURCEDIR)" "$(BUILDDIR)/linkcheck" $(SPHINXOPTS)
|
||||||
31
docs/api.rst
Normal file
31
docs/api.rst
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
API Reference
|
||||||
|
=============
|
||||||
|
|
||||||
|
This section exposes selected modules that are useful when working on tests,
|
||||||
|
diagnostics, process execution, metadata editing, and file probing.
|
||||||
|
|
||||||
|
CLI Helpers
|
||||||
|
-----------
|
||||||
|
|
||||||
|
.. automodule:: ffx.cli
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
Process Helpers
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: ffx.process
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
File Probing
|
||||||
|
------------
|
||||||
|
|
||||||
|
.. automodule:: ffx.file_properties
|
||||||
|
|
||||||
|
Metadata Editing
|
||||||
|
----------------
|
||||||
|
|
||||||
|
.. automodule:: ffx.metadata_editor
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
44
docs/conf.py
Normal file
44
docs/conf.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
SRC_DIR = ROOT_DIR / "src"
|
||||||
|
sys.path.insert(0, str(SRC_DIR))
|
||||||
|
|
||||||
|
project = "FFX"
|
||||||
|
author = "javanaut@maveno.de"
|
||||||
|
copyright = "2026, Maveno"
|
||||||
|
|
||||||
|
try:
|
||||||
|
release = package_version("ffx")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
release = "0.0.0"
|
||||||
|
version = release
|
||||||
|
|
||||||
|
extensions = [
|
||||||
|
"sphinx.ext.autodoc",
|
||||||
|
"sphinx.ext.napoleon",
|
||||||
|
"sphinx.ext.viewcode",
|
||||||
|
"sphinx_copybutton",
|
||||||
|
]
|
||||||
|
|
||||||
|
source_suffix = {
|
||||||
|
".rst": "restructuredtext",
|
||||||
|
}
|
||||||
|
|
||||||
|
templates_path = ["_templates"]
|
||||||
|
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||||
|
|
||||||
|
html_theme = "sphinx_rtd_theme"
|
||||||
|
html_title = "FFX"
|
||||||
|
html_static_path = []
|
||||||
|
|
||||||
|
autodoc_typehints = "description"
|
||||||
|
autodoc_member_order = "bysource"
|
||||||
|
napoleon_google_docstring = True
|
||||||
|
napoleon_numpy_docstring = True
|
||||||
|
|
||||||
50
docs/development.rst
Normal file
50
docs/development.rst
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
Development
|
||||||
|
===========
|
||||||
|
|
||||||
|
The repo-local ``.venv`` is the preferred environment for contributors working
|
||||||
|
on tests or documentation:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
tests/prepare.sh
|
||||||
|
|
||||||
|
The preparation script installs the package in editable mode with both test and
|
||||||
|
documentation extras:
|
||||||
|
|
||||||
|
.. code-block:: text
|
||||||
|
|
||||||
|
.[test,docs]
|
||||||
|
|
||||||
|
Run Tests
|
||||||
|
---------
|
||||||
|
|
||||||
|
Run the modern pytest suite:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
.venv/bin/python -m pytest --ignore=tests/legacy --ignore=tests/support tests
|
||||||
|
|
||||||
|
The legacy harness remains available separately and is intentionally not part of
|
||||||
|
the default pytest run.
|
||||||
|
|
||||||
|
Build Docs
|
||||||
|
----------
|
||||||
|
|
||||||
|
Build HTML documentation:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
.venv/bin/sphinx-build -b html docs docs/_build/html
|
||||||
|
|
||||||
|
The same command is wrapped by the Sphinx ``Makefile``:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
make -C docs html
|
||||||
|
|
||||||
|
VS Code
|
||||||
|
-------
|
||||||
|
|
||||||
|
The repository includes ``.vscode/extensions.json`` with recommended
|
||||||
|
extensions, including Esbonio for Sphinx language-server support. The workspace
|
||||||
|
settings point Python tooling and Esbonio at the repo-local ``.venv``.
|
||||||
BIN
docs/esbonio.db
Normal file
BIN
docs/esbonio.db
Normal file
Binary file not shown.
@@ -1,170 +0,0 @@
|
|||||||
# File Formats
|
|
||||||
|
|
||||||
This document captures source-file-format notes that complement the normative
|
|
||||||
requirements in `requirements/source_file_formats.md`.
|
|
||||||
|
|
||||||
The first documented format is a Matroska source that carries styled ASS/SSA
|
|
||||||
subtitle streams together with embedded font attachments.
|
|
||||||
|
|
||||||
## Styled ASS In Matroska With Embedded Fonts
|
|
||||||
|
|
||||||
These files are typically `.mkv` releases where subtitle rendering quality
|
|
||||||
depends on keeping both parts of the subtitle package together:
|
|
||||||
|
|
||||||
- one or more subtitle streams with codec `ass`
|
|
||||||
- one or more attachment streams that embed font files used by those subtitles
|
|
||||||
|
|
||||||
This matters because ASS subtitles are not plain text subtitles in the narrow
|
|
||||||
WebVTT sense. They can carry layout, styling, positioning, karaoke, signs, and
|
|
||||||
other typesetting effects. If the matching embedded fonts are lost, consumers
|
|
||||||
can still see subtitle text but the intended styling and sometimes glyph
|
|
||||||
coverage can be degraded.
|
|
||||||
|
|
||||||
For FFX this format is special because the ASS subtitle streams should remain
|
|
||||||
normally editable and mappable, while the related font attachments should be
|
|
||||||
transported unchanged.
|
|
||||||
|
|
||||||
## Observed Sample
|
|
||||||
|
|
||||||
Assessment date: `2026-04-17`
|
|
||||||
|
|
||||||
Observed sample file:
|
|
||||||
|
|
||||||
- `tests/assets/boruto_s01e283_ssa.mkv`
|
|
||||||
|
|
||||||
Commands used for assessment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ffprobe tests/assets/boruto_s01e283_ssa.mkv
|
|
||||||
ffprobe -hide_banner -show_format -show_streams -of json tests/assets/boruto_s01e283_ssa.mkv
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed stream layout:
|
|
||||||
|
|
||||||
| Stream index | Kind | Key details |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `0` | video | `codec_name=h264` |
|
|
||||||
| `1` | audio | `codec_name=aac`, `language=jpn` |
|
|
||||||
| `2` | subtitle | `codec_name=ass`, `language=ger`, default |
|
|
||||||
| `3` | subtitle | `codec_name=ass`, `language=eng` |
|
|
||||||
| `4`-`13` | attachment | `tags.mimetype=font/ttf`, `.ttf` filenames |
|
|
||||||
|
|
||||||
Observed attachment filenames:
|
|
||||||
|
|
||||||
- `AmazonEmberTanuki-Italic.ttf`
|
|
||||||
- `AmazonEmberTanuki-Regular.ttf`
|
|
||||||
- `Arial.ttf`
|
|
||||||
- `Arial Bold.ttf`
|
|
||||||
- `Georgia.ttf`
|
|
||||||
- `Times New Roman.ttf`
|
|
||||||
- `Times New Roman Bold.ttf`
|
|
||||||
- `Trebuchet MS.ttf`
|
|
||||||
- `Verdana.ttf`
|
|
||||||
- `Verdana Bold.ttf`
|
|
||||||
|
|
||||||
Important probe behavior from the real sample:
|
|
||||||
|
|
||||||
- Plain `ffprobe` lists the font streams as `Attachment: none`.
|
|
||||||
- Plain `ffprobe` also prints warnings such as `Could not find codec
|
|
||||||
parameters for stream 4 (Attachment: none): unknown codec` and later
|
|
||||||
`Unsupported codec with id 0 for input stream ...`.
|
|
||||||
- The JSON produced by `FileProperties.FFPROBE_COMMAND_TOKENS`
|
|
||||||
(`ffprobe -hide_banner -show_format -show_streams -of json`) still exposes
|
|
||||||
the attachment streams clearly through `codec_type="attachment"` and the
|
|
||||||
attachment tags.
|
|
||||||
- In that JSON, the attachment streams do not expose `codec_name`.
|
|
||||||
|
|
||||||
This last point is important for FFX: robust detection must not depend on
|
|
||||||
attachment `codec_name` being present.
|
|
||||||
|
|
||||||
## Detection Guidance
|
|
||||||
|
|
||||||
Current known indicators for this format are:
|
|
||||||
|
|
||||||
- one or more subtitle streams with `codec_type="subtitle"` and
|
|
||||||
`codec_name="ass"`
|
|
||||||
- one or more attachment streams with `codec_type="attachment"`
|
|
||||||
- attachment tags that identify embedded fonts, especially
|
|
||||||
`tags.mimetype="font/ttf"`
|
|
||||||
- attachment filenames that end in `.ttf`
|
|
||||||
|
|
||||||
The pattern can vary. FFX should therefore treat the above as a cluster of
|
|
||||||
signals rather than an exact signature tied to one file.
|
|
||||||
|
|
||||||
Inference from the observed sample plus FFmpeg documentation:
|
|
||||||
|
|
||||||
- MIME matching should not be limited to `font/ttf` alone.
|
|
||||||
- The Boruto sample uses `font/ttf`.
|
|
||||||
- FFmpeg's Matroska attachment example uses
|
|
||||||
`mimetype=application/x-truetype-font` for a `.ttf` attachment.
|
|
||||||
- Detection should therefore normalize multiple TTF-like MIME values rather
|
|
||||||
than depend on a single exact string.
|
|
||||||
|
|
||||||
## Processing Expectations In FFX
|
|
||||||
|
|
||||||
The format-specific requirements live in
|
|
||||||
`requirements/source_file_formats.md`. In practical terms, FFX should:
|
|
||||||
|
|
||||||
- recognize the ASS-plus-font-attachment pattern even when attachment probe
|
|
||||||
data is incomplete
|
|
||||||
- tell the operator that the pattern was detected and that special handling is
|
|
||||||
being used
|
|
||||||
- reject sidecar subtitle import for such sources, because converting or
|
|
||||||
replacing these subtitle tracks with ordinary external text subtitles would
|
|
||||||
break the intended subtitle package
|
|
||||||
- continue to allow normal manipulation of the ASS subtitle tracks themselves
|
|
||||||
- preserve the font attachment streams unchanged
|
|
||||||
|
|
||||||
## FFmpeg Notes
|
|
||||||
|
|
||||||
Relevant FFmpeg documentation confirms several behaviors that line up with
|
|
||||||
FFX's needs:
|
|
||||||
|
|
||||||
- FFmpeg documents `-attach` as adding an attachment stream to the output, and
|
|
||||||
explicitly names Matroska fonts used in subtitle rendering as an example.
|
|
||||||
- FFmpeg documents attachment streams as regular streams that are created after
|
|
||||||
the mapped media streams.
|
|
||||||
- FFmpeg documents `-dump_attachment` for extracting attachment streams, which
|
|
||||||
is useful for debugging or validating a source file's embedded fonts.
|
|
||||||
- FFmpeg's Matroska example requires a `mimetype` metadata tag for attached
|
|
||||||
fonts, which is consistent with using attachment tags as detection signals.
|
|
||||||
- FFmpeg also notes that attachments are implemented as codec extradata. That
|
|
||||||
helps explain why probe output for attachment streams can look different from
|
|
||||||
ordinary audio, video, and subtitle streams.
|
|
||||||
|
|
||||||
Implication for FFX:
|
|
||||||
|
|
||||||
- Attachment preservation is not an optional cosmetic feature for this format.
|
|
||||||
It is part of preserving the subtitle package correctly.
|
|
||||||
|
|
||||||
## Jellyfin Notes
|
|
||||||
|
|
||||||
Jellyfin's documentation also supports keeping this format intact:
|
|
||||||
|
|
||||||
- Jellyfin's subtitle compatibility table lists `ASS/SSA` as supported in
|
|
||||||
`MKV` and not supported in `MP4`.
|
|
||||||
- Jellyfin notes that when subtitles must be transcoded, they are either
|
|
||||||
converted to a supported format or burned into the video, and burning them in
|
|
||||||
is the most CPU-intensive path.
|
|
||||||
- Jellyfin's subtitle-extraction example for `SSA/ASS` first dumps attachment
|
|
||||||
streams and then extracts the ASS subtitle stream, which reflects the real
|
|
||||||
relationship between ASS subtitles and embedded fonts in MKV releases.
|
|
||||||
- Jellyfin's font documentation says text-based subtitles require fonts to
|
|
||||||
render properly.
|
|
||||||
- Jellyfin's configuration documentation says the web client uses configured
|
|
||||||
fallback fonts for ASS subtitles when other fonts such as MKV attachments or
|
|
||||||
client-side fonts are not available.
|
|
||||||
|
|
||||||
Inference from the Jellyfin compatibility tables:
|
|
||||||
|
|
||||||
- Keeping this subtitle format in Matroska is the safest interoperability
|
|
||||||
choice for Jellyfin consumers.
|
|
||||||
- Converting the subtitle payload to WebVTT would lose styled ASS behavior.
|
|
||||||
- Dropping the attachment streams would force client or fallback font
|
|
||||||
substitution and can change appearance or glyph coverage.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- FFmpeg documentation: https://ffmpeg.org/ffmpeg.html
|
|
||||||
- Jellyfin codec support: https://jellyfin.org/docs/general/clients/codec-support/
|
|
||||||
- Jellyfin configuration and fonts: https://jellyfin.org/docs/general/administration/configuration/
|
|
||||||
192
docs/file_formats.rst
Normal file
192
docs/file_formats.rst
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
File Formats
|
||||||
|
============
|
||||||
|
|
||||||
|
This document captures source-file-format notes that complement the normative
|
||||||
|
requirements in ``requirements/source_file_formats.md``.
|
||||||
|
|
||||||
|
The first documented format is a Matroska source that carries styled ASS/SSA
|
||||||
|
subtitle streams together with embedded font attachments.
|
||||||
|
|
||||||
|
Styled ASS In Matroska With Embedded Fonts
|
||||||
|
------------------------------------------
|
||||||
|
|
||||||
|
These files are typically ``.mkv`` releases where subtitle rendering quality
|
||||||
|
depends on keeping both parts of the subtitle package together:
|
||||||
|
|
||||||
|
* one or more subtitle streams with codec ``ass``
|
||||||
|
* one or more attachment streams that embed font files used by those subtitles
|
||||||
|
|
||||||
|
This matters because ASS subtitles are not plain text subtitles in the narrow
|
||||||
|
WebVTT sense. They can carry layout, styling, positioning, karaoke, signs, and
|
||||||
|
other typesetting effects. If the matching embedded fonts are lost, consumers
|
||||||
|
can still see subtitle text but the intended styling and sometimes glyph
|
||||||
|
coverage can be degraded.
|
||||||
|
|
||||||
|
For FFX this format is special because the ASS subtitle streams should remain
|
||||||
|
normally editable and mappable, while the related font attachments should be
|
||||||
|
transported unchanged.
|
||||||
|
|
||||||
|
Observed Sample
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Assessment date: ``2026-04-17``
|
||||||
|
|
||||||
|
Observed sample file:
|
||||||
|
|
||||||
|
* ``tests/assets/boruto_s01e283_ssa.mkv``
|
||||||
|
|
||||||
|
Commands used for assessment:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
ffprobe tests/assets/boruto_s01e283_ssa.mkv
|
||||||
|
ffprobe -hide_banner -show_format -show_streams -of json tests/assets/boruto_s01e283_ssa.mkv
|
||||||
|
|
||||||
|
Observed stream layout:
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Stream index
|
||||||
|
- Kind
|
||||||
|
- Key details
|
||||||
|
* - ``0``
|
||||||
|
- video
|
||||||
|
- ``codec_name=h264``
|
||||||
|
* - ``1``
|
||||||
|
- audio
|
||||||
|
- ``codec_name=aac``, ``language=jpn``
|
||||||
|
* - ``2``
|
||||||
|
- subtitle
|
||||||
|
- ``codec_name=ass``, ``language=ger``, default
|
||||||
|
* - ``3``
|
||||||
|
- subtitle
|
||||||
|
- ``codec_name=ass``, ``language=eng``
|
||||||
|
* - ``4``-``13``
|
||||||
|
- attachment
|
||||||
|
- ``tags.mimetype=font/ttf``, ``.ttf`` filenames
|
||||||
|
|
||||||
|
Observed attachment filenames:
|
||||||
|
|
||||||
|
* ``AmazonEmberTanuki-Italic.ttf``
|
||||||
|
* ``AmazonEmberTanuki-Regular.ttf``
|
||||||
|
* ``Arial.ttf``
|
||||||
|
* ``Arial Bold.ttf``
|
||||||
|
* ``Georgia.ttf``
|
||||||
|
* ``Times New Roman.ttf``
|
||||||
|
* ``Times New Roman Bold.ttf``
|
||||||
|
* ``Trebuchet MS.ttf``
|
||||||
|
* ``Verdana.ttf``
|
||||||
|
* ``Verdana Bold.ttf``
|
||||||
|
|
||||||
|
Important probe behavior from the real sample:
|
||||||
|
|
||||||
|
* Plain ``ffprobe`` lists the font streams as ``Attachment: none``.
|
||||||
|
* Plain ``ffprobe`` also prints warnings such as ``Could not find codec
|
||||||
|
parameters for stream 4 (Attachment: none): unknown codec`` and later
|
||||||
|
``Unsupported codec with id 0 for input stream ...``.
|
||||||
|
* The JSON produced by ``FileProperties.FFPROBE_COMMAND_TOKENS``
|
||||||
|
(``ffprobe -hide_banner -show_format -show_streams -of json``) still exposes
|
||||||
|
the attachment streams clearly through ``codec_type="attachment"`` and the
|
||||||
|
attachment tags.
|
||||||
|
* In that JSON, the attachment streams do not expose ``codec_name``.
|
||||||
|
|
||||||
|
This last point is important for FFX: robust detection must not depend on
|
||||||
|
attachment ``codec_name`` being present.
|
||||||
|
|
||||||
|
Detection Guidance
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Current known indicators for this format are:
|
||||||
|
|
||||||
|
* one or more subtitle streams with ``codec_type="subtitle"`` and
|
||||||
|
``codec_name="ass"``
|
||||||
|
* one or more attachment streams with ``codec_type="attachment"``
|
||||||
|
* attachment tags that identify embedded fonts, especially
|
||||||
|
``tags.mimetype="font/ttf"``
|
||||||
|
* attachment filenames that end in ``.ttf``
|
||||||
|
|
||||||
|
The pattern can vary. FFX should therefore treat the above as a cluster of
|
||||||
|
signals rather than an exact signature tied to one file.
|
||||||
|
|
||||||
|
Inference from the observed sample plus FFmpeg documentation:
|
||||||
|
|
||||||
|
* MIME matching should not be limited to ``font/ttf`` alone.
|
||||||
|
* The Boruto sample uses ``font/ttf``.
|
||||||
|
* FFmpeg's Matroska attachment example uses
|
||||||
|
``mimetype=application/x-truetype-font`` for a ``.ttf`` attachment.
|
||||||
|
* Detection should therefore normalize multiple TTF-like MIME values rather
|
||||||
|
than depend on a single exact string.
|
||||||
|
|
||||||
|
Processing Expectations In FFX
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
The format-specific requirements live in
|
||||||
|
``requirements/source_file_formats.md``. In practical terms, FFX should:
|
||||||
|
|
||||||
|
* recognize the ASS-plus-font-attachment pattern even when attachment probe data
|
||||||
|
is incomplete
|
||||||
|
* tell the operator that the pattern was detected and that special handling is
|
||||||
|
being used
|
||||||
|
* reject sidecar subtitle import for such sources, because converting or
|
||||||
|
replacing these subtitle tracks with ordinary external text subtitles would
|
||||||
|
break the intended subtitle package
|
||||||
|
* continue to allow normal manipulation of the ASS subtitle tracks themselves
|
||||||
|
* preserve the font attachment streams unchanged
|
||||||
|
|
||||||
|
FFmpeg Notes
|
||||||
|
------------
|
||||||
|
|
||||||
|
Relevant FFmpeg documentation confirms several behaviors that line up with
|
||||||
|
FFX's needs:
|
||||||
|
|
||||||
|
* FFmpeg documents ``-attach`` as adding an attachment stream to the output, and
|
||||||
|
explicitly names Matroska fonts used in subtitle rendering as an example.
|
||||||
|
* FFmpeg documents attachment streams as regular streams that are created after
|
||||||
|
the mapped media streams.
|
||||||
|
* FFmpeg documents ``-dump_attachment`` for extracting attachment streams, which
|
||||||
|
is useful for debugging or validating a source file's embedded fonts.
|
||||||
|
* FFmpeg's Matroska example requires a ``mimetype`` metadata tag for attached
|
||||||
|
fonts, which is consistent with using attachment tags as detection signals.
|
||||||
|
* FFmpeg also notes that attachments are implemented as codec extradata. That
|
||||||
|
helps explain why probe output for attachment streams can look different from
|
||||||
|
ordinary audio, video, and subtitle streams.
|
||||||
|
|
||||||
|
Implication for FFX:
|
||||||
|
|
||||||
|
* Attachment preservation is not an optional cosmetic feature for this format.
|
||||||
|
It is part of preserving the subtitle package correctly.
|
||||||
|
|
||||||
|
Jellyfin Notes
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Jellyfin's documentation also supports keeping this format intact:
|
||||||
|
|
||||||
|
* Jellyfin's subtitle compatibility table lists ``ASS/SSA`` as supported in
|
||||||
|
``MKV`` and not supported in ``MP4``.
|
||||||
|
* Jellyfin notes that when subtitles must be transcoded, they are either
|
||||||
|
converted to a supported format or burned into the video, and burning them in
|
||||||
|
is the most CPU-intensive path.
|
||||||
|
* Jellyfin's subtitle-extraction example for ``SSA/ASS`` first dumps attachment
|
||||||
|
streams and then extracts the ASS subtitle stream, which reflects the real
|
||||||
|
relationship between ASS subtitles and embedded fonts in MKV releases.
|
||||||
|
* Jellyfin's font documentation says text-based subtitles require fonts to
|
||||||
|
render properly.
|
||||||
|
* Jellyfin's configuration documentation says the web client uses configured
|
||||||
|
fallback fonts for ASS subtitles when other fonts such as MKV attachments or
|
||||||
|
client-side fonts are not available.
|
||||||
|
|
||||||
|
Inference from the Jellyfin compatibility tables:
|
||||||
|
|
||||||
|
* Keeping this subtitle format in Matroska is the safest interoperability choice
|
||||||
|
for Jellyfin consumers.
|
||||||
|
* Converting the subtitle payload to WebVTT would lose styled ASS behavior.
|
||||||
|
* Dropping the attachment streams would force client or fallback font
|
||||||
|
substitution and can change appearance or glyph coverage.
|
||||||
|
|
||||||
|
References
|
||||||
|
----------
|
||||||
|
|
||||||
|
* FFmpeg documentation: https://ffmpeg.org/ffmpeg.html
|
||||||
|
* Jellyfin codec support: https://jellyfin.org/docs/general/clients/codec-support/
|
||||||
|
* Jellyfin configuration and fonts: https://jellyfin.org/docs/general/administration/configuration/
|
||||||
128
docs/history.rst
Normal file
128
docs/history.rst
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
Version History
|
||||||
|
===============
|
||||||
|
|
||||||
|
0.4.5
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Release version bumped to 0.4.5.
|
||||||
|
|
||||||
|
0.4.4
|
||||||
|
-----
|
||||||
|
|
||||||
|
- External subtitle imports can now match prefixless sidecar files against the source basename, choose their extension, and confirm partial substitutions.
|
||||||
|
- Unmux now creates missing output directories with confirmation or the new ``--yes`` option.
|
||||||
|
- Project documentation is now built with Sphinx and includes installation, usage, development, file-format, and API references.
|
||||||
|
|
||||||
|
0.4.3
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Styled ASS subtitle sources with embedded font attachments are now detected explicitly, keep MKV output, preserve current source font attachments, and reject incompatible sidecar subtitle import.
|
||||||
|
- Attachment descriptors are now treated as source-runtime data instead of pattern schema data, so pattern persistence skips them and source-vs-pattern validation ignores them.
|
||||||
|
- Inspect differences no longer report planned changes for attachment filename/count drift while still showing attachment streams in the stream table.
|
||||||
|
|
||||||
|
0.4.2
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Pattern details now show an inline ``Show: <quality>`` hint next to the quality field when the pattern itself has no stored quality but the selected show does.
|
||||||
|
- Inspect stream tables now show attachment format labels like ``TTF`` in the codec column and keep attachment language cells blank instead of showing an undefined language.
|
||||||
|
- FFmpeg damaged-MP3 diagnostics now recognize additional corruption lines such as ``invalid new backstep``, keeping them grouped under the ``warn-corrupt-mpeg-audio`` review summary.
|
||||||
|
|
||||||
|
0.4.1
|
||||||
|
-----
|
||||||
|
|
||||||
|
- ``convert`` now supports ``--copy-video`` and ``--copy-audio`` to keep the selected stream type in copy mode without applying the corresponding reencode flags, filters, or formatting options.
|
||||||
|
- FFmpeg conversions now monitor diagnostics while the process is running, retry unset AVI packet timestamps once with ``-fflags +genpts``, and stop early when a file should be skipped instead of waiting for the full job to finish.
|
||||||
|
- End-of-run convert summaries now list only FFmpeg findings that still require review, including named remedy identifiers such as ``warn-corrupt-mpeg-audio``.
|
||||||
|
- ``upgrade`` now finishes by reporting the installed FFX version together with the active bundle branch.
|
||||||
|
|
||||||
|
0.3.1
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Debug mode screen titles now append the active Textual screen class name, making screen-specific troubleshooting easier during inspect and edit flows.
|
||||||
|
- ``--cut`` again works as a combined flag/option: omitted disables cutting, bare ``--cut`` applies the default ``60,180``, and explicit duration or ``START,DURATION`` values stay supported.
|
||||||
|
- H.265 unmux commands no longer force an invalid ``-f h265`` output format, keeping FFmpeg copy extraction aligned with the required Annex B bitstream filter.
|
||||||
|
- H.264 encoding now falls back from ``libx264`` to ``libopenh264`` with a warning when needed, and the test fixtures use the same encoder fallback so the suite remains portable across FFmpeg builds.
|
||||||
|
|
||||||
|
0.3.0
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Inspect and edit screens now refresh nested track and pattern changes more reliably, with inspect-mode tables aligned to the target pattern view shown in the differences pane.
|
||||||
|
- Metadata editing got a follow-up polish pass with clearer FFmpeg notifications, a shared in-screen log pane, safer apply/reload handling, and expanded cleanup and normalization coverage.
|
||||||
|
- Track and asset probing recognize additional codecs, and the modern test suite now covers more metadata-editor, change-set, screen-state, and asset-probe behavior.
|
||||||
|
- Textual now requires version ``8.0`` or newer to match the UI APIs used by the current screens.
|
||||||
|
|
||||||
|
0.2.6
|
||||||
|
-----
|
||||||
|
|
||||||
|
- DB-free ``ffx edit`` workflow for in-place metadata editing via temporary-file rewrite.
|
||||||
|
- Inspect and edit workflows split into dedicated Textual screens with shared media-workflow support.
|
||||||
|
- Textual tables and row actions now separate raw data from rendered labels to avoid markup leaking into stored metadata.
|
||||||
|
- Responsive screen layout pass, ``Esc`` back handling, sortable show/inspect tables, and improved edit-screen notifications/toggles.
|
||||||
|
- Application-wide UTF-8 i18n catalogs with language precedence from CLI over config over system over German default.
|
||||||
|
- Metadata normalization extended for localized subtitle titles, ISO language cleanup, and smarter track editor language/title helpers.
|
||||||
|
|
||||||
|
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
|
||||||
|
-----
|
||||||
|
|
||||||
|
- PyPI packaging.
|
||||||
|
- Output filename templating.
|
||||||
|
- Season shifting.
|
||||||
|
- DB versioning.
|
||||||
|
|
||||||
|
0.2.2
|
||||||
|
-----
|
||||||
|
|
||||||
|
- CLI overrides.
|
||||||
|
|
||||||
|
0.2.1
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Signature handling.
|
||||||
|
- Tag cleanup.
|
||||||
|
- Bugfixes and refactoring.
|
||||||
|
|
||||||
|
0.2.0
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Tests.
|
||||||
|
- Config file.
|
||||||
|
|
||||||
|
0.1.3
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Subtitle file imports.
|
||||||
|
|
||||||
|
0.1.2
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Bugfixes.
|
||||||
|
|
||||||
|
0.1.1
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Bugfixes.
|
||||||
|
- TMDB show identification.
|
||||||
31
docs/index.rst
Normal file
31
docs/index.rst
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
FFX Documentation
|
||||||
|
=================
|
||||||
|
|
||||||
|
FFX is a local command-line and Textual terminal UI for inspecting TV episode
|
||||||
|
files, storing normalization rules, and converting media into predictable
|
||||||
|
archive-ready outputs.
|
||||||
|
|
||||||
|
This documentation covers operator setup, day-to-day command usage, contributor
|
||||||
|
workflow, format-specific notes, and generated API references for the smaller
|
||||||
|
utility modules.
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: User Guide
|
||||||
|
|
||||||
|
installation
|
||||||
|
usage
|
||||||
|
file_formats
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 1
|
||||||
|
:caption: Release Notes
|
||||||
|
|
||||||
|
history
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Contributor Guide
|
||||||
|
|
||||||
|
development
|
||||||
|
api
|
||||||
52
docs/installation.rst
Normal file
52
docs/installation.rst
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
FFX is designed for a Linux-like workstation with local command execution. The
|
||||||
|
runtime media tools must be available on ``PATH``:
|
||||||
|
|
||||||
|
* ``ffmpeg``
|
||||||
|
* ``ffprobe``
|
||||||
|
* ``cpulimit``
|
||||||
|
|
||||||
|
User Bundle
|
||||||
|
-----------
|
||||||
|
|
||||||
|
The persistent user installation is prepared with the two-step flow described in
|
||||||
|
the project README:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
bash tools/setup.sh
|
||||||
|
bash tools/configure_workstation.sh
|
||||||
|
|
||||||
|
``tools/setup.sh`` creates the long-lived bundle virtualenv at
|
||||||
|
``~/.local/share/ffx.venv`` and exposes the ``ffx`` command. The workstation
|
||||||
|
script checks system tools and seeds local config directories.
|
||||||
|
|
||||||
|
Local Test And Docs Environment
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
Contributor test and documentation work uses the repo-local virtualenv:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
tests/prepare.sh
|
||||||
|
|
||||||
|
The script creates ``.venv``, installs FFX in editable mode with test and docs
|
||||||
|
extras, and verifies the Sphinx toolchain. Use check-only mode when you only
|
||||||
|
want to inspect readiness:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
tests/prepare.sh --check
|
||||||
|
|
||||||
|
Documentation Build
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
After preparation, build the documentation with:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
.venv/bin/sphinx-build -b html docs docs/_build/html
|
||||||
|
|
||||||
|
The generated site starts at ``docs/_build/html/index.html``.
|
||||||
42
docs/make.bat
Normal file
42
docs/make.bat
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
@ECHO OFF
|
||||||
|
|
||||||
|
pushd %~dp0
|
||||||
|
|
||||||
|
if "%SPHINXBUILD%" == "" if exist ..\.venv\Scripts\sphinx-build.exe (
|
||||||
|
set SPHINXBUILD=..\.venv\Scripts\sphinx-build.exe
|
||||||
|
)
|
||||||
|
if "%SPHINXBUILD%" == "" set SPHINXBUILD=sphinx-build
|
||||||
|
set SOURCEDIR=.
|
||||||
|
set BUILDDIR=_build
|
||||||
|
|
||||||
|
%SPHINXBUILD% >NUL 2>NUL
|
||||||
|
if errorlevel 9009 (
|
||||||
|
echo.
|
||||||
|
echo The 'sphinx-build' command was not found. Make sure Sphinx is installed,
|
||||||
|
echo then set SPHINXBUILD to the full path if needed.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "" goto help
|
||||||
|
if "%1" == "html" goto html
|
||||||
|
if "%1" == "linkcheck" goto linkcheck
|
||||||
|
echo.
|
||||||
|
echo Unknown target "%1".
|
||||||
|
goto help
|
||||||
|
|
||||||
|
:html
|
||||||
|
%SPHINXBUILD% -b html %SOURCEDIR% %BUILDDIR%\html %SPHINXOPTS%
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:linkcheck
|
||||||
|
%SPHINXBUILD% -b linkcheck %SOURCEDIR% %BUILDDIR%\linkcheck %SPHINXOPTS%
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:help
|
||||||
|
echo.
|
||||||
|
echo Please use 'make.bat ^<target^>' where ^<target^> is one of
|
||||||
|
echo html to make standalone HTML files
|
||||||
|
echo linkcheck to check all external links for integrity
|
||||||
|
|
||||||
|
:end
|
||||||
|
popd
|
||||||
97
docs/usage.rst
Normal file
97
docs/usage.rst
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
Usage
|
||||||
|
=====
|
||||||
|
|
||||||
|
FFX exposes a single ``ffx`` command with subcommands for inspection,
|
||||||
|
conversion, metadata editing, setup, and maintenance.
|
||||||
|
|
||||||
|
Inspect Files
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Open the inspection workflow for one or more files:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx inspect /path/to/episode.mkv
|
||||||
|
|
||||||
|
Print resolved season-shift mappings without opening the TUI:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx inspect --shift /path/to/episode.mkv
|
||||||
|
|
||||||
|
Convert Files
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Convert one or more source files using stored rules where available:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx convert /path/to/episode.mkv
|
||||||
|
|
||||||
|
Useful overrides include:
|
||||||
|
|
||||||
|
* ``--no-pattern`` to skip database pattern matching
|
||||||
|
* ``--show``, ``--season``, and ``--episode`` for explicit episode identity
|
||||||
|
* ``--output-directory`` for generated output placement
|
||||||
|
* ``--subtitle-directory`` for source-basename sidecar subtitle imports
|
||||||
|
* ``--subtitle-prefix`` for explicit or configured-prefix subtitle imports
|
||||||
|
* ``--subtitle-extension`` to select the imported sidecar format (default:
|
||||||
|
``vtt``)
|
||||||
|
* ``--yes`` to accept a valid partial sidecar set without prompting
|
||||||
|
* ``--copy-video`` or ``--copy-audio`` to preserve selected stream types
|
||||||
|
* ``--rename-only`` for filename normalization without media rewriting
|
||||||
|
|
||||||
|
Directory-only subtitle import matches the source basename. For example,
|
||||||
|
``A2_t01.mkv`` discovers files such as ``A2_t01_2_deu_DEF.vtt`` in the
|
||||||
|
provided directory:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx convert --subtitle-directory /path/to/subtitles A2_t01.mkv
|
||||||
|
|
||||||
|
Select a different sidecar set by extension, with or without the leading dot:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx convert --subtitle-directory /path/to/subtitles \
|
||||||
|
--subtitle-extension .mkv A2_t01.mkv
|
||||||
|
|
||||||
|
When only some source subtitle tracks have matching sidecar files, conversion
|
||||||
|
asks for confirmation. Use ``--yes`` to substitute that valid subset without
|
||||||
|
prompting. ``--yes`` also permits this case when ``--no-prompt`` is set.
|
||||||
|
|
||||||
|
Manage Shows And Patterns
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
Open the Textual interface for show and pattern management:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx shows
|
||||||
|
|
||||||
|
Extract Streams
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Extract streams from a file:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx unmux /path/to/episode.mkv
|
||||||
|
|
||||||
|
For subtitle-only extraction:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx unmux --subtitles-only --label show-name /path/to/episode.mkv
|
||||||
|
|
||||||
|
Detect Crop
|
||||||
|
-----------
|
||||||
|
|
||||||
|
Ask FFmpeg to suggest crop parameters:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx cropdetect /path/to/episode.mkv
|
||||||
|
|
||||||
|
The default sampling window is controlled by the application defaults and can be
|
||||||
|
overridden with command options.
|
||||||
@@ -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.4.1"
|
version = "0.4.5"
|
||||||
license = {file = "LICENSE.md"}
|
license = {file = "LICENSE.md"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"requests",
|
"requests",
|
||||||
@@ -31,6 +31,12 @@ Issues = "https://gitea.maveno.de/Javanaut/ffx/issues"
|
|||||||
test = [
|
test = [
|
||||||
"pytest",
|
"pytest",
|
||||||
]
|
]
|
||||||
|
docs = [
|
||||||
|
"esbonio",
|
||||||
|
"sphinx",
|
||||||
|
"sphinx-copybutton",
|
||||||
|
"sphinx-rtd-theme",
|
||||||
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = [
|
requires = [
|
||||||
|
|||||||
308
src/ffx/cli.py
308
src/ffx/cli.py
@@ -41,13 +41,17 @@ CPU_OPTION_HELP = (
|
|||||||
+ "Omit to disable; 0 also disables."
|
+ "Omit to disable; 0 also disables."
|
||||||
)
|
)
|
||||||
SUBTITLE_DIRECTORY_OPTION_HELP = (
|
SUBTITLE_DIRECTORY_OPTION_HELP = (
|
||||||
"Load subtitles from here. When omitted and --subtitle-prefix is set, "
|
"Load subtitles from here. Without --subtitle-prefix, match the source filename "
|
||||||
|
+ "basename. When omitted and --subtitle-prefix is set, "
|
||||||
+ "FFX uses the configured subtitlesDirectory base path plus the prefix as a subdirectory."
|
+ "FFX uses the configured subtitlesDirectory base path plus the prefix as a subdirectory."
|
||||||
)
|
)
|
||||||
SUBTITLE_PREFIX_OPTION_HELP = (
|
SUBTITLE_PREFIX_OPTION_HELP = (
|
||||||
"Subtitle filename prefix. Requires --subtitle-directory, or a configured "
|
"Subtitle filename prefix. Requires --subtitle-directory, or a configured "
|
||||||
+ "subtitlesDirectory base path that contains a matching <prefix>/ subdirectory."
|
+ "subtitlesDirectory base path that contains a matching <prefix>/ subdirectory."
|
||||||
)
|
)
|
||||||
|
SUBTITLE_EXTENSION_OPTION_HELP = (
|
||||||
|
"External subtitle filename extension. A leading dot is optional."
|
||||||
|
)
|
||||||
UNMUX_OUTPUT_DIRECTORY_OPTION_HELP = (
|
UNMUX_OUTPUT_DIRECTORY_OPTION_HELP = (
|
||||||
"Write extracted streams here. When omitted together with --subtitles-only and "
|
"Write extracted streams here. When omitted together with --subtitles-only and "
|
||||||
+ "--label, FFX uses the configured subtitlesDirectory base path plus the label."
|
+ "--label, FFX uses the configured subtitlesDirectory base path plus the label."
|
||||||
@@ -96,6 +100,31 @@ def normalizeCpuOption(ctx, param, value):
|
|||||||
raise click.BadParameter(str(ex)) from ex
|
raise click.BadParameter(str(ex)) from ex
|
||||||
|
|
||||||
|
|
||||||
|
def normalizeDeinterlaceOption(ctx, param, value):
|
||||||
|
if value == 'none':
|
||||||
|
return value
|
||||||
|
|
||||||
|
from ffx.filter.deinterlace_filter import DeinterlaceFilter
|
||||||
|
|
||||||
|
try:
|
||||||
|
DeinterlaceFilter.getModeTokens(value)
|
||||||
|
except ValueError as ex:
|
||||||
|
raise click.BadParameter(str(ex)) from ex
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def normalizeSubtitleExtension(ctx, param, value):
|
||||||
|
normalizedExtension = str(value).strip().lower()
|
||||||
|
if normalizedExtension.startswith('.'):
|
||||||
|
normalizedExtension = normalizedExtension[1:]
|
||||||
|
if not normalizedExtension or not normalizedExtension.isalnum():
|
||||||
|
raise click.BadParameter(
|
||||||
|
"Subtitle extension must contain only letters and numbers, "
|
||||||
|
+ "with an optional leading dot."
|
||||||
|
)
|
||||||
|
return normalizedExtension
|
||||||
|
|
||||||
|
|
||||||
def parseCutOptionValue(value) -> tuple[int, int] | None:
|
def parseCutOptionValue(value) -> tuple[int, int] | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -146,11 +175,21 @@ def resolveSubtitleImportOptions(context, subtitleDirectory, subtitlePrefix):
|
|||||||
else ''
|
else ''
|
||||||
)
|
)
|
||||||
|
|
||||||
if not resolvedSubtitlePrefix:
|
|
||||||
return False, resolvedSubtitleDirectory, resolvedSubtitlePrefix
|
|
||||||
|
|
||||||
if resolvedSubtitleDirectory:
|
if resolvedSubtitleDirectory:
|
||||||
return True, resolvedSubtitleDirectory, resolvedSubtitlePrefix
|
if not os.path.isdir(resolvedSubtitleDirectory):
|
||||||
|
raise click.ClickException(
|
||||||
|
"The provided subtitle directory does not exist: "
|
||||||
|
+ resolvedSubtitleDirectory
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
True,
|
||||||
|
resolvedSubtitleDirectory,
|
||||||
|
resolvedSubtitlePrefix,
|
||||||
|
not resolvedSubtitlePrefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not resolvedSubtitlePrefix:
|
||||||
|
return False, resolvedSubtitleDirectory, resolvedSubtitlePrefix, False
|
||||||
|
|
||||||
configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath()
|
configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath()
|
||||||
if not configuredSubtitlesBaseDirectory:
|
if not configuredSubtitlesBaseDirectory:
|
||||||
@@ -170,7 +209,85 @@ def resolveSubtitleImportOptions(context, subtitleDirectory, subtitlePrefix):
|
|||||||
+ resolvedSubtitleDirectory
|
+ resolvedSubtitleDirectory
|
||||||
)
|
)
|
||||||
|
|
||||||
return True, resolvedSubtitleDirectory, resolvedSubtitlePrefix
|
return True, resolvedSubtitleDirectory, resolvedSubtitlePrefix, False
|
||||||
|
|
||||||
|
|
||||||
|
def importExternalSubtitles(
|
||||||
|
context,
|
||||||
|
mediaDescriptor,
|
||||||
|
sourceFileBasename,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
preserveDispositions=False,
|
||||||
|
):
|
||||||
|
matchSourceBasename = context['subtitle_match_source_basename']
|
||||||
|
subtitlePrefix = (
|
||||||
|
sourceFileBasename
|
||||||
|
if matchSourceBasename
|
||||||
|
else context['subtitle_prefix']
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
importResult = mediaDescriptor.importSubtitles(
|
||||||
|
context['subtitle_directory'],
|
||||||
|
subtitlePrefix,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
preserve_dispositions=preserveDispositions,
|
||||||
|
extension=context['subtitle_extension'],
|
||||||
|
strict=matchSourceBasename,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError) as ex:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"External subtitle discovery failed for '{sourceFileBasename}': {ex}"
|
||||||
|
) from ex
|
||||||
|
|
||||||
|
if not matchSourceBasename:
|
||||||
|
return importResult
|
||||||
|
|
||||||
|
importedTrackIndices = importResult['imported_track_indices']
|
||||||
|
missingTrackIndices = importResult['missing_track_indices']
|
||||||
|
extension = context['subtitle_extension']
|
||||||
|
importedDescription = (
|
||||||
|
', '.join(f"#{index}" for index in importedTrackIndices)
|
||||||
|
if importedTrackIndices
|
||||||
|
else 'none'
|
||||||
|
)
|
||||||
|
click.echo(
|
||||||
|
f"External subtitle scan for '{sourceFileBasename}': found "
|
||||||
|
+ f"{importResult['candidate_count']} .{extension} file(s); "
|
||||||
|
+ f"matched subtitle tracks {importedDescription}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not missingTrackIndices:
|
||||||
|
return importResult
|
||||||
|
|
||||||
|
missingDescription = ', '.join(f"#{index}" for index in missingTrackIndices)
|
||||||
|
incompleteMessage = (
|
||||||
|
f"External subtitle files are missing for subtitle tracks "
|
||||||
|
+ f"{missingDescription} in '{sourceFileBasename}'."
|
||||||
|
)
|
||||||
|
if context.get('yes', False):
|
||||||
|
click.echo(
|
||||||
|
incompleteMessage
|
||||||
|
+ " Continuing with the matching subtitle files because --yes is set."
|
||||||
|
)
|
||||||
|
return importResult
|
||||||
|
|
||||||
|
if context['no_prompt']:
|
||||||
|
raise click.ClickException(
|
||||||
|
incompleteMessage
|
||||||
|
+ " Partial subtitle substitution requires confirmation, but --no-prompt is set."
|
||||||
|
)
|
||||||
|
|
||||||
|
click.echo(incompleteMessage)
|
||||||
|
if not click.confirm(
|
||||||
|
"Continue and substitute only the subtitle tracks with matching files?",
|
||||||
|
default=False,
|
||||||
|
):
|
||||||
|
raise click.ClickException("External subtitle substitution aborted by user.")
|
||||||
|
|
||||||
|
return importResult
|
||||||
|
|
||||||
|
|
||||||
def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label):
|
def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label):
|
||||||
@@ -181,7 +298,10 @@ def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label):
|
|||||||
)
|
)
|
||||||
resolvedLabel = str(label).strip()
|
resolvedLabel = str(label).strip()
|
||||||
|
|
||||||
if resolvedOutputDirectory or not subtitlesOnly or not resolvedLabel:
|
if resolvedOutputDirectory:
|
||||||
|
return resolvedOutputDirectory, True
|
||||||
|
|
||||||
|
if not subtitlesOnly or not resolvedLabel:
|
||||||
return resolvedOutputDirectory, False
|
return resolvedOutputDirectory, False
|
||||||
|
|
||||||
configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath()
|
configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath()
|
||||||
@@ -194,6 +314,63 @@ def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label):
|
|||||||
return os.path.join(configuredSubtitlesBaseDirectory, resolvedLabel), True
|
return os.path.join(configuredSubtitlesBaseDirectory, resolvedLabel), True
|
||||||
|
|
||||||
|
|
||||||
|
def confirmUnmuxOutputDirectoryCreation(outputDirectory):
|
||||||
|
message = (
|
||||||
|
"Create unmux output directory and missing parents: "
|
||||||
|
+ str(outputDirectory)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
return click.confirm(message, default=True)
|
||||||
|
|
||||||
|
click.echo(f"{message} [Y/n]: ", nl=False)
|
||||||
|
while True:
|
||||||
|
char = click.getchar()
|
||||||
|
if char in ('\r', '\n'):
|
||||||
|
click.echo()
|
||||||
|
return True
|
||||||
|
|
||||||
|
normalizedChar = char.lower()
|
||||||
|
if normalizedChar == 'y':
|
||||||
|
click.echo(char)
|
||||||
|
return True
|
||||||
|
if normalizedChar == 'n':
|
||||||
|
click.echo(char)
|
||||||
|
return False
|
||||||
|
if char in ('\x03', '\x04'):
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
click.echo("\nPlease respond with 'y' or 'n': ", nl=False)
|
||||||
|
|
||||||
|
|
||||||
|
def ensureUnmuxOutputDirectory(context, outputDirectory):
|
||||||
|
resolvedOutputDirectory = os.path.expanduser(str(outputDirectory).strip())
|
||||||
|
if not resolvedOutputDirectory:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if os.path.isdir(resolvedOutputDirectory):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if os.path.exists(resolvedOutputDirectory):
|
||||||
|
raise click.ClickException(
|
||||||
|
"Unmux output path exists but is not a directory: "
|
||||||
|
+ resolvedOutputDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
if context.get('dry_run', False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if context.get('yes', False):
|
||||||
|
os.makedirs(resolvedOutputDirectory, exist_ok=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not confirmUnmuxOutputDirectoryCreation(resolvedOutputDirectory):
|
||||||
|
raise click.ClickException("Unmux output directory creation aborted by user.")
|
||||||
|
|
||||||
|
os.makedirs(resolvedOutputDirectory, exist_ok=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def resolveIndicatorDigitLengths(context=None, showDescriptor=None):
|
def resolveIndicatorDigitLengths(context=None, showDescriptor=None):
|
||||||
from ffx.show_descriptor import ShowDescriptor
|
from ffx.show_descriptor import ShowDescriptor
|
||||||
|
|
||||||
@@ -716,6 +893,12 @@ def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix,
|
|||||||
@click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix')
|
@click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix')
|
||||||
@click.option("-o", "--output-directory", type=str, default='', help=UNMUX_OUTPUT_DIRECTORY_OPTION_HELP)
|
@click.option("-o", "--output-directory", type=str, default='', help=UNMUX_OUTPUT_DIRECTORY_OPTION_HELP)
|
||||||
@click.option("-s", "--subtitles-only", is_flag=True, default=False)
|
@click.option("-s", "--subtitles-only", is_flag=True, default=False)
|
||||||
|
@click.option(
|
||||||
|
"--yes",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Create a missing unmux output directory without prompting.",
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--nice',
|
'--nice',
|
||||||
type=int,
|
type=int,
|
||||||
@@ -737,6 +920,7 @@ def unmux(ctx,
|
|||||||
label,
|
label,
|
||||||
output_directory,
|
output_directory,
|
||||||
subtitles_only,
|
subtitles_only,
|
||||||
|
yes,
|
||||||
nice,
|
nice,
|
||||||
cpu):
|
cpu):
|
||||||
from ffx.file_properties import FileProperties
|
from ffx.file_properties import FileProperties
|
||||||
@@ -752,15 +936,16 @@ def unmux(ctx,
|
|||||||
ctx.obj['resource_limits']['niceness'] = nice
|
ctx.obj['resource_limits']['niceness'] = nice
|
||||||
ctx.obj['resource_limits']['cpu_limit'] = cpu
|
ctx.obj['resource_limits']['cpu_limit'] = cpu
|
||||||
ctx.obj['resource_limits']['cpu_percent'] = cpu
|
ctx.obj['resource_limits']['cpu_percent'] = cpu
|
||||||
|
ctx.obj['yes'] = bool(yes)
|
||||||
|
|
||||||
output_directory, create_output_directory = resolveUnmuxOutputDirectory(
|
output_directory, requires_output_directory = resolveUnmuxOutputDirectory(
|
||||||
ctx.obj,
|
ctx.obj,
|
||||||
output_directory,
|
output_directory,
|
||||||
subtitles_only,
|
subtitles_only,
|
||||||
label,
|
label,
|
||||||
)
|
)
|
||||||
if create_output_directory and existingSourcePaths and not ctx.obj.get('dry_run', False):
|
if requires_output_directory and existingSourcePaths:
|
||||||
os.makedirs(output_directory, exist_ok=True)
|
ensureUnmuxOutputDirectory(ctx.obj, output_directory)
|
||||||
|
|
||||||
shiftedSeasonController = ShiftedSeasonController(ctx.obj)
|
shiftedSeasonController = ShiftedSeasonController(ctx.obj)
|
||||||
|
|
||||||
@@ -974,6 +1159,14 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
|||||||
|
|
||||||
@click.option('--subtitle-directory', type=str, default='', help=SUBTITLE_DIRECTORY_OPTION_HELP)
|
@click.option('--subtitle-directory', type=str, default='', help=SUBTITLE_DIRECTORY_OPTION_HELP)
|
||||||
@click.option('--subtitle-prefix', type=str, default='', help=SUBTITLE_PREFIX_OPTION_HELP)
|
@click.option('--subtitle-prefix', type=str, default='', help=SUBTITLE_PREFIX_OPTION_HELP)
|
||||||
|
@click.option(
|
||||||
|
'--subtitle-extension',
|
||||||
|
type=str,
|
||||||
|
default='vtt',
|
||||||
|
callback=normalizeSubtitleExtension,
|
||||||
|
show_default=True,
|
||||||
|
help=SUBTITLE_EXTENSION_OPTION_HELP,
|
||||||
|
)
|
||||||
|
|
||||||
@click.option('--language', type=str, multiple=True, help='Set stream language. Use format <stream index>:<3 letter iso code>')
|
@click.option('--language', type=str, multiple=True, help='Set stream language. Use format <stream index>:<3 letter iso code>')
|
||||||
@click.option('--title', type=str, multiple=True, help='Set stream title. Use format <stream index>:<title>')
|
@click.option('--title', type=str, multiple=True, help='Set stream title. Use format <stream index>:<title>')
|
||||||
@@ -1014,7 +1207,21 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
|||||||
|
|
||||||
@click.option("--output-directory", type=str, default='')
|
@click.option("--output-directory", type=str, default='')
|
||||||
|
|
||||||
@click.option("--deinterlace", is_flag=False, flag_value="default", default="none")
|
@click.option(
|
||||||
|
"--deinterlace",
|
||||||
|
type=str,
|
||||||
|
metavar="SPEC",
|
||||||
|
is_flag=False,
|
||||||
|
flag_value="default",
|
||||||
|
default="none",
|
||||||
|
show_default=True,
|
||||||
|
callback=normalizeDeinterlaceOption,
|
||||||
|
help=(
|
||||||
|
"Deinterlace with bwdif followed by ordered upN, downN, and tempHHHH "
|
||||||
|
"components. Scale factors N range from 2 to 8; H is hexadecimal. "
|
||||||
|
"For example: up2_temp3366_down2."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@click.option("--denoise", is_flag=False, flag_value="default", default="none")
|
@click.option("--denoise", is_flag=False, flag_value="default", default="none")
|
||||||
@click.option("--denoise-use-hw", is_flag=True, default=False)
|
@click.option("--denoise-use-hw", is_flag=True, default=False)
|
||||||
@@ -1034,6 +1241,12 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
|||||||
@click.option("--dont-pass-dispositions", is_flag=True, default=False)
|
@click.option("--dont-pass-dispositions", is_flag=True, default=False)
|
||||||
|
|
||||||
@click.option("--no-prompt", is_flag=True, default=False)
|
@click.option("--no-prompt", is_flag=True, default=False)
|
||||||
|
@click.option(
|
||||||
|
"--yes",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Confirm partial external subtitle substitution without prompting.",
|
||||||
|
)
|
||||||
@click.option("--no-signature", is_flag=True, default=False)
|
@click.option("--no-signature", is_flag=True, default=False)
|
||||||
@click.option("--keep-mkvmerge-metadata", is_flag=True, default=False)
|
@click.option("--keep-mkvmerge-metadata", is_flag=True, default=False)
|
||||||
|
|
||||||
@@ -1070,6 +1283,7 @@ def convert(ctx,
|
|||||||
|
|
||||||
subtitle_directory,
|
subtitle_directory,
|
||||||
subtitle_prefix,
|
subtitle_prefix,
|
||||||
|
subtitle_extension,
|
||||||
|
|
||||||
language,
|
language,
|
||||||
title,
|
title,
|
||||||
@@ -1108,6 +1322,7 @@ def convert(ctx,
|
|||||||
no_pattern,
|
no_pattern,
|
||||||
dont_pass_dispositions,
|
dont_pass_dispositions,
|
||||||
no_prompt,
|
no_prompt,
|
||||||
|
yes,
|
||||||
no_signature,
|
no_signature,
|
||||||
keep_mkvmerge_metadata,
|
keep_mkvmerge_metadata,
|
||||||
|
|
||||||
@@ -1139,6 +1354,7 @@ def convert(ctx,
|
|||||||
from ffx.tmdb_controller import TmdbController
|
from ffx.tmdb_controller import TmdbController
|
||||||
from ffx.track_codec import TrackCodec
|
from ffx.track_codec import TrackCodec
|
||||||
from ffx.track_disposition import TrackDisposition
|
from ffx.track_disposition import TrackDisposition
|
||||||
|
from ffx.track_type import TrackType
|
||||||
from ffx.video_encoder import VideoEncoder
|
from ffx.video_encoder import VideoEncoder
|
||||||
|
|
||||||
startTime = time.perf_counter()
|
startTime = time.perf_counter()
|
||||||
@@ -1161,6 +1377,7 @@ def convert(ctx,
|
|||||||
context['use_tmdb'] = not no_tmdb
|
context['use_tmdb'] = not no_tmdb
|
||||||
context['use_pattern'] = not no_pattern
|
context['use_pattern'] = not no_pattern
|
||||||
context['no_prompt'] = no_prompt
|
context['no_prompt'] = no_prompt
|
||||||
|
context['yes'] = yes
|
||||||
context['no_signature'] = no_signature
|
context['no_signature'] = no_signature
|
||||||
context['keep_mkvmerge_metadata'] = keep_mkvmerge_metadata
|
context['keep_mkvmerge_metadata'] = keep_mkvmerge_metadata
|
||||||
|
|
||||||
@@ -1179,6 +1396,7 @@ def convert(ctx,
|
|||||||
context['import_subtitles'],
|
context['import_subtitles'],
|
||||||
resolvedSubtitleDirectory,
|
resolvedSubtitleDirectory,
|
||||||
resolvedSubtitlePrefix,
|
resolvedSubtitlePrefix,
|
||||||
|
context['subtitle_match_source_basename'],
|
||||||
) = resolveSubtitleImportOptions(
|
) = resolveSubtitleImportOptions(
|
||||||
context,
|
context,
|
||||||
subtitle_directory,
|
subtitle_directory,
|
||||||
@@ -1187,6 +1405,7 @@ def convert(ctx,
|
|||||||
if context['import_subtitles']:
|
if context['import_subtitles']:
|
||||||
context['subtitle_directory'] = resolvedSubtitleDirectory
|
context['subtitle_directory'] = resolvedSubtitleDirectory
|
||||||
context['subtitle_prefix'] = resolvedSubtitlePrefix
|
context['subtitle_prefix'] = resolvedSubtitlePrefix
|
||||||
|
context['subtitle_extension'] = subtitle_extension
|
||||||
|
|
||||||
|
|
||||||
existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in SUPPORTED_INPUT_FILE_EXTENSIONS]
|
existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in SUPPORTED_INPUT_FILE_EXTENSIONS]
|
||||||
@@ -1332,7 +1551,7 @@ def convert(ctx,
|
|||||||
NlmeansFilter(**denoiseKwargs)
|
NlmeansFilter(**denoiseKwargs)
|
||||||
|
|
||||||
if deinterlace != 'none' and not copyVideoEffective:
|
if deinterlace != 'none' and not copyVideoEffective:
|
||||||
DeinterlaceFilter()
|
DeinterlaceFilter(mode=deinterlace)
|
||||||
|
|
||||||
chainYield = list(qf.getChainYield())
|
chainYield = list(qf.getChainYield())
|
||||||
|
|
||||||
@@ -1393,13 +1612,29 @@ def convert(ctx,
|
|||||||
|
|
||||||
from ffx.attachment_format import AttachmentFormat
|
from ffx.attachment_format import AttachmentFormat
|
||||||
|
|
||||||
if ([smd for smd in sourceMediaDescriptor.getSubtitleTracks()
|
styledAssDetector = getattr(
|
||||||
if smd.getCodec() == TrackCodec.ASS]
|
sourceMediaDescriptor,
|
||||||
and [amd for amd in sourceMediaDescriptor.getAttachmentTracks()
|
"hasStyledAssSubtitlesWithFontAttachments",
|
||||||
if amd.getAttachmentFormat() == AttachmentFormat.TTF]):
|
None,
|
||||||
|
)
|
||||||
|
styledAssSourceDetected = (
|
||||||
|
bool(styledAssDetector())
|
||||||
|
if callable(styledAssDetector)
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
if styledAssSourceDetected:
|
||||||
|
styledAssMessage = (
|
||||||
|
"Styled ASS subtitles with embedded font attachments detected; "
|
||||||
|
+ "preserving source font attachments."
|
||||||
|
)
|
||||||
|
click.echo(styledAssMessage)
|
||||||
targetFormat = ''
|
targetFormat = ''
|
||||||
targetExtension = 'mkv'
|
targetExtension = 'mkv'
|
||||||
|
if context['import_subtitles']:
|
||||||
|
raise click.ClickException(
|
||||||
|
"External subtitle import is incompatible with styled ASS "
|
||||||
|
+ "sources that carry embedded font attachments."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#HINT: This is None if the filename did not match anything in database
|
#HINT: This is None if the filename did not match anything in database
|
||||||
@@ -1414,10 +1649,13 @@ def convert(ctx,
|
|||||||
currentShowDescriptor = None
|
currentShowDescriptor = None
|
||||||
|
|
||||||
if context['import_subtitles']:
|
if context['import_subtitles']:
|
||||||
sourceMediaDescriptor.importSubtitles(context['subtitle_directory'],
|
importExternalSubtitles(
|
||||||
context['subtitle_prefix'],
|
context,
|
||||||
showSeason,
|
sourceMediaDescriptor,
|
||||||
showEpisode)
|
sourceFileBasename,
|
||||||
|
showSeason,
|
||||||
|
showEpisode,
|
||||||
|
)
|
||||||
|
|
||||||
if cliOverrides:
|
if cliOverrides:
|
||||||
sourceMediaDescriptor.applyOverrides(cliOverrides)
|
sourceMediaDescriptor.applyOverrides(cliOverrides)
|
||||||
@@ -1426,6 +1664,12 @@ def convert(ctx,
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj)
|
targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj)
|
||||||
|
if styledAssSourceDetected:
|
||||||
|
targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks(
|
||||||
|
sourceMediaDescriptor,
|
||||||
|
AttachmentFormat.TTF,
|
||||||
|
context=ctx.obj,
|
||||||
|
)
|
||||||
checkUniqueDispositions(context, targetMediaDescriptor)
|
checkUniqueDispositions(context, targetMediaDescriptor)
|
||||||
currentShowDescriptor = currentPattern.getShowDescriptor(ctx.obj)
|
currentShowDescriptor = currentPattern.getShowDescriptor(ctx.obj)
|
||||||
|
|
||||||
@@ -1435,6 +1679,8 @@ def convert(ctx,
|
|||||||
targetTrackDescriptorList = targetMediaDescriptor.getTrackDescriptors()
|
targetTrackDescriptorList = targetMediaDescriptor.getTrackDescriptors()
|
||||||
|
|
||||||
for ttd in targetTrackDescriptorList:
|
for ttd in targetTrackDescriptorList:
|
||||||
|
if ttd.getType() == TrackType.ATTACHMENT:
|
||||||
|
continue
|
||||||
|
|
||||||
tti = ttd.getIndex()
|
tti = ttd.getIndex()
|
||||||
ttsi = ttd.getSourceIndex()
|
ttsi = ttd.getSourceIndex()
|
||||||
@@ -1453,11 +1699,14 @@ def convert(ctx,
|
|||||||
|
|
||||||
|
|
||||||
if context['import_subtitles']:
|
if context['import_subtitles']:
|
||||||
targetMediaDescriptor.importSubtitles(context['subtitle_directory'],
|
importExternalSubtitles(
|
||||||
context['subtitle_prefix'],
|
context,
|
||||||
showSeason,
|
targetMediaDescriptor,
|
||||||
showEpisode,
|
sourceFileBasename,
|
||||||
preserve_dispositions=True)
|
showSeason,
|
||||||
|
showEpisode,
|
||||||
|
preserveDispositions=True,
|
||||||
|
)
|
||||||
|
|
||||||
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
|
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
|
||||||
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")
|
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")
|
||||||
@@ -1468,7 +1717,10 @@ def convert(ctx,
|
|||||||
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
|
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
|
||||||
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")
|
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")
|
||||||
|
|
||||||
ctx.obj['logger'].debug(f"Input mapping tokens (2nd pass): {targetMediaDescriptor.getInputMappingTokens()}")
|
ctx.obj['logger'].debug(
|
||||||
|
"Input mapping tokens (2nd pass): "
|
||||||
|
+ f"{targetMediaDescriptor.getInputMappingTokens(sourceMediaDescriptor=sourceMediaDescriptor)}"
|
||||||
|
)
|
||||||
|
|
||||||
fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor)
|
fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor)
|
||||||
|
|
||||||
@@ -1628,7 +1880,7 @@ def convert(ctx,
|
|||||||
for summaryLine in iterUnremediedIssueSummaryLines(context):
|
for summaryLine in iterUnremediedIssueSummaryLines(context):
|
||||||
ctx.obj['logger'].warning(summaryLine)
|
ctx.obj['logger'].warning(summaryLine)
|
||||||
else:
|
else:
|
||||||
ctx.obj['logger'].info("All files converted with no ffmpeg findings requiring review.")
|
ctx.obj['logger'].info("All files converted with no issues.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
VERSION='0.4.1'
|
VERSION='0.4.5'
|
||||||
DATABASE_VERSION = 3
|
DATABASE_VERSION = 3
|
||||||
|
|
||||||
DEFAULT_QUALITY = 32
|
DEFAULT_QUALITY = 32
|
||||||
|
|||||||
@@ -108,12 +108,18 @@ def ensureCurrentSchemaCompatibility(databaseContext):
|
|||||||
column['name']
|
column['name']
|
||||||
for column in inspector.get_columns('shows')
|
for column in inspector.get_columns('shows')
|
||||||
}
|
}
|
||||||
|
trackColumns = {
|
||||||
|
column['name']
|
||||||
|
for column in inspector.get_columns('tracks')
|
||||||
|
}
|
||||||
|
|
||||||
alterStatements = []
|
alterStatements = []
|
||||||
if 'quality' not in showColumns:
|
if 'quality' not in showColumns:
|
||||||
alterStatements.append("ALTER TABLE shows ADD COLUMN quality INTEGER DEFAULT 0")
|
alterStatements.append("ALTER TABLE shows ADD COLUMN quality INTEGER DEFAULT 0")
|
||||||
if 'notes' not in showColumns:
|
if 'notes' not in showColumns:
|
||||||
alterStatements.append("ALTER TABLE shows ADD COLUMN notes TEXT DEFAULT ''")
|
alterStatements.append("ALTER TABLE shows ADD COLUMN notes TEXT DEFAULT ''")
|
||||||
|
if 'dropped' not in trackColumns:
|
||||||
|
alterStatements.append("ALTER TABLE tracks ADD COLUMN dropped INTEGER DEFAULT 0")
|
||||||
|
|
||||||
if not alterStatements:
|
if not alterStatements:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class WarnCorruptMpegAudioRemedy(FfmpegRemedy):
|
|||||||
identifier = "warn-corrupt-mpeg-audio"
|
identifier = "warn-corrupt-mpeg-audio"
|
||||||
PATTERNS = (
|
PATTERNS = (
|
||||||
re.compile(r"\[mp3float @ .*\] invalid block type", re.IGNORECASE),
|
re.compile(r"\[mp3float @ .*\] invalid block type", re.IGNORECASE),
|
||||||
|
re.compile(r"\[mp3float @ .*\] invalid new backstep -?\d+", re.IGNORECASE),
|
||||||
re.compile(r"\[mp3float @ .*\] Header missing"),
|
re.compile(r"\[mp3float @ .*\] Header missing"),
|
||||||
re.compile(r"\[mp3float @ .*\] overread, skip ", re.IGNORECASE),
|
re.compile(r"\[mp3float @ .*\] overread, skip ", re.IGNORECASE),
|
||||||
re.compile(r"Error while decoding MPEG audio frame\."),
|
re.compile(r"Error while decoding MPEG audio frame\."),
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from textual.app import App
|
from textual.app import App
|
||||||
|
|
||||||
from .i18n import set_current_language, t
|
from .i18n import set_current_language, t
|
||||||
|
from .confirm_screen import ConfirmScreen
|
||||||
from .shows_screen import ShowsScreen
|
from .shows_screen import ShowsScreen
|
||||||
from .inspect_details_screen import InspectDetailsScreen
|
from .inspect_details_screen import InspectDetailsScreen
|
||||||
from .media_edit_screen import MediaEditScreen
|
from .media_edit_screen import MediaEditScreen
|
||||||
|
from .pattern_controller import InvalidPatternCaptureGroupError, PatternController
|
||||||
from .screen_support import configure_screen_log_handler, set_screen_log_pane_enabled
|
from .screen_support import configure_screen_log_handler, set_screen_log_pane_enabled
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +42,20 @@ class FfxApp(App):
|
|||||||
self.push_screen(ShowsScreen())
|
self.push_screen(ShowsScreen())
|
||||||
|
|
||||||
if self.context['command'] == 'inspect':
|
if self.context['command'] == 'inspect':
|
||||||
self.push_screen(InspectDetailsScreen())
|
try:
|
||||||
|
self.push_screen(InspectDetailsScreen())
|
||||||
|
except InvalidPatternCaptureGroupError as ex:
|
||||||
|
self._invalid_pattern_error = ex
|
||||||
|
self.push_screen(
|
||||||
|
ConfirmScreen(
|
||||||
|
t(
|
||||||
|
"Pattern #{pattern_id} matches the filename but has no capture group for the season/episode indicator. Delete this pattern and quit?"
|
||||||
|
).format(pattern_id=ex.pattern_id),
|
||||||
|
confirm_label=t("Delete"),
|
||||||
|
cancel_label=t("Quit"),
|
||||||
|
),
|
||||||
|
self._handle_invalid_pattern_confirmation,
|
||||||
|
)
|
||||||
|
|
||||||
if self.context['command'] == 'edit':
|
if self.context['command'] == 'edit':
|
||||||
self.push_screen(MediaEditScreen())
|
self.push_screen(MediaEditScreen())
|
||||||
@@ -49,3 +64,13 @@ class FfxApp(App):
|
|||||||
def getContext(self):
|
def getContext(self):
|
||||||
"""Data 'output' method"""
|
"""Data 'output' method"""
|
||||||
return self.context
|
return self.context
|
||||||
|
|
||||||
|
def _handle_invalid_pattern_confirmation(self, confirmed: bool):
|
||||||
|
invalid_pattern_error = getattr(self, "_invalid_pattern_error", None)
|
||||||
|
|
||||||
|
if confirmed and invalid_pattern_error is not None:
|
||||||
|
PatternController(self.context).deletePattern(
|
||||||
|
invalid_pattern_error.pattern_id
|
||||||
|
)
|
||||||
|
|
||||||
|
self.exit()
|
||||||
|
|||||||
@@ -501,12 +501,7 @@ class FfxController():
|
|||||||
commandSequence1 = (commandTokens
|
commandSequence1 = (commandTokens
|
||||||
+ self.__targetMediaDescriptor.getInputMappingTokens(only_video=True))
|
+ self.__targetMediaDescriptor.getInputMappingTokens(only_video=True))
|
||||||
|
|
||||||
# Optional tokens
|
commandSequence1 += filterTokens
|
||||||
#NOTE: Filters and so needs to run on the first pass as well, as here
|
|
||||||
# the required bitrate for the second run is determined and recorded
|
|
||||||
# TODO: Results seems to be slightly better with first pass omitted,
|
|
||||||
# Confirm or find better filter settings for 2-pass
|
|
||||||
# commandSequence1 += self.__context['denoiser'].generatefilterTokens()
|
|
||||||
|
|
||||||
for td in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.VIDEO):
|
for td in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.VIDEO):
|
||||||
#HINT: Attached thumbnails are not supported by .webm container format
|
#HINT: Attached thumbnails are not supported by .webm container format
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import itertools
|
import re
|
||||||
|
|
||||||
from .filter import Filter
|
from .filter import Filter
|
||||||
|
|
||||||
@@ -7,6 +7,14 @@ class DeinterlaceFilter(Filter):
|
|||||||
|
|
||||||
IDENTIFIER = 'bwdif'
|
IDENTIFIER = 'bwdif'
|
||||||
|
|
||||||
|
MODE_KEY = 'mode'
|
||||||
|
DEFAULT_MODE = 'default'
|
||||||
|
|
||||||
|
DEFAULT_SCALE_FACTOR = 2
|
||||||
|
MIN_SCALE_FACTOR = 2
|
||||||
|
MAX_SCALE_FACTOR = 8
|
||||||
|
DEFAULT_TEMP_PARAMETERS = '3366'
|
||||||
|
|
||||||
# DEFAULT_STRENGTH: float = 2.8
|
# DEFAULT_STRENGTH: float = 2.8
|
||||||
# DEFAULT_PATCH_SIZE: int = 13
|
# DEFAULT_PATCH_SIZE: int = 13
|
||||||
# DEFAULT_CHROMA_PATCH_SIZE: int = 9
|
# DEFAULT_CHROMA_PATCH_SIZE: int = 9
|
||||||
@@ -21,7 +29,12 @@ class DeinterlaceFilter(Filter):
|
|||||||
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
|
self.__mode = kwargs.get(
|
||||||
|
DeinterlaceFilter.MODE_KEY,
|
||||||
|
DeinterlaceFilter.DEFAULT_MODE,
|
||||||
|
)
|
||||||
|
self.__tokens = DeinterlaceFilter.getModeTokens(self.__mode)
|
||||||
|
|
||||||
# self.__useHardware = kwargs.get('use_hardware', False)
|
# self.__useHardware = kwargs.get('use_hardware', False)
|
||||||
|
|
||||||
# self.__strengthList = []
|
# self.__strengthList = []
|
||||||
@@ -109,6 +122,59 @@ class DeinterlaceFilter(Filter):
|
|||||||
|
|
||||||
super().__init__(self)
|
super().__init__(self)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getModeTokens(mode):
|
||||||
|
if mode == DeinterlaceFilter.DEFAULT_MODE:
|
||||||
|
return ['bwdif=mode=1']
|
||||||
|
|
||||||
|
if not isinstance(mode, str) or not mode:
|
||||||
|
raise ValueError("Deinterlace mode must not be empty")
|
||||||
|
|
||||||
|
tokens = ['bwdif=mode=1']
|
||||||
|
for component in mode.split('_'):
|
||||||
|
scaleMatch = re.fullmatch(r'(up|down)(\d*)', component)
|
||||||
|
if scaleMatch:
|
||||||
|
direction, parameter = scaleMatch.groups()
|
||||||
|
factor = (
|
||||||
|
int(parameter)
|
||||||
|
if parameter
|
||||||
|
else DeinterlaceFilter.DEFAULT_SCALE_FACTOR
|
||||||
|
)
|
||||||
|
if not (
|
||||||
|
DeinterlaceFilter.MIN_SCALE_FACTOR
|
||||||
|
<= factor
|
||||||
|
<= DeinterlaceFilter.MAX_SCALE_FACTOR
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Scale factor in '{component}' must be between "
|
||||||
|
+ f"{DeinterlaceFilter.MIN_SCALE_FACTOR} and "
|
||||||
|
+ f"{DeinterlaceFilter.MAX_SCALE_FACTOR}"
|
||||||
|
)
|
||||||
|
operator = '*' if direction == 'up' else '/'
|
||||||
|
tokens.append(
|
||||||
|
f"scale=iw{operator}{factor}:ih{operator}{factor}:flags=lanczos"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
tempMatch = re.fullmatch(r'temp([0-9a-fA-F]*)', component)
|
||||||
|
if tempMatch:
|
||||||
|
parameters = (
|
||||||
|
tempMatch.group(1)
|
||||||
|
or DeinterlaceFilter.DEFAULT_TEMP_PARAMETERS
|
||||||
|
)
|
||||||
|
if len(parameters) != 4:
|
||||||
|
raise ValueError(
|
||||||
|
f"Temporal parameters in '{component}' must contain "
|
||||||
|
+ "four hexadecimal digits"
|
||||||
|
)
|
||||||
|
strengths = [f"{int(digit, 16) / 2:g}" for digit in parameters]
|
||||||
|
tokens.append(f"hqdn3d={':'.join(strengths)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise ValueError(f"Unknown deinterlace component: '{component}'")
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
def getPayload(self):
|
def getPayload(self):
|
||||||
|
|
||||||
@@ -123,10 +189,12 @@ class DeinterlaceFilter(Filter):
|
|||||||
# filterName = 'nlmeans_opencl' if self.__useHardware else 'nlmeans'
|
# filterName = 'nlmeans_opencl' if self.__useHardware else 'nlmeans'
|
||||||
|
|
||||||
payload = {'identifier': DeinterlaceFilter.IDENTIFIER,
|
payload = {'identifier': DeinterlaceFilter.IDENTIFIER,
|
||||||
'parameters': {},
|
'parameters': {
|
||||||
|
DeinterlaceFilter.MODE_KEY: self.__mode,
|
||||||
|
},
|
||||||
'suffices': suffices,
|
'suffices': suffices,
|
||||||
'variant': f"DEINT",
|
'variant': f"DEINT",
|
||||||
'tokens': ['bwdif=mode=1']}
|
'tokens': list(self.__tokens)}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from textual.widgets._data_table import CellDoesNotExist
|
|||||||
from ffx.file_properties import FileProperties
|
from ffx.file_properties import FileProperties
|
||||||
from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY
|
from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY
|
||||||
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
|
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
|
||||||
|
from ffx.pattern_controller import InvalidPatternCaptureGroupError
|
||||||
from ffx.show_descriptor import ShowDescriptor
|
from ffx.show_descriptor import ShowDescriptor
|
||||||
from ffx.track_descriptor import TrackDescriptor
|
from ffx.track_descriptor import TrackDescriptor
|
||||||
|
|
||||||
@@ -472,11 +473,15 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
|
|||||||
):
|
):
|
||||||
mediaTags[tagKey] = tagValue
|
mediaTags[tagKey] = tagValue
|
||||||
|
|
||||||
patternId = self._pc.savePatternSchema(
|
try:
|
||||||
patternObj,
|
patternId = self._pc.savePatternSchema(
|
||||||
trackDescriptors=self._sourceMediaDescriptor.getTrackDescriptors(),
|
patternObj,
|
||||||
mediaTags=mediaTags,
|
trackDescriptors=self._sourceMediaDescriptor.getTrackDescriptors(),
|
||||||
)
|
mediaTags=mediaTags,
|
||||||
|
)
|
||||||
|
except InvalidPatternCaptureGroupError as ex:
|
||||||
|
self.notify(str(ex), severity="error")
|
||||||
|
return
|
||||||
if patternId:
|
if patternId:
|
||||||
self.reloadProperties(reset_draft=True)
|
self.reloadProperties(reset_draft=True)
|
||||||
self.updateMediaTags()
|
self.updateMediaTags()
|
||||||
@@ -498,10 +503,14 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
|
|||||||
patternObj
|
patternObj
|
||||||
and self._currentPattern.getPattern() != patternObj["pattern"]
|
and self._currentPattern.getPattern() != patternObj["pattern"]
|
||||||
):
|
):
|
||||||
updated = self._pc.updatePattern(
|
try:
|
||||||
self._currentPattern.getId(),
|
updated = self._pc.updatePattern(
|
||||||
patternObj,
|
self._currentPattern.getId(),
|
||||||
)
|
patternObj,
|
||||||
|
)
|
||||||
|
except InvalidPatternCaptureGroupError as ex:
|
||||||
|
self.notify(str(ex), severity="error")
|
||||||
|
return False
|
||||||
if updated:
|
if updated:
|
||||||
self.reloadProperties(reset_draft=True)
|
self.reloadProperties(reset_draft=True)
|
||||||
self.updateMediaTags()
|
self.updateMediaTags()
|
||||||
|
|||||||
@@ -329,6 +329,96 @@ class MediaDescriptor:
|
|||||||
if s.getType() == TrackType.ATTACHMENT
|
if s.getType() == TrackType.ATTACHMENT
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def hasStyledAssSubtitlesWithFontAttachments(self) -> bool:
|
||||||
|
return (
|
||||||
|
any(
|
||||||
|
trackDescriptor.getCodec() == TrackCodec.ASS
|
||||||
|
for trackDescriptor in self.getSubtitleTracks()
|
||||||
|
)
|
||||||
|
and any(
|
||||||
|
trackDescriptor.getAttachmentFormat() == AttachmentFormat.TTF
|
||||||
|
for trackDescriptor in self.getAttachmentTracks()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def withoutAttachmentTracks(
|
||||||
|
self,
|
||||||
|
attachmentFormat: AttachmentFormat | None = None,
|
||||||
|
context: dict | None = None,
|
||||||
|
):
|
||||||
|
filteredTrackDescriptors = []
|
||||||
|
for trackDescriptor in self.__trackDescriptors:
|
||||||
|
if trackDescriptor.getType() == TrackType.ATTACHMENT and (
|
||||||
|
attachmentFormat is None
|
||||||
|
or trackDescriptor.getAttachmentFormat() == attachmentFormat
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
filteredTrackDescriptors.append(
|
||||||
|
trackDescriptor.clone(
|
||||||
|
context=context if context is not None else self.__context
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
MediaDescriptor.TAGS_KEY: dict(self.__mediaTags),
|
||||||
|
MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY: filteredTrackDescriptors,
|
||||||
|
}
|
||||||
|
if context is not None:
|
||||||
|
kwargs[MediaDescriptor.CONTEXT_KEY] = context
|
||||||
|
elif self.__context:
|
||||||
|
kwargs[MediaDescriptor.CONTEXT_KEY] = self.__context
|
||||||
|
|
||||||
|
filteredMediaDescriptor = MediaDescriptor(**kwargs)
|
||||||
|
filteredMediaDescriptor.reindexSubIndices()
|
||||||
|
return filteredMediaDescriptor
|
||||||
|
|
||||||
|
def withoutAttachmentsForComparison(self):
|
||||||
|
return self.withoutAttachmentTracks(context=self.__context)
|
||||||
|
|
||||||
|
def withSourceAttachmentTracks(
|
||||||
|
self,
|
||||||
|
sourceMediaDescriptor: Self,
|
||||||
|
attachmentFormat: AttachmentFormat | None = None,
|
||||||
|
context: dict | None = None,
|
||||||
|
):
|
||||||
|
trackDescriptors = []
|
||||||
|
for trackDescriptor in self.__trackDescriptors:
|
||||||
|
if trackDescriptor.getType() == TrackType.ATTACHMENT and (
|
||||||
|
attachmentFormat is None
|
||||||
|
or trackDescriptor.getAttachmentFormat() == attachmentFormat
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
trackDescriptors.append(
|
||||||
|
trackDescriptor.clone(
|
||||||
|
context=context if context is not None else self.__context
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for sourceTrackDescriptor in sourceMediaDescriptor.getAttachmentTracks():
|
||||||
|
if (
|
||||||
|
attachmentFormat is not None
|
||||||
|
and sourceTrackDescriptor.getAttachmentFormat() != attachmentFormat
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
attachmentClone = sourceTrackDescriptor.clone(
|
||||||
|
context=context if context is not None else self.__context
|
||||||
|
)
|
||||||
|
attachmentClone.setIndex(len(trackDescriptors))
|
||||||
|
trackDescriptors.append(attachmentClone)
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
MediaDescriptor.TAGS_KEY: dict(self.__mediaTags),
|
||||||
|
MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY: trackDescriptors,
|
||||||
|
}
|
||||||
|
if context is not None:
|
||||||
|
kwargs[MediaDescriptor.CONTEXT_KEY] = context
|
||||||
|
elif self.__context:
|
||||||
|
kwargs[MediaDescriptor.CONTEXT_KEY] = self.__context
|
||||||
|
|
||||||
|
mergedMediaDescriptor = MediaDescriptor(**kwargs)
|
||||||
|
mergedMediaDescriptor.reindexSubIndices()
|
||||||
|
return mergedMediaDescriptor
|
||||||
|
|
||||||
|
|
||||||
def getImportFileTokens(self, use_sub_index: bool = True):
|
def getImportFileTokens(self, use_sub_index: bool = True):
|
||||||
"""Generate ffmpeg import options for external stream files"""
|
"""Generate ffmpeg import options for external stream files"""
|
||||||
@@ -341,10 +431,13 @@ class MediaDescriptor:
|
|||||||
importedFilePath = td.getExternalSourceFilePath()
|
importedFilePath = td.getExternalSourceFilePath()
|
||||||
|
|
||||||
if importedFilePath:
|
if importedFilePath:
|
||||||
|
substitutionMessage = (
|
||||||
self.__logger.info(f"Substituting subtitle stream #{td.getIndex()} "
|
f"Substituting subtitle stream #{td.getIndex()} "
|
||||||
+ f"({td.getType().label()}:{td.getSubIndex()}) "
|
+ f"({td.getType().label()}:{td.getSubIndex()}) "
|
||||||
+ f"with import from file {td.getExternalSourceFilePath()}")
|
+ f"with import from file {td.getExternalSourceFilePath()}"
|
||||||
|
)
|
||||||
|
click.echo(substitutionMessage)
|
||||||
|
self.__logger.debug(substitutionMessage)
|
||||||
|
|
||||||
importFileTokens += [
|
importFileTokens += [
|
||||||
"-i",
|
"-i",
|
||||||
@@ -380,45 +473,48 @@ class MediaDescriptor:
|
|||||||
td: TrackDescriptor = sortedTrackDescriptors[trackIndex]
|
td: TrackDescriptor = sortedTrackDescriptors[trackIndex]
|
||||||
|
|
||||||
#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:
|
trackType = td.getType()
|
||||||
|
trackCodec = td.getCodec()
|
||||||
|
if (
|
||||||
|
trackCodec == TrackCodec.PNG
|
||||||
|
or trackType == TrackType.ATTACHMENT
|
||||||
|
or (only_video and trackType != TrackType.VIDEO)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
sourceTrackDescriptor = sourceTrackDescriptorsByIndex.get(td.getSourceIndex())
|
sourceTrackDescriptor = sourceTrackDescriptorsByIndex.get(td.getSourceIndex())
|
||||||
if sourceTrackDescriptor is None:
|
if sourceTrackDescriptor is None:
|
||||||
raise ValueError(f"No source track descriptor found for source index {td.getSourceIndex()}")
|
raise ValueError(f"No source track descriptor found for source index {td.getSourceIndex()}")
|
||||||
|
|
||||||
stdi = sourceTrackDescriptor.getIndex()
|
stdi = sourceTrackDescriptor.getIndex()
|
||||||
stdsi = sourceTrackDescriptor.getSubIndex()
|
stdsi = sourceTrackDescriptor.getSubIndex()
|
||||||
|
|
||||||
trackType = td.getType()
|
importedFilePath = td.getExternalSourceFilePath()
|
||||||
trackCodec = td.getCodec()
|
|
||||||
|
|
||||||
if (trackType != TrackType.ATTACHMENT
|
if use_sub_index:
|
||||||
and (trackType == TrackType.VIDEO or not only_video)):
|
|
||||||
|
|
||||||
|
if importedFilePath:
|
||||||
|
|
||||||
importedFilePath = td.getExternalSourceFilePath()
|
inputMappingTokens += [
|
||||||
|
"-map",
|
||||||
|
f"{filePointer}:{trackType.indicator()}:0",
|
||||||
|
]
|
||||||
|
filePointer += 1
|
||||||
|
|
||||||
if use_sub_index:
|
else:
|
||||||
|
|
||||||
if importedFilePath:
|
if trackCodec not in [TrackCodec.PGS, TrackCodec.VOBSUB]:
|
||||||
|
inputMappingTokens += [
|
||||||
|
"-map",
|
||||||
|
f"0:{trackType.indicator()}:{stdsi}",
|
||||||
|
]
|
||||||
|
|
||||||
inputMappingTokens += [
|
else:
|
||||||
"-map",
|
if trackCodec not in [TrackCodec.PGS, TrackCodec.VOBSUB]:
|
||||||
f"{filePointer}:{trackType.indicator()}:0",
|
inputMappingTokens += ["-map", f"0:{stdi}"]
|
||||||
]
|
|
||||||
filePointer += 1
|
|
||||||
|
|
||||||
else:
|
if only_video:
|
||||||
|
return inputMappingTokens
|
||||||
if not trackCodec in [TrackCodec.PGS, TrackCodec.VOBSUB]:
|
|
||||||
inputMappingTokens += [
|
|
||||||
"-map",
|
|
||||||
f"0:{trackType.indicator()}:{stdsi}",
|
|
||||||
]
|
|
||||||
|
|
||||||
else:
|
|
||||||
if not trackCodec in [TrackCodec.PGS, TrackCodec.VOBSUB]:
|
|
||||||
inputMappingTokens += ["-map", f"0:{stdi}"]
|
|
||||||
|
|
||||||
if sourceMediaDescriptor:
|
if sourceMediaDescriptor:
|
||||||
fontDescriptors = [ftd for ftd in sourceMediaDescriptor.getAttachmentTracks()
|
fontDescriptors = [ftd for ftd in sourceMediaDescriptor.getAttachmentTracks()
|
||||||
@@ -429,71 +525,161 @@ class MediaDescriptor:
|
|||||||
and ftd.getAttachmentFormat() == AttachmentFormat.TTF]
|
and ftd.getAttachmentFormat() == AttachmentFormat.TTF]
|
||||||
|
|
||||||
for ad in sorted(fontDescriptors, key=lambda d: d.getIndex()):
|
for ad in sorted(fontDescriptors, key=lambda d: d.getIndex()):
|
||||||
inputMappingTokens += ["-map", f"0:{ad.getIndex()}"]
|
sourceIndex = ad.getIndex()
|
||||||
|
if not sourceMediaDescriptor and ad.getSourceIndex() > -1:
|
||||||
|
sourceIndex = ad.getSourceIndex()
|
||||||
|
inputMappingTokens += ["-map", f"0:{sourceIndex}"]
|
||||||
|
|
||||||
return inputMappingTokens
|
return inputMappingTokens
|
||||||
|
|
||||||
|
|
||||||
def searchSubtitleFiles(self, searchDirectory, prefix):
|
def searchSubtitleFiles(
|
||||||
|
self,
|
||||||
|
searchDirectory,
|
||||||
|
prefix,
|
||||||
|
extension=SUBTITLE_FILE_EXTENSION,
|
||||||
|
strict=False,
|
||||||
|
):
|
||||||
|
|
||||||
sesld_match = re.compile(f"{prefix}_{MediaDescriptor.SEASON_EPISODE_STREAM_LANGUAGE_DISPOSITIONS_MATCH}")
|
normalizedExtension = str(extension).strip().lower()
|
||||||
sld_match = re.compile(f"{prefix}_{MediaDescriptor.STREAM_LANGUAGE_DISPOSITIONS_MATCH}")
|
if normalizedExtension.startswith('.'):
|
||||||
|
normalizedExtension = normalizedExtension[1:]
|
||||||
|
escapedPrefix = re.escape(prefix)
|
||||||
|
sesld_match = re.compile(
|
||||||
|
f"{escapedPrefix}_{MediaDescriptor.SEASON_EPISODE_STREAM_LANGUAGE_DISPOSITIONS_MATCH}"
|
||||||
|
)
|
||||||
|
sld_match = re.compile(
|
||||||
|
f"{escapedPrefix}_{MediaDescriptor.STREAM_LANGUAGE_DISPOSITIONS_MATCH}"
|
||||||
|
)
|
||||||
|
|
||||||
subtitleFileDescriptors = []
|
subtitleFileDescriptors = []
|
||||||
|
subtitleFilenames = []
|
||||||
|
|
||||||
for subtitleFilename in os.listdir(searchDirectory):
|
for subtitleFilename in sorted(os.listdir(searchDirectory)):
|
||||||
if subtitleFilename.startswith(prefix) and subtitleFilename.endswith(
|
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
|
||||||
"." + MediaDescriptor.SUBTITLE_FILE_EXTENSION
|
subtitleFilenameStem, subtitleFilenameExtension = os.path.splitext(
|
||||||
|
subtitleFilename
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
os.path.isfile(subtitleFilePath)
|
||||||
|
and subtitleFilenameStem.startswith(prefix + '_')
|
||||||
|
and subtitleFilenameExtension.lower() == '.' + normalizedExtension
|
||||||
):
|
):
|
||||||
|
subtitleFilenames.append(subtitleFilename)
|
||||||
|
|
||||||
sesld_result = sesld_match.search(subtitleFilename)
|
expectedSubtitleTrackIndices = {
|
||||||
sld_result = None if not sesld_result is None else sld_match.search(subtitleFilename)
|
subtitleTrack.getIndex()
|
||||||
|
for subtitleTrack in self.getSubtitleTracks()
|
||||||
if not sesld_result is None:
|
}
|
||||||
|
if strict and len(subtitleFilenames) > len(expectedSubtitleTrackIndices):
|
||||||
|
raise ValueError(
|
||||||
|
f"Found {len(subtitleFilenames)} matching .{normalizedExtension} files "
|
||||||
|
+ f"for {len(expectedSubtitleTrackIndices)} subtitle tracks."
|
||||||
|
)
|
||||||
|
|
||||||
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
|
for subtitleFilename in subtitleFilenames:
|
||||||
if os.path.isfile(subtitleFilePath):
|
subtitleFilenameStem = os.path.splitext(subtitleFilename)[0]
|
||||||
|
sesld_result = (
|
||||||
|
None
|
||||||
|
if strict
|
||||||
|
else sesld_match.fullmatch(subtitleFilenameStem)
|
||||||
|
)
|
||||||
|
sld_result = (
|
||||||
|
None
|
||||||
|
if sesld_result is not None
|
||||||
|
else sld_match.fullmatch(subtitleFilenameStem)
|
||||||
|
)
|
||||||
|
|
||||||
subtitleFileDescriptor = {}
|
if strict and sesld_result is None and sld_result is None:
|
||||||
subtitleFileDescriptor["path"] = subtitleFilePath
|
raise ValueError(
|
||||||
subtitleFileDescriptor["season"] = int(sesld_result.group(1))
|
f"Subtitle filename does not match the expected pattern: "
|
||||||
subtitleFileDescriptor["episode"] = int(sesld_result.group(2))
|
+ subtitleFilename
|
||||||
subtitleFileDescriptor["index"] = int(sesld_result.group(3))
|
)
|
||||||
subtitleFileDescriptor["language"] = sesld_result.group(4)
|
|
||||||
|
|
||||||
dispSet = set()
|
if sesld_result is not None:
|
||||||
dispCaptGroups = sesld_result.groups()
|
|
||||||
numCaptGroups = len(dispCaptGroups)
|
|
||||||
if numCaptGroups > 4:
|
|
||||||
for groupIndex in range(numCaptGroups - 4):
|
|
||||||
disp = TrackDisposition.fromIndicator(dispCaptGroups[groupIndex + 4])
|
|
||||||
if disp is not None:
|
|
||||||
dispSet.add(disp)
|
|
||||||
subtitleFileDescriptor["disposition_set"] = dispSet
|
|
||||||
|
|
||||||
subtitleFileDescriptors.append(subtitleFileDescriptor)
|
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
|
||||||
|
|
||||||
if not sld_result is None:
|
subtitleFileDescriptor = {}
|
||||||
|
subtitleFileDescriptor["path"] = subtitleFilePath
|
||||||
|
subtitleFileDescriptor["season"] = int(sesld_result.group(1))
|
||||||
|
subtitleFileDescriptor["episode"] = int(sesld_result.group(2))
|
||||||
|
subtitleFileDescriptor["index"] = int(sesld_result.group(3))
|
||||||
|
subtitleFileDescriptor["language"] = sesld_result.group(4)
|
||||||
|
|
||||||
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
|
dispSet = set()
|
||||||
if os.path.isfile(subtitleFilePath):
|
dispCaptGroups = sesld_result.groups()
|
||||||
|
numCaptGroups = len(dispCaptGroups)
|
||||||
|
if numCaptGroups > 4:
|
||||||
|
for groupIndex in range(numCaptGroups - 4):
|
||||||
|
disp = TrackDisposition.fromIndicator(
|
||||||
|
dispCaptGroups[groupIndex + 4]
|
||||||
|
)
|
||||||
|
if disp is not None:
|
||||||
|
dispSet.add(disp)
|
||||||
|
subtitleFileDescriptor["disposition_set"] = dispSet
|
||||||
|
|
||||||
subtitleFileDescriptor = {}
|
subtitleFileDescriptors.append(subtitleFileDescriptor)
|
||||||
subtitleFileDescriptor["path"] = subtitleFilePath
|
|
||||||
subtitleFileDescriptor["index"] = int(sld_result.group(1))
|
|
||||||
subtitleFileDescriptor["language"] = sld_result.group(2)
|
|
||||||
|
|
||||||
dispSet = set()
|
if sld_result is not None:
|
||||||
dispCaptGroups = sld_result.groups()
|
|
||||||
numCaptGroups = len(dispCaptGroups)
|
|
||||||
if numCaptGroups > 2:
|
|
||||||
for groupIndex in range(numCaptGroups - 2):
|
|
||||||
disp = TrackDisposition.fromIndicator(dispCaptGroups[groupIndex + 2])
|
|
||||||
if disp is not None:
|
|
||||||
dispSet.add(disp)
|
|
||||||
subtitleFileDescriptor["disposition_set"] = dispSet
|
|
||||||
|
|
||||||
subtitleFileDescriptors.append(subtitleFileDescriptor)
|
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
|
||||||
|
|
||||||
|
subtitleFileDescriptor = {}
|
||||||
|
subtitleFileDescriptor["path"] = subtitleFilePath
|
||||||
|
subtitleFileDescriptor["index"] = int(sld_result.group(1))
|
||||||
|
subtitleFileDescriptor["language"] = sld_result.group(2)
|
||||||
|
|
||||||
|
dispSet = set()
|
||||||
|
dispCaptGroups = sld_result.groups()
|
||||||
|
numCaptGroups = len(dispCaptGroups)
|
||||||
|
if numCaptGroups > 2:
|
||||||
|
for groupIndex in range(numCaptGroups - 2):
|
||||||
|
disp = TrackDisposition.fromIndicator(
|
||||||
|
dispCaptGroups[groupIndex + 2]
|
||||||
|
)
|
||||||
|
if disp is not None:
|
||||||
|
dispSet.add(disp)
|
||||||
|
subtitleFileDescriptor["disposition_set"] = dispSet
|
||||||
|
|
||||||
|
subtitleFileDescriptors.append(subtitleFileDescriptor)
|
||||||
|
|
||||||
|
if strict:
|
||||||
|
discoveredTrackIndices = [
|
||||||
|
descriptor['index'] for descriptor in subtitleFileDescriptors
|
||||||
|
]
|
||||||
|
duplicateTrackIndices = sorted(
|
||||||
|
{
|
||||||
|
trackIndex
|
||||||
|
for trackIndex in discoveredTrackIndices
|
||||||
|
if discoveredTrackIndices.count(trackIndex) > 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if duplicateTrackIndices:
|
||||||
|
duplicateDescription = ', '.join(
|
||||||
|
f"#{index}" for index in duplicateTrackIndices
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
"Multiple external subtitle files refer to subtitle track(s) "
|
||||||
|
+ duplicateDescription
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
|
||||||
|
unexpectedTrackIndices = sorted(
|
||||||
|
set(discoveredTrackIndices) - expectedSubtitleTrackIndices
|
||||||
|
)
|
||||||
|
if unexpectedTrackIndices:
|
||||||
|
unexpectedDescription = ', '.join(
|
||||||
|
f"#{index}" for index in unexpectedTrackIndices
|
||||||
|
)
|
||||||
|
expectedDescription = ', '.join(
|
||||||
|
f"#{index}" for index in sorted(expectedSubtitleTrackIndices)
|
||||||
|
) or 'none'
|
||||||
|
raise ValueError(
|
||||||
|
"External subtitle track index pattern does not match the media "
|
||||||
|
+ f"subtitle tracks: found {unexpectedDescription}; "
|
||||||
|
+ f"expected a subset of {expectedDescription}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
self.__logger.debug(f"searchSubtitleFiles(): Available subtitle files {subtitleFileDescriptors}")
|
self.__logger.debug(f"searchSubtitleFiles(): Available subtitle files {subtitleFileDescriptors}")
|
||||||
@@ -508,12 +694,19 @@ class MediaDescriptor:
|
|||||||
season: int = -1,
|
season: int = -1,
|
||||||
episode: int = -1,
|
episode: int = -1,
|
||||||
preserve_dispositions: bool = False,
|
preserve_dispositions: bool = False,
|
||||||
|
extension: str = SUBTITLE_FILE_EXTENSION,
|
||||||
|
strict: 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}")
|
||||||
|
|
||||||
availableFileSubtitleDescriptors = self.searchSubtitleFiles(searchDirectory, prefix)
|
availableFileSubtitleDescriptors = self.searchSubtitleFiles(
|
||||||
|
searchDirectory,
|
||||||
|
prefix,
|
||||||
|
extension=extension,
|
||||||
|
strict=strict,
|
||||||
|
)
|
||||||
|
|
||||||
self.__logger.debug(f"importSubtitles(): availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}")
|
self.__logger.debug(f"importSubtitles(): availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}")
|
||||||
|
|
||||||
@@ -526,7 +719,8 @@ class MediaDescriptor:
|
|||||||
[
|
[
|
||||||
d
|
d
|
||||||
for d in availableFileSubtitleDescriptors
|
for d in availableFileSubtitleDescriptors
|
||||||
if ((season == -1 and episode == -1)
|
if (strict
|
||||||
|
or (season == -1 and episode == -1)
|
||||||
or (
|
or (
|
||||||
d.get("season") == int(season)
|
d.get("season") == int(season)
|
||||||
and d.get("episode") == int(episode)
|
and d.get("episode") == int(episode)
|
||||||
@@ -540,6 +734,7 @@ class MediaDescriptor:
|
|||||||
|
|
||||||
self.__logger.debug(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}")
|
self.__logger.debug(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}")
|
||||||
|
|
||||||
|
importedTrackIndices = []
|
||||||
for msfd in matchingSubtitleFileDescriptors:
|
for msfd in matchingSubtitleFileDescriptors:
|
||||||
matchingSubtitleTrackDescriptor = [s for s in subtitleTracks if s.getIndex() == msfd["index"]]
|
matchingSubtitleTrackDescriptor = [s for s in subtitleTracks if s.getIndex() == msfd["index"]]
|
||||||
if matchingSubtitleTrackDescriptor:
|
if matchingSubtitleTrackDescriptor:
|
||||||
@@ -553,6 +748,19 @@ class MediaDescriptor:
|
|||||||
matchingTrack.getTags()["language"] = msfd["language"]
|
matchingTrack.getTags()["language"] = msfd["language"]
|
||||||
if msfd["disposition_set"] and not preserve_dispositions:
|
if msfd["disposition_set"] and not preserve_dispositions:
|
||||||
matchingTrack.setDispositionSet(msfd["disposition_set"])
|
matchingTrack.setDispositionSet(msfd["disposition_set"])
|
||||||
|
importedTrackIndices.append(matchingTrack.getIndex())
|
||||||
|
|
||||||
|
expectedTrackIndices = sorted(
|
||||||
|
subtitleTrack.getIndex() for subtitleTrack in subtitleTracks
|
||||||
|
)
|
||||||
|
importedTrackIndices = sorted(set(importedTrackIndices))
|
||||||
|
return {
|
||||||
|
"candidate_count": len(availableFileSubtitleDescriptors),
|
||||||
|
"imported_track_indices": importedTrackIndices,
|
||||||
|
"missing_track_indices": sorted(
|
||||||
|
set(expectedTrackIndices) - set(importedTrackIndices)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def getConfiguration(self, label: str = ''):
|
def getConfiguration(self, label: str = ''):
|
||||||
|
|||||||
@@ -56,8 +56,24 @@ class MediaDescriptorChangeSet():
|
|||||||
and 'ignore' in metadataConfiguration['streams'].keys() else [])
|
and 'ignore' in metadataConfiguration['streams'].keys() else [])
|
||||||
|
|
||||||
|
|
||||||
self.__targetTrackDescriptors = targetMediaDescriptor.getTrackDescriptors() if targetMediaDescriptor is not None else []
|
self.__targetTrackDescriptors = (
|
||||||
self.__sourceTrackDescriptors = sourceMediaDescriptor.getTrackDescriptors() if sourceMediaDescriptor is not None else []
|
[
|
||||||
|
trackDescriptor
|
||||||
|
for trackDescriptor in targetMediaDescriptor.getTrackDescriptors()
|
||||||
|
if trackDescriptor.getType() != TrackType.ATTACHMENT
|
||||||
|
]
|
||||||
|
if targetMediaDescriptor is not None
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
self.__sourceTrackDescriptors = (
|
||||||
|
[
|
||||||
|
trackDescriptor
|
||||||
|
for trackDescriptor in sourceMediaDescriptor.getTrackDescriptors()
|
||||||
|
if trackDescriptor.getType() != TrackType.ATTACHMENT
|
||||||
|
]
|
||||||
|
if sourceMediaDescriptor is not None
|
||||||
|
else []
|
||||||
|
)
|
||||||
self.__targetTrackDescriptorsByIndex = {
|
self.__targetTrackDescriptorsByIndex = {
|
||||||
trackDescriptor.getIndex(): trackDescriptor
|
trackDescriptor.getIndex(): trackDescriptor
|
||||||
for trackDescriptor in self.__targetTrackDescriptors
|
for trackDescriptor in self.__targetTrackDescriptors
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from textual.screen import Screen
|
|||||||
from textual.widgets import DataTable
|
from textual.widgets import DataTable
|
||||||
from textual.widgets._data_table import CellDoesNotExist
|
from textual.widgets._data_table import CellDoesNotExist
|
||||||
|
|
||||||
|
from ffx.attachment_format import AttachmentFormat
|
||||||
from ffx.audio_layout import AudioLayout
|
from ffx.audio_layout import AudioLayout
|
||||||
from ffx.file_properties import FileProperties
|
from ffx.file_properties import FileProperties
|
||||||
from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY
|
from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY
|
||||||
@@ -127,9 +128,17 @@ class MediaWorkflowScreenBase(Screen):
|
|||||||
|
|
||||||
def _track_codec_cell_value(self, trackDescriptor: TrackDescriptor) -> str:
|
def _track_codec_cell_value(self, trackDescriptor: TrackDescriptor) -> str:
|
||||||
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||||
return " "
|
attachmentFormat = trackDescriptor.getAttachmentFormat()
|
||||||
|
if attachmentFormat == AttachmentFormat.UNKNOWN:
|
||||||
|
return attachmentFormat.identifier()
|
||||||
|
return attachmentFormat.label()
|
||||||
return trackDescriptor.getFormatDescriptor().label()
|
return trackDescriptor.getFormatDescriptor().label()
|
||||||
|
|
||||||
|
def _track_language_cell_value(self, trackDescriptor: TrackDescriptor) -> str:
|
||||||
|
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||||
|
return " "
|
||||||
|
return trackDescriptor.getLanguage().label()
|
||||||
|
|
||||||
def _track_disposition_cell_value(
|
def _track_disposition_cell_value(
|
||||||
self,
|
self,
|
||||||
trackDescriptor: TrackDescriptor,
|
trackDescriptor: TrackDescriptor,
|
||||||
@@ -157,10 +166,9 @@ class MediaWorkflowScreenBase(Screen):
|
|||||||
self._baselineMediaDescriptor = probedMediaDescriptor
|
self._baselineMediaDescriptor = probedMediaDescriptor
|
||||||
self._sourceMediaDescriptor = probedMediaDescriptor
|
self._sourceMediaDescriptor = probedMediaDescriptor
|
||||||
self._currentPattern = self._mediaFileProperties.getPattern()
|
self._currentPattern = self._mediaFileProperties.getPattern()
|
||||||
self._targetMediaDescriptor = (
|
self._targetMediaDescriptor = self._resolve_target_media_descriptor(
|
||||||
self._currentPattern.getMediaDescriptor(self.context)
|
self._currentPattern,
|
||||||
if self._currentPattern is not None
|
self._sourceMediaDescriptor,
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.rebuildChangeSet()
|
self.rebuildChangeSet()
|
||||||
@@ -196,6 +204,25 @@ class MediaWorkflowScreenBase(Screen):
|
|||||||
def getTrackEditSourceDescriptor(self) -> TrackDescriptor | None:
|
def getTrackEditSourceDescriptor(self) -> TrackDescriptor | None:
|
||||||
return self.getSelectedTrackDescriptor()
|
return self.getSelectedTrackDescriptor()
|
||||||
|
|
||||||
|
def _resolve_target_media_descriptor(self, currentPattern, sourceMediaDescriptor):
|
||||||
|
if currentPattern is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
targetMediaDescriptor = currentPattern.getMediaDescriptor(self.context)
|
||||||
|
styledAssDetector = getattr(
|
||||||
|
sourceMediaDescriptor,
|
||||||
|
"hasStyledAssSubtitlesWithFontAttachments",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if callable(styledAssDetector) and styledAssDetector():
|
||||||
|
targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks(
|
||||||
|
sourceMediaDescriptor,
|
||||||
|
AttachmentFormat.TTF,
|
||||||
|
context=self.context,
|
||||||
|
)
|
||||||
|
|
||||||
|
return targetMediaDescriptor
|
||||||
|
|
||||||
def updateMediaTags(self):
|
def updateMediaTags(self):
|
||||||
displayedMediaDescriptor = self.getDisplayedMediaDescriptor()
|
displayedMediaDescriptor = self.getDisplayedMediaDescriptor()
|
||||||
self._sourceMediaTagRowData = populate_tag_table(
|
self._sourceMediaTagRowData = populate_tag_table(
|
||||||
@@ -244,7 +271,7 @@ class MediaWorkflowScreenBase(Screen):
|
|||||||
if trackType == TrackType.AUDIO
|
if trackType == TrackType.AUDIO
|
||||||
and audioLayout != AudioLayout.LAYOUT_UNDEFINED
|
and audioLayout != AudioLayout.LAYOUT_UNDEFINED
|
||||||
else " ",
|
else " ",
|
||||||
trackDescriptor.getLanguage().label(),
|
self._track_language_cell_value(trackDescriptor),
|
||||||
trackTitle,
|
trackTitle,
|
||||||
self._track_disposition_cell_value(
|
self._track_disposition_cell_value(
|
||||||
trackDescriptor,
|
trackDescriptor,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from .show import Base, Show
|
|||||||
|
|
||||||
from ffx.media_descriptor import MediaDescriptor
|
from ffx.media_descriptor import MediaDescriptor
|
||||||
from ffx.show_descriptor import ShowDescriptor
|
from ffx.show_descriptor import ShowDescriptor
|
||||||
|
from ffx.track_type import TrackType
|
||||||
|
|
||||||
|
|
||||||
class Pattern(Base):
|
class Pattern(Base):
|
||||||
@@ -74,11 +75,17 @@ class Pattern(Base):
|
|||||||
|
|
||||||
# Set ordered subindices
|
# Set ordered subindices
|
||||||
subIndexCounter = {}
|
subIndexCounter = {}
|
||||||
|
outputIndex = 0
|
||||||
for track in self.tracks:
|
for track in self.tracks:
|
||||||
trackType = track.getType()
|
trackType = track.getType()
|
||||||
|
if trackType == TrackType.ATTACHMENT or track.isDropped():
|
||||||
|
continue
|
||||||
if not trackType in subIndexCounter.keys():
|
if not trackType in subIndexCounter.keys():
|
||||||
subIndexCounter[trackType] = 0
|
subIndexCounter[trackType] = 0
|
||||||
kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY].append(track.getDescriptor(context, subIndex = subIndexCounter[trackType]))
|
trackDescriptor = track.getDescriptor(context, subIndex = subIndexCounter[trackType])
|
||||||
|
trackDescriptor.setIndex(outputIndex)
|
||||||
|
kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY].append(trackDescriptor)
|
||||||
subIndexCounter[trackType] += 1
|
subIndexCounter[trackType] += 1
|
||||||
|
outputIndex += 1
|
||||||
|
|
||||||
return MediaDescriptor(**kwargs)
|
return MediaDescriptor(**kwargs)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class Track(Base):
|
|||||||
|
|
||||||
index = Column(Integer)
|
index = Column(Integer)
|
||||||
source_index = Column(Integer)
|
source_index = Column(Integer)
|
||||||
|
dropped = Column(Integer, default=0)
|
||||||
|
|
||||||
# v1.x
|
# v1.x
|
||||||
pattern_id = Column(Integer, ForeignKey('patterns.id', ondelete="CASCADE"))
|
pattern_id = Column(Integer, ForeignKey('patterns.id', ondelete="CASCADE"))
|
||||||
@@ -181,6 +182,9 @@ class Track(Base):
|
|||||||
def getSourceIndex(self):
|
def getSourceIndex(self):
|
||||||
return int(self.source_index) if self.source_index is not None else -1
|
return int(self.source_index) if self.source_index is not None else -1
|
||||||
|
|
||||||
|
def isDropped(self) -> bool:
|
||||||
|
return bool(self.dropped)
|
||||||
|
|
||||||
def getLanguage(self):
|
def getLanguage(self):
|
||||||
tags = {t.key:t.value for t in self.track_tags}
|
tags = {t.key:t.value for t in self.track_tags}
|
||||||
return IsoLanguage.findThreeLetter(tags['language']) if 'language' in tags.keys() else IsoLanguage.UNDEFINED
|
return IsoLanguage.findThreeLetter(tags['language']) if 'language' in tags.keys() else IsoLanguage.UNDEFINED
|
||||||
@@ -221,6 +225,7 @@ class Track(Base):
|
|||||||
|
|
||||||
kwargs[TrackDescriptor.INDEX_KEY] = self.getIndex()
|
kwargs[TrackDescriptor.INDEX_KEY] = self.getIndex()
|
||||||
kwargs[TrackDescriptor.SOURCE_INDEX_KEY] = self.getSourceIndex()
|
kwargs[TrackDescriptor.SOURCE_INDEX_KEY] = self.getSourceIndex()
|
||||||
|
kwargs[TrackDescriptor.DROPPED_KEY] = self.isDropped()
|
||||||
|
|
||||||
if subIndex > -1:
|
if subIndex > -1:
|
||||||
kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex
|
kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from ffx.model.track import Track
|
|||||||
from ffx.model.track_tag import TrackTag
|
from ffx.model.track_tag import TrackTag
|
||||||
from ffx.track_descriptor import TrackDescriptor
|
from ffx.track_descriptor import TrackDescriptor
|
||||||
from ffx.track_disposition import TrackDisposition
|
from ffx.track_disposition import TrackDisposition
|
||||||
|
from ffx.track_type import TrackType
|
||||||
|
|
||||||
|
|
||||||
class DuplicatePatternMatchError(click.ClickException):
|
class DuplicatePatternMatchError(click.ClickException):
|
||||||
@@ -18,6 +19,21 @@ class InvalidPatternSchemaError(click.ClickException):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPatternCaptureGroupError(click.ClickException):
|
||||||
|
def __init__(self, pattern_id: int, pattern_expression: str):
|
||||||
|
self.pattern_id = int(pattern_id)
|
||||||
|
self.pattern_expression = str(pattern_expression)
|
||||||
|
pattern_label = (
|
||||||
|
f"Pattern #{self.pattern_id}"
|
||||||
|
if self.pattern_id >= 0
|
||||||
|
else "Pattern"
|
||||||
|
)
|
||||||
|
super().__init__(
|
||||||
|
f"{pattern_label} must contain at least one capture group "
|
||||||
|
f"for the season/episode indicator: {self.pattern_expression!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PatternController:
|
class PatternController:
|
||||||
_compiled_regex_cache: dict[str, re.Pattern] = {}
|
_compiled_regex_cache: dict[str, re.Pattern] = {}
|
||||||
|
|
||||||
@@ -65,6 +81,13 @@ class PatternController:
|
|||||||
cls._compiled_regex_cache[expression_text] = compiled
|
cls._compiled_regex_cache[expression_text] = compiled
|
||||||
return compiled
|
return compiled
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_pattern_capture_groups(cls, pattern_id: int, expression: str):
|
||||||
|
compiled = cls._compile_pattern_expression(pattern_id, expression)
|
||||||
|
if compiled.groups < 1:
|
||||||
|
raise InvalidPatternCaptureGroupError(pattern_id, expression)
|
||||||
|
return compiled
|
||||||
|
|
||||||
def _coerce_pattern_fields(self, patternObj):
|
def _coerce_pattern_fields(self, patternObj):
|
||||||
return {
|
return {
|
||||||
"show_id": int(patternObj["show_id"]),
|
"show_id": int(patternObj["show_id"]),
|
||||||
@@ -86,22 +109,38 @@ class PatternController:
|
|||||||
)
|
)
|
||||||
|
|
||||||
normalized_descriptors = []
|
normalized_descriptors = []
|
||||||
|
filtered_attachments = False
|
||||||
for trackDescriptor in trackDescriptors:
|
for trackDescriptor in trackDescriptors:
|
||||||
if type(trackDescriptor) is not TrackDescriptor:
|
if type(trackDescriptor) is not TrackDescriptor:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"PatternController: All track descriptors are required to be of type TrackDescriptor"
|
"PatternController: All track descriptors are required to be of type TrackDescriptor"
|
||||||
)
|
)
|
||||||
normalized_descriptors.append(trackDescriptor)
|
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||||
|
filtered_attachments = True
|
||||||
|
continue
|
||||||
|
normalized_descriptors.append(trackDescriptor.clone())
|
||||||
|
|
||||||
if not normalized_descriptors:
|
if not normalized_descriptors:
|
||||||
raise InvalidPatternSchemaError(
|
raise InvalidPatternSchemaError(
|
||||||
"Patterns must define at least one track before they can be stored."
|
"Patterns must define at least one track before they can be stored."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not any(
|
||||||
|
not descriptor.isDropped()
|
||||||
|
for descriptor in normalized_descriptors
|
||||||
|
):
|
||||||
|
raise InvalidPatternSchemaError(
|
||||||
|
"Patterns must define at least one non-dropped track before they can be stored."
|
||||||
|
)
|
||||||
|
|
||||||
normalized_descriptors = sorted(
|
normalized_descriptors = sorted(
|
||||||
normalized_descriptors, key=lambda descriptor: descriptor.getIndex()
|
normalized_descriptors, key=lambda descriptor: descriptor.getIndex()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if filtered_attachments:
|
||||||
|
for index, descriptor in enumerate(normalized_descriptors):
|
||||||
|
descriptor.setIndex(index)
|
||||||
|
|
||||||
index_set = {descriptor.getIndex() for descriptor in normalized_descriptors}
|
index_set = {descriptor.getIndex() for descriptor in normalized_descriptors}
|
||||||
expected_indexes = set(range(len(normalized_descriptors)))
|
expected_indexes = set(range(len(normalized_descriptors)))
|
||||||
if index_set != expected_indexes:
|
if index_set != expected_indexes:
|
||||||
@@ -137,6 +176,7 @@ class PatternController:
|
|||||||
codec_name=str(trackDescriptor.getFormatDescriptor().identifier()),
|
codec_name=str(trackDescriptor.getFormatDescriptor().identifier()),
|
||||||
index=int(trackDescriptor.getIndex()),
|
index=int(trackDescriptor.getIndex()),
|
||||||
source_index=int(trackDescriptor.getSourceIndex()),
|
source_index=int(trackDescriptor.getSourceIndex()),
|
||||||
|
dropped=1 if trackDescriptor.isDropped() else 0,
|
||||||
disposition_flags=int(
|
disposition_flags=int(
|
||||||
TrackDisposition.toFlags(trackDescriptor.getDispositionSet())
|
TrackDisposition.toFlags(trackDescriptor.getDispositionSet())
|
||||||
),
|
),
|
||||||
@@ -170,10 +210,19 @@ class PatternController:
|
|||||||
pattern.tracks.append(self._build_track_row(trackDescriptor))
|
pattern.tracks.append(self._build_track_row(trackDescriptor))
|
||||||
|
|
||||||
def _validate_persisted_pattern(self, pattern: Pattern):
|
def _validate_persisted_pattern(self, pattern: Pattern):
|
||||||
if not pattern.tracks:
|
persisted_tracks = [
|
||||||
|
track
|
||||||
|
for track in pattern.tracks
|
||||||
|
if track.getType() != TrackType.ATTACHMENT
|
||||||
|
]
|
||||||
|
if not persisted_tracks:
|
||||||
raise InvalidPatternSchemaError(
|
raise InvalidPatternSchemaError(
|
||||||
f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no tracks."
|
f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no tracks."
|
||||||
)
|
)
|
||||||
|
if not any(not track.isDropped() for track in persisted_tracks):
|
||||||
|
raise InvalidPatternSchemaError(
|
||||||
|
f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no non-dropped tracks."
|
||||||
|
)
|
||||||
|
|
||||||
def savePatternSchema(
|
def savePatternSchema(
|
||||||
self,
|
self,
|
||||||
@@ -185,6 +234,10 @@ class PatternController:
|
|||||||
fields = self._coerce_pattern_fields(patternObj)
|
fields = self._coerce_pattern_fields(patternObj)
|
||||||
normalized_tracks = self._normalize_track_descriptors(trackDescriptors)
|
normalized_tracks = self._normalize_track_descriptors(trackDescriptors)
|
||||||
normalized_tags = self._coerce_media_tags(mediaTags)
|
normalized_tags = self._coerce_media_tags(mediaTags)
|
||||||
|
self._validate_pattern_capture_groups(
|
||||||
|
patternId if patternId is not None else -1,
|
||||||
|
fields["pattern"],
|
||||||
|
)
|
||||||
session = None
|
session = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -247,6 +300,7 @@ class PatternController:
|
|||||||
def updatePattern(self, patternId, patternObj):
|
def updatePattern(self, patternId, patternObj):
|
||||||
|
|
||||||
fields = self._coerce_pattern_fields(patternObj)
|
fields = self._coerce_pattern_fields(patternObj)
|
||||||
|
self._validate_pattern_capture_groups(patternId, fields["pattern"])
|
||||||
session = None
|
session = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -382,6 +436,11 @@ class PatternController:
|
|||||||
patternMatch = compiled.search(str(filename))
|
patternMatch = compiled.search(str(filename))
|
||||||
if patternMatch is None:
|
if patternMatch is None:
|
||||||
continue
|
continue
|
||||||
|
if not patternMatch.groups():
|
||||||
|
raise InvalidPatternCaptureGroupError(
|
||||||
|
pattern.getId(),
|
||||||
|
pattern.getPattern(),
|
||||||
|
)
|
||||||
|
|
||||||
self._validate_persisted_pattern(pattern)
|
self._validate_persisted_pattern(pattern)
|
||||||
matches.append({"match": patternMatch, "pattern": pattern})
|
matches.append({"match": patternMatch, "pattern": pattern})
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import click, re
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from textual import events
|
from textual import events
|
||||||
|
from textual.css.query import NoMatches
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
from textual.widgets import Header, Footer, Static, Button, Input, DataTable, TextArea
|
from textual.widgets import Header, Footer, Static, Button, Input, DataTable, TextArea
|
||||||
from textual.containers import Grid
|
from textual.containers import Grid
|
||||||
|
|
||||||
from ffx.model.pattern import Pattern
|
from ffx.model.pattern import Pattern
|
||||||
|
from ffx.pattern_controller import InvalidPatternCaptureGroupError
|
||||||
|
|
||||||
from .track_details_screen import TrackDetailsScreen
|
from .track_details_screen import TrackDetailsScreen
|
||||||
from .track_delete_screen import TrackDeleteScreen
|
|
||||||
from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen
|
from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen
|
||||||
from .shifted_season_details_screen import ShiftedSeasonDetailsScreen
|
from .shifted_season_details_screen import ShiftedSeasonDetailsScreen
|
||||||
|
|
||||||
@@ -88,6 +89,9 @@ class PatternDetailsScreen(Screen):
|
|||||||
.three {
|
.three {
|
||||||
column-span: 3;
|
column-span: 3;
|
||||||
}
|
}
|
||||||
|
.two {
|
||||||
|
column-span: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.four {
|
.four {
|
||||||
column-span: 4;
|
column-span: 4;
|
||||||
@@ -114,7 +118,7 @@ class PatternDetailsScreen(Screen):
|
|||||||
}
|
}
|
||||||
|
|
||||||
.yellow {
|
.yellow {
|
||||||
tint: yellow 40%;
|
color: yellow;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -151,6 +155,13 @@ class PatternDetailsScreen(Screen):
|
|||||||
|
|
||||||
|
|
||||||
def updateTracks(self):
|
def updateTracks(self):
|
||||||
|
selectedTrack = self.getSelectedTrackDescriptor()
|
||||||
|
selectedTrackIndex = (
|
||||||
|
self._track_row_index(selectedTrack)
|
||||||
|
if selectedTrack is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
selectedCursorColumn = getattr(self.tracksTable.cursor_coordinate, "column", 0)
|
||||||
|
|
||||||
self.tracksTable.clear()
|
self.tracksTable.clear()
|
||||||
self.__trackRowData = {}
|
self.__trackRowData = {}
|
||||||
@@ -158,6 +169,7 @@ class PatternDetailsScreen(Screen):
|
|||||||
tracks = self.getCurrentTrackDescriptors()
|
tracks = self.getCurrentTrackDescriptors()
|
||||||
|
|
||||||
typeCounter = {}
|
typeCounter = {}
|
||||||
|
restoredRowIndex = None
|
||||||
|
|
||||||
td: TrackDescriptor
|
td: TrackDescriptor
|
||||||
for td in tracks:
|
for td in tracks:
|
||||||
@@ -182,13 +194,77 @@ class PatternDetailsScreen(Screen):
|
|||||||
td.getTitle(),
|
td.getTitle(),
|
||||||
t('Yes') if TrackDisposition.DEFAULT in dispoSet else t('No'),
|
t('Yes') if TrackDisposition.DEFAULT in dispoSet else t('No'),
|
||||||
t('Yes') if TrackDisposition.FORCED in dispoSet else t('No'),
|
t('Yes') if TrackDisposition.FORCED in dispoSet else t('No'),
|
||||||
|
t('Yes') if td.isDropped() else t('No'),
|
||||||
td.getSourceIndex())
|
td.getSourceIndex())
|
||||||
|
|
||||||
row_key = self.tracksTable.add_row(*map(str, row))
|
row_key = self.tracksTable.add_row(*map(str, row))
|
||||||
self.__trackRowData[row_key] = td
|
self.__trackRowData[row_key] = td
|
||||||
|
if (
|
||||||
|
selectedTrack is not None
|
||||||
|
and self._descriptor_refs_same_track(td, selectedTrack)
|
||||||
|
):
|
||||||
|
restoredRowIndex = len(self.__trackRowData) - 1
|
||||||
|
|
||||||
typeCounter[trackType] += 1
|
typeCounter[trackType] += 1
|
||||||
|
|
||||||
|
if restoredRowIndex is None and selectedTrackIndex is not None and self.__trackRowData:
|
||||||
|
restoredRowIndex = min(selectedTrackIndex, len(self.__trackRowData) - 1)
|
||||||
|
|
||||||
|
if restoredRowIndex is not None:
|
||||||
|
self.tracksTable.move_cursor(
|
||||||
|
row=restoredRowIndex,
|
||||||
|
column=selectedCursorColumn,
|
||||||
|
animate=False,
|
||||||
|
scroll=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.updateTrackDropButtonState()
|
||||||
|
|
||||||
|
def _track_row_index(self, trackDescriptor: TrackDescriptor | None):
|
||||||
|
if trackDescriptor is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for rowIndex, descriptor in enumerate(self.__trackRowData.values()):
|
||||||
|
if self._descriptor_refs_same_track(descriptor, trackDescriptor):
|
||||||
|
return rowIndex
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _descriptor_refs_same_track(
|
||||||
|
self,
|
||||||
|
descriptor: TrackDescriptor,
|
||||||
|
otherDescriptor: TrackDescriptor,
|
||||||
|
) -> bool:
|
||||||
|
if descriptor.getId() != -1 and otherDescriptor.getId() != -1:
|
||||||
|
return descriptor.getId() == otherDescriptor.getId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
descriptor.getPatternId() == otherDescriptor.getPatternId()
|
||||||
|
and descriptor.getIndex() == otherDescriptor.getIndex()
|
||||||
|
and descriptor.getSubIndex() == otherDescriptor.getSubIndex()
|
||||||
|
and descriptor.getType() == otherDescriptor.getType()
|
||||||
|
)
|
||||||
|
|
||||||
|
def updateTrackDropButtonState(self):
|
||||||
|
try:
|
||||||
|
button = self.query_one("#button_toggle_track_drop", Button)
|
||||||
|
except (NoMatches, AttributeError):
|
||||||
|
return
|
||||||
|
|
||||||
|
selectedTrack = self.getSelectedTrackDescriptor()
|
||||||
|
button.disabled = selectedTrack is None
|
||||||
|
button.variant = (
|
||||||
|
"warning"
|
||||||
|
if selectedTrack is not None and selectedTrack.isDropped()
|
||||||
|
else "default"
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_data_table_row_highlighted(
|
||||||
|
self,
|
||||||
|
event: DataTable.RowHighlighted,
|
||||||
|
) -> None:
|
||||||
|
if event.data_table.id == "tracks_table":
|
||||||
|
self.updateTrackDropButtonState()
|
||||||
|
|
||||||
|
|
||||||
def getCurrentTrackDescriptors(self) -> List[TrackDescriptor]:
|
def getCurrentTrackDescriptors(self) -> List[TrackDescriptor]:
|
||||||
if self.__pattern is not None:
|
if self.__pattern is not None:
|
||||||
@@ -331,6 +407,7 @@ class PatternDetailsScreen(Screen):
|
|||||||
|
|
||||||
if not self.__showDescriptor is None:
|
if not self.__showDescriptor is None:
|
||||||
self.query_one("#showlabel", Static).update(f"{self.__showDescriptor.getId()} - {self.__showDescriptor.getName()} ({self.__showDescriptor.getYear()})")
|
self.query_one("#showlabel", Static).update(f"{self.__showDescriptor.getId()} - {self.__showDescriptor.getName()} ({self.__showDescriptor.getYear()})")
|
||||||
|
self.updateShowQualityHint()
|
||||||
|
|
||||||
if self.__pattern is not None:
|
if self.__pattern is not None:
|
||||||
|
|
||||||
@@ -350,6 +427,7 @@ class PatternDetailsScreen(Screen):
|
|||||||
if not hasattr(self, "tracksTable") or not hasattr(self, "tagsTable"):
|
if not hasattr(self, "tracksTable") or not hasattr(self, "tagsTable"):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self.updateShowQualityHint()
|
||||||
self.updateTags()
|
self.updateTags()
|
||||||
self.updateTracks()
|
self.updateTracks()
|
||||||
|
|
||||||
@@ -379,6 +457,7 @@ class PatternDetailsScreen(Screen):
|
|||||||
self.column_key_track_title = add_auto_table_column(self.tracksTable, t("Title"))
|
self.column_key_track_title = add_auto_table_column(self.tracksTable, t("Title"))
|
||||||
self.column_key_track_default = add_auto_table_column(self.tracksTable, t("Default"))
|
self.column_key_track_default = add_auto_table_column(self.tracksTable, t("Default"))
|
||||||
self.column_key_track_forced = add_auto_table_column(self.tracksTable, t("Forced"))
|
self.column_key_track_forced = add_auto_table_column(self.tracksTable, t("Forced"))
|
||||||
|
self.column_key_track_drop = add_auto_table_column(self.tracksTable, t("Drop"))
|
||||||
self.column_key_track_source_index = add_auto_table_column(self.tracksTable, t("SrcIndex"))
|
self.column_key_track_source_index = add_auto_table_column(self.tracksTable, t("SrcIndex"))
|
||||||
|
|
||||||
self.tracksTable.cursor_type = 'row'
|
self.tracksTable.cursor_type = 'row'
|
||||||
@@ -415,7 +494,9 @@ class PatternDetailsScreen(Screen):
|
|||||||
# Row 4
|
# Row 4
|
||||||
yield Static(t("Quality"))
|
yield Static(t("Quality"))
|
||||||
yield Input(type="integer", id="quality_input")
|
yield Input(type="integer", id="quality_input")
|
||||||
yield Static(' ', classes="five")
|
yield Static(" ")
|
||||||
|
yield Static("", id="show_quality_hint", classes="two yellow")
|
||||||
|
yield Static(' ', classes="two")
|
||||||
|
|
||||||
|
|
||||||
# Row 5
|
# Row 5
|
||||||
@@ -474,7 +555,7 @@ class PatternDetailsScreen(Screen):
|
|||||||
yield Static(t("Streams"))
|
yield Static(t("Streams"))
|
||||||
yield Button(t("Add"), id="button_add_track")
|
yield Button(t("Add"), id="button_add_track")
|
||||||
yield Button(t("Edit"), id="button_edit_track")
|
yield Button(t("Edit"), id="button_edit_track")
|
||||||
yield Button(t("Delete"), id="button_delete_track")
|
yield Button(t("Drop"), id="button_toggle_track_drop")
|
||||||
|
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
yield Button(t("Up"), id="button_track_up")
|
yield Button(t("Up"), id="button_track_up")
|
||||||
@@ -495,7 +576,7 @@ class PatternDetailsScreen(Screen):
|
|||||||
yield Static(" ", classes="five")
|
yield Static(" ", classes="five")
|
||||||
|
|
||||||
# Row 20
|
# Row 20
|
||||||
yield Static(" ", classes="seven")
|
yield Static(" ", id="messagestatic", classes="seven")
|
||||||
|
|
||||||
yield build_screen_log_pane()
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
@@ -504,6 +585,23 @@ class PatternDetailsScreen(Screen):
|
|||||||
def getPatternFromInput(self):
|
def getPatternFromInput(self):
|
||||||
return str(self.query_one("#pattern_input", Input).value)
|
return str(self.query_one("#pattern_input", Input).value)
|
||||||
|
|
||||||
|
def getShowQualityHintText(self):
|
||||||
|
if self.__showDescriptor is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
showQuality = int(self.__showDescriptor.getQuality() or 0)
|
||||||
|
if showQuality <= 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
patternQuality = int(getattr(self.__pattern, "quality", 0) or 0)
|
||||||
|
if patternQuality > 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return f"{t('Show')}: {showQuality}"
|
||||||
|
|
||||||
|
def updateShowQualityHint(self):
|
||||||
|
self.query_one("#show_quality_hint", Static).update(self.getShowQualityHintText())
|
||||||
|
|
||||||
def getQualityFromInput(self):
|
def getQualityFromInput(self):
|
||||||
try:
|
try:
|
||||||
return int(self.query_one("#quality_input", Input).value)
|
return int(self.query_one("#quality_input", Input).value)
|
||||||
@@ -513,6 +611,12 @@ class PatternDetailsScreen(Screen):
|
|||||||
def getNotesFromInput(self):
|
def getNotesFromInput(self):
|
||||||
return str(self.query_one("#notes_textarea", TextArea).text)
|
return str(self.query_one("#notes_textarea", TextArea).text)
|
||||||
|
|
||||||
|
def setMessage(self, message: str):
|
||||||
|
try:
|
||||||
|
self.query_one("#messagestatic", Static).update(str(message) or " ")
|
||||||
|
except (NoMatches, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def getSelectedTrackDescriptor(self):
|
def getSelectedTrackDescriptor(self):
|
||||||
|
|
||||||
@@ -560,25 +664,26 @@ class PatternDetailsScreen(Screen):
|
|||||||
patternDescriptor['quality'] = self.getQualityFromInput()
|
patternDescriptor['quality'] = self.getQualityFromInput()
|
||||||
patternDescriptor['notes'] = self.getNotesFromInput()
|
patternDescriptor['notes'] = self.getNotesFromInput()
|
||||||
|
|
||||||
if self.__pattern is not None:
|
try:
|
||||||
|
if self.__pattern is not None:
|
||||||
|
|
||||||
if self.__pc.updatePattern(self.__pattern.getId(), patternDescriptor):
|
if self.__pc.updatePattern(self.__pattern.getId(), patternDescriptor):
|
||||||
self.dismiss(patternDescriptor)
|
self.dismiss(patternDescriptor)
|
||||||
else:
|
else:
|
||||||
#TODO: Meldung
|
self.setMessage(t("Unable to update pattern."))
|
||||||
self.app.pop_screen()
|
|
||||||
|
|
||||||
else:
|
|
||||||
patternId = self.__pc.savePatternSchema(
|
|
||||||
patternDescriptor,
|
|
||||||
trackDescriptors=self.__draftTracks,
|
|
||||||
mediaTags=self.__draftTags,
|
|
||||||
)
|
|
||||||
if patternId:
|
|
||||||
self.dismiss(patternDescriptor)
|
|
||||||
else:
|
else:
|
||||||
#TODO: Meldung
|
patternId = self.__pc.savePatternSchema(
|
||||||
self.app.pop_screen()
|
patternDescriptor,
|
||||||
|
trackDescriptors=self.__draftTracks,
|
||||||
|
mediaTags=self.__draftTags,
|
||||||
|
)
|
||||||
|
if patternId:
|
||||||
|
self.dismiss(patternDescriptor)
|
||||||
|
else:
|
||||||
|
self.setMessage(t("Unable to save pattern."))
|
||||||
|
except InvalidPatternCaptureGroupError as ex:
|
||||||
|
self.setMessage(str(ex))
|
||||||
|
|
||||||
|
|
||||||
if event.button.id == "cancel_button":
|
if event.button.id == "cancel_button":
|
||||||
@@ -639,11 +744,8 @@ class PatternDetailsScreen(Screen):
|
|||||||
),
|
),
|
||||||
self.handle_edit_track,
|
self.handle_edit_track,
|
||||||
)
|
)
|
||||||
if event.button.id == "button_delete_track":
|
if event.button.id == "button_toggle_track_drop":
|
||||||
self.app.push_screen(
|
self.toggleSelectedTrackDropped(selectedTrack)
|
||||||
TrackDeleteScreen(trackDescriptor = selectedTrack),
|
|
||||||
self.handle_delete_track,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if event.button.id == "button_add_tag":
|
if event.button.id == "button_add_tag":
|
||||||
@@ -727,6 +829,27 @@ class PatternDetailsScreen(Screen):
|
|||||||
|
|
||||||
self.updateTracks()
|
self.updateTracks()
|
||||||
|
|
||||||
|
def toggleSelectedTrackDropped(self, trackDescriptor: TrackDescriptor):
|
||||||
|
if trackDescriptor is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
updatedTrack = trackDescriptor.clone(context=self.context)
|
||||||
|
updatedTrack.setDropped(not trackDescriptor.isDropped())
|
||||||
|
|
||||||
|
if self.__pattern is not None:
|
||||||
|
if not self.__tc.updateTrack(updatedTrack.getId(), updatedTrack):
|
||||||
|
raise click.ClickException("PatternDetailsScreen.toggleSelectedTrackDropped(): track update failed")
|
||||||
|
else:
|
||||||
|
for currentTrack in self.__draftTracks:
|
||||||
|
if (
|
||||||
|
currentTrack.getIndex() == trackDescriptor.getIndex()
|
||||||
|
and currentTrack.getSubIndex() == trackDescriptor.getSubIndex()
|
||||||
|
):
|
||||||
|
currentTrack.setDropped(updatedTrack.isDropped())
|
||||||
|
break
|
||||||
|
|
||||||
|
self.updateTracks()
|
||||||
|
|
||||||
|
|
||||||
def handle_delete_track(self, trackDescriptor : TrackDescriptor):
|
def handle_delete_track(self, trackDescriptor : TrackDescriptor):
|
||||||
if trackDescriptor is None:
|
if trackDescriptor is None:
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class TrackController():
|
|||||||
|
|
||||||
|
|
||||||
def addTrack(self, trackDescriptor : TrackDescriptor, patternId = None):
|
def addTrack(self, trackDescriptor : TrackDescriptor, patternId = None):
|
||||||
|
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||||
|
return False
|
||||||
|
|
||||||
# option to override pattern id in case track descriptor has not set it
|
# option to override pattern id in case track descriptor has not set it
|
||||||
patId = int(trackDescriptor.getPatternId() if patternId is None else patternId)
|
patId = int(trackDescriptor.getPatternId() if patternId is None else patternId)
|
||||||
@@ -46,6 +48,7 @@ class TrackController():
|
|||||||
codec_name = str(trackDescriptor.getFormatDescriptor().identifier()),
|
codec_name = str(trackDescriptor.getFormatDescriptor().identifier()),
|
||||||
index = int(trackDescriptor.getIndex()),
|
index = int(trackDescriptor.getIndex()),
|
||||||
source_index = int(trackDescriptor.getSourceIndex()),
|
source_index = int(trackDescriptor.getSourceIndex()),
|
||||||
|
dropped = 1 if trackDescriptor.isDropped() else 0,
|
||||||
disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())),
|
disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())),
|
||||||
audio_layout = trackDescriptor.getAudioLayout().index())
|
audio_layout = trackDescriptor.getAudioLayout().index())
|
||||||
|
|
||||||
@@ -72,6 +75,8 @@ class TrackController():
|
|||||||
|
|
||||||
if type(trackDescriptor) is not TrackDescriptor:
|
if type(trackDescriptor) is not TrackDescriptor:
|
||||||
raise TypeError('TrackController.updateTrack(): Argument trackDescriptor is required to be of type TrackDescriptor')
|
raise TypeError('TrackController.updateTrack(): Argument trackDescriptor is required to be of type TrackDescriptor')
|
||||||
|
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s = self.Session()
|
s = self.Session()
|
||||||
@@ -79,7 +84,21 @@ class TrackController():
|
|||||||
|
|
||||||
if track is not None:
|
if track is not None:
|
||||||
|
|
||||||
|
patternId = int(track.pattern_id)
|
||||||
|
if trackDescriptor.isDropped() and not track.isDropped():
|
||||||
|
activeSiblingCount = (
|
||||||
|
s.query(Track)
|
||||||
|
.filter(Track.pattern_id == patternId, Track.dropped == 0)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if activeSiblingCount <= 1:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"Cannot drop the last active track from pattern #{patternId}. Patterns must define at least one non-dropped track."
|
||||||
|
)
|
||||||
|
|
||||||
track.index = int(trackDescriptor.getIndex())
|
track.index = int(trackDescriptor.getIndex())
|
||||||
|
track.source_index = int(trackDescriptor.getSourceIndex())
|
||||||
|
track.dropped = 1 if trackDescriptor.isDropped() else 0
|
||||||
|
|
||||||
track.track_type = int(trackDescriptor.getType().index())
|
track.track_type = int(trackDescriptor.getType().index())
|
||||||
track.codec_name = str(trackDescriptor.getFormatDescriptor().identifier())
|
track.codec_name = str(trackDescriptor.getFormatDescriptor().identifier())
|
||||||
@@ -251,6 +270,17 @@ class TrackController():
|
|||||||
f"Cannot delete the last track from pattern #{patternId}. Patterns must define at least one track."
|
f"Cannot delete the last track from pattern #{patternId}. Patterns must define at least one track."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not track.isDropped():
|
||||||
|
activeSiblingTracks = [
|
||||||
|
siblingTrack
|
||||||
|
for siblingTrack in siblingTracks
|
||||||
|
if not siblingTrack.isDropped()
|
||||||
|
]
|
||||||
|
if len(activeSiblingTracks) <= 1:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"Cannot delete the last active track from pattern #{patternId}. Patterns must define at least one non-dropped track."
|
||||||
|
)
|
||||||
|
|
||||||
index = 0
|
index = 0
|
||||||
for track in siblingTracks:
|
for track in siblingTracks:
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class TrackDescriptor:
|
|||||||
SUB_INDEX_KEY = "sub_index"
|
SUB_INDEX_KEY = "sub_index"
|
||||||
PATTERN_ID_KEY = "pattern_id"
|
PATTERN_ID_KEY = "pattern_id"
|
||||||
EXTERNAL_SOURCE_FILE_PATH_KEY = "external_source_file"
|
EXTERNAL_SOURCE_FILE_PATH_KEY = "external_source_file"
|
||||||
|
DROPPED_KEY = "dropped"
|
||||||
|
|
||||||
DISPOSITION_SET_KEY = "disposition_set"
|
DISPOSITION_SET_KEY = "disposition_set"
|
||||||
TAGS_KEY = "tags"
|
TAGS_KEY = "tags"
|
||||||
@@ -77,6 +78,8 @@ class TrackDescriptor:
|
|||||||
else:
|
else:
|
||||||
self.__externalSourceFilePath = ''
|
self.__externalSourceFilePath = ''
|
||||||
|
|
||||||
|
self.__dropped = bool(kwargs.get(TrackDescriptor.DROPPED_KEY, False))
|
||||||
|
|
||||||
if TrackDescriptor.INDEX_KEY in kwargs.keys():
|
if TrackDescriptor.INDEX_KEY in kwargs.keys():
|
||||||
if type(kwargs[TrackDescriptor.INDEX_KEY]) is not int:
|
if type(kwargs[TrackDescriptor.INDEX_KEY]) is not int:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
@@ -378,11 +381,18 @@ class TrackDescriptor:
|
|||||||
def getExternalSourceFilePath(self):
|
def getExternalSourceFilePath(self):
|
||||||
return self.__externalSourceFilePath
|
return self.__externalSourceFilePath
|
||||||
|
|
||||||
|
def isDropped(self) -> bool:
|
||||||
|
return self.__dropped
|
||||||
|
|
||||||
|
def setDropped(self, dropped: bool):
|
||||||
|
self.__dropped = bool(dropped)
|
||||||
|
|
||||||
def clone(self, context: dict | None = None):
|
def clone(self, context: dict | None = None):
|
||||||
kwargs = {
|
kwargs = {
|
||||||
TrackDescriptor.ID_KEY: int(self.__trackId),
|
TrackDescriptor.ID_KEY: int(self.__trackId),
|
||||||
TrackDescriptor.PATTERN_ID_KEY: int(self.__patternId),
|
TrackDescriptor.PATTERN_ID_KEY: int(self.__patternId),
|
||||||
TrackDescriptor.EXTERNAL_SOURCE_FILE_PATH_KEY: str(self.__externalSourceFilePath),
|
TrackDescriptor.EXTERNAL_SOURCE_FILE_PATH_KEY: str(self.__externalSourceFilePath),
|
||||||
|
TrackDescriptor.DROPPED_KEY: bool(self.__dropped),
|
||||||
TrackDescriptor.INDEX_KEY: int(self.__index),
|
TrackDescriptor.INDEX_KEY: int(self.__index),
|
||||||
TrackDescriptor.SOURCE_INDEX_KEY: int(self.__sourceIndex),
|
TrackDescriptor.SOURCE_INDEX_KEY: int(self.__sourceIndex),
|
||||||
TrackDescriptor.SUB_INDEX_KEY: int(self.__subIndex),
|
TrackDescriptor.SUB_INDEX_KEY: int(self.__subIndex),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import click
|
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, Checkbox
|
||||||
from textual.containers import Grid
|
from textual.containers import Grid
|
||||||
from textual.widgets._data_table import CellDoesNotExist
|
from textual.widgets._data_table import CellDoesNotExist
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ class TrackDetailsScreen(Screen):
|
|||||||
|
|
||||||
Grid {
|
Grid {
|
||||||
grid-size: 5 24;
|
grid-size: 5 24;
|
||||||
grid-rows: 2 2 2 2 2 3 3 2 2 3 2 2 2 2 2 6 2 2 6 2 2 2;
|
grid-rows: 2 2 2 2 2 3 3 3 2 3 2 2 2 2 2 6 2 2 6 2 2 2;
|
||||||
grid-columns: 18 1fr 1fr 1fr 4fr;
|
grid-columns: 18 1fr 1fr 1fr 4fr;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -146,6 +146,7 @@ class TrackDetailsScreen(Screen):
|
|||||||
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED
|
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED
|
||||||
self.__index = index
|
self.__index = index
|
||||||
self.__subIndex = subIndex
|
self.__subIndex = subIndex
|
||||||
|
self.__dropped = False
|
||||||
self.__draftTrackTags = {}
|
self.__draftTrackTags = {}
|
||||||
initial_language = IsoLanguage.UNDEFINED
|
initial_language = IsoLanguage.UNDEFINED
|
||||||
initial_title = ""
|
initial_title = ""
|
||||||
@@ -156,6 +157,7 @@ class TrackDetailsScreen(Screen):
|
|||||||
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.__dropped = trackDescriptor.isDropped()
|
||||||
self.__draftTrackTags = {
|
self.__draftTrackTags = {
|
||||||
key: value
|
key: value
|
||||||
for key, value in trackDescriptor.getTags().items()
|
for key, value in trackDescriptor.getTags().items()
|
||||||
@@ -255,6 +257,8 @@ class TrackDetailsScreen(Screen):
|
|||||||
|
|
||||||
self.query_one("#audio_layout_select", Select).value = self.__audioLayout
|
self.query_one("#audio_layout_select", Select).value = self.__audioLayout
|
||||||
|
|
||||||
|
self.query_one("#drop_checkbox", Checkbox).value = self.__dropped
|
||||||
|
|
||||||
for disposition in TrackDisposition:
|
for disposition in TrackDisposition:
|
||||||
|
|
||||||
dispositionIsSet = (
|
dispositionIsSet = (
|
||||||
@@ -284,6 +288,8 @@ class TrackDetailsScreen(Screen):
|
|||||||
self.query_one("#type_select", Select).disabled = True
|
self.query_one("#type_select", Select).disabled = True
|
||||||
self.query_one("#audio_layout_select", Select).disabled = True
|
self.query_one("#audio_layout_select", Select).disabled = True
|
||||||
|
|
||||||
|
self.query_one("#drop_checkbox", Checkbox).disabled = True
|
||||||
|
|
||||||
def on_select_changed(self, event: Select.Changed) -> None:
|
def on_select_changed(self, event: Select.Changed) -> None:
|
||||||
if event.select.id == "language_select":
|
if event.select.id == "language_select":
|
||||||
self._handle_language_selection_changed(event.value)
|
self._handle_language_selection_changed(event.value)
|
||||||
@@ -344,7 +350,9 @@ class TrackDetailsScreen(Screen):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Row 8
|
# Row 8
|
||||||
yield Static(" ", classes="five")
|
yield Static(t("Drop"))
|
||||||
|
|
||||||
|
yield Checkbox(id="drop_checkbox", classes="four")
|
||||||
|
|
||||||
# Row 9
|
# Row 9
|
||||||
yield Static(" ", classes="five")
|
yield Static(" ", classes="five")
|
||||||
@@ -462,6 +470,9 @@ class TrackDetailsScreen(Screen):
|
|||||||
trackTags["title"] = title
|
trackTags["title"] = title
|
||||||
|
|
||||||
kwargs[TrackDescriptor.TAGS_KEY] = trackTags
|
kwargs[TrackDescriptor.TAGS_KEY] = trackTags
|
||||||
|
kwargs[TrackDescriptor.DROPPED_KEY] = bool(
|
||||||
|
self.query_one("#drop_checkbox", Checkbox).value
|
||||||
|
)
|
||||||
|
|
||||||
dispositionFlags = sum(
|
dispositionFlags = sum(
|
||||||
[2 ** flag for flag in self.query_one("#dispositions_selection_list", SelectionList).selected]
|
[2 ** flag for flag in self.query_one("#dispositions_selection_list", SelectionList).selected]
|
||||||
@@ -504,6 +515,7 @@ class TrackDetailsScreen(Screen):
|
|||||||
descriptor
|
descriptor
|
||||||
for descriptor in siblingTrackList
|
for descriptor in siblingTrackList
|
||||||
if descriptor.getType() == trackDescriptor.getType()
|
if descriptor.getType() == trackDescriptor.getType()
|
||||||
|
and not descriptor.isDropped()
|
||||||
]
|
]
|
||||||
|
|
||||||
numDefaultTracks = len(
|
numDefaultTracks = len(
|
||||||
@@ -527,10 +539,12 @@ class TrackDetailsScreen(Screen):
|
|||||||
trackDescriptor.setSubIndex(int(self.__subIndex))
|
trackDescriptor.setSubIndex(int(self.__subIndex))
|
||||||
|
|
||||||
if (
|
if (
|
||||||
TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet()
|
not trackDescriptor.isDropped()
|
||||||
|
and TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet()
|
||||||
and numDefaultTracks
|
and numDefaultTracks
|
||||||
) or (
|
) or (
|
||||||
TrackDisposition.FORCED in trackDescriptor.getDispositionSet()
|
not trackDescriptor.isDropped()
|
||||||
|
and TrackDisposition.FORCED in trackDescriptor.getDispositionSet()
|
||||||
and numForcedTracks
|
and numForcedTracks
|
||||||
):
|
):
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class PatternManagementCliTests(unittest.TestCase):
|
|||||||
[to_track_descriptor(track_descriptors[0])],
|
[to_track_descriptor(track_descriptors[0])],
|
||||||
)
|
)
|
||||||
controller.savePatternSchema(
|
controller.savePatternSchema(
|
||||||
{"show_id": 2, "pattern": r"^dup_.*$"},
|
{"show_id": 2, "pattern": r"^dup_(s[0-9]+e[0-9]+).*$"},
|
||||||
[to_track_descriptor(track_descriptors[0])],
|
[to_track_descriptor(track_descriptors[0])],
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from tests.support.ffx_bundle import (
|
|||||||
write_vtt,
|
write_vtt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ffx.attachment_format import AttachmentFormat
|
||||||
from ffx.track_type import TrackType
|
from ffx.track_type import TrackType
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -143,6 +144,73 @@ class SubtrackMappingBundleTests(unittest.TestCase):
|
|||||||
self.assertIn("first embedded subtitle", extracted_subtitle)
|
self.assertIn("first embedded subtitle", extracted_subtitle)
|
||||||
self.assertNotIn("second embedded subtitle", extracted_subtitle)
|
self.assertNotIn("second embedded subtitle", extracted_subtitle)
|
||||||
|
|
||||||
|
def test_pattern_dropped_track_is_omitted_like_deleted_track(self):
|
||||||
|
source_filename = "dropped_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"),
|
||||||
|
SourceTrackSpec(
|
||||||
|
TrackType.SUBTITLE,
|
||||||
|
identity="subtitle-2",
|
||||||
|
language="eng",
|
||||||
|
subtitle_lines=("kept subtitle payload",),
|
||||||
|
),
|
||||||
|
SourceTrackSpec(
|
||||||
|
TrackType.SUBTITLE,
|
||||||
|
identity="subtitle-3",
|
||||||
|
language="eng",
|
||||||
|
subtitle_lines=("dropped subtitle payload",),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
prepare_pattern_database(
|
||||||
|
self.database_path,
|
||||||
|
r"^dropped_(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),
|
||||||
|
PatternTrackSpec(
|
||||||
|
index=3,
|
||||||
|
source_index=3,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
dropped=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
streams = ffprobe_json(output_path)["streams"]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[stream["codec_type"] for stream in streams],
|
||||||
|
["video", "audio", "subtitle"],
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"subtitle-3",
|
||||||
|
[get_tag(stream, "THIS_IS") for stream in streams],
|
||||||
|
)
|
||||||
|
extracted_subtitle = extract_first_subtitle_text(self.workdir, output_path)
|
||||||
|
self.assertIn("kept subtitle payload", extracted_subtitle)
|
||||||
|
self.assertNotIn("dropped subtitle payload", extracted_subtitle)
|
||||||
|
|
||||||
def test_cli_rearrange_streams_reorders_tracks_without_database_pattern(self):
|
def test_cli_rearrange_streams_reorders_tracks_without_database_pattern(self):
|
||||||
source_filename = "cli_s01e01.mkv"
|
source_filename = "cli_s01e01.mkv"
|
||||||
source_path = create_source_fixture(
|
source_path = create_source_fixture(
|
||||||
@@ -280,6 +348,72 @@ class SubtrackMappingBundleTests(unittest.TestCase):
|
|||||||
self.assertIn("non-existent source track #99", error_output)
|
self.assertIn("non-existent source track #99", error_output)
|
||||||
self.assertFalse(expected_output_path(self.workdir, source_filename).exists())
|
self.assertFalse(expected_output_path(self.workdir, source_filename).exists())
|
||||||
|
|
||||||
|
def test_styled_ass_source_preserves_current_font_attachments_when_pattern_count_differs(self):
|
||||||
|
source_filename = "styled_ass_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"),
|
||||||
|
SourceTrackSpec(
|
||||||
|
TrackType.SUBTITLE,
|
||||||
|
identity="subtitle-2",
|
||||||
|
language="eng",
|
||||||
|
subtitle_lines=("styled subtitle payload",),
|
||||||
|
),
|
||||||
|
SourceTrackSpec(TrackType.ATTACHMENT, attachment_name="current.ttf"),
|
||||||
|
],
|
||||||
|
subtitle_encoder="ass",
|
||||||
|
)
|
||||||
|
|
||||||
|
prepare_pattern_database(
|
||||||
|
self.database_path,
|
||||||
|
r"^styled_ass_(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),
|
||||||
|
PatternTrackSpec(
|
||||||
|
index=3,
|
||||||
|
source_index=3,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
),
|
||||||
|
PatternTrackSpec(
|
||||||
|
index=4,
|
||||||
|
source_index=4,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
self.assertIn("Styled ASS subtitles", completed.stdout)
|
||||||
|
|
||||||
|
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", "subtitle", "attachment"],
|
||||||
|
)
|
||||||
|
self.assertEqual(streams[2]["codec_name"], "ass")
|
||||||
|
self.assertEqual(streams[3]["codec_name"], "ttf")
|
||||||
|
self.assertEqual(get_tag(streams[3], "filename"), "current.ttf")
|
||||||
|
|
||||||
def test_external_subtitle_file_replaces_payload_and_overrides_metadata(self):
|
def test_external_subtitle_file_replaces_payload_and_overrides_metadata(self):
|
||||||
source_filename = "substitute_s01e01.mkv"
|
source_filename = "substitute_s01e01.mkv"
|
||||||
self.write_config(
|
self.write_config(
|
||||||
@@ -354,6 +488,59 @@ class SubtrackMappingBundleTests(unittest.TestCase):
|
|||||||
self.assertIn("external subtitle payload", extracted_subtitle)
|
self.assertIn("external subtitle payload", extracted_subtitle)
|
||||||
self.assertNotIn("embedded subtitle payload", extracted_subtitle)
|
self.assertNotIn("embedded subtitle payload", extracted_subtitle)
|
||||||
|
|
||||||
|
def test_subtitle_directory_without_prefix_uses_source_basename(self):
|
||||||
|
source_filename = "basename_substitute.mkv"
|
||||||
|
subtitle_directory = self.workdir / "sidecars"
|
||||||
|
subtitle_directory.mkdir()
|
||||||
|
source_path = create_source_fixture(
|
||||||
|
self.workdir,
|
||||||
|
source_filename,
|
||||||
|
[
|
||||||
|
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||||
|
SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng"),
|
||||||
|
SourceTrackSpec(
|
||||||
|
TrackType.SUBTITLE,
|
||||||
|
identity="embedded-subtitle",
|
||||||
|
language="eng",
|
||||||
|
subtitle_lines=("embedded subtitle payload",),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
write_vtt(
|
||||||
|
subtitle_directory / "basename_substitute_2_deu_DEF.vtt",
|
||||||
|
("external subtitle payload",),
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = run_ffx_convert(
|
||||||
|
self.workdir,
|
||||||
|
self.home_dir,
|
||||||
|
self.database_path,
|
||||||
|
"--video-encoder",
|
||||||
|
"copy",
|
||||||
|
"--no-pattern",
|
||||||
|
"--no-tmdb",
|
||||||
|
"--no-prompt",
|
||||||
|
"--no-signature",
|
||||||
|
"--subtitle-directory",
|
||||||
|
str(subtitle_directory),
|
||||||
|
str(source_path),
|
||||||
|
)
|
||||||
|
self.assertCompleted(completed)
|
||||||
|
self.assertIn("matched subtitle tracks #2", completed.stdout)
|
||||||
|
self.assertIn("Substituting subtitle stream #2", completed.stdout)
|
||||||
|
|
||||||
|
output_path = expected_output_path(self.workdir, source_filename)
|
||||||
|
subtitle_stream = [
|
||||||
|
stream
|
||||||
|
for stream in ffprobe_json(output_path)["streams"]
|
||||||
|
if stream["codec_type"] == "subtitle"
|
||||||
|
][0]
|
||||||
|
self.assertEqual("deu", get_tag(subtitle_stream, "language"))
|
||||||
|
|
||||||
|
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):
|
def test_subtitle_prefix_uses_configured_base_directory_when_directory_is_omitted(self):
|
||||||
source_filename = "substitute_default_s01e01.mkv"
|
source_filename = "substitute_default_s01e01.mkv"
|
||||||
subtitle_prefix = "substitute_default"
|
subtitle_prefix = "substitute_default"
|
||||||
|
|||||||
@@ -35,7 +35,13 @@ if pytest is not None:
|
|||||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
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]:
|
def run_ffx_unmux(
|
||||||
|
workdir: Path,
|
||||||
|
home_dir: Path,
|
||||||
|
database_path: Path,
|
||||||
|
*args: str,
|
||||||
|
input_text: str | None = None,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["HOME"] = str(home_dir)
|
env["HOME"] = str(home_dir)
|
||||||
existing_pythonpath = env.get("PYTHONPATH", "")
|
existing_pythonpath = env.get("PYTHONPATH", "")
|
||||||
@@ -50,7 +56,14 @@ def run_ffx_unmux(workdir: Path, home_dir: Path, database_path: Path, *args: str
|
|||||||
"unmux",
|
"unmux",
|
||||||
*args,
|
*args,
|
||||||
]
|
]
|
||||||
return subprocess.run(command, cwd=workdir, env=env, capture_output=True, text=True)
|
return subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=workdir,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
input=input_text,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UnmuxCliTests(unittest.TestCase):
|
class UnmuxCliTests(unittest.TestCase):
|
||||||
@@ -161,6 +174,7 @@ class UnmuxCliTests(unittest.TestCase):
|
|||||||
self.home_dir,
|
self.home_dir,
|
||||||
self.database_path,
|
self.database_path,
|
||||||
"--subtitles-only",
|
"--subtitles-only",
|
||||||
|
"--yes",
|
||||||
"--label",
|
"--label",
|
||||||
"dball",
|
"dball",
|
||||||
str(source_path),
|
str(source_path),
|
||||||
|
|||||||
471
tests/prepare.sh
Executable file
471
tests/prepare.sh
Executable file
@@ -0,0 +1,471 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
VENV_DIR="${FFX_TEST_VENV_DIR:-${ROOT_DIR}/.venv}"
|
||||||
|
VENV_BIN_DIR="${VENV_DIR}/bin"
|
||||||
|
VENV_PYTHON="${VENV_BIN_DIR}/python"
|
||||||
|
VENV_PIP="${VENV_BIN_DIR}/pip"
|
||||||
|
|
||||||
|
CHECK_ONLY=0
|
||||||
|
READINESS_FAILURES=0
|
||||||
|
INSTALL_FAILURES=0
|
||||||
|
|
||||||
|
MISSING_REQUIRED_SYSTEM=()
|
||||||
|
|
||||||
|
COLOR_RESET=""
|
||||||
|
COLOR_GREEN=""
|
||||||
|
COLOR_YELLOW=""
|
||||||
|
COLOR_RED=""
|
||||||
|
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
COLOR_RESET="$(printf '\033[0m')"
|
||||||
|
COLOR_GREEN="$(printf '\033[32m')"
|
||||||
|
COLOR_YELLOW="$(printf '\033[33m')"
|
||||||
|
COLOR_RED="$(printf '\033[31m')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [--check] [--help]
|
||||||
|
|
||||||
|
Prepare the repo-local FFX test environment at:
|
||||||
|
${VENV_DIR}
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- verify or install required system commands for tests
|
||||||
|
- create or reuse the repo-local test virtualenv
|
||||||
|
- install this repository into the venv with Python test and docs extras
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--check Report readiness only. Do not create, install, or modify.
|
||||||
|
--help Show this help text.
|
||||||
|
|
||||||
|
Environment overrides:
|
||||||
|
FFX_TEST_VENV_DIR Override the test virtualenv path. Defaults to ${ROOT_DIR}/.venv.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This script prepares a project-local test environment, not the persistent user bundle.
|
||||||
|
- The persistent bundle setup remains owned by tools/setup.sh.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
status_ok() {
|
||||||
|
printf '%sok%s' "${COLOR_GREEN}" "${COLOR_RESET}"
|
||||||
|
}
|
||||||
|
|
||||||
|
status_warn() {
|
||||||
|
printf '%swarn%s' "${COLOR_YELLOW}" "${COLOR_RESET}"
|
||||||
|
}
|
||||||
|
|
||||||
|
status_fail() {
|
||||||
|
printf '%sfailed%s' "${COLOR_RED}" "${COLOR_RESET}"
|
||||||
|
}
|
||||||
|
|
||||||
|
report_component() {
|
||||||
|
local level="$1"
|
||||||
|
local label="$2"
|
||||||
|
local detail="$3"
|
||||||
|
local rendered_status=""
|
||||||
|
|
||||||
|
case "${level}" in
|
||||||
|
ok)
|
||||||
|
rendered_status="$(status_ok)"
|
||||||
|
;;
|
||||||
|
warn)
|
||||||
|
rendered_status="$(status_warn)"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
rendered_status="$(status_fail)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
printf '[%s] %s%s\n' "${rendered_status}" "${label}" "${detail:+: $detail}"
|
||||||
|
}
|
||||||
|
|
||||||
|
command_exists() {
|
||||||
|
command -v "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_python_venv_support() {
|
||||||
|
python3 -m venv --help >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_system_command() {
|
||||||
|
command_exists "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_venv_python() {
|
||||||
|
[ -x "${VENV_PYTHON}" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
check_venv_pip() {
|
||||||
|
check_venv_python && "${VENV_PIP}" --version >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_venv_ffx() {
|
||||||
|
check_venv_python && "${VENV_PYTHON}" -m ffx version >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_venv_pytest() {
|
||||||
|
check_venv_python && "${VENV_PYTHON}" -m pytest --version >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_venv_sphinx() {
|
||||||
|
check_venv_python && "${VENV_BIN_DIR}/sphinx-build" --version >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_venv_docs_packages() {
|
||||||
|
check_venv_python && "${VENV_PYTHON}" - <<'PY' >/dev/null 2>&1
|
||||||
|
import esbonio
|
||||||
|
import sphinx
|
||||||
|
import sphinx_rtd_theme
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
check_editable_install() {
|
||||||
|
check_venv_python && FFX_REPO_ROOT="${ROOT_DIR}" "${VENV_PYTHON}" - <<'PY' >/dev/null 2>&1
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import ffx
|
||||||
|
|
||||||
|
repo_root = Path(os.environ["FFX_REPO_ROOT"]).resolve()
|
||||||
|
package_path = Path(ffx.__file__).resolve()
|
||||||
|
|
||||||
|
raise SystemExit(0 if repo_root in package_path.parents else 1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
check_python_environment_ready() {
|
||||||
|
check_venv_python &&
|
||||||
|
check_venv_pip &&
|
||||||
|
check_venv_pytest &&
|
||||||
|
check_venv_sphinx &&
|
||||||
|
check_venv_docs_packages &&
|
||||||
|
check_venv_ffx &&
|
||||||
|
check_editable_install
|
||||||
|
}
|
||||||
|
|
||||||
|
command_detail() {
|
||||||
|
command -v "$1" || printf "command '%s' not found" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
python_venv_detail() {
|
||||||
|
if check_python_venv_support; then
|
||||||
|
printf 'python3 -m venv is available'
|
||||||
|
else
|
||||||
|
printf 'python3 venv support is unavailable'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
venv_python_detail() {
|
||||||
|
if check_venv_python; then
|
||||||
|
printf '%s' "${VENV_PYTHON}"
|
||||||
|
else
|
||||||
|
printf 'missing %s' "${VENV_PYTHON}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
venv_pip_detail() {
|
||||||
|
if check_venv_pip; then
|
||||||
|
"${VENV_PIP}" --version
|
||||||
|
else
|
||||||
|
printf 'missing pip in %s' "${VENV_DIR}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
venv_ffx_detail() {
|
||||||
|
if check_venv_ffx; then
|
||||||
|
printf 'ffx import and CLI entry are available'
|
||||||
|
else
|
||||||
|
printf 'ffx is not installed in %s' "${VENV_DIR}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
venv_pytest_detail() {
|
||||||
|
if check_venv_pytest; then
|
||||||
|
"${VENV_PYTHON}" -m pytest --version 2>/dev/null | head -n 1
|
||||||
|
else
|
||||||
|
printf 'pytest is not installed in %s' "${VENV_DIR}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
venv_sphinx_detail() {
|
||||||
|
if check_venv_sphinx; then
|
||||||
|
"${VENV_BIN_DIR}/sphinx-build" --version 2>&1
|
||||||
|
else
|
||||||
|
printf 'sphinx-build is not installed in %s' "${VENV_DIR}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
venv_docs_packages_detail() {
|
||||||
|
if check_venv_docs_packages; then
|
||||||
|
printf 'Sphinx, Read the Docs theme, and Esbonio packages are importable'
|
||||||
|
else
|
||||||
|
printf 'one or more docs packages are missing in %s' "${VENV_DIR}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
editable_install_detail() {
|
||||||
|
if check_editable_install; then
|
||||||
|
printf 'ffx resolves from %s' "${ROOT_DIR}"
|
||||||
|
else
|
||||||
|
printf 'ffx does not resolve from the project source tree'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
report_required_command() {
|
||||||
|
local label="$1"
|
||||||
|
local command_name="$2"
|
||||||
|
|
||||||
|
if check_system_command "${command_name}"; then
|
||||||
|
report_component ok "${label}" "$(command_detail "${command_name}")"
|
||||||
|
else
|
||||||
|
report_component failed "${label}" "$(command_detail "${command_name}")"
|
||||||
|
MISSING_REQUIRED_SYSTEM+=("${command_name}")
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_system_status() {
|
||||||
|
MISSING_REQUIRED_SYSTEM=()
|
||||||
|
|
||||||
|
echo "System toolchain status:"
|
||||||
|
report_required_command "git" "git"
|
||||||
|
report_required_command "python3" "python3"
|
||||||
|
|
||||||
|
if check_system_command "python3" && check_python_venv_support; then
|
||||||
|
report_component ok "python3 venv" "$(python_venv_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "python3 venv" "$(python_venv_detail)"
|
||||||
|
MISSING_REQUIRED_SYSTEM+=("python3-venv")
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
report_required_command "ffmpeg" "ffmpeg"
|
||||||
|
report_required_command "ffprobe" "ffprobe"
|
||||||
|
report_required_command "cpulimit" "cpulimit"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_python_status() {
|
||||||
|
echo "Repo test and docs virtualenv status:"
|
||||||
|
|
||||||
|
if check_venv_python; then
|
||||||
|
report_component ok "test virtualenv" "$(venv_python_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "test virtualenv" "$(venv_python_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_venv_pip; then
|
||||||
|
report_component ok "test pip" "$(venv_pip_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "test pip" "$(venv_pip_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_venv_pytest; then
|
||||||
|
report_component ok "test pytest" "$(venv_pytest_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "test pytest" "$(venv_pytest_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_venv_sphinx; then
|
||||||
|
report_component ok "docs sphinx" "$(venv_sphinx_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "docs sphinx" "$(venv_sphinx_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_venv_docs_packages; then
|
||||||
|
report_component ok "docs packages" "$(venv_docs_packages_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "docs packages" "$(venv_docs_packages_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_venv_ffx; then
|
||||||
|
report_component ok "test ffx" "$(venv_ffx_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "test ffx" "$(venv_ffx_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_editable_install; then
|
||||||
|
report_component ok "editable source" "$(editable_install_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "editable source" "$(editable_install_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_status_report() {
|
||||||
|
READINESS_FAILURES=0
|
||||||
|
|
||||||
|
print_system_status
|
||||||
|
echo
|
||||||
|
print_python_status
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_package_manager() {
|
||||||
|
if command_exists apt-get; then
|
||||||
|
printf 'apt-get\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if command_exists pacman; then
|
||||||
|
printf 'pacman\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_root_command() {
|
||||||
|
if [ "${EUID}" -eq 0 ]; then
|
||||||
|
"$@"
|
||||||
|
elif command_exists sudo; then
|
||||||
|
sudo -n "$@"
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_system_requirements() {
|
||||||
|
local package_manager
|
||||||
|
|
||||||
|
if [ "${#MISSING_REQUIRED_SYSTEM[@]}" -eq 0 ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! package_manager="$(detect_package_manager)"; then
|
||||||
|
printf 'No supported package manager found for automatic system preparation.\n' >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${package_manager}" in
|
||||||
|
apt-get)
|
||||||
|
printf 'Installing required system dependencies via apt-get...\n'
|
||||||
|
if ! run_root_command apt-get update; then
|
||||||
|
printf 'apt-get update failed or requires interactive sudo.\n' >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! run_root_command apt-get install -y git python3 python3-venv ffmpeg cpulimit; then
|
||||||
|
printf 'apt-get install failed or requires interactive sudo.\n' >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
pacman)
|
||||||
|
printf 'Installing required system dependencies via pacman...\n'
|
||||||
|
if ! run_root_command pacman -Sy --noconfirm git python ffmpeg cpulimit; then
|
||||||
|
printf 'pacman install failed or requires interactive sudo.\n' >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_test_venv() {
|
||||||
|
if ! check_venv_python; then
|
||||||
|
printf 'Creating repo test virtualenv at %s...\n' "${VENV_DIR}"
|
||||||
|
if ! python3 -m venv "${VENV_DIR}"; then
|
||||||
|
printf 'Failed to create test virtualenv at %s.\n' "${VENV_DIR}" >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! check_venv_pip; then
|
||||||
|
printf 'Missing pip in %s.\n' "${VENV_DIR}" >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Installing FFX package with test and docs extras into %s...\n' "${VENV_DIR}"
|
||||||
|
if ! (
|
||||||
|
cd "${ROOT_DIR}" &&
|
||||||
|
"${VENV_PIP}" install --editable '.[test,docs]'
|
||||||
|
); then
|
||||||
|
printf 'Failed to install FFX package with test and docs extras into %s.\n' "${VENV_DIR}" >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--check)
|
||||||
|
CHECK_ONLY=1
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'Unknown option: %s\n\n' "$1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
parse_args "$@"
|
||||||
|
|
||||||
|
print_status_report
|
||||||
|
|
||||||
|
if [ "${CHECK_ONLY}" -eq 0 ]; then
|
||||||
|
if [ "${#MISSING_REQUIRED_SYSTEM[@]}" -gt 0 ]; then
|
||||||
|
echo
|
||||||
|
install_system_requirements
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_python_environment_ready; then
|
||||||
|
echo
|
||||||
|
report_component ok "Python package install" "repo test and docs virtualenv is already ready"
|
||||||
|
elif check_system_command "python3" && check_python_venv_support; then
|
||||||
|
echo
|
||||||
|
ensure_test_venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
print_status_report
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "${INSTALL_FAILURES}" -gt 0 ]; then
|
||||||
|
echo "One or more test preparation steps failed; see the status checks above." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${READINESS_FAILURES}" -gt 0 ]; then
|
||||||
|
if [ "${CHECK_ONLY}" -eq 1 ]; then
|
||||||
|
echo "The FFX test and docs environment is incomplete." >&2
|
||||||
|
else
|
||||||
|
echo "Required test or docs components are still missing after preparation." >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${CHECK_ONLY}" -eq 1 ]; then
|
||||||
|
echo "The FFX test and docs environment is ready."
|
||||||
|
else
|
||||||
|
echo "The FFX test and docs environment is prepared."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -18,6 +18,7 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(SRC_ROOT))
|
sys.path.insert(0, str(SRC_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
from ffx.attachment_format import AttachmentFormat
|
||||||
from ffx.audio_layout import AudioLayout
|
from ffx.audio_layout import AudioLayout
|
||||||
from ffx.database import databaseContext
|
from ffx.database import databaseContext
|
||||||
from ffx.pattern_controller import PatternController
|
from ffx.pattern_controller import PatternController
|
||||||
@@ -55,7 +56,9 @@ class PatternTrackSpec:
|
|||||||
track_type: TrackType
|
track_type: TrackType
|
||||||
tags: Mapping[str, str] = field(default_factory=dict)
|
tags: Mapping[str, str] = field(default_factory=dict)
|
||||||
dispositions: tuple[TrackDisposition, ...] = ()
|
dispositions: tuple[TrackDisposition, ...] = ()
|
||||||
|
dropped: bool = False
|
||||||
audio_layout: AudioLayout = AudioLayout.LAYOUT_STEREO
|
audio_layout: AudioLayout = AudioLayout.LAYOUT_STEREO
|
||||||
|
attachment_format: AttachmentFormat = AttachmentFormat.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
def make_logger(name: str) -> logging.Logger:
|
def make_logger(name: str) -> logging.Logger:
|
||||||
@@ -296,9 +299,12 @@ def prepare_pattern_database(database_path: Path, filename_pattern: str, track_s
|
|||||||
TrackDescriptor.TRACK_TYPE_KEY: track.track_type,
|
TrackDescriptor.TRACK_TYPE_KEY: track.track_type,
|
||||||
TrackDescriptor.TAGS_KEY: dict(track.tags),
|
TrackDescriptor.TAGS_KEY: dict(track.tags),
|
||||||
TrackDescriptor.DISPOSITION_SET_KEY: set(track.dispositions),
|
TrackDescriptor.DISPOSITION_SET_KEY: set(track.dispositions),
|
||||||
|
TrackDescriptor.DROPPED_KEY: track.dropped,
|
||||||
}
|
}
|
||||||
if track.track_type == TrackType.AUDIO:
|
if track.track_type == TrackType.AUDIO:
|
||||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = track.audio_layout
|
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = track.audio_layout
|
||||||
|
if track.track_type == TrackType.ATTACHMENT:
|
||||||
|
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = track.attachment_format
|
||||||
track_descriptors.append(TrackDescriptor(**kwargs))
|
track_descriptors.append(TrackDescriptor(**kwargs))
|
||||||
|
|
||||||
pattern_id = PatternController(context).savePatternSchema(
|
pattern_id = PatternController(context).savePatternSchema(
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ class ConvertDiagnosticCliTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(0, result.exit_code, result.output)
|
self.assertEqual(0, result.exit_code, result.output)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"All files converted with no ffmpeg findings requiring review.",
|
"All files converted with no issues.",
|
||||||
result.output,
|
result.output,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from pathlib import Path
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import click
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +19,10 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
|
|
||||||
|
|
||||||
from ffx import cli # noqa: E402
|
from ffx import cli # noqa: E402
|
||||||
|
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||||
|
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||||
|
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
||||||
|
from ffx.track_type import TrackType # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
class SubtitleDirectoryCliTests(unittest.TestCase):
|
class SubtitleDirectoryCliTests(unittest.TestCase):
|
||||||
@@ -48,6 +54,35 @@ class SubtitleDirectoryCliTests(unittest.TestCase):
|
|||||||
env={**os.environ, "HOME": str(self.home_dir)},
|
env={**os.environ, "HOME": str(self.home_dir)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def make_subtitle_descriptor(self, indices=(2, 3, 4)) -> MediaDescriptor:
|
||||||
|
return MediaDescriptor(
|
||||||
|
context={"logger": get_ffx_logger()},
|
||||||
|
track_descriptors=[
|
||||||
|
TrackDescriptor(
|
||||||
|
index=index,
|
||||||
|
source_index=index,
|
||||||
|
sub_index=subIndex,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
)
|
||||||
|
for subIndex, index in enumerate(indices)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_import_context(
|
||||||
|
self,
|
||||||
|
subtitleDirectory: Path,
|
||||||
|
noPrompt: bool,
|
||||||
|
yes: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"subtitle_match_source_basename": True,
|
||||||
|
"subtitle_directory": str(subtitleDirectory),
|
||||||
|
"subtitle_prefix": "",
|
||||||
|
"subtitle_extension": "vtt",
|
||||||
|
"no_prompt": noPrompt,
|
||||||
|
"yes": yes,
|
||||||
|
}
|
||||||
|
|
||||||
def test_subtitle_prefix_without_directory_or_default_fails(self):
|
def test_subtitle_prefix_without_directory_or_default_fails(self):
|
||||||
result = self.invoke_convert("--subtitle-prefix", "dball")
|
result = self.invoke_convert("--subtitle-prefix", "dball")
|
||||||
|
|
||||||
@@ -79,6 +114,153 @@ class SubtitleDirectoryCliTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(0, result.exit_code, result.output)
|
self.assertEqual(0, result.exit_code, result.output)
|
||||||
|
|
||||||
|
def test_explicit_directory_without_prefix_enables_basename_matching(self):
|
||||||
|
explicitSubtitleDirectory = self.home_dir / "manual-subtitles"
|
||||||
|
explicitSubtitleDirectory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
enabled, directory, prefix, matchBasename = cli.resolveSubtitleImportOptions(
|
||||||
|
{},
|
||||||
|
str(explicitSubtitleDirectory),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(enabled)
|
||||||
|
self.assertEqual(str(explicitSubtitleDirectory), directory)
|
||||||
|
self.assertEqual("", prefix)
|
||||||
|
self.assertTrue(matchBasename)
|
||||||
|
|
||||||
|
def test_subtitle_extension_accepts_optional_leading_dot(self):
|
||||||
|
self.assertEqual("mkv", cli.normalizeSubtitleExtension(None, None, "mkv"))
|
||||||
|
self.assertEqual("mkv", cli.normalizeSubtitleExtension(None, None, ".mkv"))
|
||||||
|
|
||||||
|
def test_subtitle_extension_rejects_multiple_leading_dots(self):
|
||||||
|
with self.assertRaises(click.BadParameter):
|
||||||
|
cli.normalizeSubtitleExtension(None, None, "..mkv")
|
||||||
|
|
||||||
|
def test_complete_basename_set_does_not_prompt(self):
|
||||||
|
subtitleDirectory = self.home_dir / "complete-subtitles"
|
||||||
|
subtitleDirectory.mkdir()
|
||||||
|
for basename in (
|
||||||
|
"A2_t01_2_deu_DEF",
|
||||||
|
"A2_t01_3_eng",
|
||||||
|
"A2_t01_4_eng",
|
||||||
|
):
|
||||||
|
(subtitleDirectory / f"{basename}.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
descriptor = self.make_subtitle_descriptor()
|
||||||
|
context = self.make_import_context(subtitleDirectory, noPrompt=True)
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mockedConfirm:
|
||||||
|
result = cli.importExternalSubtitles(
|
||||||
|
context,
|
||||||
|
descriptor,
|
||||||
|
"A2_t01",
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([], result["missing_track_indices"])
|
||||||
|
mockedConfirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_incomplete_basename_set_fails_with_no_prompt(self):
|
||||||
|
descriptor = self.make_subtitle_descriptor()
|
||||||
|
subtitleDirectory = self.home_dir / "partial-subtitles"
|
||||||
|
subtitleDirectory.mkdir()
|
||||||
|
(subtitleDirectory / "episode_2_deu.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
context = self.make_import_context(subtitleDirectory, noPrompt=True)
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mockedConfirm:
|
||||||
|
with self.assertRaisesRegex(click.ClickException, "--no-prompt is set"):
|
||||||
|
cli.importExternalSubtitles(
|
||||||
|
context,
|
||||||
|
descriptor,
|
||||||
|
"episode",
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
mockedConfirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_incomplete_basename_set_can_be_confirmed(self):
|
||||||
|
descriptor = self.make_subtitle_descriptor()
|
||||||
|
subtitleDirectory = self.home_dir / "partial-subtitles"
|
||||||
|
subtitleDirectory.mkdir()
|
||||||
|
(subtitleDirectory / "episode_2_deu.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
context = self.make_import_context(subtitleDirectory, noPrompt=False)
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm", return_value=True) as mockedConfirm:
|
||||||
|
result = cli.importExternalSubtitles(
|
||||||
|
context,
|
||||||
|
descriptor,
|
||||||
|
"episode",
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([3, 4], result["missing_track_indices"])
|
||||||
|
mockedConfirm.assert_called_once()
|
||||||
|
|
||||||
|
def test_incomplete_basename_set_with_yes_does_not_prompt(self):
|
||||||
|
descriptor = self.make_subtitle_descriptor()
|
||||||
|
subtitleDirectory = self.home_dir / "partial-subtitles"
|
||||||
|
subtitleDirectory.mkdir()
|
||||||
|
(subtitleDirectory / "episode_2_deu.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
context = self.make_import_context(
|
||||||
|
subtitleDirectory,
|
||||||
|
noPrompt=False,
|
||||||
|
yes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mockedConfirm:
|
||||||
|
result = cli.importExternalSubtitles(
|
||||||
|
context,
|
||||||
|
descriptor,
|
||||||
|
"episode",
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([2], result["imported_track_indices"])
|
||||||
|
self.assertEqual([3, 4], result["missing_track_indices"])
|
||||||
|
mockedConfirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_yes_takes_precedence_over_no_prompt_for_incomplete_set(self):
|
||||||
|
descriptor = self.make_subtitle_descriptor()
|
||||||
|
subtitleDirectory = self.home_dir / "partial-subtitles"
|
||||||
|
subtitleDirectory.mkdir()
|
||||||
|
(subtitleDirectory / "episode_2_deu.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
context = self.make_import_context(
|
||||||
|
subtitleDirectory,
|
||||||
|
noPrompt=True,
|
||||||
|
yes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mockedConfirm:
|
||||||
|
result = cli.importExternalSubtitles(
|
||||||
|
context,
|
||||||
|
descriptor,
|
||||||
|
"episode",
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([3, 4], result["missing_track_indices"])
|
||||||
|
mockedConfirm.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ class UnmuxOutputDirectoryTests(unittest.TestCase):
|
|||||||
self.assertEqual(str(Path(tempdir) / "subtitles" / "dball"), resolved_output_directory)
|
self.assertEqual(str(Path(tempdir) / "subtitles" / "dball"), resolved_output_directory)
|
||||||
self.assertTrue(should_create)
|
self.assertTrue(should_create)
|
||||||
|
|
||||||
def test_explicit_output_directory_keeps_existing_behavior(self):
|
def test_explicit_output_directory_requires_directory(self):
|
||||||
with tempfile.TemporaryDirectory() as tempdir:
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
context = {
|
context = {
|
||||||
"config": StaticConfig(str(Path(tempdir) / "subtitles")),
|
"config": StaticConfig(str(Path(tempdir) / "subtitles")),
|
||||||
@@ -57,7 +58,7 @@ class UnmuxOutputDirectoryTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(explicit_output_directory, resolved_output_directory)
|
self.assertEqual(explicit_output_directory, resolved_output_directory)
|
||||||
self.assertFalse(should_create)
|
self.assertTrue(should_create)
|
||||||
|
|
||||||
def test_subtitles_only_without_label_keeps_existing_behavior(self):
|
def test_subtitles_only_without_label_keeps_existing_behavior(self):
|
||||||
context = {
|
context = {
|
||||||
@@ -89,6 +90,110 @@ class UnmuxOutputDirectoryTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("subtitlesDirectory default", str(caught.exception))
|
self.assertIn("subtitlesDirectory default", str(caught.exception))
|
||||||
|
|
||||||
|
def test_missing_output_directory_can_be_confirmed_and_created_with_parents(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_directory = Path(tempdir) / "missing" / "parents" / "manual"
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm", return_value=True) as mocked_confirm:
|
||||||
|
created = cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": False},
|
||||||
|
str(output_directory),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(created)
|
||||||
|
self.assertTrue(output_directory.is_dir())
|
||||||
|
mocked_confirm.assert_called_once()
|
||||||
|
|
||||||
|
def test_tty_carriage_return_accepts_default_directory_creation(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_directory = Path(tempdir) / "missing" / "manual"
|
||||||
|
|
||||||
|
with patch("ffx.cli.sys.stdin.isatty", return_value=True), patch(
|
||||||
|
"ffx.cli.click.getchar",
|
||||||
|
return_value="\r",
|
||||||
|
) as mocked_getchar, patch("ffx.cli.click.confirm") as mocked_confirm:
|
||||||
|
created = cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": False},
|
||||||
|
str(output_directory),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(created)
|
||||||
|
self.assertTrue(output_directory.is_dir())
|
||||||
|
mocked_getchar.assert_called_once()
|
||||||
|
mocked_confirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_yes_creates_missing_output_directory_without_prompt(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_directory = Path(tempdir) / "missing" / "parents" / "manual"
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mocked_confirm:
|
||||||
|
created = cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": False, "yes": True},
|
||||||
|
str(output_directory),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(created)
|
||||||
|
self.assertTrue(output_directory.is_dir())
|
||||||
|
mocked_confirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_missing_output_directory_can_be_rejected(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_directory = Path(tempdir) / "missing" / "manual"
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm", return_value=False) as mocked_confirm:
|
||||||
|
with self.assertRaises(click.ClickException) as caught:
|
||||||
|
cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": False},
|
||||||
|
str(output_directory),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(output_directory.exists())
|
||||||
|
self.assertIn("aborted by user", str(caught.exception))
|
||||||
|
mocked_confirm.assert_called_once()
|
||||||
|
|
||||||
|
def test_existing_output_directory_does_not_prompt(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_directory = Path(tempdir) / "manual"
|
||||||
|
output_directory.mkdir()
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mocked_confirm:
|
||||||
|
created = cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": False},
|
||||||
|
str(output_directory),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(created)
|
||||||
|
mocked_confirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_existing_non_directory_output_path_fails_without_prompt(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_path = Path(tempdir) / "manual"
|
||||||
|
output_path.write_text("not a directory", encoding="utf-8")
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mocked_confirm:
|
||||||
|
with self.assertRaises(click.ClickException) as caught:
|
||||||
|
cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": False},
|
||||||
|
str(output_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("not a directory", str(caught.exception))
|
||||||
|
mocked_confirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_dry_run_does_not_prompt_or_create_missing_output_directory(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_directory = Path(tempdir) / "missing" / "manual"
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mocked_confirm:
|
||||||
|
created = cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": True},
|
||||||
|
str(output_directory),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(created)
|
||||||
|
self.assertFalse(output_directory.exists())
|
||||||
|
mocked_confirm.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -149,6 +149,49 @@ class DatabaseContextTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
cursor.execute("DROP TABLE shifted_seasons_current")
|
cursor.execute("DROP TABLE shifted_seasons_current")
|
||||||
|
|
||||||
|
def rewrite_tracks_table_without_dropped(self, cursor):
|
||||||
|
cursor.execute("ALTER TABLE tracks RENAME TO tracks_current")
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE tracks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
track_type INTEGER,
|
||||||
|
"index" INTEGER,
|
||||||
|
source_index INTEGER,
|
||||||
|
pattern_id INTEGER,
|
||||||
|
disposition_flags INTEGER,
|
||||||
|
codec_name VARCHAR,
|
||||||
|
audio_layout INTEGER,
|
||||||
|
FOREIGN KEY(pattern_id) REFERENCES patterns(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tracks (
|
||||||
|
id,
|
||||||
|
track_type,
|
||||||
|
"index",
|
||||||
|
source_index,
|
||||||
|
pattern_id,
|
||||||
|
disposition_flags,
|
||||||
|
codec_name,
|
||||||
|
audio_layout
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
track_type,
|
||||||
|
"index",
|
||||||
|
source_index,
|
||||||
|
pattern_id,
|
||||||
|
disposition_flags,
|
||||||
|
codec_name,
|
||||||
|
audio_layout
|
||||||
|
FROM tracks_current
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute("DROP TABLE tracks_current")
|
||||||
|
|
||||||
def test_database_context_bootstraps_new_database_with_current_version(self):
|
def test_database_context_bootstraps_new_database_with_current_version(self):
|
||||||
with patch("ffx.database.Base.metadata.create_all", wraps=Base.metadata.create_all) as mocked_create_all:
|
with patch("ffx.database.Base.metadata.create_all", wraps=Base.metadata.create_all) as mocked_create_all:
|
||||||
context = databaseContext(str(self.database_path))
|
context = databaseContext(str(self.database_path))
|
||||||
@@ -321,6 +364,41 @@ class DatabaseContextTests(unittest.TestCase):
|
|||||||
mocked_confirm.assert_not_called()
|
mocked_confirm.assert_not_called()
|
||||||
mocked_echo.assert_not_called()
|
mocked_echo.assert_not_called()
|
||||||
|
|
||||||
|
def test_database_context_repairs_current_track_schema_without_version_bump(self):
|
||||||
|
context = databaseContext(str(self.database_path))
|
||||||
|
context["engine"].dispose()
|
||||||
|
|
||||||
|
connection = sqlite3.connect(self.database_path)
|
||||||
|
try:
|
||||||
|
cursor = connection.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=OFF")
|
||||||
|
self.rewrite_tracks_table_without_dropped(cursor)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
with patch("ffx.database.click.confirm") as mocked_confirm, patch(
|
||||||
|
"ffx.database.click.echo"
|
||||||
|
) as mocked_echo:
|
||||||
|
reopened_context = databaseContext(str(self.database_path))
|
||||||
|
try:
|
||||||
|
self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context))
|
||||||
|
|
||||||
|
connection = sqlite3.connect(self.database_path)
|
||||||
|
try:
|
||||||
|
column_names = {
|
||||||
|
row[1]
|
||||||
|
for row in connection.execute("PRAGMA table_info(tracks)").fetchall()
|
||||||
|
}
|
||||||
|
self.assertIn("dropped", column_names)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
finally:
|
||||||
|
reopened_context["engine"].dispose()
|
||||||
|
|
||||||
|
mocked_confirm.assert_not_called()
|
||||||
|
mocked_echo.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
175
tests/unit/test_deinterlace_filter.py
Normal file
175
tests/unit/test_deinterlace_filter.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from ffx.cli import convert
|
||||||
|
from ffx.filter.deinterlace_filter import DeinterlaceFilter
|
||||||
|
from ffx.filter.filter import Filter
|
||||||
|
|
||||||
|
|
||||||
|
class DeinterlaceFilterTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
Filter.filterChain.clear()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
Filter.filterChain.clear()
|
||||||
|
|
||||||
|
def getPayload(self, mode=DeinterlaceFilter.DEFAULT_MODE):
|
||||||
|
deinterlaceFilter = DeinterlaceFilter(mode=mode)
|
||||||
|
payload, = deinterlaceFilter.getYield()
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def test_default_mode_yields_only_bwdif(self):
|
||||||
|
payload = self.getPayload()
|
||||||
|
|
||||||
|
self.assertEqual(['bwdif=mode=1'], payload['tokens'])
|
||||||
|
|
||||||
|
def test_up_down_mode_yields_lanczos_scales_after_bwdif(self):
|
||||||
|
payload = self.getPayload('up_down')
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
'bwdif=mode=1',
|
||||||
|
'scale=iw*2:ih*2:flags=lanczos',
|
||||||
|
'scale=iw/2:ih/2:flags=lanczos',
|
||||||
|
],
|
||||||
|
payload['tokens'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_temp_mode_yields_hqdn3d_after_bwdif(self):
|
||||||
|
payload = self.getPayload('temp')
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
['bwdif=mode=1', 'hqdn3d=1.5:1.5:3:3'],
|
||||||
|
payload['tokens'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_up_down_temp_mode_yields_combined_filters_after_bwdif(self):
|
||||||
|
payload = self.getPayload('up_down_temp')
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
'bwdif=mode=1',
|
||||||
|
'scale=iw*2:ih*2:flags=lanczos',
|
||||||
|
'scale=iw/2:ih/2:flags=lanczos',
|
||||||
|
'hqdn3d=1.5:1.5:3:3',
|
||||||
|
],
|
||||||
|
payload['tokens'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_scale_factors_and_filter_order_are_preserved(self):
|
||||||
|
payload = self.getPayload('up4_temp3366_down4')
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
'bwdif=mode=1',
|
||||||
|
'scale=iw*4:ih*4:flags=lanczos',
|
||||||
|
'hqdn3d=1.5:1.5:3:3',
|
||||||
|
'scale=iw/4:ih/4:flags=lanczos',
|
||||||
|
],
|
||||||
|
payload['tokens'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_temporal_digits_are_divided_by_two(self):
|
||||||
|
payload = self.getPayload('temp0044_up2_down2')
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
'bwdif=mode=1',
|
||||||
|
'hqdn3d=0:0:2:2',
|
||||||
|
'scale=iw*2:ih*2:flags=lanczos',
|
||||||
|
'scale=iw/2:ih/2:flags=lanczos',
|
||||||
|
],
|
||||||
|
payload['tokens'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_temporal_parameters_accept_hexadecimal_digits(self):
|
||||||
|
lowerPayload = self.getPayload('temp00ff')
|
||||||
|
mixedPayload = self.getPayload('tempFf33')
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
['bwdif=mode=1', 'hqdn3d=0:0:7.5:7.5'],
|
||||||
|
lowerPayload['tokens'],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
['bwdif=mode=1', 'hqdn3d=7.5:7.5:1.5:1.5'],
|
||||||
|
mixedPayload['tokens'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_scale_factors_accept_boundaries_from_two_to_eight(self):
|
||||||
|
payload = self.getPayload('up8_down2')
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
'bwdif=mode=1',
|
||||||
|
'scale=iw*8:ih*8:flags=lanczos',
|
||||||
|
'scale=iw/2:ih/2:flags=lanczos',
|
||||||
|
],
|
||||||
|
payload['tokens'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_each_component_uses_its_default_when_parameters_are_omitted(self):
|
||||||
|
payload = self.getPayload('up_temp_down')
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
'bwdif=mode=1',
|
||||||
|
'scale=iw*2:ih*2:flags=lanczos',
|
||||||
|
'hqdn3d=1.5:1.5:3:3',
|
||||||
|
'scale=iw/2:ih/2:flags=lanczos',
|
||||||
|
],
|
||||||
|
payload['tokens'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_mode_is_rejected(self):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ValueError,
|
||||||
|
"Unknown deinterlace component: 'other'",
|
||||||
|
):
|
||||||
|
DeinterlaceFilter(mode='other')
|
||||||
|
|
||||||
|
def test_invalid_parameters_are_rejected(self):
|
||||||
|
invalidModes = ('up1', 'down9', 'temp123', 'temp00gg', 'up__down')
|
||||||
|
|
||||||
|
for mode in invalidModes:
|
||||||
|
with self.subTest(mode=mode), self.assertRaises(ValueError):
|
||||||
|
DeinterlaceFilter(mode=mode)
|
||||||
|
|
||||||
|
def test_cli_accepts_named_modes_and_preserves_bare_flag_behavior(self):
|
||||||
|
upDownContext = convert.make_context(
|
||||||
|
'convert',
|
||||||
|
['--deinterlace', 'up_down'],
|
||||||
|
)
|
||||||
|
tempContext = convert.make_context(
|
||||||
|
'convert',
|
||||||
|
['--deinterlace', 'temp'],
|
||||||
|
)
|
||||||
|
combinedContext = convert.make_context(
|
||||||
|
'convert',
|
||||||
|
['--deinterlace', 'up_down_temp'],
|
||||||
|
)
|
||||||
|
complexContext = convert.make_context(
|
||||||
|
'convert',
|
||||||
|
['--deinterlace', 'up4_temp3366_down4'],
|
||||||
|
)
|
||||||
|
bareFlagContext = convert.make_context('convert', ['--deinterlace'])
|
||||||
|
omittedContext = convert.make_context('convert', [])
|
||||||
|
|
||||||
|
self.assertEqual('up_down', upDownContext.params['deinterlace'])
|
||||||
|
self.assertEqual('temp', tempContext.params['deinterlace'])
|
||||||
|
self.assertEqual('up_down_temp', combinedContext.params['deinterlace'])
|
||||||
|
self.assertEqual(
|
||||||
|
'up4_temp3366_down4',
|
||||||
|
complexContext.params['deinterlace'],
|
||||||
|
)
|
||||||
|
self.assertEqual('default', bareFlagContext.params['deinterlace'])
|
||||||
|
self.assertEqual('none', omittedContext.params['deinterlace'])
|
||||||
|
|
||||||
|
def test_cli_rejects_an_invalid_specification(self):
|
||||||
|
with self.assertRaises(click.BadParameter):
|
||||||
|
convert.make_context('convert', ['--deinterlace', 'temp123'])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -126,6 +126,9 @@ class FfmpegDiagnosticsTests(unittest.TestCase):
|
|||||||
["ffmpeg", "-y", "-i", "input.avi", "output.mkv"],
|
["ffmpeg", "-y", "-i", "input.avi", "output.mkv"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
monitor.handle_stderr_line("[mp3float @ 0x1] invalid new backstep -1")
|
||||||
|
)
|
||||||
self.assertFalse(monitor.handle_stderr_line("[mp3float @ 0x1] invalid block type"))
|
self.assertFalse(monitor.handle_stderr_line("[mp3float @ 0x1] invalid block type"))
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
monitor.handle_stderr_line(
|
monitor.handle_stderr_line(
|
||||||
|
|||||||
@@ -161,6 +161,55 @@ class FfxControllerTests(unittest.TestCase):
|
|||||||
any(token.startswith("ENCODING_PRESET=") for token in commands[1])
|
any(token.startswith("ENCODING_PRESET=") for token in commands[1])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_vp9_run_job_applies_identical_video_filters_to_both_passes(self):
|
||||||
|
context = self.make_context(VideoEncoder.VP9)
|
||||||
|
target_descriptor, source_descriptor = self.make_media_descriptors()
|
||||||
|
controller = FfxController(context, target_descriptor, source_descriptor)
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
controller,
|
||||||
|
"executeCommandSequence",
|
||||||
|
side_effect=lambda command: commands.append(command) or ("", "", 0),
|
||||||
|
),
|
||||||
|
patch("ffx.ffx_controller.os.path.exists", return_value=False),
|
||||||
|
):
|
||||||
|
controller.runJob(
|
||||||
|
"input.mkv",
|
||||||
|
"output.webm",
|
||||||
|
targetFormat="webm",
|
||||||
|
chainIteration=[
|
||||||
|
{
|
||||||
|
"identifier": "quality",
|
||||||
|
"parameters": {"quality": 27},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "nlmeans",
|
||||||
|
"parameters": {},
|
||||||
|
"tokens": ["nlmeans=s=2.8"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "bwdif",
|
||||||
|
"parameters": {},
|
||||||
|
"tokens": ["bwdif=mode=1"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cropArguments={
|
||||||
|
"output_width": 704,
|
||||||
|
"output_height": 576,
|
||||||
|
"x_offset": 8,
|
||||||
|
"y_offset": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(2, len(commands))
|
||||||
|
expectedFilterChain = (
|
||||||
|
"crop=704:576:8:0, nlmeans=s=2.8, bwdif=mode=1"
|
||||||
|
)
|
||||||
|
self.assert_token_pair(commands[0], "-vf", expectedFilterChain)
|
||||||
|
self.assert_token_pair(commands[1], "-vf", expectedFilterChain)
|
||||||
|
|
||||||
def test_av1_run_job_emits_file_level_quality_and_preset_metadata(self):
|
def test_av1_run_job_emits_file_level_quality_and_preset_metadata(self):
|
||||||
context = self.make_context(VideoEncoder.AV1)
|
context = self.make_context(VideoEncoder.AV1)
|
||||||
target_descriptor, source_descriptor = self.make_media_descriptors()
|
target_descriptor, source_descriptor = self.make_media_descriptors()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||||
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402
|
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402
|
||||||
|
from ffx.attachment_format import AttachmentFormat # noqa: E402
|
||||||
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
||||||
from ffx.track_type import TrackType # noqa: E402
|
from ffx.track_type import TrackType # noqa: E402
|
||||||
from ffx.i18n import set_current_language # noqa: E402
|
from ffx.i18n import set_current_language # noqa: E402
|
||||||
@@ -436,6 +437,47 @@ class MediaDescriptorChangeSetTests(unittest.TestCase):
|
|||||||
self.assertNotIn("creation_time=", metadata_tokens)
|
self.assertNotIn("creation_time=", metadata_tokens)
|
||||||
self.assertNotIn("BPS=", metadata_tokens)
|
self.assertNotIn("BPS=", metadata_tokens)
|
||||||
|
|
||||||
|
def test_attachment_tracks_are_ignored_for_pattern_comparison(self):
|
||||||
|
context = {
|
||||||
|
"logger": get_ffx_logger(),
|
||||||
|
"config": StaticConfig({}),
|
||||||
|
}
|
||||||
|
|
||||||
|
source_track = TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
tags={"filename": "current.ttf", "mimetype": "font/ttf"},
|
||||||
|
)
|
||||||
|
target_track = TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
tags={"filename": "stored.ttf", "mimetype": "font/ttf"},
|
||||||
|
)
|
||||||
|
stale_target_track = TrackDescriptor(
|
||||||
|
index=1,
|
||||||
|
source_index=1,
|
||||||
|
sub_index=1,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
tags={"filename": "missing.ttf", "mimetype": "font/ttf"},
|
||||||
|
)
|
||||||
|
|
||||||
|
change_set = MediaDescriptorChangeSet(
|
||||||
|
context,
|
||||||
|
MediaDescriptor(track_descriptors=[target_track, stale_target_track]),
|
||||||
|
MediaDescriptor(track_descriptors=[source_track]),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual({}, change_set.getChangeSetObj())
|
||||||
|
self.assertEqual([], change_set.generateMetadataTokens())
|
||||||
|
self.assertEqual([], change_set.generateDispositionTokens())
|
||||||
|
|
||||||
def test_normalization_can_be_disabled_per_context(self):
|
def test_normalization_can_be_disabled_per_context(self):
|
||||||
context = {
|
context = {
|
||||||
"logger": get_ffx_logger(),
|
"logger": get_ffx_logger(),
|
||||||
|
|||||||
@@ -20,18 +20,32 @@ from ffx.track_type import TrackType # noqa: E402
|
|||||||
|
|
||||||
|
|
||||||
class MediaDescriptorImportSubtitlesTests(unittest.TestCase):
|
class MediaDescriptorImportSubtitlesTests(unittest.TestCase):
|
||||||
def make_descriptor(self) -> MediaDescriptor:
|
COMPLETE_SIDECAR_NAMES = (
|
||||||
|
"A2_t01_2_deu_DEF",
|
||||||
|
"A2_t01_3_eng",
|
||||||
|
"A2_t01_4_eng",
|
||||||
|
)
|
||||||
|
|
||||||
|
def write_complete_sidecar_set(self, directory: str, extension: str) -> None:
|
||||||
|
for basename in self.COMPLETE_SIDECAR_NAMES:
|
||||||
|
(Path(directory) / f"{basename}.{extension}").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_descriptor(self, indices=(3,)) -> MediaDescriptor:
|
||||||
return MediaDescriptor(
|
return MediaDescriptor(
|
||||||
context={"logger": get_ffx_logger()},
|
context={"logger": get_ffx_logger()},
|
||||||
track_descriptors=[
|
track_descriptors=[
|
||||||
TrackDescriptor(
|
TrackDescriptor(
|
||||||
index=3,
|
index=index,
|
||||||
source_index=3,
|
source_index=index,
|
||||||
sub_index=0,
|
sub_index=subIndex,
|
||||||
track_type=TrackType.SUBTITLE,
|
track_type=TrackType.SUBTITLE,
|
||||||
tags={"language": "eng", "title": "DB Subtitle"},
|
tags={"language": "eng", "title": "DB Subtitle"},
|
||||||
disposition_set={TrackDisposition.DEFAULT},
|
disposition_set={TrackDisposition.DEFAULT},
|
||||||
)
|
)
|
||||||
|
for subIndex, index in enumerate(indices)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,6 +88,114 @@ class MediaDescriptorImportSubtitlesTests(unittest.TestCase):
|
|||||||
self.assertEqual("deu", track.getTags()["language"])
|
self.assertEqual("deu", track.getTags()["language"])
|
||||||
self.assertEqual({TrackDisposition.FORCED}, track.getDispositionSet())
|
self.assertEqual({TrackDisposition.FORCED}, track.getDispositionSet())
|
||||||
|
|
||||||
|
def test_strict_basename_import_recognizes_vtt_asset_set(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2, 3, 4))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
self.write_complete_sidecar_set(tmpdir, "vtt")
|
||||||
|
result = descriptor.importSubtitles(
|
||||||
|
tmpdir,
|
||||||
|
"A2_t01",
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(3, result["candidate_count"])
|
||||||
|
self.assertEqual([2, 3, 4], result["imported_track_indices"])
|
||||||
|
self.assertEqual([], result["missing_track_indices"])
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"A2_t01_2_deu_DEF.vtt",
|
||||||
|
"A2_t01_3_eng.vtt",
|
||||||
|
"A2_t01_4_eng.vtt",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Path(track.getExternalSourceFilePath()).name
|
||||||
|
for track in descriptor.getSubtitleTracks()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_strict_basename_import_accepts_dotted_mkv_extension(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2, 3, 4))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
self.write_complete_sidecar_set(tmpdir, "mkv")
|
||||||
|
result = descriptor.importSubtitles(
|
||||||
|
tmpdir,
|
||||||
|
"A2_t01",
|
||||||
|
extension=".mkv",
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(3, result["candidate_count"])
|
||||||
|
self.assertEqual([2, 3, 4], result["imported_track_indices"])
|
||||||
|
self.assertEqual([], result["missing_track_indices"])
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
track.getExternalSourceFilePath().endswith(".mkv")
|
||||||
|
for track in descriptor.getSubtitleTracks()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_strict_basename_import_reports_missing_tracks(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2, 3, 4))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sidecarPath = Path(tmpdir) / "episode_2_deu.vtt"
|
||||||
|
sidecarPath.write_text("WEBVTT\n\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = descriptor.importSubtitles(
|
||||||
|
tmpdir,
|
||||||
|
"episode",
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([2], result["imported_track_indices"])
|
||||||
|
self.assertEqual([3, 4], result["missing_track_indices"])
|
||||||
|
|
||||||
|
def test_strict_basename_import_rejects_too_many_files(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2,))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
for filename in ("episode_2_deu.vtt", "episode_3_eng.vtt"):
|
||||||
|
(Path(tmpdir) / filename).write_text("WEBVTT\n\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "2 matching .* for 1 subtitle tracks"):
|
||||||
|
descriptor.importSubtitles(tmpdir, "episode", strict=True)
|
||||||
|
|
||||||
|
def test_strict_basename_import_rejects_unknown_track_index(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2, 3, 4))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
(Path(tmpdir) / "episode_9_eng.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "track index pattern does not match"):
|
||||||
|
descriptor.importSubtitles(tmpdir, "episode", strict=True)
|
||||||
|
|
||||||
|
def test_strict_basename_import_rejects_malformed_filtered_filename(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2, 3, 4))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
(Path(tmpdir) / "episode_s01e01_2_deu.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "expected pattern"):
|
||||||
|
descriptor.importSubtitles(tmpdir, "episode", strict=True)
|
||||||
|
|
||||||
|
def test_strict_basename_import_rejects_duplicate_track_indices(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2, 3, 4))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
for filename in ("episode_2_deu.vtt", "episode_2_eng.vtt"):
|
||||||
|
(Path(tmpdir) / filename).write_text("WEBVTT\n\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "Multiple external subtitle files"):
|
||||||
|
descriptor.importSubtitles(tmpdir, "episode", strict=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
146
tests/unit/test_media_descriptor_mapping.py
Normal file
146
tests/unit/test_media_descriptor_mapping.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||||
|
|
||||||
|
if str(SRC_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
from ffx.attachment_format import AttachmentFormat # noqa: E402
|
||||||
|
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||||
|
from ffx.track_codec import TrackCodec # noqa: E402
|
||||||
|
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
||||||
|
from ffx.track_type import TrackType # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class MediaDescriptorMappingTests(unittest.TestCase):
|
||||||
|
def make_target_descriptor(self) -> MediaDescriptor:
|
||||||
|
return MediaDescriptor(
|
||||||
|
track_descriptors=[
|
||||||
|
TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.VIDEO,
|
||||||
|
codec_name=TrackCodec.H264,
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=1,
|
||||||
|
source_index=7,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.ASS,
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=2,
|
||||||
|
source_index=8,
|
||||||
|
sub_index=1,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.ASS,
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=3,
|
||||||
|
source_index=9,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_source_descriptor(self) -> MediaDescriptor:
|
||||||
|
return MediaDescriptor(
|
||||||
|
track_descriptors=[
|
||||||
|
TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.VIDEO,
|
||||||
|
codec_name=TrackCodec.H264,
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=3,
|
||||||
|
source_index=3,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.VOBSUB,
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=4,
|
||||||
|
source_index=4,
|
||||||
|
sub_index=1,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.VOBSUB,
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=5,
|
||||||
|
source_index=5,
|
||||||
|
sub_index=2,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.PGS,
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=6,
|
||||||
|
source_index=6,
|
||||||
|
sub_index=3,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.PGS,
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=7,
|
||||||
|
source_index=7,
|
||||||
|
sub_index=4,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.ASS,
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=8,
|
||||||
|
source_index=8,
|
||||||
|
sub_index=5,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.ASS,
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=9,
|
||||||
|
source_index=9,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_source_aware_mapping_uses_original_subtitle_and_attachment_indices(self):
|
||||||
|
tokens = self.make_target_descriptor().getInputMappingTokens(
|
||||||
|
sourceMediaDescriptor=self.make_source_descriptor()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"-map",
|
||||||
|
"0:v:0",
|
||||||
|
"-map",
|
||||||
|
"0:s:4",
|
||||||
|
"-map",
|
||||||
|
"0:s:5",
|
||||||
|
"-map",
|
||||||
|
"0:9",
|
||||||
|
],
|
||||||
|
tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_video_only_mapping_does_not_map_font_attachments(self):
|
||||||
|
tokens = self.make_target_descriptor().getInputMappingTokens(
|
||||||
|
only_video=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["-map", "0:v:0"], tokens)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -21,6 +21,7 @@ from ffx.file_properties import FileProperties # noqa: E402
|
|||||||
from ffx.model.pattern import Pattern # noqa: E402
|
from ffx.model.pattern import Pattern # noqa: E402
|
||||||
from ffx.pattern_controller import ( # noqa: E402
|
from ffx.pattern_controller import ( # noqa: E402
|
||||||
DuplicatePatternMatchError,
|
DuplicatePatternMatchError,
|
||||||
|
InvalidPatternCaptureGroupError,
|
||||||
InvalidPatternSchemaError,
|
InvalidPatternSchemaError,
|
||||||
PatternController,
|
PatternController,
|
||||||
)
|
)
|
||||||
@@ -65,6 +66,7 @@ def make_track_descriptor(
|
|||||||
track_type: TrackType = TrackType.VIDEO,
|
track_type: TrackType = TrackType.VIDEO,
|
||||||
title: str = "",
|
title: str = "",
|
||||||
dispositions: set[TrackDisposition] | None = None,
|
dispositions: set[TrackDisposition] | None = None,
|
||||||
|
dropped: bool = False,
|
||||||
) -> TrackDescriptor:
|
) -> TrackDescriptor:
|
||||||
kwargs = {
|
kwargs = {
|
||||||
TrackDescriptor.INDEX_KEY: index,
|
TrackDescriptor.INDEX_KEY: index,
|
||||||
@@ -72,6 +74,7 @@ def make_track_descriptor(
|
|||||||
TrackDescriptor.TRACK_TYPE_KEY: track_type,
|
TrackDescriptor.TRACK_TYPE_KEY: track_type,
|
||||||
TrackDescriptor.TAGS_KEY: {"title": title} if title else {},
|
TrackDescriptor.TAGS_KEY: {"title": title} if title else {},
|
||||||
TrackDescriptor.DISPOSITION_SET_KEY: dispositions or set(),
|
TrackDescriptor.DISPOSITION_SET_KEY: dispositions or set(),
|
||||||
|
TrackDescriptor.DROPPED_KEY: dropped,
|
||||||
}
|
}
|
||||||
if track_type == TrackType.AUDIO:
|
if track_type == TrackType.AUDIO:
|
||||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_STEREO
|
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_STEREO
|
||||||
@@ -142,7 +145,7 @@ class PatternManagementTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_match_filename_raises_for_duplicate_matches_in_same_show(self):
|
def test_match_filename_raises_for_duplicate_matches_in_same_show(self):
|
||||||
self.save_pattern(1, r"^same_(s[0-9]+e[0-9]+)\.mkv$")
|
self.save_pattern(1, r"^same_(s[0-9]+e[0-9]+)\.mkv$")
|
||||||
self.save_pattern(1, r"^same_.*$")
|
self.save_pattern(1, r"^same_(.*)$")
|
||||||
|
|
||||||
with self.assertRaises(DuplicatePatternMatchError) as caught:
|
with self.assertRaises(DuplicatePatternMatchError) as caught:
|
||||||
self.pattern_controller.matchFilename("same_s01e01.mkv")
|
self.pattern_controller.matchFilename("same_s01e01.mkv")
|
||||||
@@ -152,7 +155,7 @@ class PatternManagementTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_match_filename_raises_for_duplicate_matches_across_shows(self):
|
def test_match_filename_raises_for_duplicate_matches_across_shows(self):
|
||||||
self.save_pattern(1, r"^cross_(s[0-9]+e[0-9]+)\.mkv$")
|
self.save_pattern(1, r"^cross_(s[0-9]+e[0-9]+)\.mkv$")
|
||||||
self.save_pattern(2, r"^cross_.*$")
|
self.save_pattern(2, r"^cross_(.*)$")
|
||||||
|
|
||||||
with self.assertRaises(DuplicatePatternMatchError) as caught:
|
with self.assertRaises(DuplicatePatternMatchError) as caught:
|
||||||
self.pattern_controller.matchFilename("cross_s01e01.mkv")
|
self.pattern_controller.matchFilename("cross_s01e01.mkv")
|
||||||
@@ -179,6 +182,48 @@ class PatternManagementTests(unittest.TestCase):
|
|||||||
match = self.pattern_controller.matchFilename("after_s01e01.mkv")
|
match = self.pattern_controller.matchFilename("after_s01e01.mkv")
|
||||||
self.assertEqual(pattern_id, match["pattern"].getId())
|
self.assertEqual(pattern_id, match["pattern"].getId())
|
||||||
|
|
||||||
|
def test_save_pattern_schema_rejects_pattern_without_capture_group(self):
|
||||||
|
self.add_show(1, "No Capture Pattern Show")
|
||||||
|
|
||||||
|
with self.assertRaises(InvalidPatternCaptureGroupError) as caught:
|
||||||
|
self.pattern_controller.savePatternSchema(
|
||||||
|
{
|
||||||
|
"show_id": 1,
|
||||||
|
"pattern": r"^nocapture_s[0-9]+e[0-9]+\.mkv$",
|
||||||
|
},
|
||||||
|
trackDescriptors=[make_track_descriptor(0)],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("capture group", str(caught.exception))
|
||||||
|
|
||||||
|
def test_update_pattern_rejects_pattern_without_capture_group(self):
|
||||||
|
pattern_id = self.save_pattern(1, r"^valid_(s[0-9]+e[0-9]+)\.mkv$")
|
||||||
|
|
||||||
|
with self.assertRaises(InvalidPatternCaptureGroupError):
|
||||||
|
self.pattern_controller.updatePattern(
|
||||||
|
pattern_id,
|
||||||
|
{
|
||||||
|
"show_id": 1,
|
||||||
|
"pattern": r"^invalid_s[0-9]+e[0-9]+\.mkv$",
|
||||||
|
"quality": 0,
|
||||||
|
"notes": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
match = self.pattern_controller.matchFilename("valid_s01e01.mkv")
|
||||||
|
self.assertEqual(pattern_id, match["pattern"].getId())
|
||||||
|
|
||||||
|
def test_match_filename_raises_specific_error_for_legacy_pattern_without_capture_group(self):
|
||||||
|
pattern_id = self.insert_trackless_pattern_row(
|
||||||
|
1,
|
||||||
|
r"^legacy_s[0-9]+e[0-9]+\.mkv$",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(InvalidPatternCaptureGroupError) as caught:
|
||||||
|
self.pattern_controller.matchFilename("legacy_s01e01.mkv")
|
||||||
|
|
||||||
|
self.assertEqual(pattern_id, caught.exception.pattern_id)
|
||||||
|
|
||||||
def test_save_pattern_schema_rejects_zero_track_patterns(self):
|
def test_save_pattern_schema_rejects_zero_track_patterns(self):
|
||||||
self.add_show(1, "Empty Pattern Show")
|
self.add_show(1, "Empty Pattern Show")
|
||||||
|
|
||||||
@@ -193,6 +238,77 @@ class PatternManagementTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("at least one track", str(caught.exception))
|
self.assertIn("at least one track", str(caught.exception))
|
||||||
|
|
||||||
|
def test_save_pattern_schema_rejects_all_dropped_patterns(self):
|
||||||
|
self.add_show(1, "Dropped Pattern Show")
|
||||||
|
|
||||||
|
with self.assertRaises(InvalidPatternSchemaError) as caught:
|
||||||
|
self.pattern_controller.savePatternSchema(
|
||||||
|
{
|
||||||
|
"show_id": 1,
|
||||||
|
"pattern": r"^dropped_(s[0-9]+e[0-9]+)\.mkv$",
|
||||||
|
},
|
||||||
|
trackDescriptors=[make_track_descriptor(0, dropped=True)],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("non-dropped track", str(caught.exception))
|
||||||
|
|
||||||
|
def test_dropped_pattern_track_is_not_in_conversion_media_descriptor(self):
|
||||||
|
pattern_id = self.save_pattern(
|
||||||
|
1,
|
||||||
|
r"^active_(s[0-9]+e[0-9]+)\.mkv$",
|
||||||
|
tracks=[
|
||||||
|
make_track_descriptor(0, track_type=TrackType.VIDEO),
|
||||||
|
make_track_descriptor(1, track_type=TrackType.AUDIO, dropped=True),
|
||||||
|
make_track_descriptor(2, track_type=TrackType.SUBTITLE),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
Session = self.context["database"]["session"]
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
pattern = session.query(Pattern).filter(Pattern.id == pattern_id).first()
|
||||||
|
media_descriptor = pattern.getMediaDescriptor(self.context)
|
||||||
|
tracks = media_descriptor.getTrackDescriptors()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[TrackType.VIDEO, TrackType.SUBTITLE],
|
||||||
|
[track.getType() for track in tracks],
|
||||||
|
)
|
||||||
|
self.assertEqual([0, 1], [track.getIndex() for track in tracks])
|
||||||
|
self.assertEqual([0, 2], [track.getSourceIndex() for track in tracks])
|
||||||
|
|
||||||
|
def test_save_pattern_schema_does_not_persist_attachment_tracks(self):
|
||||||
|
pattern_id = self.save_pattern(
|
||||||
|
1,
|
||||||
|
r"^noattachments_(s[0-9]+e[0-9]+)\.mkv$",
|
||||||
|
tracks=[
|
||||||
|
make_track_descriptor(0, track_type=TrackType.VIDEO),
|
||||||
|
make_track_descriptor(1, track_type=TrackType.ATTACHMENT),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
Session = self.context["database"]["session"]
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
tracks = session.query(Pattern).filter(Pattern.id == pattern_id).first().tracks
|
||||||
|
self.assertEqual(1, len(tracks))
|
||||||
|
self.assertEqual(TrackType.VIDEO, tracks[0].getType())
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_track_controller_does_not_add_attachment_tracks_to_patterns(self):
|
||||||
|
pattern_id = self.save_pattern(1, r"^skipadd_(s[0-9]+e[0-9]+)\.mkv$")
|
||||||
|
|
||||||
|
added = self.track_controller.addTrack(
|
||||||
|
make_track_descriptor(1, track_type=TrackType.ATTACHMENT),
|
||||||
|
patternId=pattern_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(added)
|
||||||
|
self.assertEqual(1, len(self.track_controller.findTracks(pattern_id)))
|
||||||
|
|
||||||
def test_match_filename_rejects_existing_trackless_pattern_rows(self):
|
def test_match_filename_rejects_existing_trackless_pattern_rows(self):
|
||||||
self.insert_trackless_pattern_row(1, r"^invalid_(s[0-9]+e[0-9]+)\.mkv$")
|
self.insert_trackless_pattern_row(1, r"^invalid_(s[0-9]+e[0-9]+)\.mkv$")
|
||||||
|
|
||||||
@@ -203,7 +319,7 @@ class PatternManagementTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_file_properties_skips_pattern_matching_when_disabled(self):
|
def test_file_properties_skips_pattern_matching_when_disabled(self):
|
||||||
self.save_pattern(1, r"^nopattern_(s[0-9]+e[0-9]+)\.mkv$")
|
self.save_pattern(1, r"^nopattern_(s[0-9]+e[0-9]+)\.mkv$")
|
||||||
self.save_pattern(2, r"^nopattern_.*$")
|
self.save_pattern(2, r"^nopattern_(.*)$")
|
||||||
|
|
||||||
no_pattern_context = dict(self.context)
|
no_pattern_context = dict(self.context)
|
||||||
no_pattern_context["use_pattern"] = False
|
no_pattern_context["use_pattern"] = False
|
||||||
@@ -227,6 +343,17 @@ class PatternManagementTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("last track", str(caught.exception))
|
self.assertIn("last track", str(caught.exception))
|
||||||
|
|
||||||
|
def test_track_controller_refuses_to_drop_last_active_track(self):
|
||||||
|
pattern_id = self.save_pattern(1, r"^drop_(s[0-9]+e[0-9]+)\.mkv$")
|
||||||
|
track = self.track_controller.getTrack(pattern_id, 0)
|
||||||
|
descriptor = track.getDescriptor()
|
||||||
|
descriptor.setDropped(True)
|
||||||
|
|
||||||
|
with self.assertRaises(click.ClickException) as caught:
|
||||||
|
self.track_controller.updateTrack(track.getId(), descriptor)
|
||||||
|
|
||||||
|
self.assertIn("last active track", str(caught.exception))
|
||||||
|
|
||||||
def test_exact_duplicate_pattern_definition_is_rejected(self):
|
def test_exact_duplicate_pattern_definition_is_rejected(self):
|
||||||
self.save_pattern(1, r"^unique_(s[0-9]+e[0-9]+)\.mkv$")
|
self.save_pattern(1, r"^unique_(s[0-9]+e[0-9]+)\.mkv$")
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from ffx.audio_layout import AudioLayout # noqa: E402
|
from ffx.audio_layout import AudioLayout # noqa: E402
|
||||||
from ffx.attachment_format import AttachmentFormat # noqa: E402
|
from ffx.attachment_format import AttachmentFormat # noqa: E402
|
||||||
from ffx.helper import DIFF_ADDED_KEY # noqa: E402
|
from ffx.helper import DIFF_ADDED_KEY, DIFF_REMOVED_KEY # noqa: E402
|
||||||
from ffx.iso_language import IsoLanguage # noqa: E402
|
from ffx.iso_language import IsoLanguage # noqa: E402
|
||||||
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||||
from ffx.inspect_details_screen import InspectDetailsScreen # noqa: E402
|
from ffx.inspect_details_screen import InspectDetailsScreen # noqa: E402
|
||||||
from ffx.i18n import set_current_language # noqa: E402
|
from ffx.i18n import set_current_language # noqa: E402
|
||||||
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||||
|
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402
|
||||||
from ffx.media_edit_screen import MediaEditScreen # noqa: E402
|
from ffx.media_edit_screen import MediaEditScreen # noqa: E402
|
||||||
from ffx.pattern_details_screen import PatternDetailsScreen # noqa: E402
|
from ffx.pattern_details_screen import PatternDetailsScreen # noqa: E402
|
||||||
from ffx.show_descriptor import ShowDescriptor # noqa: E402
|
from ffx.show_descriptor import ShowDescriptor # noqa: E402
|
||||||
@@ -75,6 +76,12 @@ class FakeTagTable:
|
|||||||
def select_row(self, row_key):
|
def select_row(self, row_key):
|
||||||
self._selected_row_key = row_key
|
self._selected_row_key = row_key
|
||||||
|
|
||||||
|
def move_cursor(self, *, row=None, column=None, animate=False, scroll=True):
|
||||||
|
if row is not None and self._row_order:
|
||||||
|
self._selected_row_key = self._row_order[int(row)]
|
||||||
|
if column is not None:
|
||||||
|
self.cursor_coordinate = (self.cursor_coordinate[0], int(column))
|
||||||
|
|
||||||
def get_row_index(self, row_key):
|
def get_row_index(self, row_key):
|
||||||
return self._row_order.index(row_key)
|
return self._row_order.index(row_key)
|
||||||
|
|
||||||
@@ -109,6 +116,12 @@ class FakeValueWidget:
|
|||||||
self.disabled = False
|
self.disabled = False
|
||||||
|
|
||||||
|
|
||||||
|
class FakeButtonWidget:
|
||||||
|
def __init__(self):
|
||||||
|
self.disabled = False
|
||||||
|
self.variant = "default"
|
||||||
|
|
||||||
|
|
||||||
class FakeInputWidget:
|
class FakeInputWidget:
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
self.value = value
|
self.value = value
|
||||||
@@ -125,18 +138,20 @@ class FakeStaticWidget:
|
|||||||
class FakeSelectionListWidget:
|
class FakeSelectionListWidget:
|
||||||
def __init__(self, selected):
|
def __init__(self, selected):
|
||||||
self.selected = selected
|
self.selected = selected
|
||||||
|
self.disabled = False
|
||||||
|
|
||||||
def add_option(self, _option):
|
def add_option(self, _option):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def make_track_descriptor(index, sub_index, track_type):
|
def make_track_descriptor(index, sub_index, track_type, *, dropped=False):
|
||||||
return TrackDescriptor(
|
return TrackDescriptor(
|
||||||
index=index,
|
index=index,
|
||||||
sub_index=sub_index,
|
sub_index=sub_index,
|
||||||
track_type=track_type,
|
track_type=track_type,
|
||||||
codec_name=TrackCodec.UNKNOWN,
|
codec_name=TrackCodec.UNKNOWN,
|
||||||
audio_layout=AudioLayout.LAYOUT_UNDEFINED,
|
audio_layout=AudioLayout.LAYOUT_UNDEFINED,
|
||||||
|
dropped=dropped,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -187,6 +202,7 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
widgets = {
|
widgets = {
|
||||||
"#type_select": FakeValueWidget(TrackType.AUDIO),
|
"#type_select": FakeValueWidget(TrackType.AUDIO),
|
||||||
"#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_STEREO),
|
"#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_STEREO),
|
||||||
|
"#drop_checkbox": FakeValueWidget(True),
|
||||||
"#language_select": FakeValueWidget(IsoLanguage.GERMAN),
|
"#language_select": FakeValueWidget(IsoLanguage.GERMAN),
|
||||||
"#title_input": FakeInputWidget("German Audio"),
|
"#title_input": FakeInputWidget("German Audio"),
|
||||||
"#dispositions_selection_list": FakeSelectionListWidget({0, 6}),
|
"#dispositions_selection_list": FakeSelectionListWidget({0, 6}),
|
||||||
@@ -200,6 +216,7 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
self.assertEqual("deu", descriptor.getTags()["language"])
|
self.assertEqual("deu", descriptor.getTags()["language"])
|
||||||
self.assertEqual("German Audio", descriptor.getTitle())
|
self.assertEqual("German Audio", descriptor.getTitle())
|
||||||
self.assertEqual("value", descriptor.getTags()["KEEP"])
|
self.assertEqual("value", descriptor.getTags()["KEEP"])
|
||||||
|
self.assertTrue(descriptor.isDropped())
|
||||||
|
|
||||||
def test_track_details_screen_preserves_attachment_format_for_attachment_tracks(self):
|
def test_track_details_screen_preserves_attachment_format_for_attachment_tracks(self):
|
||||||
screen = object.__new__(TrackDetailsScreen)
|
screen = object.__new__(TrackDetailsScreen)
|
||||||
@@ -215,6 +232,7 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
widgets = {
|
widgets = {
|
||||||
"#type_select": FakeValueWidget(TrackType.ATTACHMENT),
|
"#type_select": FakeValueWidget(TrackType.ATTACHMENT),
|
||||||
"#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_UNDEFINED),
|
"#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_UNDEFINED),
|
||||||
|
"#drop_checkbox": FakeValueWidget(False),
|
||||||
"#language_select": FakeValueWidget(Select.NULL),
|
"#language_select": FakeValueWidget(Select.NULL),
|
||||||
"#title_input": FakeInputWidget(""),
|
"#title_input": FakeInputWidget(""),
|
||||||
"#dispositions_selection_list": FakeSelectionListWidget(set()),
|
"#dispositions_selection_list": FakeSelectionListWidget(set()),
|
||||||
@@ -306,6 +324,7 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
audio_layout=AudioLayout.LAYOUT_STEREO,
|
audio_layout=AudioLayout.LAYOUT_STEREO,
|
||||||
tags={"language": "ger"},
|
tags={"language": "ger"},
|
||||||
)
|
)
|
||||||
|
screen._TrackDetailsScreen__dropped = False
|
||||||
screen._TrackDetailsScreen__metadataOnly = True
|
screen._TrackDetailsScreen__metadataOnly = True
|
||||||
screen._TrackDetailsScreen__titleAutoManaged = True
|
screen._TrackDetailsScreen__titleAutoManaged = True
|
||||||
screen._TrackDetailsScreen__suppressTitleChanged = False
|
screen._TrackDetailsScreen__suppressTitleChanged = False
|
||||||
@@ -322,6 +341,7 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
"#pattern_label": FakeStaticWidget(),
|
"#pattern_label": FakeStaticWidget(),
|
||||||
"#type_select": FakeValueWidget(None),
|
"#type_select": FakeValueWidget(None),
|
||||||
"#audio_layout_select": FakeValueWidget(None),
|
"#audio_layout_select": FakeValueWidget(None),
|
||||||
|
"#drop_checkbox": FakeValueWidget(False),
|
||||||
"#dispositions_selection_list": FakeSelectionListWidget(set()),
|
"#dispositions_selection_list": FakeSelectionListWidget(set()),
|
||||||
"#language_select": FakeValueWidget(None),
|
"#language_select": FakeValueWidget(None),
|
||||||
"#title_input": FakeInputWidget(""),
|
"#title_input": FakeInputWidget(""),
|
||||||
@@ -354,7 +374,7 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_pattern_details_screen_reads_selected_track_from_row_mapping(self):
|
def test_pattern_details_screen_reads_selected_track_from_row_mapping(self):
|
||||||
first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
|
first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
|
||||||
second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE)
|
second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE, dropped=True)
|
||||||
|
|
||||||
screen = object.__new__(PatternDetailsScreen)
|
screen = object.__new__(PatternDetailsScreen)
|
||||||
screen.tracksTable = FakeTagTable()
|
screen.tracksTable = FakeTagTable()
|
||||||
@@ -366,6 +386,101 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
screen.tracksTable.select_row("row-1")
|
screen.tracksTable.select_row("row-1")
|
||||||
|
|
||||||
self.assertIs(second_track, screen.getSelectedTrackDescriptor())
|
self.assertIs(second_track, screen.getSelectedTrackDescriptor())
|
||||||
|
self.assertEqual("Ja", screen.tracksTable.rows["row-1"][9])
|
||||||
|
|
||||||
|
def test_pattern_details_screen_toggles_draft_track_drop_state(self):
|
||||||
|
first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
|
||||||
|
second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE)
|
||||||
|
drop_button = FakeButtonWidget()
|
||||||
|
|
||||||
|
screen = object.__new__(PatternDetailsScreen)
|
||||||
|
screen.context = {"logger": get_ffx_logger()}
|
||||||
|
screen.tracksTable = FakeTagTable()
|
||||||
|
screen._PatternDetailsScreen__draftTracks = [first_track, second_track]
|
||||||
|
screen._PatternDetailsScreen__pattern = None
|
||||||
|
screen._PatternDetailsScreen__trackRowData = {}
|
||||||
|
screen.query_one = lambda selector, _widget_type=None: drop_button
|
||||||
|
|
||||||
|
screen.updateTracks()
|
||||||
|
screen.tracksTable.select_row("row-1")
|
||||||
|
|
||||||
|
screen.toggleSelectedTrackDropped(screen.getSelectedTrackDescriptor())
|
||||||
|
|
||||||
|
self.assertFalse(first_track.isDropped())
|
||||||
|
self.assertTrue(second_track.isDropped())
|
||||||
|
self.assertEqual("Ja", list(screen.tracksTable.rows.values())[1][9])
|
||||||
|
self.assertIs(second_track, screen.getSelectedTrackDescriptor())
|
||||||
|
self.assertEqual("warning", drop_button.variant)
|
||||||
|
|
||||||
|
def test_pattern_details_screen_updates_drop_button_for_selected_track(self):
|
||||||
|
first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
|
||||||
|
second_track = make_track_descriptor(
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
TrackType.SUBTITLE,
|
||||||
|
dropped=True,
|
||||||
|
)
|
||||||
|
drop_button = FakeButtonWidget()
|
||||||
|
|
||||||
|
screen = object.__new__(PatternDetailsScreen)
|
||||||
|
screen.tracksTable = FakeTagTable()
|
||||||
|
screen._PatternDetailsScreen__draftTracks = [first_track, second_track]
|
||||||
|
screen._PatternDetailsScreen__pattern = None
|
||||||
|
screen._PatternDetailsScreen__trackRowData = {}
|
||||||
|
screen.query_one = lambda selector, _widget_type=None: drop_button
|
||||||
|
|
||||||
|
screen.updateTracks()
|
||||||
|
|
||||||
|
self.assertEqual("default", drop_button.variant)
|
||||||
|
|
||||||
|
screen.tracksTable.select_row("row-1")
|
||||||
|
screen.updateTrackDropButtonState()
|
||||||
|
|
||||||
|
self.assertEqual("warning", drop_button.variant)
|
||||||
|
|
||||||
|
screen.tracksTable.select_row("row-0")
|
||||||
|
screen.updateTrackDropButtonState()
|
||||||
|
|
||||||
|
self.assertEqual("default", drop_button.variant)
|
||||||
|
|
||||||
|
def test_pattern_details_screen_toggles_persisted_track_drop_state(self):
|
||||||
|
class _FakePattern:
|
||||||
|
def getId(self):
|
||||||
|
return 5
|
||||||
|
|
||||||
|
class _FakeTrackController:
|
||||||
|
def __init__(self):
|
||||||
|
self.updated = []
|
||||||
|
|
||||||
|
def updateTrack(self, track_id, track_descriptor):
|
||||||
|
self.updated.append((track_id, track_descriptor))
|
||||||
|
return True
|
||||||
|
|
||||||
|
track = TrackDescriptor(
|
||||||
|
id=17,
|
||||||
|
pattern_id=5,
|
||||||
|
index=1,
|
||||||
|
source_index=1,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.UNKNOWN,
|
||||||
|
dropped=False,
|
||||||
|
)
|
||||||
|
track_controller = _FakeTrackController()
|
||||||
|
|
||||||
|
screen = object.__new__(PatternDetailsScreen)
|
||||||
|
screen.context = {"logger": get_ffx_logger()}
|
||||||
|
screen._PatternDetailsScreen__pattern = _FakePattern()
|
||||||
|
screen._PatternDetailsScreen__tc = track_controller
|
||||||
|
screen.updateTracks = lambda: None
|
||||||
|
|
||||||
|
screen.toggleSelectedTrackDropped(track)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(track_controller.updated))
|
||||||
|
track_id, updated_track = track_controller.updated[0]
|
||||||
|
self.assertEqual(17, track_id)
|
||||||
|
self.assertFalse(track.isDropped())
|
||||||
|
self.assertTrue(updated_track.isDropped())
|
||||||
|
|
||||||
def test_pattern_details_screen_reads_selected_tag_from_raw_row_mapping(self):
|
def test_pattern_details_screen_reads_selected_tag_from_raw_row_mapping(self):
|
||||||
screen = object.__new__(PatternDetailsScreen)
|
screen = object.__new__(PatternDetailsScreen)
|
||||||
@@ -548,6 +663,11 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
screen.tagsTable = FakeTagTable()
|
screen.tagsTable = FakeTagTable()
|
||||||
screen.shiftedSeasonsTable = FakeTagTable()
|
screen.shiftedSeasonsTable = FakeTagTable()
|
||||||
screen._PatternDetailsScreen__pattern = object()
|
screen._PatternDetailsScreen__pattern = object()
|
||||||
|
screen._PatternDetailsScreen__showDescriptor = None
|
||||||
|
widgets = {
|
||||||
|
"#show_quality_hint": FakeStaticWidget(),
|
||||||
|
}
|
||||||
|
screen.query_one = lambda selector, _type=None: widgets[selector]
|
||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
screen.updateTags = lambda: calls.append("updateTags")
|
screen.updateTags = lambda: calls.append("updateTags")
|
||||||
@@ -561,6 +681,48 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
calls,
|
calls,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_pattern_details_screen_on_mount_shows_show_quality_hint_for_new_pattern(self):
|
||||||
|
set_current_language("en")
|
||||||
|
|
||||||
|
screen = object.__new__(PatternDetailsScreen)
|
||||||
|
screen.context = {}
|
||||||
|
screen._PatternDetailsScreen__showDescriptor = ShowDescriptor(
|
||||||
|
id=7,
|
||||||
|
name="Demo",
|
||||||
|
year=1999,
|
||||||
|
quality=23,
|
||||||
|
)
|
||||||
|
screen._PatternDetailsScreen__pattern = None
|
||||||
|
|
||||||
|
widgets = {
|
||||||
|
"#showlabel": FakeStaticWidget(),
|
||||||
|
"#show_quality_hint": FakeStaticWidget(),
|
||||||
|
}
|
||||||
|
screen.query_one = lambda selector, _type=None: widgets[selector]
|
||||||
|
|
||||||
|
screen.on_mount()
|
||||||
|
|
||||||
|
self.assertEqual("7 - Demo (1999)", widgets["#showlabel"].value)
|
||||||
|
self.assertEqual("Show: 23", widgets["#show_quality_hint"].value)
|
||||||
|
|
||||||
|
def test_pattern_details_screen_show_quality_hint_is_hidden_when_pattern_quality_exists(self):
|
||||||
|
set_current_language("en")
|
||||||
|
|
||||||
|
screen = object.__new__(PatternDetailsScreen)
|
||||||
|
screen._PatternDetailsScreen__showDescriptor = ShowDescriptor(
|
||||||
|
id=7,
|
||||||
|
name="Demo",
|
||||||
|
year=1999,
|
||||||
|
quality=23,
|
||||||
|
)
|
||||||
|
screen._PatternDetailsScreen__pattern = type(
|
||||||
|
"_Pattern",
|
||||||
|
(),
|
||||||
|
{"quality": 19},
|
||||||
|
)()
|
||||||
|
|
||||||
|
self.assertEqual("", screen.getShowQualityHintText())
|
||||||
|
|
||||||
def test_inspect_details_screen_handle_edit_pattern_refreshes_even_without_result(self):
|
def test_inspect_details_screen_handle_edit_pattern_refreshes_even_without_result(self):
|
||||||
screen = object.__new__(InspectDetailsScreen)
|
screen = object.__new__(InspectDetailsScreen)
|
||||||
|
|
||||||
@@ -722,7 +884,7 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
self.assertIn("English Full", screen.tracksTable.rows["row-0"])
|
self.assertIn("English Full", screen.tracksTable.rows["row-0"])
|
||||||
self.assertIs(target_track, screen.getSelectedTrackDescriptor())
|
self.assertIs(target_track, screen.getSelectedTrackDescriptor())
|
||||||
|
|
||||||
def test_inspect_details_screen_update_tracks_blanks_irrelevant_attachment_fields(self):
|
def test_inspect_details_screen_update_tracks_shows_attachment_format_and_blanks_language(self):
|
||||||
attachment_track = TrackDescriptor(
|
attachment_track = TrackDescriptor(
|
||||||
index=4,
|
index=4,
|
||||||
source_index=4,
|
source_index=4,
|
||||||
@@ -745,10 +907,119 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
row = screen.tracksTable.rows["row-0"]
|
row = screen.tracksTable.rows["row-0"]
|
||||||
|
|
||||||
self.assertEqual("4", row[0])
|
self.assertEqual("4", row[0])
|
||||||
self.assertEqual(" ", row[3])
|
self.assertEqual("TTF", row[3])
|
||||||
|
self.assertEqual(" ", row[5])
|
||||||
self.assertEqual(" ", row[7])
|
self.assertEqual(" ", row[7])
|
||||||
self.assertEqual(" ", row[8])
|
self.assertEqual(" ", row[8])
|
||||||
|
|
||||||
|
def test_inspect_details_screen_update_tracks_shows_unknown_for_unknown_attachment_format(self):
|
||||||
|
attachment_track = TrackDescriptor(
|
||||||
|
index=5,
|
||||||
|
source_index=5,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.UNKNOWN,
|
||||||
|
tags={"filename": "blob.bin", "mimetype": "application/octet-stream"},
|
||||||
|
)
|
||||||
|
|
||||||
|
screen = object.__new__(InspectDetailsScreen)
|
||||||
|
screen.tracksTable = FakeTagTable()
|
||||||
|
screen._sourceMediaDescriptor = FakeMediaDescriptor([attachment_track])
|
||||||
|
screen._targetMediaDescriptor = None
|
||||||
|
screen._currentPattern = None
|
||||||
|
screen._trackRowData = {}
|
||||||
|
screen._applyNormalization = False
|
||||||
|
|
||||||
|
screen.updateTracks()
|
||||||
|
|
||||||
|
row = screen.tracksTable.rows["row-0"]
|
||||||
|
|
||||||
|
self.assertEqual("unknown", row[3])
|
||||||
|
self.assertEqual(" ", row[5])
|
||||||
|
|
||||||
|
def test_inspect_details_screen_uses_source_font_attachments_for_styled_ass(self):
|
||||||
|
class _Config:
|
||||||
|
def getData(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
class _Pattern:
|
||||||
|
def __init__(self, media_descriptor):
|
||||||
|
self._media_descriptor = media_descriptor
|
||||||
|
|
||||||
|
def getMediaDescriptor(self, _context):
|
||||||
|
return self._media_descriptor
|
||||||
|
|
||||||
|
source_descriptor = MediaDescriptor(
|
||||||
|
track_descriptors=[
|
||||||
|
TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.ASS,
|
||||||
|
tags={"title": "Styled Subtitle"},
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=1,
|
||||||
|
source_index=1,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
tags={"filename": "current.ttf", "mimetype": "font/ttf"},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
pattern_descriptor = MediaDescriptor(
|
||||||
|
track_descriptors=[
|
||||||
|
TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.ASS,
|
||||||
|
tags={"title": "Styled Subtitle"},
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=1,
|
||||||
|
source_index=1,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
tags={"filename": "old.ttf", "mimetype": "font/ttf"},
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=2,
|
||||||
|
source_index=2,
|
||||||
|
sub_index=1,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
tags={"filename": "missing.ttf", "mimetype": "font/ttf"},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
screen = object.__new__(InspectDetailsScreen)
|
||||||
|
screen.context = {"logger": get_ffx_logger(), "config": _Config()}
|
||||||
|
|
||||||
|
resolved_descriptor = screen._resolve_target_media_descriptor(
|
||||||
|
_Pattern(pattern_descriptor),
|
||||||
|
source_descriptor,
|
||||||
|
)
|
||||||
|
attachment_tracks = resolved_descriptor.getAttachmentTracks()
|
||||||
|
|
||||||
|
self.assertEqual(1, len(attachment_tracks))
|
||||||
|
self.assertEqual({"filename": "current.ttf", "mimetype": "font/ttf"}, attachment_tracks[0].getTags())
|
||||||
|
|
||||||
|
change_set = MediaDescriptorChangeSet(
|
||||||
|
screen.context,
|
||||||
|
resolved_descriptor,
|
||||||
|
source_descriptor,
|
||||||
|
).getChangeSetObj()
|
||||||
|
self.assertNotIn(
|
||||||
|
1,
|
||||||
|
change_set.get("tracks", {}).get(DIFF_REMOVED_KEY, {}),
|
||||||
|
)
|
||||||
|
|
||||||
def test_inspect_details_screen_maps_target_selection_back_to_source_track(self):
|
def test_inspect_details_screen_maps_target_selection_back_to_source_track(self):
|
||||||
source_track = TrackDescriptor(
|
source_track = TrackDescriptor(
|
||||||
index=3,
|
index=3,
|
||||||
|
|||||||
Reference in New Issue
Block a user