Compare commits
49 Commits
dev-editor
...
cdc01062d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdc01062d3 | ||
|
|
aa864dfd4d | ||
|
|
697645be25 | ||
|
|
c0056b6c3c | ||
|
|
dd810ed41f | ||
|
|
f9d9db5871 | ||
|
|
56f801631f | ||
|
|
89409907f9 | ||
|
|
0415087e75 | ||
|
|
20a9bb36b8 | ||
|
|
3ac139a2dc | ||
|
|
912db3c39a | ||
|
|
8a375ccce1 | ||
|
|
176cfa06eb | ||
|
|
f794f822f2 | ||
|
|
1a11710df7 | ||
|
|
93d19629dc | ||
|
|
db43501ce2 | ||
|
|
87568989fe | ||
|
|
20ab08626b | ||
|
|
12be6e985a | ||
|
|
12310942ae | ||
|
|
f913cb4fe3 | ||
|
|
0a153280e3 | ||
|
|
6ca0cd54b0 | ||
|
|
502a822bb4 | ||
|
|
6cc21b5f36 | ||
|
|
0034f8ca97 | ||
|
|
eedcbaed0a | ||
|
|
653ce7b417 | ||
|
|
b80c055826 | ||
|
|
c5fc6ac13d | ||
|
|
1bead05d19 | ||
|
|
9fe2a842e9 | ||
|
|
849d03d054 | ||
|
|
3a87bbbba6 | ||
|
|
ab5e8e53e1 | ||
|
|
0ab2408444 | ||
|
|
bc1e0889e7 | ||
|
|
6dfbe1022a | ||
|
|
d3d2de8a0d | ||
|
|
0728ece4b8 | ||
|
|
02e375fbf2 | ||
|
|
14e6ce8458 | ||
|
|
d921629947 | ||
|
|
65490e2a7f | ||
|
|
6c5b518e4d | ||
|
|
e3c18f22d4 | ||
|
|
57185c7f10 |
7
.gitignore
vendored
7
.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
|
||||||
@@ -10,15 +9,19 @@ tools/ansible/inventory/group_vars/all.yml
|
|||||||
ffx_test_report.log
|
ffx_test_report.log
|
||||||
bin/conversiontest.py
|
bin/conversiontest.py
|
||||||
|
|
||||||
|
tests/assets/
|
||||||
|
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
docs/_build/
|
||||||
.codex
|
.codex
|
||||||
|
|
||||||
|
|
||||||
*.mkv
|
*.mkv
|
||||||
*.webm
|
*.webm
|
||||||
|
*.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"
|
||||||
|
}
|
||||||
66
README.md
66
README.md
@@ -99,68 +99,4 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
|
|||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
### 0.2.6
|
See the [version history](docs/history.rst) for release notes.
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|||||||
@@ -69,3 +69,7 @@
|
|||||||
## Delete When
|
## Delete When
|
||||||
|
|
||||||
- Delete this scratchpad once the optimization backlog is either converted into issues/work items or distilled into durable project guidance.
|
- Delete this scratchpad once the optimization backlog is either converted into issues/work items or distilled into durable project guidance.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## TODO: Review styled ASS separate handling
|
||||||
|
|||||||
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.
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/
|
||||||
123
docs/history.rst
Normal file
123
docs/history.rst
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
Version History
|
||||||
|
===============
|
||||||
|
|
||||||
|
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,13 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ffx"
|
name = "ffx"
|
||||||
description = "FFX recoding and metadata managing tool"
|
description = "FFX recoding and metadata managing tool"
|
||||||
version = "0.2.6"
|
version = "0.4.4"
|
||||||
license = {file = "LICENSE.md"}
|
license = {file = "LICENSE.md"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"requests",
|
"requests",
|
||||||
"jinja2",
|
"jinja2",
|
||||||
"click",
|
"click",
|
||||||
"textual",
|
"textual>=8.0",
|
||||||
"sqlalchemy",
|
"sqlalchemy",
|
||||||
]
|
]
|
||||||
readme = {file = "README.md", content-type = "text/markdown"}
|
readme = {file = "README.md", content-type = "text/markdown"}
|
||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ The main missing pieces are:
|
|||||||
- `METADATA_EDITOR-0013`: The command shall write changes through an ffmpeg
|
- `METADATA_EDITOR-0013`: The command shall write changes through an ffmpeg
|
||||||
stream-copy remux workflow only. No transcoding shall be performed as part of
|
stream-copy remux workflow only. No transcoding shall be performed as part of
|
||||||
`ffx edit`.
|
`ffx edit`.
|
||||||
|
- `METADATA_EDITOR-0013A`: The ffmpeg invocation used by `ffx edit` shall map
|
||||||
|
all source streams with `-map 0` and shall copy all mapped streams with a
|
||||||
|
single `-c copy`. It shall not emit conversion-style per-stream `-map` or
|
||||||
|
`-c:*` options that could drop, reorder, or transcode streams during a
|
||||||
|
metadata-only edit.
|
||||||
- `METADATA_EDITOR-0014`: Because ffmpeg cannot rewrite the source file in
|
- `METADATA_EDITOR-0014`: Because ffmpeg cannot rewrite the source file in
|
||||||
place, `ffx edit` shall write to a temporary output file on the same
|
place, `ffx edit` shall write to a temporary output file on the same
|
||||||
filesystem as the source file and shall replace the original path only after
|
filesystem as the source file and shall replace the original path only after
|
||||||
@@ -130,13 +135,19 @@ The main missing pieces are:
|
|||||||
configuration shall be surfaced clearly in the UI and in the planned-changes
|
configuration shall be surfaced clearly in the UI and in the planned-changes
|
||||||
view. If those rules are applied during save, the operator shall be able to
|
view. If those rules are applied during save, the operator shall be able to
|
||||||
tell that the file will be cleaned in addition to any manual edits.
|
tell that the file will be cleaned in addition to any manual edits.
|
||||||
- `METADATA_EDITOR-0022`: The command shall provide an invocation-level way to
|
- `METADATA_EDITOR-0022`: Edit mode shall provide an in-screen operator toggle
|
||||||
disable config-driven cleanup when the operator wants a pure manual metadata
|
for config-driven cleanup so a user can switch between pure manual metadata
|
||||||
edit without automatic tag removal.
|
edits and metadata edits plus configured tag cleanup without leaving the
|
||||||
|
editor.
|
||||||
- `METADATA_EDITOR-0023`: The existing global `--dry-run` behavior shall apply
|
- `METADATA_EDITOR-0023`: The existing global `--dry-run` behavior shall apply
|
||||||
to `ffx edit`. In dry-run mode the command shall not replace the original
|
to `ffx edit`. In dry-run mode the command shall not replace the original
|
||||||
file and shall expose the planned write operation clearly enough for the user
|
file and shall expose the planned write operation clearly enough for the user
|
||||||
to understand what would happen.
|
to understand what would happen.
|
||||||
|
- `METADATA_EDITOR-0024`: Every ffmpeg invocation performed by `ffx edit`
|
||||||
|
shall be surfaced to the operator as a notification in the edit UI.
|
||||||
|
- `METADATA_EDITOR-0025`: When application verbosity is greater than zero, the
|
||||||
|
notification for an `ffx edit` ffmpeg invocation shall include the concrete
|
||||||
|
ffmpeg command line.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
@@ -154,6 +165,8 @@ The main missing pieces are:
|
|||||||
- The planned-changes view reflects manual edits relative to the original file
|
- The planned-changes view reflects manual edits relative to the original file
|
||||||
and, when enabled, any configured cleanup removals.
|
and, when enabled, any configured cleanup removals.
|
||||||
- No rendered Rich or Textual color markup appears in the saved file metadata.
|
- No rendered Rich or Textual color markup appears in the saved file metadata.
|
||||||
|
- Saving metadata with files that contain PGS subtitle tracks or other
|
||||||
|
non-text subtitle codecs preserves those streams instead of dropping them.
|
||||||
- If ffmpeg fails while saving, the original file remains present and readable
|
- If ffmpeg fails while saving, the original file remains present and readable
|
||||||
at the original path.
|
at the original path.
|
||||||
- In dry-run mode, the original file remains untouched.
|
- In dry-run mode, the original file remains untouched.
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
- Intended for local execution, not server deployment.
|
- Intended for local execution, not server deployment.
|
||||||
- Stores default state in `~/.local/etc/ffx.json`, `~/.local/var/ffx/ffx.db`, and `~/.local/var/log/ffx.log`.
|
- Stores default state in `~/.local/etc/ffx.json`, `~/.local/var/ffx/ffx.db`, and `~/.local/var/log/ffx.log`.
|
||||||
- Timeline constraints:
|
- Timeline constraints:
|
||||||
- The current implemented scope reflects a compact alpha release stream up to version `0.2.6`.
|
- The current implemented scope reflects a compact alpha release stream up to version `0.3.1`.
|
||||||
- Team capacity assumptions:
|
- Team capacity assumptions:
|
||||||
- Maintained as a small codebase where simple patterns and direct controller logic are preferred over framework-heavy abstractions.
|
- Maintained as a small codebase where simple patterns and direct controller logic are preferred over framework-heavy abstractions.
|
||||||
- Third-party dependencies:
|
- Third-party dependencies:
|
||||||
|
|||||||
90
requirements/source_file_formats.md
Normal file
90
requirements/source_file_formats.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Source File Formats
|
||||||
|
|
||||||
|
This file defines source-file-format-specific processing requirements for FFX.
|
||||||
|
It is intended to grow as additional relevant source file types are identified.
|
||||||
|
|
||||||
|
The first covered format is Matroska media that contains styled ASS/SSA
|
||||||
|
subtitle streams together with embedded font attachments.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Detecting source files that use ASS subtitle streams together with embedded
|
||||||
|
font attachments needed for correct rendering.
|
||||||
|
- Defining the required `ffx convert` behavior when this format is present.
|
||||||
|
- Preserving the required attachment streams during conversion.
|
||||||
|
- Keeping normal subtitle-track manipulation behavior for the ASS subtitle
|
||||||
|
tracks themselves.
|
||||||
|
|
||||||
|
## Out Of Scope
|
||||||
|
|
||||||
|
- General subtitle behavior for sources that do not carry this pattern.
|
||||||
|
- A complete catalog of all source file formats FFX may support later.
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
- `styled ASS source`: a source media file that contains one or more subtitle
|
||||||
|
streams with `codec_type="subtitle"` and `codec_name="ass"` together with
|
||||||
|
one or more font-bearing attachment streams.
|
||||||
|
- `font attachment`: an attachment stream whose metadata identifies a font
|
||||||
|
payload, commonly through `tags.mimetype` and attachment filename metadata.
|
||||||
|
- `external subtitle feed`: subtitle tracks supplied from separate subtitle
|
||||||
|
files through the existing subtitle-import path.
|
||||||
|
- `special attachment subtracks`: the embedded font attachment streams that
|
||||||
|
belong to the styled ASS source pattern.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- `SOURCE_FILE_FORMATS-0001`: The system shall recognize the styled ASS source
|
||||||
|
pattern.
|
||||||
|
- `SOURCE_FILE_FORMATS-0002`: Recognition shall not depend on fixed stream
|
||||||
|
counts, fixed stream indices, or one exact attachment count.
|
||||||
|
- `SOURCE_FILE_FORMATS-0003`: Recognition shall use the best available ffprobe
|
||||||
|
signals. For known subtitle streams this includes
|
||||||
|
`codec_type="subtitle"` together with `codec_name="ass"`.
|
||||||
|
- `SOURCE_FILE_FORMATS-0004`: Recognition of the special attachment subtracks
|
||||||
|
shall use attachment-oriented signals such as `codec_type="attachment"` and
|
||||||
|
font-identifying metadata such as `tags.mimetype="font/ttf"` when present.
|
||||||
|
- `SOURCE_FILE_FORMATS-0005`: Recognition shall tolerate known ffprobe
|
||||||
|
variation in attachment reporting, including files where attachment streams
|
||||||
|
do not expose a `codec_name` but do expose `codec_type="attachment"` and
|
||||||
|
font-identifying tags.
|
||||||
|
- `SOURCE_FILE_FORMATS-0006`: When attachment metadata varies across files,
|
||||||
|
detection shall not depend on one exact MIME string alone. Detection shall
|
||||||
|
be written so the known pattern can vary while still recognizing font
|
||||||
|
attachments.
|
||||||
|
- `SOURCE_FILE_FORMATS-0007`: When the styled ASS source pattern is detected,
|
||||||
|
`ffx convert` shall emit an operator-facing message that reports the
|
||||||
|
detection and hints that special subtitle preservation handling is being
|
||||||
|
applied.
|
||||||
|
- `SOURCE_FILE_FORMATS-0008`: When the styled ASS source pattern is present on
|
||||||
|
the source file, `ffx convert` shall not process an external subtitle feed.
|
||||||
|
The command shall stop before conversion and report an error that explains
|
||||||
|
that separate subtitle-file import is incompatible with this source format.
|
||||||
|
- `SOURCE_FILE_FORMATS-0009`: Normal manipulation of the ASS subtitle streams
|
||||||
|
themselves shall continue to work through the usual selection, ordering,
|
||||||
|
metadata, language, title, and disposition handling paths.
|
||||||
|
- `SOURCE_FILE_FORMATS-0010`: The special attachment subtracks shall be
|
||||||
|
preserved in the target media file as-is rather than transcoded,
|
||||||
|
regenerated, or replaced from external sources.
|
||||||
|
- `SOURCE_FILE_FORMATS-0011`: Preserving the special attachment subtracks
|
||||||
|
as-is includes retaining the attachment payload and the attachment metadata
|
||||||
|
required by consumers, especially attachment filename and mimetype
|
||||||
|
information.
|
||||||
|
- `SOURCE_FILE_FORMATS-0012`: This file shall remain the extension point for
|
||||||
|
additional source-file-format contracts as FFX adds support for more special
|
||||||
|
source formats.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- A source file matching the observed pattern of embedded ASS subtitles plus
|
||||||
|
font attachments is recognized even when the attachment streams do not carry
|
||||||
|
a `codec_name`.
|
||||||
|
- `ffx convert` output contains a clear detection message before the actual
|
||||||
|
conversion work proceeds.
|
||||||
|
- If external subtitle import is requested for such a source file, the command
|
||||||
|
fails fast with an explicit error instead of mixing sidecar subtitles into
|
||||||
|
the job.
|
||||||
|
- Existing manipulation of the ASS subtitle tracks still works for metadata,
|
||||||
|
titles, languages, ordering, and dispositions.
|
||||||
|
- The output media preserves the required font attachment streams and their
|
||||||
|
identifying metadata needed by downstream media players.
|
||||||
@@ -46,6 +46,13 @@ Secondary source: `tests/legacy/`, used only to clarify intent and reveal gaps.
|
|||||||
- `SUBTRACK_MAPPING-0016`: Metadata for a substituted target track shall be merged from the regular source track and the separate source file when available.
|
- `SUBTRACK_MAPPING-0016`: Metadata for a substituted target track shall be merged from the regular source track and the separate source file when available.
|
||||||
- `SUBTRACK_MAPPING-0017`: If the separate source file provides a metadata field that is also present on the regular source track, the separate source file value shall win in the target output.
|
- `SUBTRACK_MAPPING-0017`: If the separate source file provides a metadata field that is also present on the regular source track, the separate source file value shall win in the target output.
|
||||||
- `SUBTRACK_MAPPING-0018`: If a metadata field is absent from the separate source file, the system shall fall back to the corresponding metadata from the regular source track or target schema rewrite rules.
|
- `SUBTRACK_MAPPING-0018`: If a metadata field is absent from the separate source file, the system shall fall back to the corresponding metadata from the regular source track or target schema rewrite rules.
|
||||||
|
- `SUBTRACK_MAPPING-0019`: When `ffx convert` receives an explicit subtitle directory without a subtitle prefix, it shall discover sidecar files using the source media basename as the filename prefix.
|
||||||
|
- `SUBTRACK_MAPPING-0020`: Basename-driven subtitle discovery shall first filter regular files by the exact `<source-basename>_` filename prefix and the configured subtitle extension.
|
||||||
|
- `SUBTRACK_MAPPING-0021`: `--subtitle-extension` shall accept an extension with or without a leading dot, default to `vtt`, and apply to both basename-driven and explicit-prefix subtitle discovery.
|
||||||
|
- `SUBTRACK_MAPPING-0022`: Basename-driven sidecar filenames shall identify the target subtitle track using the existing `<prefix>_<stream-index>_<language>[_<disposition>].<extension>` filename contract.
|
||||||
|
- `SUBTRACK_MAPPING-0023`: A complete, valid basename-driven sidecar set shall proceed without confirmation and shall report the discovered substitutions to the operator.
|
||||||
|
- `SUBTRACK_MAPPING-0024`: An incomplete but otherwise valid basename-driven sidecar set shall require confirmation before substituting only the represented subtitle tracks. `--yes` shall supply that confirmation without prompting. With `--no-prompt` and without `--yes`, conversion shall fail with an explanation instead.
|
||||||
|
- `SUBTRACK_MAPPING-0025`: Basename-driven discovery shall fail before conversion when the filtered set contains too many files, malformed filenames, duplicate stream indices, or stream indices that do not identify subtitle tracks in the active media descriptor.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
@@ -57,6 +64,9 @@ Secondary source: `tests/legacy/`, used only to clarify intent and reveal gaps.
|
|||||||
- If target-track metadata is rewritten after reordering, it is written onto the correct source-derived logical track rather than the track that merely occupies the same final output position.
|
- If target-track metadata is rewritten after reordering, it is written onto the correct source-derived logical track rather than the track that merely occupies the same final output position.
|
||||||
- Invalid target-to-source references fail deterministically before the conversion job is launched.
|
- Invalid target-to-source references fail deterministically before the conversion job is launched.
|
||||||
- If a separate source file substitutes one target track, that track keeps its target slot and ordering while metadata is merged with separate-file values taking precedence when both sides provide the same field.
|
- If a separate source file substitutes one target track, that track keeps its target slot and ordering while metadata is merged with separate-file values taking precedence when both sides provide the same field.
|
||||||
|
- Given `A2_t01.mkv` and an explicit subtitle directory containing `A2_t01_2_deu_DEF.vtt`, `A2_t01_3_eng.vtt`, and `A2_t01_4_eng.vtt`, directory-only subtitle import recognizes and substitutes all three tracks without prompting.
|
||||||
|
- Selecting `--subtitle-extension mkv` or `--subtitle-extension .mkv` selects the equivalent basename-matched `.mkv` sidecar set instead of the default `.vtt` set.
|
||||||
|
- Given an incomplete but valid basename-matched sidecar set, `--yes` proceeds with only the represented subtitle substitutions, including when `--no-prompt` is also set.
|
||||||
- A test proving subtrack mapping must assert at least one of: exact `source_index` to output-order mapping, omission of named source tracks, or preservation of per-track metadata after reorder.
|
- A test proving subtrack mapping must assert at least one of: exact `source_index` to output-order mapping, omission of named source tracks, or preservation of per-track metadata after reorder.
|
||||||
|
|
||||||
## Test Notes
|
## Test Notes
|
||||||
|
|||||||
67
src/ffx/attachment_format.py
Normal file
67
src/ffx/attachment_format.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from enum import Enum
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentFormat(Enum):
|
||||||
|
|
||||||
|
TTF = {'identifier': 'ttf', 'format': None, 'extension': 'ttf', 'label': 'TTF'}
|
||||||
|
PNG = {'identifier': 'png', 'format': None, 'extension': 'png', 'label': 'PNG'}
|
||||||
|
|
||||||
|
UNKNOWN = {'identifier': 'unknown', 'format': None, 'extension': None, 'label': 'UNKNOWN'}
|
||||||
|
|
||||||
|
def identifier(self):
|
||||||
|
return str(self.value['identifier'])
|
||||||
|
|
||||||
|
def label(self):
|
||||||
|
return str(self.value['label'])
|
||||||
|
|
||||||
|
def format(self):
|
||||||
|
return self.value['format']
|
||||||
|
|
||||||
|
def extension(self):
|
||||||
|
return str(self.value['extension'])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def identify(identifier: str):
|
||||||
|
formats = [f for f in AttachmentFormat if f.value['identifier'] == str(identifier)]
|
||||||
|
if formats:
|
||||||
|
return formats[0]
|
||||||
|
return AttachmentFormat.UNKNOWN
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def identifyFfprobeStream(streamObj: dict):
|
||||||
|
identifier = streamObj.get("codec_name")
|
||||||
|
identifiedFormat = AttachmentFormat.identify(identifier)
|
||||||
|
if identifiedFormat != AttachmentFormat.UNKNOWN:
|
||||||
|
return identifiedFormat
|
||||||
|
|
||||||
|
if str(streamObj.get("codec_type", "")).strip() != "attachment":
|
||||||
|
return AttachmentFormat.UNKNOWN
|
||||||
|
|
||||||
|
tags = streamObj.get("tags", {}) or {}
|
||||||
|
mimetype = str(tags.get("mimetype", "")).strip().lower()
|
||||||
|
filename = str(tags.get("filename", "")).strip().lower()
|
||||||
|
filenameExtension = os.path.splitext(filename)[1]
|
||||||
|
|
||||||
|
if (
|
||||||
|
mimetype in {
|
||||||
|
"font/ttf",
|
||||||
|
"application/x-truetype-font",
|
||||||
|
"application/x-font-ttf",
|
||||||
|
}
|
||||||
|
or "truetype" in mimetype
|
||||||
|
or filenameExtension == ".ttf"
|
||||||
|
):
|
||||||
|
return AttachmentFormat.TTF
|
||||||
|
|
||||||
|
if mimetype in {"image/png", "image/x-png"} or filenameExtension == ".png":
|
||||||
|
return AttachmentFormat.PNG
|
||||||
|
|
||||||
|
return AttachmentFormat.UNKNOWN
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fromTrackCodec(trackCodec):
|
||||||
|
identifier = getattr(trackCodec, "identifier", None)
|
||||||
|
if callable(identifier):
|
||||||
|
return AttachmentFormat.identify(trackCodec.identifier())
|
||||||
|
return AttachmentFormat.UNKNOWN
|
||||||
494
src/ffx/cli.py
494
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."
|
||||||
@@ -68,6 +72,14 @@ CUT_OPTION_HELP = (
|
|||||||
+ "or --cut START,DURATION for an explicit start and duration. "
|
+ "or --cut START,DURATION for an explicit start and duration. "
|
||||||
+ "Omit to disable."
|
+ "Omit to disable."
|
||||||
)
|
)
|
||||||
|
COPY_VIDEO_OPTION_HELP = (
|
||||||
|
"Copy video streams without re-encoding. Skips video encoder options "
|
||||||
|
+ "and video filters."
|
||||||
|
)
|
||||||
|
COPY_AUDIO_OPTION_HELP = (
|
||||||
|
"Copy audio streams without re-encoding. Skips audio encoder options "
|
||||||
|
+ "and audio filters."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def normalizeNicenessOption(ctx, param, value):
|
def normalizeNicenessOption(ctx, param, value):
|
||||||
@@ -88,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
|
||||||
@@ -138,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:
|
||||||
@@ -162,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):
|
||||||
@@ -173,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()
|
||||||
@@ -186,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
|
||||||
|
|
||||||
@@ -252,9 +437,15 @@ def buildRenameTargetFilename(
|
|||||||
@click.pass_context
|
@click.pass_context
|
||||||
@click.option('--language', 'app_language', type=str, default='', help='Set application language')
|
@click.option('--language', 'app_language', type=str, default='', help='Set application language')
|
||||||
@click.option('--database-file', type=str, default='', help='Path to database file')
|
@click.option('--database-file', type=str, default='', help='Path to database file')
|
||||||
|
@click.option(
|
||||||
|
'--debug',
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help='Enable debug-only TUI diagnostics such as the log pane',
|
||||||
|
)
|
||||||
@click.option('-v', '--verbose', type=int, default=0, help='Set verbosity of output')
|
@click.option('-v', '--verbose', type=int, default=0, help='Set verbosity of output')
|
||||||
@click.option("--dry-run", is_flag=True, default=False)
|
@click.option("--dry-run", is_flag=True, default=False)
|
||||||
def ffx(ctx, app_language, database_file, verbose, dry_run):
|
def ffx(ctx, app_language, database_file, debug, verbose, dry_run):
|
||||||
"""FFX"""
|
"""FFX"""
|
||||||
|
|
||||||
ctx.obj = {}
|
ctx.obj = {}
|
||||||
@@ -274,6 +465,7 @@ def ffx(ctx, app_language, database_file, verbose, dry_run):
|
|||||||
)
|
)
|
||||||
set_current_language(resolvedLanguage)
|
set_current_language(resolvedLanguage)
|
||||||
ctx.obj['language'] = resolvedLanguage
|
ctx.obj['language'] = resolvedLanguage
|
||||||
|
ctx.obj['debug'] = bool(debug)
|
||||||
|
|
||||||
if ctx.invoked_subcommand in LIGHTWEIGHT_COMMANDS:
|
if ctx.invoked_subcommand in LIGHTWEIGHT_COMMANDS:
|
||||||
ctx.obj['dry_run'] = dry_run
|
ctx.obj['dry_run'] = dry_run
|
||||||
@@ -287,6 +479,7 @@ def ffx(ctx, app_language, database_file, verbose, dry_run):
|
|||||||
|
|
||||||
ctx.obj['dry_run'] = dry_run
|
ctx.obj['dry_run'] = dry_run
|
||||||
ctx.obj['verbosity'] = verbose
|
ctx.obj['verbosity'] = verbose
|
||||||
|
ctx.obj['debug'] = bool(debug)
|
||||||
ctx.obj['language'] = resolve_application_language(
|
ctx.obj['language'] = resolve_application_language(
|
||||||
cli_language=app_language,
|
cli_language=app_language,
|
||||||
config_language=ctx.obj['config'].getLanguage(),
|
config_language=ctx.obj['config'].getLanguage(),
|
||||||
@@ -377,6 +570,41 @@ def getTrackedGitChanges(repoPath):
|
|||||||
return [line for line in completed.stdout.splitlines() if line.strip()]
|
return [line for line in completed.stdout.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def getCurrentGitBranch(repoPath):
|
||||||
|
completed = subprocess.run(
|
||||||
|
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
|
cwd=repoPath,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if completed.returncode != 0:
|
||||||
|
commandLabel = 'git rev-parse --abbrev-ref HEAD'
|
||||||
|
errorOutput = completed.stderr.strip() or completed.stdout.strip()
|
||||||
|
raise click.ClickException(
|
||||||
|
f"Unable to inspect bundle repository branch using '{commandLabel}': {errorOutput}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return completed.stdout.strip() or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def getBundleVersion(repoPath):
|
||||||
|
constantsPath = os.path.join(repoPath, 'src', 'ffx', 'constants.py')
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(constantsPath, encoding='utf-8') as constantsFile:
|
||||||
|
for line in constantsFile:
|
||||||
|
strippedLine = line.strip()
|
||||||
|
if strippedLine.startswith('VERSION=') or strippedLine.startswith('VERSION ='):
|
||||||
|
return strippedLine.split('=', 1)[1].strip().strip('"\'')
|
||||||
|
except OSError as ex:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"Unable to inspect bundle version from {constantsPath}: {ex}"
|
||||||
|
) from ex
|
||||||
|
|
||||||
|
raise click.ClickException(f"Unable to inspect bundle version from {constantsPath}")
|
||||||
|
|
||||||
|
|
||||||
def runScriptWrapper(ctx, scriptPath, missingDescription, commandArgs):
|
def runScriptWrapper(ctx, scriptPath, missingDescription, commandArgs):
|
||||||
if not os.path.isfile(scriptPath):
|
if not os.path.isfile(scriptPath):
|
||||||
raise click.ClickException(f"{missingDescription} not found at {scriptPath}")
|
raise click.ClickException(f"{missingDescription} not found at {scriptPath}")
|
||||||
@@ -391,6 +619,20 @@ def runScriptWrapper(ctx, scriptPath, missingDescription, commandArgs):
|
|||||||
ctx.exit(completed.returncode)
|
ctx.exit(completed.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
def runTuiApp(ctx) -> None:
|
||||||
|
from ffx.ffx_app import FfxApp
|
||||||
|
from ffx.logging_utils import set_ffx_console_logging_enabled
|
||||||
|
|
||||||
|
logger = ctx.obj.get('logger')
|
||||||
|
set_ffx_console_logging_enabled(logger, enabled=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
app = FfxApp(ctx.obj)
|
||||||
|
app.run()
|
||||||
|
finally:
|
||||||
|
set_ffx_console_logging_enabled(logger, enabled=True)
|
||||||
|
|
||||||
|
|
||||||
@ffx.command(name='setup')
|
@ffx.command(name='setup')
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@click.option('--check', is_flag=True, default=False, help='Only verify bundle-setup readiness')
|
@click.option('--check', is_flag=True, default=False, help='Only verify bundle-setup readiness')
|
||||||
@@ -485,6 +727,10 @@ def upgrade(ctx, branch):
|
|||||||
if completed.returncode != 0:
|
if completed.returncode != 0:
|
||||||
ctx.exit(completed.returncode)
|
ctx.exit(completed.returncode)
|
||||||
|
|
||||||
|
upgradedBranch = getCurrentGitBranch(bundleRepoPath)
|
||||||
|
upgradedVersion = getBundleVersion(bundleRepoPath)
|
||||||
|
click.echo(f"Updated FFX to version {upgradedVersion} from branch {upgradedBranch}.")
|
||||||
|
|
||||||
|
|
||||||
@ffx.command()
|
@ffx.command()
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@@ -527,14 +773,11 @@ def inspect(ctx, shift, filenames):
|
|||||||
if len(filenames) != 1:
|
if len(filenames) != 1:
|
||||||
raise click.ClickException("Inspect without --shift requires exactly one filename.")
|
raise click.ClickException("Inspect without --shift requires exactly one filename.")
|
||||||
|
|
||||||
from ffx.ffx_app import FfxApp
|
|
||||||
|
|
||||||
ctx.obj['command'] = 'inspect'
|
ctx.obj['command'] = 'inspect'
|
||||||
ctx.obj['arguments'] = {}
|
ctx.obj['arguments'] = {}
|
||||||
ctx.obj['arguments']['filename'] = filenames[0]
|
ctx.obj['arguments']['filename'] = filenames[0]
|
||||||
|
|
||||||
app = FfxApp(ctx.obj)
|
runTuiApp(ctx)
|
||||||
app.run()
|
|
||||||
|
|
||||||
|
|
||||||
@ffx.command()
|
@ffx.command()
|
||||||
@@ -544,8 +787,6 @@ def edit(ctx, filename):
|
|||||||
if not os.path.isfile(filename):
|
if not os.path.isfile(filename):
|
||||||
raise click.ClickException(f"File not found: {filename}")
|
raise click.ClickException(f"File not found: {filename}")
|
||||||
|
|
||||||
from ffx.ffx_app import FfxApp
|
|
||||||
|
|
||||||
ctx.obj['command'] = 'edit'
|
ctx.obj['command'] = 'edit'
|
||||||
ctx.obj['arguments'] = {'filename': filename}
|
ctx.obj['arguments'] = {'filename': filename}
|
||||||
ctx.obj['use_pattern'] = False
|
ctx.obj['use_pattern'] = False
|
||||||
@@ -554,8 +795,7 @@ def edit(ctx, filename):
|
|||||||
ctx.obj['apply_metadata_normalization'] = True
|
ctx.obj['apply_metadata_normalization'] = True
|
||||||
ctx.obj['resource_limits'] = ctx.obj.get('resource_limits', {})
|
ctx.obj['resource_limits'] = ctx.obj.get('resource_limits', {})
|
||||||
|
|
||||||
app = FfxApp(ctx.obj)
|
runTuiApp(ctx)
|
||||||
app.run()
|
|
||||||
|
|
||||||
|
|
||||||
@ffx.command()
|
@ffx.command()
|
||||||
@@ -615,29 +855,33 @@ def rename(ctx, paths, prefix, season, suffix, dry_run):
|
|||||||
|
|
||||||
|
|
||||||
def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix, targetDirectory = ''):
|
def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix, targetDirectory = ''):
|
||||||
|
from ffx.track_codec import TrackCodec
|
||||||
|
from ffx.track_type import TrackType
|
||||||
|
|
||||||
# executable and input file
|
# executable and input file
|
||||||
commandTokens = list(FFMPEG_COMMAND_TOKENS) + ['-i', sourcePath]
|
commandTokens = list(FFMPEG_COMMAND_TOKENS) + ['-i', sourcePath]
|
||||||
|
|
||||||
trackType = trackDescriptor.getType()
|
trackType = trackDescriptor.getType()
|
||||||
|
trackCodec = trackDescriptor.getCodec()
|
||||||
|
trackFormat = trackDescriptor.getFormatDescriptor()
|
||||||
|
|
||||||
targetPathBase = os.path.join(targetDirectory, targetPrefix) if targetDirectory else targetPrefix
|
targetPathBase = os.path.join(targetDirectory, targetPrefix) if targetDirectory else targetPrefix
|
||||||
|
|
||||||
# mapping
|
# mapping
|
||||||
commandTokens += ['-map',
|
commandTokens += ['-map', f"0:{trackType.indicator()}:{trackDescriptor.getSubIndex()}"]
|
||||||
f"0:{trackType.indicator()}:{trackDescriptor.getSubIndex()}",
|
|
||||||
'-c',
|
|
||||||
'copy']
|
|
||||||
|
|
||||||
trackCodec = trackDescriptor.getCodec()
|
if trackType == TrackType.VIDEO and trackCodec == TrackCodec.H265:
|
||||||
|
commandTokens += ['-c:v', 'copy', '-bsf:v', 'hevc_mp4toannexb']
|
||||||
|
else:
|
||||||
|
commandTokens += ['-c', 'copy']
|
||||||
|
|
||||||
# output format
|
# output format
|
||||||
codecFormat = trackCodec.format()
|
codecFormat = trackFormat.format()
|
||||||
if codecFormat is not None:
|
if codecFormat is not None:
|
||||||
commandTokens += ['-f', codecFormat]
|
commandTokens += ['-f', codecFormat]
|
||||||
|
|
||||||
# output filename
|
# output filename
|
||||||
commandTokens += [f"{targetPathBase}.{trackCodec.extension()}"]
|
commandTokens += [f"{targetPathBase}.{trackFormat.extension()}"]
|
||||||
|
|
||||||
return commandTokens
|
return commandTokens
|
||||||
|
|
||||||
@@ -649,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,
|
||||||
@@ -670,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
|
||||||
@@ -685,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)
|
||||||
|
|
||||||
@@ -752,7 +1004,7 @@ def unmux(ctx,
|
|||||||
if not ctx.obj['dry_run']:
|
if not ctx.obj['dry_run']:
|
||||||
|
|
||||||
#TODO #425: Codec Enum
|
#TODO #425: Codec Enum
|
||||||
ctx.obj['logger'].info(f"Unmuxing stream {trackDescriptor.getIndex()} into file {targetPrefix}.{trackDescriptor.getCodec().extension()}")
|
ctx.obj['logger'].info(f"Unmuxing stream {trackDescriptor.getIndex()} into file {targetPrefix}.{trackDescriptor.getFormatDescriptor().extension()}")
|
||||||
|
|
||||||
ctx.obj['logger'].debug(f"Executing unmuxing sequence")
|
ctx.obj['logger'].debug(f"Executing unmuxing sequence")
|
||||||
|
|
||||||
@@ -837,12 +1089,8 @@ def cropdetect(ctx,
|
|||||||
@click.pass_context
|
@click.pass_context
|
||||||
|
|
||||||
def shows(ctx):
|
def shows(ctx):
|
||||||
from ffx.ffx_app import FfxApp
|
|
||||||
|
|
||||||
ctx.obj['command'] = 'shows'
|
ctx.obj['command'] = 'shows'
|
||||||
|
runTuiApp(ctx)
|
||||||
app = FfxApp(ctx.obj)
|
|
||||||
app.run()
|
|
||||||
|
|
||||||
|
|
||||||
def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
||||||
@@ -899,6 +1147,8 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
|||||||
@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('-v', '--video-encoder', type=str, default=DEFAULT_VIDEO_ENCODER_LABEL, help=f"Target video encoder (vp9, av1, h264 or copy)", show_default=True)
|
@click.option('-v', '--video-encoder', type=str, default=DEFAULT_VIDEO_ENCODER_LABEL, help=f"Target video encoder (vp9, av1, h264 or copy)", show_default=True)
|
||||||
|
@click.option('--copy-video', is_flag=True, default=False, help=COPY_VIDEO_OPTION_HELP)
|
||||||
|
@click.option('--copy-audio', is_flag=True, default=False, help=COPY_AUDIO_OPTION_HELP)
|
||||||
|
|
||||||
@click.option('-q', '--quality', type=str, default="", help=f"Quality settings to be used with VP9/H264 encoder")
|
@click.option('-q', '--quality', type=str, default="", help=f"Quality settings to be used with VP9/H264 encoder")
|
||||||
@click.option('-p', '--preset', type=str, default="", help=f"Quality preset to be used with AV1 encoder")
|
@click.option('-p', '--preset', type=str, default="", help=f"Quality preset to be used with AV1 encoder")
|
||||||
@@ -909,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>')
|
||||||
@@ -943,14 +1201,27 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
|||||||
metavar="DURATION|START,DURATION",
|
metavar="DURATION|START,DURATION",
|
||||||
is_flag=False,
|
is_flag=False,
|
||||||
flag_value=DEFAULT_CUT_OPTION_VALUE,
|
flag_value=DEFAULT_CUT_OPTION_VALUE,
|
||||||
default=None,
|
|
||||||
callback=normalizeCutOption,
|
callback=normalizeCutOption,
|
||||||
help=CUT_OPTION_HELP,
|
help=CUT_OPTION_HELP,
|
||||||
)
|
)
|
||||||
|
|
||||||
@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)
|
||||||
@@ -970,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)
|
||||||
|
|
||||||
@@ -996,6 +1273,8 @@ def convert(ctx,
|
|||||||
paths,
|
paths,
|
||||||
label,
|
label,
|
||||||
video_encoder,
|
video_encoder,
|
||||||
|
copy_video,
|
||||||
|
copy_audio,
|
||||||
quality,
|
quality,
|
||||||
preset,
|
preset,
|
||||||
stereo_bitrate,
|
stereo_bitrate,
|
||||||
@@ -1004,6 +1283,7 @@ def convert(ctx,
|
|||||||
|
|
||||||
subtitle_directory,
|
subtitle_directory,
|
||||||
subtitle_prefix,
|
subtitle_prefix,
|
||||||
|
subtitle_extension,
|
||||||
|
|
||||||
language,
|
language,
|
||||||
title,
|
title,
|
||||||
@@ -1042,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,
|
||||||
|
|
||||||
@@ -1055,6 +1336,11 @@ def convert(ctx,
|
|||||||
Suffices will we appended to filename in case of multiple created files
|
Suffices will we appended to filename in case of multiple created files
|
||||||
or if the filename has not changed."""
|
or if the filename has not changed."""
|
||||||
from ffx.ffx_controller import FfxController
|
from ffx.ffx_controller import FfxController
|
||||||
|
from ffx.diagnostics import (
|
||||||
|
FfmpegSkipFileWarning,
|
||||||
|
getUnremediedIssues,
|
||||||
|
iterUnremediedIssueSummaryLines,
|
||||||
|
)
|
||||||
from ffx.file_properties import FileProperties
|
from ffx.file_properties import FileProperties
|
||||||
from ffx.filter.crop_filter import CropFilter
|
from ffx.filter.crop_filter import CropFilter
|
||||||
from ffx.filter.deinterlace_filter import DeinterlaceFilter
|
from ffx.filter.deinterlace_filter import DeinterlaceFilter
|
||||||
@@ -1068,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()
|
||||||
@@ -1075,9 +1362,12 @@ def convert(ctx,
|
|||||||
context = ctx.obj
|
context = ctx.obj
|
||||||
|
|
||||||
context['video_encoder'] = VideoEncoder.fromLabel(video_encoder)
|
context['video_encoder'] = VideoEncoder.fromLabel(video_encoder)
|
||||||
|
context['copy_video'] = copy_video
|
||||||
|
context['copy_audio'] = copy_audio
|
||||||
|
copyVideoEffective = copy_video or context['video_encoder'] == VideoEncoder.COPY
|
||||||
|
|
||||||
# HINT: quick and dirty override for h264, todo improve
|
# HINT: quick and dirty override for h264, todo improve
|
||||||
if context['video_encoder'] in (VideoEncoder.H264, VideoEncoder.COPY):
|
if context['video_encoder'] in (VideoEncoder.H264, VideoEncoder.COPY) or copy_video or copy_audio:
|
||||||
targetFormat = ''
|
targetFormat = ''
|
||||||
targetExtension = 'mkv'
|
targetExtension = 'mkv'
|
||||||
else:
|
else:
|
||||||
@@ -1087,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
|
||||||
|
|
||||||
@@ -1105,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,
|
||||||
@@ -1113,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]
|
||||||
@@ -1210,37 +1503,55 @@ def convert(ctx,
|
|||||||
tc = TmdbController() if context['use_tmdb'] else None
|
tc = TmdbController() if context['use_tmdb'] else None
|
||||||
|
|
||||||
|
|
||||||
qualityKwargs = {QualityFilter.QUALITY_KEY: str(quality)}
|
if copyVideoEffective and quality:
|
||||||
|
ctx.obj['logger'].warning("Ignoring quality settings because video is being copied")
|
||||||
|
|
||||||
|
qualityKwargs = {
|
||||||
|
QualityFilter.QUALITY_KEY: "" if copyVideoEffective else str(quality)
|
||||||
|
}
|
||||||
qf = QualityFilter(**qualityKwargs)
|
qf = QualityFilter(**qualityKwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if context['video_encoder'] == VideoEncoder.AV1 and preset:
|
if context['video_encoder'] == VideoEncoder.AV1 and preset and not copyVideoEffective:
|
||||||
presetKwargs = {PresetFilter.PRESET_KEY: preset}
|
presetKwargs = {PresetFilter.PRESET_KEY: preset}
|
||||||
PresetFilter(**presetKwargs)
|
PresetFilter(**presetKwargs)
|
||||||
|
|
||||||
cf = None
|
cf = None
|
||||||
# if crop != 'none':
|
# if crop != 'none':
|
||||||
if crop == 'auto':
|
videoFilterOptionsRequested = (
|
||||||
|
crop != 'none'
|
||||||
|
or deinterlace != 'none'
|
||||||
|
or denoise != 'none'
|
||||||
|
or denoise_strength
|
||||||
|
or denoise_patch_size
|
||||||
|
or denoise_chroma_patch_size
|
||||||
|
or denoise_research_window
|
||||||
|
or denoise_chroma_research_window
|
||||||
|
)
|
||||||
|
if copyVideoEffective and videoFilterOptionsRequested:
|
||||||
|
ctx.obj['logger'].warning("Ignoring video filter options because video is being copied")
|
||||||
|
|
||||||
|
if crop == 'auto' and not copyVideoEffective:
|
||||||
cropKwargs = {}
|
cropKwargs = {}
|
||||||
cf = CropFilter(**cropKwargs)
|
cf = CropFilter(**cropKwargs)
|
||||||
|
|
||||||
denoiseKwargs = {}
|
denoiseKwargs = {}
|
||||||
if denoise_strength:
|
if denoise_strength and not copyVideoEffective:
|
||||||
denoiseKwargs[NlmeansFilter.STRENGTH_KEY] = denoise_strength
|
denoiseKwargs[NlmeansFilter.STRENGTH_KEY] = denoise_strength
|
||||||
if denoise_patch_size:
|
if denoise_patch_size and not copyVideoEffective:
|
||||||
denoiseKwargs[NlmeansFilter.PATCH_SIZE_KEY] = denoise_patch_size
|
denoiseKwargs[NlmeansFilter.PATCH_SIZE_KEY] = denoise_patch_size
|
||||||
if denoise_chroma_patch_size:
|
if denoise_chroma_patch_size and not copyVideoEffective:
|
||||||
denoiseKwargs[NlmeansFilter.CHROMA_PATCH_SIZE_KEY] = denoise_chroma_patch_size
|
denoiseKwargs[NlmeansFilter.CHROMA_PATCH_SIZE_KEY] = denoise_chroma_patch_size
|
||||||
if denoise_research_window:
|
if denoise_research_window and not copyVideoEffective:
|
||||||
denoiseKwargs[NlmeansFilter.RESEARCH_WINDOW_KEY] = denoise_research_window
|
denoiseKwargs[NlmeansFilter.RESEARCH_WINDOW_KEY] = denoise_research_window
|
||||||
if denoise_chroma_research_window:
|
if denoise_chroma_research_window and not copyVideoEffective:
|
||||||
denoiseKwargs[NlmeansFilter.CHROMA_RESEARCH_WINDOW_KEY] = denoise_chroma_research_window
|
denoiseKwargs[NlmeansFilter.CHROMA_RESEARCH_WINDOW_KEY] = denoise_chroma_research_window
|
||||||
if denoise != 'none' or denoiseKwargs:
|
if not copyVideoEffective and (denoise != 'none' or denoiseKwargs):
|
||||||
NlmeansFilter(**denoiseKwargs)
|
NlmeansFilter(**denoiseKwargs)
|
||||||
|
|
||||||
if deinterlace != 'none':
|
if deinterlace != 'none' and not copyVideoEffective:
|
||||||
DeinterlaceFilter()
|
DeinterlaceFilter(mode=deinterlace)
|
||||||
|
|
||||||
chainYield = list(qf.getChainYield())
|
chainYield = list(qf.getChainYield())
|
||||||
|
|
||||||
@@ -1299,13 +1610,31 @@ def convert(ctx,
|
|||||||
sourceMediaDescriptor = mediaFileProperties.getMediaDescriptor()
|
sourceMediaDescriptor = mediaFileProperties.getMediaDescriptor()
|
||||||
|
|
||||||
|
|
||||||
if ([smd for smd in sourceMediaDescriptor.getSubtitleTracks()
|
from ffx.attachment_format import AttachmentFormat
|
||||||
if smd.getCodec() == TrackCodec.ASS]
|
|
||||||
and [amd for amd in sourceMediaDescriptor.getAttachmentTracks()
|
styledAssDetector = getattr(
|
||||||
if amd.getCodec() == TrackCodec.TTF]):
|
sourceMediaDescriptor,
|
||||||
|
"hasStyledAssSubtitlesWithFontAttachments",
|
||||||
|
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
|
||||||
@@ -1320,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)
|
||||||
@@ -1332,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)
|
||||||
|
|
||||||
@@ -1341,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()
|
||||||
@@ -1359,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()]}")
|
||||||
@@ -1374,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)
|
||||||
|
|
||||||
@@ -1511,18 +1857,30 @@ def convert(ctx,
|
|||||||
if rename_only:
|
if rename_only:
|
||||||
shutil.move(sourcePath, targetPath)
|
shutil.move(sourcePath, targetPath)
|
||||||
else:
|
else:
|
||||||
fc.runJob(sourcePath,
|
try:
|
||||||
targetPath,
|
fc.runJob(sourcePath,
|
||||||
targetFormat,
|
targetPath,
|
||||||
chainIteration,
|
targetFormat,
|
||||||
cropArguments,
|
chainIteration,
|
||||||
currentPattern,
|
cropArguments,
|
||||||
currentShowDescriptor)
|
currentPattern,
|
||||||
|
currentShowDescriptor)
|
||||||
|
except FfmpegSkipFileWarning:
|
||||||
|
if os.path.exists(targetPath):
|
||||||
|
os.remove(targetPath)
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
endTime = time.perf_counter()
|
endTime = time.perf_counter()
|
||||||
ctx.obj['logger'].info(f"\nDONE\nTime elapsed {endTime - startTime}")
|
ctx.obj['logger'].info(f"\nDONE\nTime elapsed {endTime - startTime}")
|
||||||
|
unremediedIssues = getUnremediedIssues(context)
|
||||||
|
if unremediedIssues:
|
||||||
|
ctx.obj['logger'].warning("\nFiles with ffmpeg findings that require review:")
|
||||||
|
for summaryLine in iterUnremediedIssueSummaryLines(context):
|
||||||
|
ctx.obj['logger'].warning(summaryLine)
|
||||||
|
else:
|
||||||
|
ctx.obj['logger'].info("All files converted with no issues.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from textual.screen import Screen
|
|||||||
from textual.widgets import Button, Footer, Header, Static
|
from textual.widgets import Button, Footer, Header, Static
|
||||||
|
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
|
from .screen_support import build_screen_log_pane
|
||||||
|
|
||||||
class ConfirmScreen(Screen):
|
class ConfirmScreen(Screen):
|
||||||
|
|
||||||
@@ -58,8 +59,16 @@ class ConfirmScreen(Screen):
|
|||||||
yield Button(self.__confirmLabel, id="confirm_button")
|
yield Button(self.__confirmLabel, id="confirm_button")
|
||||||
yield Button(self.__cancelLabel, id="cancel_button")
|
yield Button(self.__cancelLabel, id="cancel_button")
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
|
||||||
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
if event.button.id == "confirm_button":
|
if event.button.id == "confirm_button":
|
||||||
self.dismiss(True)
|
self.dismiss(True)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
VERSION='0.2.6'
|
VERSION='0.4.4'
|
||||||
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
|
||||||
|
|||||||
24
src/ffx/diagnostics/__init__.py
Normal file
24
src/ffx/diagnostics/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from .base import FfmpegRemedy, FfmpegRemedyDecision, FfmpegSkipFileWarning
|
||||||
|
from .monitor import FfmpegCommandRunner, FfmpegDiagnosticMonitor
|
||||||
|
from .retry_with_generated_pts import RetryWithGeneratedPtsRemedy
|
||||||
|
from .state import (
|
||||||
|
getDiagnosticsState,
|
||||||
|
getUnremediedIssues,
|
||||||
|
iterUnremediedIssueSummaryLines,
|
||||||
|
recordUnremediedIssue,
|
||||||
|
)
|
||||||
|
from .warn_corrupt_mpeg_audio import WarnCorruptMpegAudioRemedy
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FfmpegCommandRunner",
|
||||||
|
"FfmpegDiagnosticMonitor",
|
||||||
|
"FfmpegRemedy",
|
||||||
|
"FfmpegRemedyDecision",
|
||||||
|
"FfmpegSkipFileWarning",
|
||||||
|
"RetryWithGeneratedPtsRemedy",
|
||||||
|
"WarnCorruptMpegAudioRemedy",
|
||||||
|
"getDiagnosticsState",
|
||||||
|
"getUnremediedIssues",
|
||||||
|
"iterUnremediedIssueSummaryLines",
|
||||||
|
"recordUnremediedIssue",
|
||||||
|
]
|
||||||
33
src/ffx/diagnostics/base.py
Normal file
33
src/ffx/diagnostics/base.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
class FfmpegSkipFileWarning(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FfmpegRemedyDecision:
|
||||||
|
stop_process: bool = False
|
||||||
|
retry_input_tokens: tuple[str, ...] = ()
|
||||||
|
skip_file: bool = False
|
||||||
|
console_warning: str = ""
|
||||||
|
summary_identifier: str = ""
|
||||||
|
unremedied_issue_identifier: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def retry_requested(self) -> bool:
|
||||||
|
return bool(self.retry_input_tokens)
|
||||||
|
|
||||||
|
|
||||||
|
class FfmpegRemedy:
|
||||||
|
identifier = "ffmpeg-remedy"
|
||||||
|
harmless = False
|
||||||
|
|
||||||
|
def inspect_line(
|
||||||
|
self,
|
||||||
|
line: str,
|
||||||
|
session: "FfmpegDiagnosticMonitor",
|
||||||
|
) -> FfmpegRemedyDecision | None:
|
||||||
|
raise NotImplementedError
|
||||||
222
src/ffx/diagnostics/monitor.py
Normal file
222
src/ffx/diagnostics/monitor.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ffx.logging_utils import get_ffx_logger
|
||||||
|
from ffx.process import executeProcess
|
||||||
|
|
||||||
|
from .base import FfmpegSkipFileWarning, FfmpegRemedy
|
||||||
|
from .retry_with_generated_pts import RetryWithGeneratedPtsRemedy
|
||||||
|
from .state import recordUnremediedIssue
|
||||||
|
from .warn_corrupt_mpeg_audio import WarnCorruptMpegAudioRemedy
|
||||||
|
|
||||||
|
UNHANDLED_DIAGNOSTIC_PATTERNS = (
|
||||||
|
re.compile(r"\bwarning\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\berror\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bfailed\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\binvalid\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bmissing\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bcorrupt\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\boverflow\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bdeprecated\b", re.IGNORECASE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FfmpegDiagnosticMonitor:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
context: dict | None,
|
||||||
|
command_sequence: list[str],
|
||||||
|
*,
|
||||||
|
remedies: list[FfmpegRemedy] | None = None,
|
||||||
|
emittedWarnings: set[str] | None = None,
|
||||||
|
):
|
||||||
|
self.context = context or {}
|
||||||
|
self.command_sequence = list(command_sequence)
|
||||||
|
self.logger = self.context.get("logger", get_ffx_logger())
|
||||||
|
self.source_path = str(self.context.get("current_source_path", "")).strip()
|
||||||
|
self.remedies = remedies or [
|
||||||
|
RetryWithGeneratedPtsRemedy(),
|
||||||
|
WarnCorruptMpegAudioRemedy(),
|
||||||
|
]
|
||||||
|
self._emittedWarnings = emittedWarnings if emittedWarnings is not None else set()
|
||||||
|
self.retry_input_tokens: tuple[str, ...] = ()
|
||||||
|
self.skip_file = False
|
||||||
|
self.skip_file_message = ""
|
||||||
|
|
||||||
|
def describe_source(self) -> str:
|
||||||
|
return self.source_path if self.source_path else "current file"
|
||||||
|
|
||||||
|
def command_contains_tokens(self, tokens: tuple[str, ...]) -> bool:
|
||||||
|
tokenCount = len(tokens)
|
||||||
|
if tokenCount == 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return any(
|
||||||
|
tuple(self.command_sequence[index:index + tokenCount]) == tuple(tokens)
|
||||||
|
for index in range(len(self.command_sequence) - tokenCount + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
def emitConsoleWarning(self, warningMessage: str) -> None:
|
||||||
|
if warningMessage and warningMessage not in self._emittedWarnings:
|
||||||
|
self.logger.warning(warningMessage)
|
||||||
|
self._emittedWarnings.add(warningMessage)
|
||||||
|
|
||||||
|
def recordUnremediedIssue(self, issueIdentifier: str, issueLine: str) -> None:
|
||||||
|
isFirstIssueForFile = recordUnremediedIssue(
|
||||||
|
self.context,
|
||||||
|
self.describe_source(),
|
||||||
|
issueIdentifier,
|
||||||
|
)
|
||||||
|
if not isFirstIssueForFile:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.emitConsoleWarning(
|
||||||
|
f"ffmpeg reported a diagnostic with no automatic remedy while converting "
|
||||||
|
+ f"{self.describe_source()}. FFX will continue, but review the output "
|
||||||
|
+ f"file. First unhandled line: {issueLine}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def lineLooksLikeUnhandledDiagnostic(self, line: str) -> bool:
|
||||||
|
return any(pattern.search(line) for pattern in UNHANDLED_DIAGNOSTIC_PATTERNS)
|
||||||
|
|
||||||
|
def getUnhandledDiagnosticIdentifier(self, line: str) -> str:
|
||||||
|
loweredLine = str(line).lower()
|
||||||
|
|
||||||
|
if any(token in loweredLine for token in ("error", "failed", "invalid", "missing", "corrupt", "overflow")):
|
||||||
|
return "unhandled-error"
|
||||||
|
if any(token in loweredLine for token in ("warning", "deprecated")):
|
||||||
|
return "unhandled-warning"
|
||||||
|
return "unhandled-diagnostic"
|
||||||
|
|
||||||
|
def getSummaryIdentifier(
|
||||||
|
self,
|
||||||
|
remedy: FfmpegRemedy,
|
||||||
|
decision,
|
||||||
|
) -> str:
|
||||||
|
explicitIdentifier = str(decision.summary_identifier).strip()
|
||||||
|
if explicitIdentifier:
|
||||||
|
return explicitIdentifier
|
||||||
|
|
||||||
|
remedyIdentifier = str(getattr(remedy, "identifier", "")).strip()
|
||||||
|
if remedyIdentifier and remedyIdentifier != FfmpegRemedy.identifier:
|
||||||
|
return remedyIdentifier
|
||||||
|
|
||||||
|
return str(decision.unremedied_issue_identifier).strip()
|
||||||
|
|
||||||
|
def shouldRecordSummary(
|
||||||
|
self,
|
||||||
|
remedy: FfmpegRemedy,
|
||||||
|
decision,
|
||||||
|
) -> bool:
|
||||||
|
if getattr(remedy, "harmless", False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if decision.retry_requested and not decision.skip_file:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return bool(self.getSummaryIdentifier(remedy, decision))
|
||||||
|
|
||||||
|
def handle_stderr_line(self, line: str) -> bool:
|
||||||
|
strippedLine = str(line).strip()
|
||||||
|
if not strippedLine:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for remedy in self.remedies:
|
||||||
|
decision = remedy.inspect_line(strippedLine, self)
|
||||||
|
if decision is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.emitConsoleWarning(decision.console_warning)
|
||||||
|
|
||||||
|
if decision.retry_requested:
|
||||||
|
self.retry_input_tokens = tuple(decision.retry_input_tokens)
|
||||||
|
|
||||||
|
if self.shouldRecordSummary(remedy, decision):
|
||||||
|
recordUnremediedIssue(
|
||||||
|
self.context,
|
||||||
|
self.describe_source(),
|
||||||
|
self.getSummaryIdentifier(remedy, decision),
|
||||||
|
)
|
||||||
|
|
||||||
|
if decision.skip_file:
|
||||||
|
self.skip_file = True
|
||||||
|
self.skip_file_message = (
|
||||||
|
decision.console_warning
|
||||||
|
or f"Skipping file {self.describe_source()} because ffmpeg reported a fatal diagnostic."
|
||||||
|
)
|
||||||
|
|
||||||
|
return bool(decision.stop_process)
|
||||||
|
|
||||||
|
if self.lineLooksLikeUnhandledDiagnostic(strippedLine):
|
||||||
|
self.recordUnremediedIssue(
|
||||||
|
self.getUnhandledDiagnosticIdentifier(strippedLine),
|
||||||
|
strippedLine,
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def retry_requested(self) -> bool:
|
||||||
|
return bool(self.retry_input_tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def insertFfmpegInputOptions(
|
||||||
|
commandSequence: list[str],
|
||||||
|
extraTokens: tuple[str, ...],
|
||||||
|
) -> list[str]:
|
||||||
|
if not extraTokens:
|
||||||
|
return list(commandSequence)
|
||||||
|
|
||||||
|
if not commandSequence:
|
||||||
|
return list(extraTokens)
|
||||||
|
|
||||||
|
return [commandSequence[0]] + list(extraTokens) + list(commandSequence[1:])
|
||||||
|
|
||||||
|
|
||||||
|
class FfmpegCommandRunner:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
context: dict | None,
|
||||||
|
*,
|
||||||
|
remedies: list[FfmpegRemedy] | None = None,
|
||||||
|
):
|
||||||
|
self.__context = context or {}
|
||||||
|
self.__remedies = remedies
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
commandSequence: list[str],
|
||||||
|
*,
|
||||||
|
directory: str = None,
|
||||||
|
timeoutSeconds: float = None,
|
||||||
|
):
|
||||||
|
emittedWarnings: set[str] = set()
|
||||||
|
attemptCommandSequence = list(commandSequence)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
monitor = FfmpegDiagnosticMonitor(
|
||||||
|
self.__context,
|
||||||
|
attemptCommandSequence,
|
||||||
|
remedies=self.__remedies,
|
||||||
|
emittedWarnings=emittedWarnings,
|
||||||
|
)
|
||||||
|
out, err, rc = executeProcess(
|
||||||
|
attemptCommandSequence,
|
||||||
|
directory=directory,
|
||||||
|
context=self.__context,
|
||||||
|
timeoutSeconds=timeoutSeconds,
|
||||||
|
stderrLineHandler=monitor.handle_stderr_line,
|
||||||
|
)
|
||||||
|
|
||||||
|
if monitor.retry_requested:
|
||||||
|
attemptCommandSequence = insertFfmpegInputOptions(
|
||||||
|
attemptCommandSequence,
|
||||||
|
monitor.retry_input_tokens,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if monitor.skip_file:
|
||||||
|
raise FfmpegSkipFileWarning(monitor.skip_file_message)
|
||||||
|
|
||||||
|
return out, err, rc
|
||||||
41
src/ffx/diagnostics/retry_with_generated_pts.py
Normal file
41
src/ffx/diagnostics/retry_with_generated_pts.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .base import FfmpegRemedy, FfmpegRemedyDecision
|
||||||
|
|
||||||
|
|
||||||
|
class RetryWithGeneratedPtsRemedy(FfmpegRemedy):
|
||||||
|
identifier = "retry-with-generated-pts"
|
||||||
|
RETRY_INPUT_TOKENS = ("-fflags", "+genpts")
|
||||||
|
TIMESTAMP_UNSET_PATTERN = re.compile(
|
||||||
|
r"Timestamps are unset in a packet for stream \d+"
|
||||||
|
)
|
||||||
|
|
||||||
|
def inspect_line(
|
||||||
|
self,
|
||||||
|
line: str,
|
||||||
|
session: "FfmpegDiagnosticMonitor",
|
||||||
|
) -> FfmpegRemedyDecision | None:
|
||||||
|
if self.TIMESTAMP_UNSET_PATTERN.search(line) is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if session.command_contains_tokens(self.RETRY_INPUT_TOKENS):
|
||||||
|
return FfmpegRemedyDecision(
|
||||||
|
stop_process=True,
|
||||||
|
skip_file=True,
|
||||||
|
console_warning=(
|
||||||
|
f"Skipping file {session.describe_source()}: ffmpeg still reported "
|
||||||
|
+ "unset packet timestamps after retry with -fflags +genpts."
|
||||||
|
),
|
||||||
|
unremedied_issue_identifier="timestamp-unset-after-genpts",
|
||||||
|
)
|
||||||
|
|
||||||
|
return FfmpegRemedyDecision(
|
||||||
|
stop_process=True,
|
||||||
|
retry_input_tokens=self.RETRY_INPUT_TOKENS,
|
||||||
|
console_warning=(
|
||||||
|
f"ffmpeg reported unset packet timestamps for {session.describe_source()}. "
|
||||||
|
+ "Stopping early and retrying with -fflags +genpts."
|
||||||
|
),
|
||||||
|
)
|
||||||
53
src/ffx/diagnostics/state.py
Normal file
53
src/ffx/diagnostics/state.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
DIAGNOSTICS_STATE_KEY = "diagnostics_state"
|
||||||
|
UNREMEDIED_ISSUES_KEY = "unremedied_issues"
|
||||||
|
|
||||||
|
|
||||||
|
def getDiagnosticsState(context: dict | None) -> dict:
|
||||||
|
if context is None:
|
||||||
|
return {UNREMEDIED_ISSUES_KEY: {}}
|
||||||
|
|
||||||
|
if DIAGNOSTICS_STATE_KEY not in context:
|
||||||
|
context[DIAGNOSTICS_STATE_KEY] = {
|
||||||
|
UNREMEDIED_ISSUES_KEY: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
return context[DIAGNOSTICS_STATE_KEY]
|
||||||
|
|
||||||
|
|
||||||
|
def recordUnremediedIssue(
|
||||||
|
context: dict | None,
|
||||||
|
sourcePath: str,
|
||||||
|
identifier: str,
|
||||||
|
) -> bool:
|
||||||
|
if not sourcePath:
|
||||||
|
return False
|
||||||
|
|
||||||
|
diagnosticsState = getDiagnosticsState(context)
|
||||||
|
unremediedIssues = diagnosticsState[UNREMEDIED_ISSUES_KEY]
|
||||||
|
issueList = unremediedIssues.setdefault(sourcePath, [])
|
||||||
|
strippedIdentifier = str(identifier).strip()
|
||||||
|
|
||||||
|
if not strippedIdentifier or strippedIdentifier in issueList:
|
||||||
|
return False
|
||||||
|
|
||||||
|
issueList.append(strippedIdentifier)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def getUnremediedIssues(context: dict | None) -> dict[str, list[str]]:
|
||||||
|
diagnosticsState = getDiagnosticsState(context)
|
||||||
|
return diagnosticsState.get(UNREMEDIED_ISSUES_KEY, {})
|
||||||
|
|
||||||
|
|
||||||
|
def iterUnremediedIssueSummaryLines(context: dict | None) -> list[str]:
|
||||||
|
summaryLines = []
|
||||||
|
unremediedIssues = getUnremediedIssues(context)
|
||||||
|
for sourcePath in sorted(unremediedIssues.keys()):
|
||||||
|
identifiers = unremediedIssues[sourcePath]
|
||||||
|
summaryLines.append(f"{os.path.basename(sourcePath)}: {', '.join(identifiers)}")
|
||||||
|
return summaryLines
|
||||||
35
src/ffx/diagnostics/warn_corrupt_mpeg_audio.py
Normal file
35
src/ffx/diagnostics/warn_corrupt_mpeg_audio.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .base import FfmpegRemedy, FfmpegRemedyDecision
|
||||||
|
|
||||||
|
|
||||||
|
class WarnCorruptMpegAudioRemedy(FfmpegRemedy):
|
||||||
|
identifier = "warn-corrupt-mpeg-audio"
|
||||||
|
PATTERNS = (
|
||||||
|
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 @ .*\] overread, skip ", re.IGNORECASE),
|
||||||
|
re.compile(r"Error while decoding MPEG audio frame\."),
|
||||||
|
re.compile(
|
||||||
|
r"Error submitting packet to decoder: Invalid data found when processing input"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def inspect_line(
|
||||||
|
self,
|
||||||
|
line: str,
|
||||||
|
session: "FfmpegDiagnosticMonitor",
|
||||||
|
) -> FfmpegRemedyDecision | None:
|
||||||
|
if not any(pattern.search(line) for pattern in self.PATTERNS):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return FfmpegRemedyDecision(
|
||||||
|
console_warning=(
|
||||||
|
f"ffmpeg reported damaged MPEG audio frames while converting "
|
||||||
|
+ f"{session.describe_source()}. FFX will continue, but the output "
|
||||||
|
+ "audio may contain gaps or glitches."
|
||||||
|
),
|
||||||
|
)
|
||||||
27
src/ffx/ffmpeg_diagnostics.py
Normal file
27
src/ffx/ffmpeg_diagnostics.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from .diagnostics import (
|
||||||
|
FfmpegCommandRunner,
|
||||||
|
FfmpegDiagnosticMonitor,
|
||||||
|
FfmpegRemedy,
|
||||||
|
FfmpegRemedyDecision,
|
||||||
|
FfmpegSkipFileWarning,
|
||||||
|
RetryWithGeneratedPtsRemedy,
|
||||||
|
WarnCorruptMpegAudioRemedy,
|
||||||
|
getDiagnosticsState,
|
||||||
|
getUnremediedIssues,
|
||||||
|
iterUnremediedIssueSummaryLines,
|
||||||
|
recordUnremediedIssue,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FfmpegCommandRunner",
|
||||||
|
"FfmpegDiagnosticMonitor",
|
||||||
|
"FfmpegRemedy",
|
||||||
|
"FfmpegRemedyDecision",
|
||||||
|
"FfmpegSkipFileWarning",
|
||||||
|
"RetryWithGeneratedPtsRemedy",
|
||||||
|
"WarnCorruptMpegAudioRemedy",
|
||||||
|
"getDiagnosticsState",
|
||||||
|
"getUnremediedIssues",
|
||||||
|
"iterUnremediedIssueSummaryLines",
|
||||||
|
"recordUnremediedIssue",
|
||||||
|
]
|
||||||
@@ -4,6 +4,7 @@ from .i18n import set_current_language, t
|
|||||||
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 .screen_support import configure_screen_log_handler, set_screen_log_pane_enabled
|
||||||
|
|
||||||
|
|
||||||
class FfxApp(App):
|
class FfxApp(App):
|
||||||
@@ -22,6 +23,13 @@ class FfxApp(App):
|
|||||||
# Data 'input' variable
|
# Data 'input' variable
|
||||||
self.context = context
|
self.context = context
|
||||||
set_current_language(self.context.get("language"))
|
set_current_language(self.context.get("language"))
|
||||||
|
debug_mode = bool(self.context.get("debug", False))
|
||||||
|
set_screen_log_pane_enabled(debug_mode)
|
||||||
|
configure_screen_log_handler(
|
||||||
|
self.context.get("logger"),
|
||||||
|
self,
|
||||||
|
enabled=debug_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import os, click
|
import os, click, subprocess
|
||||||
|
from functools import lru_cache
|
||||||
from logging import Logger
|
from logging import Logger
|
||||||
|
|
||||||
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
|
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
|
||||||
|
from ffx.diagnostics import FfmpegCommandRunner
|
||||||
|
|
||||||
from ffx.media_descriptor import MediaDescriptor
|
from ffx.media_descriptor import MediaDescriptor
|
||||||
from ffx.audio_layout import AudioLayout
|
from ffx.audio_layout import AudioLayout
|
||||||
@@ -61,10 +63,52 @@ class FfxController():
|
|||||||
sourceMediaDescriptor)
|
sourceMediaDescriptor)
|
||||||
|
|
||||||
self.__logger: Logger = context['logger']
|
self.__logger: Logger = context['logger']
|
||||||
|
self.__warnedH264Fallback = False
|
||||||
|
self.__ffmpegCommandRunner = FfmpegCommandRunner(context)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def isFfmpegEncoderAvailable(encoderName: str) -> bool:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["ffmpeg", "-encoders"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
resolvedEncoderName = str(encoderName).strip()
|
||||||
|
|
||||||
|
for line in completed.stdout.splitlines():
|
||||||
|
if not line.startswith(" "):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tokens = line.split(maxsplit=2)
|
||||||
|
if len(tokens) >= 2 and tokens[1] == resolvedEncoderName:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getSupportedSoftwareH264Encoder(cls) -> str | None:
|
||||||
|
if cls.isFfmpegEncoderAvailable("libx264"):
|
||||||
|
return "libx264"
|
||||||
|
if cls.isFfmpegEncoderAvailable("libopenh264"):
|
||||||
|
return "libopenh264"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def executeCommandSequence(self, commandSequence):
|
def executeCommandSequence(self, commandSequence):
|
||||||
out, err, rc = executeProcess(commandSequence, context=self.__context)
|
if commandSequence and str(commandSequence[0]).strip() == "ffmpeg":
|
||||||
|
out, err, rc = self.__ffmpegCommandRunner.execute(
|
||||||
|
commandSequence,
|
||||||
|
timeoutSeconds=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
out, err, rc = executeProcess(commandSequence, context=self.__context)
|
||||||
if rc:
|
if rc:
|
||||||
raise click.ClickException(f"Command resulted in error: rc={rc} error={err}")
|
raise click.ClickException(f"Command resulted in error: rc={rc} error={err}")
|
||||||
return out, err, rc
|
return out, err, rc
|
||||||
@@ -79,10 +123,27 @@ class FfxController():
|
|||||||
|
|
||||||
# -c:v libx264 -preset slow -crf 17
|
# -c:v libx264 -preset slow -crf 17
|
||||||
def generateH264Tokens(self, quality, subIndex : int = 0):
|
def generateH264Tokens(self, quality, subIndex : int = 0):
|
||||||
|
h264Encoder = self.getSupportedSoftwareH264Encoder()
|
||||||
|
|
||||||
return [f"-c:v:{int(subIndex)}", 'libx264',
|
if h264Encoder == "libx264":
|
||||||
"-preset", "slow",
|
return [f"-c:v:{int(subIndex)}", 'libx264',
|
||||||
'-crf', str(quality)]
|
"-preset", "slow",
|
||||||
|
'-crf', str(quality)]
|
||||||
|
|
||||||
|
if h264Encoder == "libopenh264":
|
||||||
|
if not self.__warnedH264Fallback:
|
||||||
|
self.__logger.warning(
|
||||||
|
"libx264 encoder unavailable; falling back to libopenh264 for H.264 encoding."
|
||||||
|
)
|
||||||
|
self.__warnedH264Fallback = True
|
||||||
|
|
||||||
|
return [f"-c:v:{int(subIndex)}", 'libopenh264',
|
||||||
|
'-pix_fmt', 'yuv420p']
|
||||||
|
|
||||||
|
raise click.ClickException(
|
||||||
|
"H.264 encoding requested but no supported software H.264 encoder is available. "
|
||||||
|
+ "Tried libx264 and libopenh264."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -c:v:0 libvpx-vp9 -row-mt 1 -crf 32 -pass 1 -speed 4 -frame-parallel 0 -g 9999 -aq-mode 0
|
# -c:v:0 libvpx-vp9 -row-mt 1 -crf 32 -pass 1 -speed 4 -frame-parallel 0 -g 9999 -aq-mode 0
|
||||||
@@ -119,6 +180,16 @@ class FfxController():
|
|||||||
def generateAudioCopyTokens(self, subIndex):
|
def generateAudioCopyTokens(self, subIndex):
|
||||||
return [f"-c:a:{int(subIndex)}", 'copy']
|
return [f"-c:a:{int(subIndex)}", 'copy']
|
||||||
|
|
||||||
|
def generateVideoCopyAllTokens(self):
|
||||||
|
if self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.VIDEO):
|
||||||
|
return ["-c:v", "copy"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def generateAudioCopyAllTokens(self):
|
||||||
|
if self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.AUDIO):
|
||||||
|
return ["-c:a", "copy"]
|
||||||
|
return []
|
||||||
|
|
||||||
def generateSubtitleCopyTokens(self, subIndex):
|
def generateSubtitleCopyTokens(self, subIndex):
|
||||||
return [f"-c:s:{int(subIndex)}", 'copy']
|
return [f"-c:s:{int(subIndex)}", 'copy']
|
||||||
|
|
||||||
@@ -239,6 +310,12 @@ class FfxController():
|
|||||||
return audioTokens
|
return audioTokens
|
||||||
|
|
||||||
|
|
||||||
|
def generateAudioProcessingTokens(self):
|
||||||
|
if self.__context.get('copy_audio', False):
|
||||||
|
return self.generateAudioCopyAllTokens()
|
||||||
|
return self.generateAudioEncodingTokens()
|
||||||
|
|
||||||
|
|
||||||
def runJob(self,
|
def runJob(self,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
targetPath,
|
targetPath,
|
||||||
@@ -252,6 +329,8 @@ class FfxController():
|
|||||||
|
|
||||||
|
|
||||||
videoEncoder: VideoEncoder = self.__context.get('video_encoder', VideoEncoder.VP9)
|
videoEncoder: VideoEncoder = self.__context.get('video_encoder', VideoEncoder.VP9)
|
||||||
|
self.__context['current_source_path'] = sourcePath
|
||||||
|
copyVideo = self.__context.get('copy_video', False) or videoEncoder == VideoEncoder.COPY
|
||||||
|
|
||||||
|
|
||||||
qualityFilters = [fy for fy in chainIteration if fy['identifier'] == 'quality']
|
qualityFilters = [fy for fy in chainIteration if fy['identifier'] == 'quality']
|
||||||
@@ -262,30 +341,35 @@ class FfxController():
|
|||||||
deinterlaceFilters = [fy for fy in chainIteration if fy['identifier'] == 'bwdif']
|
deinterlaceFilters = [fy for fy in chainIteration if fy['identifier'] == 'bwdif']
|
||||||
|
|
||||||
|
|
||||||
if qualityFilters and (quality := qualityFilters[0]['parameters']['quality']):
|
if copyVideo:
|
||||||
self.__logger.info(f"Setting quality {quality} from command line")
|
quality = None
|
||||||
elif currentPattern is not None and (quality := currentPattern.quality):
|
self.__context['encoding_metadata_tags'] = {}
|
||||||
self.__logger.info(f"Setting quality {quality} from pattern")
|
|
||||||
elif currentShowDescriptor is not None and (quality := currentShowDescriptor.getQuality()):
|
|
||||||
self.__logger.info(f"Setting quality {quality} from show")
|
|
||||||
else:
|
else:
|
||||||
quality = (QualityFilter.DEFAULT_H264_QUALITY
|
if qualityFilters and (quality := qualityFilters[0]['parameters']['quality']):
|
||||||
if (videoEncoder == VideoEncoder.H264)
|
self.__logger.info(f"Setting quality {quality} from command line")
|
||||||
else QualityFilter.DEFAULT_VP9_QUALITY)
|
elif currentPattern is not None and (quality := currentPattern.quality):
|
||||||
self.__logger.info(f"Setting quality {quality} from default")
|
self.__logger.info(f"Setting quality {quality} from pattern")
|
||||||
|
elif currentShowDescriptor is not None and (quality := currentShowDescriptor.getQuality()):
|
||||||
|
self.__logger.info(f"Setting quality {quality} from show")
|
||||||
|
else:
|
||||||
|
quality = (QualityFilter.DEFAULT_H264_QUALITY
|
||||||
|
if (videoEncoder == VideoEncoder.H264)
|
||||||
|
else QualityFilter.DEFAULT_VP9_QUALITY)
|
||||||
|
self.__logger.info(f"Setting quality {quality} from default")
|
||||||
|
|
||||||
|
|
||||||
preset = presetFilters[0]['parameters']['preset'] if presetFilters else PresetFilter.DEFAULT_PRESET
|
preset = presetFilters[0]['parameters']['preset'] if presetFilters else PresetFilter.DEFAULT_PRESET
|
||||||
self.__context['encoding_metadata_tags'] = self.generateEncodingMetadataTags(
|
if not copyVideo:
|
||||||
videoEncoder,
|
self.__context['encoding_metadata_tags'] = self.generateEncodingMetadataTags(
|
||||||
quality,
|
videoEncoder,
|
||||||
preset,
|
quality,
|
||||||
)
|
preset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
filterParamTokens = []
|
filterParamTokens = []
|
||||||
|
|
||||||
if cropArguments:
|
if cropArguments and not copyVideo:
|
||||||
|
|
||||||
cropParams = (f"crop="
|
cropParams = (f"crop="
|
||||||
+ f"{cropArguments[CropFilter.OUTPUT_WIDTH_KEY]}"
|
+ f"{cropArguments[CropFilter.OUTPUT_WIDTH_KEY]}"
|
||||||
@@ -295,8 +379,9 @@ class FfxController():
|
|||||||
|
|
||||||
filterParamTokens.append(cropParams)
|
filterParamTokens.append(cropParams)
|
||||||
|
|
||||||
filterParamTokens.extend(denoiseFilters[0]['tokens'] if denoiseFilters else [])
|
if not copyVideo:
|
||||||
filterParamTokens.extend(deinterlaceFilters[0]['tokens'] if deinterlaceFilters else [])
|
filterParamTokens.extend(denoiseFilters[0]['tokens'] if denoiseFilters else [])
|
||||||
|
filterParamTokens.extend(deinterlaceFilters[0]['tokens'] if deinterlaceFilters else [])
|
||||||
|
|
||||||
deinterlaceFilters
|
deinterlaceFilters
|
||||||
|
|
||||||
@@ -327,6 +412,29 @@ class FfxController():
|
|||||||
self.executeCommandSequence(commandSequence)
|
self.executeCommandSequence(commandSequence)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if copyVideo:
|
||||||
|
|
||||||
|
commandSequence = (commandTokens
|
||||||
|
+ self.__targetMediaDescriptor.getImportFileTokens()
|
||||||
|
+ self.__targetMediaDescriptor.getInputMappingTokens(sourceMediaDescriptor = self.__sourceMediaDescriptor)
|
||||||
|
+ self.__mdcs.generateDispositionTokens())
|
||||||
|
|
||||||
|
commandSequence += self.__mdcs.generateMetadataTokens()
|
||||||
|
commandSequence += self.generateVideoCopyAllTokens()
|
||||||
|
commandSequence += self.generateAudioProcessingTokens()
|
||||||
|
|
||||||
|
if self.__context['perform_cut']:
|
||||||
|
commandSequence += self.generateCropTokens()
|
||||||
|
|
||||||
|
commandSequence += self.generateOutputTokens(targetPath,
|
||||||
|
targetFormat)
|
||||||
|
|
||||||
|
self.__logger.debug("FfxController.runJob(): Running command sequence")
|
||||||
|
|
||||||
|
if not self.__context['dry_run']:
|
||||||
|
self.executeCommandSequence(commandSequence)
|
||||||
|
return
|
||||||
|
|
||||||
if videoEncoder == VideoEncoder.AV1:
|
if videoEncoder == VideoEncoder.AV1:
|
||||||
|
|
||||||
commandSequence = (commandTokens
|
commandSequence = (commandTokens
|
||||||
@@ -343,7 +451,7 @@ class FfxController():
|
|||||||
if td.getCodec != TrackCodec.PNG:
|
if td.getCodec != TrackCodec.PNG:
|
||||||
commandSequence += self.generateAV1Tokens(int(quality), int(preset))
|
commandSequence += self.generateAV1Tokens(int(quality), int(preset))
|
||||||
|
|
||||||
commandSequence += self.generateAudioEncodingTokens()
|
commandSequence += self.generateAudioProcessingTokens()
|
||||||
|
|
||||||
if self.__context['perform_cut']:
|
if self.__context['perform_cut']:
|
||||||
commandSequence += self.generateCropTokens()
|
commandSequence += self.generateCropTokens()
|
||||||
@@ -373,7 +481,7 @@ class FfxController():
|
|||||||
if td.getCodec != TrackCodec.PNG:
|
if td.getCodec != TrackCodec.PNG:
|
||||||
commandSequence += self.generateH264Tokens(int(quality))
|
commandSequence += self.generateH264Tokens(int(quality))
|
||||||
|
|
||||||
commandSequence += self.generateAudioEncodingTokens()
|
commandSequence += self.generateAudioProcessingTokens()
|
||||||
|
|
||||||
if self.__context['perform_cut']:
|
if self.__context['perform_cut']:
|
||||||
commandSequence += self.generateCropTokens()
|
commandSequence += self.generateCropTokens()
|
||||||
@@ -393,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
|
||||||
@@ -432,7 +535,7 @@ class FfxController():
|
|||||||
if td.getCodec != TrackCodec.PNG:
|
if td.getCodec != TrackCodec.PNG:
|
||||||
commandSequence2 += self.generateVP9Pass2Tokens(int(quality))
|
commandSequence2 += self.generateVP9Pass2Tokens(int(quality))
|
||||||
|
|
||||||
commandSequence2 += self.generateAudioEncodingTokens()
|
commandSequence2 += self.generateAudioProcessingTokens()
|
||||||
|
|
||||||
if self.__context['perform_cut']:
|
if self.__context['perform_cut']:
|
||||||
commandSequence2 += self.generateCropTokens()
|
commandSequence2 += self.generateCropTokens()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from textual.screen import Screen
|
|||||||
from textual.widgets import Footer, Placeholder
|
from textual.widgets import Footer, Placeholder
|
||||||
|
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
from .screen_support import go_back_or_exit
|
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||||
|
|
||||||
class HelpScreen(Screen):
|
class HelpScreen(Screen):
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
@@ -17,7 +17,15 @@ class HelpScreen(Screen):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
# Row 1
|
# Row 1
|
||||||
yield Placeholder(t("Help Screen"))
|
yield Placeholder(t("Help Screen"))
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
|
||||||
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
|
|
||||||
def action_back(self):
|
def action_back(self):
|
||||||
go_back_or_exit(self)
|
go_back_or_exit(self)
|
||||||
|
|||||||
@@ -6,12 +6,23 @@ from .configuration_controller import ConfigurationController
|
|||||||
from .logging_utils import get_ffx_logger
|
from .logging_utils import get_ffx_logger
|
||||||
from .show_descriptor import ShowDescriptor
|
from .show_descriptor import ShowDescriptor
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
class EmptyStringUndefined(Undefined):
|
class EmptyStringUndefined(Undefined):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
class LogLevel(Enum):
|
||||||
|
|
||||||
|
DEBUG = 'debug'
|
||||||
|
INFO = 'info'
|
||||||
|
WARNING = 'warning'
|
||||||
|
ERROR = 'error'
|
||||||
|
CRITICAL = 'critical'
|
||||||
|
|
||||||
|
|
||||||
DIFF_ADDED_KEY = 'added'
|
DIFF_ADDED_KEY = 'added'
|
||||||
DIFF_REMOVED_KEY = 'removed'
|
DIFF_REMOVED_KEY = 'removed'
|
||||||
DIFF_CHANGED_KEY = 'changed'
|
DIFF_CHANGED_KEY = 'changed'
|
||||||
@@ -119,7 +130,7 @@ def setDiff(a : set, b : set) -> set:
|
|||||||
def permutateList(inputList: list, permutation: list):
|
def permutateList(inputList: list, permutation: list):
|
||||||
|
|
||||||
# 0,1,2: ABC
|
# 0,1,2: ABC
|
||||||
# 0,2,1: ACB
|
# 0,2,1: ACBffmpeg:
|
||||||
# 1,2,0: BCA
|
# 1,2,0: BCA
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from textual.widgets import Button, Footer, Header, Input, Static
|
|||||||
from textual.widgets._data_table import CellDoesNotExist
|
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.media_descriptor_change_set import MediaDescriptorChangeSet
|
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
|
||||||
from ffx.show_descriptor import ShowDescriptor
|
from ffx.show_descriptor import ShowDescriptor
|
||||||
from ffx.track_descriptor import TrackDescriptor
|
from ffx.track_descriptor import TrackDescriptor
|
||||||
@@ -18,6 +19,7 @@ from .pattern_details_screen import PatternDetailsScreen
|
|||||||
from .screen_support import (
|
from .screen_support import (
|
||||||
add_auto_table_column,
|
add_auto_table_column,
|
||||||
build_screen_controllers,
|
build_screen_controllers,
|
||||||
|
build_screen_log_pane,
|
||||||
go_back_or_exit,
|
go_back_or_exit,
|
||||||
localized_column_width,
|
localized_column_width,
|
||||||
update_table_column_label,
|
update_table_column_label,
|
||||||
@@ -37,8 +39,8 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
|
|||||||
CSS = f"""
|
CSS = f"""
|
||||||
|
|
||||||
Grid {{
|
Grid {{
|
||||||
grid-size: 6 11;
|
grid-size: 6 8;
|
||||||
grid-rows: 9 2 2 2 2 8 2 2 2 8 8;
|
grid-rows: 9 2 2 2 2 10 2 10;
|
||||||
grid-columns: {GRID_COLUMN_LABEL_MIN} {GRID_COLUMN_2} {GRID_COLUMN_3} {GRID_COLUMN_4} {GRID_COLUMN_5} {GRID_COLUMN_6};
|
grid-columns: {GRID_COLUMN_LABEL_MIN} {GRID_COLUMN_2} {GRID_COLUMN_3} {GRID_COLUMN_4} {GRID_COLUMN_5} {GRID_COLUMN_6};
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -86,6 +88,10 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
|
|||||||
#differences-table {{
|
#differences-table {{
|
||||||
row-span: 10;
|
row-span: 10;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
.yellow {{
|
||||||
|
tint: yellow 40%;
|
||||||
|
}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -155,6 +161,7 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
|
|||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
yield self.differencesTable
|
yield self.differencesTable
|
||||||
|
|
||||||
|
|
||||||
# Row 2
|
# Row 2
|
||||||
yield Static(" ", classes="five")
|
yield Static(" ", classes="five")
|
||||||
|
|
||||||
@@ -163,33 +170,31 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
|
|||||||
yield Button(t("Substitute"), id="pattern_button")
|
yield Button(t("Substitute"), id="pattern_button")
|
||||||
yield Static(" ", classes="three")
|
yield Static(" ", classes="three")
|
||||||
|
|
||||||
|
|
||||||
# Row 4
|
# Row 4
|
||||||
yield Static(t("Pattern"))
|
yield Static(t("Pattern"))
|
||||||
yield Input(type="text", id="pattern_input", classes="three")
|
yield Input(type="text", id="pattern_input", classes="three")
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
|
|
||||||
# Row 5
|
# Row 5
|
||||||
yield Static(" ", classes="five")
|
yield Static(" ", classes="five")
|
||||||
|
|
||||||
# Row 6
|
# Row 6
|
||||||
yield Static(t("Media Tags"))
|
yield Static(t("Media Tags"))
|
||||||
yield self.mediaTagsTable
|
yield self.mediaTagsTable
|
||||||
yield Static(" ", classes="two")
|
yield Static(" ")
|
||||||
|
|
||||||
|
|
||||||
# Row 7
|
# Row 7
|
||||||
yield Static(" ", classes="five")
|
yield Static(" ", classes="five")
|
||||||
|
|
||||||
# Row 8
|
# Row 8
|
||||||
yield Static(" ")
|
|
||||||
yield Button(t("Set Default"), id="select_default_button")
|
|
||||||
yield Button(t("Set Forced"), id="select_forced_button")
|
|
||||||
yield Static(" ", classes="two")
|
|
||||||
|
|
||||||
# Row 9
|
|
||||||
yield Static(t("Streams"))
|
yield Static(t("Streams"))
|
||||||
yield self.tracksTable
|
yield self.tracksTable
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def _update_grid_layout(self) -> None:
|
def _update_grid_layout(self) -> None:
|
||||||
@@ -205,6 +210,30 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
|
|||||||
def action_back(self):
|
def action_back(self):
|
||||||
go_back_or_exit(self)
|
go_back_or_exit(self)
|
||||||
|
|
||||||
|
def getDisplayedMediaDescriptor(self):
|
||||||
|
if self._currentPattern is not None and self._targetMediaDescriptor is not None:
|
||||||
|
return self._targetMediaDescriptor
|
||||||
|
return self._sourceMediaDescriptor
|
||||||
|
|
||||||
|
def getTrackEditSourceDescriptor(self):
|
||||||
|
selectedTrackDescriptor = self.getSelectedTrackDescriptor()
|
||||||
|
if (
|
||||||
|
selectedTrackDescriptor is None
|
||||||
|
or self._currentPattern is None
|
||||||
|
or self._targetMediaDescriptor is None
|
||||||
|
):
|
||||||
|
return selectedTrackDescriptor
|
||||||
|
|
||||||
|
for sourceTrackDescriptor in self._sourceMediaDescriptor.getTrackDescriptors():
|
||||||
|
if (
|
||||||
|
sourceTrackDescriptor.getSourceIndex()
|
||||||
|
== selectedTrackDescriptor.getSourceIndex()
|
||||||
|
and sourceTrackDescriptor.getType() == selectedTrackDescriptor.getType()
|
||||||
|
):
|
||||||
|
return sourceTrackDescriptor
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def _build_shows_table(self):
|
def _build_shows_table(self):
|
||||||
from textual.widgets import DataTable
|
from textual.widgets import DataTable
|
||||||
|
|
||||||
@@ -287,6 +316,10 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
|
|||||||
self._update_show_header_labels()
|
self._update_show_header_labels()
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
self._update_grid_layout()
|
self._update_grid_layout()
|
||||||
|
|
||||||
if self._currentPattern is None:
|
if self._currentPattern is None:
|
||||||
@@ -476,8 +509,6 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
|
|||||||
self.updateDifferences()
|
self.updateDifferences()
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
self.reloadProperties(reset_draft=True)
|
|
||||||
|
|
||||||
tagDifferences = self._mediaChangeSetObj.get(MediaDescriptorChangeSet.TAGS_KEY, {})
|
tagDifferences = self._mediaChangeSetObj.get(MediaDescriptorChangeSet.TAGS_KEY, {})
|
||||||
for addedTagKey in tagDifferences.get(DIFF_ADDED_KEY, {}).keys():
|
for addedTagKey in tagDifferences.get(DIFF_ADDED_KEY, {}).keys():
|
||||||
self._tac.deleteMediaTagByKey(self._currentPattern.getId(), addedTagKey)
|
self._tac.deleteMediaTagByKey(self._currentPattern.getId(), addedTagKey)
|
||||||
@@ -564,9 +595,6 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def handle_edit_pattern(self, screenResult):
|
def handle_edit_pattern(self, screenResult):
|
||||||
if not screenResult:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.reloadProperties(reset_draft=True)
|
self.reloadProperties(reset_draft=True)
|
||||||
if self._currentPattern is not None:
|
if self._currentPattern is not None:
|
||||||
self.query_one("#pattern_input", Input).value = self._currentPattern.getPattern()
|
self.query_one("#pattern_input", Input).value = self._currentPattern.getPattern()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
FFX_LOGGER_NAME = "FFX"
|
FFX_LOGGER_NAME = "FFX"
|
||||||
CONSOLE_HANDLER_NAME = "ffx-console"
|
CONSOLE_HANDLER_NAME = "ffx-console"
|
||||||
FILE_HANDLER_NAME = "ffx-file"
|
FILE_HANDLER_NAME = "ffx-file"
|
||||||
|
MUTED_CONSOLE_LEVEL = logging.CRITICAL + 1
|
||||||
|
|
||||||
|
|
||||||
def get_ffx_logger(name: str = FFX_LOGGER_NAME) -> logging.Logger:
|
def get_ffx_logger(name: str = FFX_LOGGER_NAME) -> logging.Logger:
|
||||||
@@ -66,3 +67,31 @@ def configure_ffx_logger(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def set_ffx_console_logging_enabled(
|
||||||
|
logger: logging.Logger | None,
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
):
|
||||||
|
if logger is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
console_handler = next(
|
||||||
|
(handler for handler in logger.handlers if handler.get_name() == CONSOLE_HANDLER_NAME),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if console_handler is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
saved_level = getattr(console_handler, "_ffx_saved_level", None)
|
||||||
|
if saved_level is not None:
|
||||||
|
console_handler.setLevel(saved_level)
|
||||||
|
delattr(console_handler, "_ffx_saved_level")
|
||||||
|
return console_handler
|
||||||
|
|
||||||
|
if not hasattr(console_handler, "_ffx_saved_level"):
|
||||||
|
console_handler._ffx_saved_level = console_handler.level
|
||||||
|
console_handler.setLevel(MUTED_CONSOLE_LEVEL)
|
||||||
|
return console_handler
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import os, re, click
|
|||||||
|
|
||||||
from typing import List, Self
|
from typing import List, Self
|
||||||
|
|
||||||
|
from ffx.attachment_format import AttachmentFormat
|
||||||
from ffx.track_type import TrackType
|
from ffx.track_type import TrackType
|
||||||
from ffx.iso_language import IsoLanguage
|
from ffx.iso_language import IsoLanguage
|
||||||
|
|
||||||
@@ -328,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"""
|
||||||
@@ -340,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",
|
||||||
@@ -379,120 +473,213 @@ 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()
|
||||||
if ftd.getCodec() == TrackCodec.TTF]
|
if ftd.getAttachmentFormat() == AttachmentFormat.TTF]
|
||||||
else:
|
else:
|
||||||
fontDescriptors = [ftd for ftd in self.__trackDescriptors
|
fontDescriptors = [ftd for ftd in self.__trackDescriptors
|
||||||
if ftd.getType() == TrackType.ATTACHMENT
|
if ftd.getType() == TrackType.ATTACHMENT
|
||||||
and ftd.getCodec() == TrackCodec.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}")
|
||||||
@@ -507,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}")
|
||||||
|
|
||||||
@@ -525,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)
|
||||||
@@ -539,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:
|
||||||
@@ -552,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
|
||||||
@@ -203,7 +219,7 @@ class MediaDescriptorChangeSet():
|
|||||||
if (
|
if (
|
||||||
self.__applyMetadataNormalization
|
self.__applyMetadataNormalization
|
||||||
and trackDescriptor is not None
|
and trackDescriptor is not None
|
||||||
and trackDescriptor.getType() == TrackType.SUBTITLE
|
and trackDescriptor.getType() in (TrackType.VIDEO, TrackType.AUDIO, TrackType.SUBTITLE)
|
||||||
):
|
):
|
||||||
trackTitle = str(normalizedTrackTags.get("title", "")).strip()
|
trackTitle = str(normalizedTrackTags.get("title", "")).strip()
|
||||||
fallbackTitle = str((fallbackTrackTags or {}).get("title", "")).strip()
|
fallbackTitle = str((fallbackTrackTags or {}).get("title", "")).strip()
|
||||||
@@ -260,6 +276,8 @@ class MediaDescriptorChangeSet():
|
|||||||
# else:
|
# else:
|
||||||
# dispositionTokens += [f"-disposition:{streamIndicator}:{subIndex}", '0']
|
# dispositionTokens += [f"-disposition:{streamIndicator}:{subIndex}", '0']
|
||||||
for ttd in self.__targetTrackDescriptors:
|
for ttd in self.__targetTrackDescriptors:
|
||||||
|
if ttd.getType() == TrackType.ATTACHMENT:
|
||||||
|
continue
|
||||||
|
|
||||||
targetDispositions = ttd.getDispositionSet()
|
targetDispositions = ttd.getDispositionSet()
|
||||||
streamIndicator = ttd.getType().indicator()
|
streamIndicator = ttd.getType().indicator()
|
||||||
@@ -344,7 +362,7 @@ class MediaDescriptorChangeSet():
|
|||||||
for tagKey, tagValue in self.normalizeTrackTags(
|
for tagKey, tagValue in self.normalizeTrackTags(
|
||||||
outputTrackTags,
|
outputTrackTags,
|
||||||
trackDescriptor=trackDescriptor,
|
trackDescriptor=trackDescriptor,
|
||||||
fallbackTrackTags=unchangedTrackTags | removedTrackTags,
|
fallbackTrackTags=trackDescriptor.getTags(),
|
||||||
).items():
|
).items():
|
||||||
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
|
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
|
||||||
+ f":{trackDescriptor.getSubIndex()}",
|
+ f":{trackDescriptor.getSubIndex()}",
|
||||||
@@ -366,6 +384,7 @@ class MediaDescriptorChangeSet():
|
|||||||
for tagKey, tagValue in self.normalizeTrackTags(
|
for tagKey, tagValue in self.normalizeTrackTags(
|
||||||
preservedTrackTags,
|
preservedTrackTags,
|
||||||
trackDescriptor=trackDescriptor,
|
trackDescriptor=trackDescriptor,
|
||||||
|
fallbackTrackTags=trackDescriptor.getTags(),
|
||||||
).items():
|
).items():
|
||||||
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
|
metadataTokens += [f"-metadata:s:{trackDescriptor.getType().indicator()}"
|
||||||
+ f":{trackDescriptor.getSubIndex()}",
|
+ f":{trackDescriptor.getSubIndex()}",
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
from textual import events, work
|
||||||
from textual.containers import Grid
|
from textual.containers import Grid
|
||||||
|
from textual.worker import Worker, WorkerState
|
||||||
from textual.widgets import Button, Footer, Header, Static
|
from textual.widgets import Button, Footer, Header, Static
|
||||||
|
|
||||||
from ffx.metadata_editor import apply_metadata_edits
|
from ffx.metadata_editor import apply_metadata_edits
|
||||||
@@ -9,11 +12,13 @@ from ffx.track_descriptor import TrackDescriptor
|
|||||||
from .i18n import t
|
from .i18n import t
|
||||||
from .confirm_screen import ConfirmScreen
|
from .confirm_screen import ConfirmScreen
|
||||||
from .media_workflow_screen_base import MediaWorkflowScreenBase
|
from .media_workflow_screen_base import MediaWorkflowScreenBase
|
||||||
from .screen_support import localized_column_width
|
from .screen_support import build_screen_log_pane, localized_column_width
|
||||||
from .tag_delete_screen import TagDeleteScreen
|
from .tag_delete_screen import TagDeleteScreen
|
||||||
from .tag_details_screen import TagDetailsScreen
|
from .tag_details_screen import TagDetailsScreen
|
||||||
from .track_details_screen import TrackDetailsScreen
|
from .track_details_screen import TrackDetailsScreen
|
||||||
|
|
||||||
|
from .helper import LogLevel
|
||||||
|
|
||||||
|
|
||||||
class MediaEditScreen(MediaWorkflowScreenBase):
|
class MediaEditScreen(MediaWorkflowScreenBase):
|
||||||
|
|
||||||
@@ -169,14 +174,27 @@ class MediaEditScreen(MediaWorkflowScreenBase):
|
|||||||
yield Button(t("Quit"), id="quit_button")
|
yield Button(t("Quit"), id="quit_button")
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
self._update_grid_layout()
|
self._update_grid_layout()
|
||||||
self.updateMediaTags()
|
self.updateMediaTags()
|
||||||
self.updateTracks()
|
self.updateTracks()
|
||||||
self.updateDifferences()
|
self.updateDifferences()
|
||||||
self.updateToggleButtons()
|
self.updateToggleButtons()
|
||||||
|
self._applyChangesWorker = None
|
||||||
|
|
||||||
|
def on_screen_resume(self, _event: events.ScreenResume) -> None:
|
||||||
|
if not hasattr(self, "tracksTable"):
|
||||||
|
return
|
||||||
|
|
||||||
|
self.refreshAfterDraftChange()
|
||||||
|
self.updateToggleButtons()
|
||||||
|
|
||||||
def _update_grid_layout(self) -> None:
|
def _update_grid_layout(self) -> None:
|
||||||
leftColumnWidth = max(
|
leftColumnWidth = max(
|
||||||
@@ -195,6 +213,41 @@ class MediaEditScreen(MediaWorkflowScreenBase):
|
|||||||
if self._messageText:
|
if self._messageText:
|
||||||
self.notify(self._messageText)
|
self.notify(self._messageText)
|
||||||
|
|
||||||
|
|
||||||
|
def workerLoggingHandler(self,
|
||||||
|
message: str,
|
||||||
|
level: LogLevel = LogLevel.INFO) -> None:
|
||||||
|
|
||||||
|
if level == LogLevel.DEBUG:
|
||||||
|
self.context["logger"].debug(str(message))
|
||||||
|
elif level == LogLevel.INFO:
|
||||||
|
self.context["logger"].info(str(message))
|
||||||
|
elif level == LogLevel.WARNING:
|
||||||
|
self.context["logger"].warning(str(message))
|
||||||
|
elif level == LogLevel.ERROR:
|
||||||
|
self.context["logger"].error(str(message))
|
||||||
|
elif level == LogLevel.CRITICAL:
|
||||||
|
self.context["logger"].critical(str(message))
|
||||||
|
else:
|
||||||
|
raise Exception(f"Undefined Logging Level (msg={message})")
|
||||||
|
|
||||||
|
|
||||||
|
def _report_apply_timings(self, applyResult: dict, reloadSeconds: float = 0.0) -> None:
|
||||||
|
timings = dict(applyResult.get("timings", {}))
|
||||||
|
ffmpegSeconds = float(timings.get("ffmpeg_seconds", 0.0))
|
||||||
|
replaceSeconds = float(timings.get("replace_seconds", 0.0))
|
||||||
|
writeSeconds = float(timings.get("write_seconds", ffmpegSeconds + replaceSeconds))
|
||||||
|
reloadSeconds = float(reloadSeconds)
|
||||||
|
totalSeconds = writeSeconds + reloadSeconds
|
||||||
|
|
||||||
|
timingSummary = (
|
||||||
|
f"ffx edit timings: ffmpeg={ffmpegSeconds:.2f}s "
|
||||||
|
+ f"replace={replaceSeconds:.2f}s "
|
||||||
|
+ f"reload={reloadSeconds:.2f}s "
|
||||||
|
+ f"total={totalSeconds:.2f}s"
|
||||||
|
)
|
||||||
|
self.context["logger"].info(timingSummary)
|
||||||
|
|
||||||
def updateToggleButtons(self):
|
def updateToggleButtons(self):
|
||||||
self._set_toggle_button_state(
|
self._set_toggle_button_state(
|
||||||
"#cleanup_toggle_button",
|
"#cleanup_toggle_button",
|
||||||
@@ -296,6 +349,7 @@ class MediaEditScreen(MediaWorkflowScreenBase):
|
|||||||
def action_toggle_normalization(self):
|
def action_toggle_normalization(self):
|
||||||
self.setApplyNormalization(not self._applyNormalization)
|
self.setApplyNormalization(not self._applyNormalization)
|
||||||
self.updateToggleButtons()
|
self.updateToggleButtons()
|
||||||
|
self.updateTracks()
|
||||||
self.updateDifferences()
|
self.updateDifferences()
|
||||||
self.setMessage(
|
self.setMessage(
|
||||||
t("Normalization enabled.")
|
t("Normalization enabled.")
|
||||||
@@ -323,30 +377,35 @@ class MediaEditScreen(MediaWorkflowScreenBase):
|
|||||||
if trackDescriptor is None:
|
if trackDescriptor is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
updatedTracks = []
|
nextSourceMediaDescriptor = self._sourceMediaDescriptor.clone(context=self.context)
|
||||||
|
updatedTracks = nextSourceMediaDescriptor.getTrackDescriptors()
|
||||||
|
replacementTrack = trackDescriptor.clone(context=self.context)
|
||||||
replaced = False
|
replaced = False
|
||||||
for currentTrack in self._sourceMediaDescriptor.getTrackDescriptors():
|
|
||||||
if (
|
for trackIndex, currentTrack in enumerate(updatedTracks):
|
||||||
currentTrack.getIndex() == trackDescriptor.getIndex()
|
sameSourceTrack = (
|
||||||
and currentTrack.getSubIndex() == trackDescriptor.getSubIndex()
|
currentTrack.getSourceIndex() == replacementTrack.getSourceIndex()
|
||||||
):
|
and currentTrack.getType() == replacementTrack.getType()
|
||||||
updatedTracks.append(trackDescriptor)
|
)
|
||||||
|
sameVisibleTrack = (
|
||||||
|
currentTrack.getIndex() == replacementTrack.getIndex()
|
||||||
|
and currentTrack.getSubIndex() == replacementTrack.getSubIndex()
|
||||||
|
)
|
||||||
|
if sameSourceTrack or sameVisibleTrack:
|
||||||
|
updatedTracks[trackIndex] = replacementTrack
|
||||||
replaced = True
|
replaced = True
|
||||||
else:
|
break
|
||||||
updatedTracks.append(currentTrack)
|
|
||||||
|
|
||||||
if not replaced:
|
if not replaced:
|
||||||
self.setMessage(t("Unable to update selected stream."))
|
self.setMessage(t("Unable to update selected stream."))
|
||||||
return
|
return
|
||||||
|
|
||||||
self._sourceMediaDescriptor = self._sourceMediaDescriptor.clone(context=self.context)
|
self._sourceMediaDescriptor = nextSourceMediaDescriptor
|
||||||
self._sourceMediaDescriptor.getTrackDescriptors().clear()
|
|
||||||
self._sourceMediaDescriptor.getTrackDescriptors().extend(updatedTracks)
|
|
||||||
self.setMessage(
|
self.setMessage(
|
||||||
t(
|
t(
|
||||||
"Updated stream #{index} ({track_type}).",
|
"Updated stream #{index} ({track_type}).",
|
||||||
index=trackDescriptor.getIndex(),
|
index=replacementTrack.getIndex(),
|
||||||
track_type=t(trackDescriptor.getType().label()),
|
track_type=t(replacementTrack.getType().label()),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.refreshAfterDraftChange()
|
self.refreshAfterDraftChange()
|
||||||
@@ -356,33 +415,77 @@ class MediaEditScreen(MediaWorkflowScreenBase):
|
|||||||
self.setMessage(t("No changes to apply."))
|
self.setMessage(t("No changes to apply."))
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
if self._applyChangesWorker is not None and self._applyChangesWorker.is_running:
|
||||||
applyResult = apply_metadata_edits(
|
self.setMessage(t("Apply already running."))
|
||||||
self.context,
|
|
||||||
self._mediaFilename,
|
|
||||||
self._baselineMediaDescriptor,
|
|
||||||
self._sourceMediaDescriptor,
|
|
||||||
)
|
|
||||||
except Exception as ex:
|
|
||||||
self.context["logger"].exception(
|
|
||||||
"Failed to apply metadata edits for %s",
|
|
||||||
self._mediaFilename,
|
|
||||||
)
|
|
||||||
self.setMessage(t("Apply failed: {error}", error=ex))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self.context["logger"].info(
|
||||||
|
t("Starting metadata apply for {filename}.", filename=self._mediaFilename)
|
||||||
|
)
|
||||||
|
self._applyChangesWorker = self.run_apply_changes_worker()
|
||||||
|
|
||||||
|
@work(
|
||||||
|
thread=True,
|
||||||
|
exclusive=True,
|
||||||
|
group="media-edit-apply",
|
||||||
|
exit_on_error=False,
|
||||||
|
)
|
||||||
|
def run_apply_changes_worker(self):
|
||||||
|
return apply_metadata_edits(
|
||||||
|
self.context,
|
||||||
|
self._mediaFilename,
|
||||||
|
self._baselineMediaDescriptor,
|
||||||
|
self._sourceMediaDescriptor,
|
||||||
|
loggingHandler = self.workerLoggingHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
||||||
|
if event.worker is not self._applyChangesWorker:
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.state == WorkerState.ERROR:
|
||||||
|
error = event.worker.error
|
||||||
|
if error is not None:
|
||||||
|
self.context["logger"].error(
|
||||||
|
"Failed to apply metadata edits for %s",
|
||||||
|
self._mediaFilename,
|
||||||
|
exc_info=(type(error), error, error.__traceback__),
|
||||||
|
)
|
||||||
|
self.setMessage(t("Apply failed: {error}", error=error))
|
||||||
|
self._applyChangesWorker = None
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.state != WorkerState.SUCCESS:
|
||||||
|
return
|
||||||
|
|
||||||
|
applyResult = event.worker.result or {}
|
||||||
|
|
||||||
if applyResult.get("dry_run", False):
|
if applyResult.get("dry_run", False):
|
||||||
|
self._report_apply_timings(applyResult, reloadSeconds=0.0)
|
||||||
|
self.context["logger"].info(
|
||||||
|
t(
|
||||||
|
"Dry-run prepared temporary output {target_path}.",
|
||||||
|
target_path=applyResult["target_path"],
|
||||||
|
),
|
||||||
|
)
|
||||||
self.setMessage(
|
self.setMessage(
|
||||||
t(
|
t(
|
||||||
"Dry-run: would rewrite via temporary file {target_path}",
|
"Dry-run: would rewrite via temporary file {target_path}",
|
||||||
target_path=applyResult["target_path"],
|
target_path=applyResult["target_path"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self._applyChangesWorker = None
|
||||||
return
|
return
|
||||||
|
|
||||||
|
reloadStart = monotonic()
|
||||||
|
self.context["logger"].info(t("Reloading file after metadata write."))
|
||||||
self.reloadProperties(reset_draft=True)
|
self.reloadProperties(reset_draft=True)
|
||||||
self.refreshAfterDraftChange()
|
self.refreshAfterDraftChange()
|
||||||
|
reloadSeconds = monotonic() - reloadStart
|
||||||
|
self._report_apply_timings(applyResult, reloadSeconds=reloadSeconds)
|
||||||
|
self.context["logger"].info(t("Changes applied and file reloaded."))
|
||||||
self.setMessage(t("Changes applied and file reloaded."))
|
self.setMessage(t("Changes applied and file reloaded."))
|
||||||
|
self._applyChangesWorker = None
|
||||||
|
|
||||||
def action_revert_changes(self):
|
def action_revert_changes(self):
|
||||||
if not self.hasPendingChanges():
|
if not self.hasPendingChanges():
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ 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
|
||||||
|
from ffx.iso_language import IsoLanguage
|
||||||
|
from ffx.media_descriptor import MediaDescriptor
|
||||||
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
|
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
|
||||||
from ffx.track_descriptor import TrackDescriptor
|
from ffx.track_descriptor import TrackDescriptor
|
||||||
from ffx.track_disposition import TrackDisposition
|
from ffx.track_disposition import TrackDisposition
|
||||||
@@ -123,6 +126,32 @@ class MediaWorkflowScreenBase(Screen):
|
|||||||
add_auto_table_column(self.differencesTable, t(self.DIFFERENCES_COLUMN_LABEL))
|
add_auto_table_column(self.differencesTable, t(self.DIFFERENCES_COLUMN_LABEL))
|
||||||
self.differencesTable.cursor_type = "row"
|
self.differencesTable.cursor_type = "row"
|
||||||
|
|
||||||
|
def _track_codec_cell_value(self, trackDescriptor: TrackDescriptor) -> str:
|
||||||
|
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||||
|
attachmentFormat = trackDescriptor.getAttachmentFormat()
|
||||||
|
if attachmentFormat == AttachmentFormat.UNKNOWN:
|
||||||
|
return attachmentFormat.identifier()
|
||||||
|
return attachmentFormat.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(
|
||||||
|
self,
|
||||||
|
trackDescriptor: TrackDescriptor,
|
||||||
|
disposition: TrackDisposition,
|
||||||
|
) -> str:
|
||||||
|
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||||
|
return " "
|
||||||
|
return (
|
||||||
|
t("Yes")
|
||||||
|
if disposition in trackDescriptor.getDispositionSet()
|
||||||
|
else t("No")
|
||||||
|
)
|
||||||
|
|
||||||
def reloadProperties(self, reset_draft: bool = True):
|
def reloadProperties(self, reset_draft: bool = True):
|
||||||
self._mediaFileProperties = FileProperties(self.context, self._mediaFilename)
|
self._mediaFileProperties = FileProperties(self.context, self._mediaFilename)
|
||||||
probedMediaDescriptor = self._mediaFileProperties.getMediaDescriptor()
|
probedMediaDescriptor = self._mediaFileProperties.getMediaDescriptor()
|
||||||
@@ -137,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()
|
||||||
@@ -170,10 +198,36 @@ class MediaWorkflowScreenBase(Screen):
|
|||||||
def hasPendingChanges(self) -> bool:
|
def hasPendingChanges(self) -> bool:
|
||||||
return bool(self._mediaChangeSetObj)
|
return bool(self._mediaChangeSetObj)
|
||||||
|
|
||||||
|
def getDisplayedMediaDescriptor(self) -> MediaDescriptor | None:
|
||||||
|
return self._sourceMediaDescriptor
|
||||||
|
|
||||||
|
def getTrackEditSourceDescriptor(self) -> TrackDescriptor | None:
|
||||||
|
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()
|
||||||
self._sourceMediaTagRowData = populate_tag_table(
|
self._sourceMediaTagRowData = populate_tag_table(
|
||||||
self.mediaTagsTable,
|
self.mediaTagsTable,
|
||||||
self._sourceMediaDescriptor.getTags(),
|
displayedMediaDescriptor.getTags() if displayedMediaDescriptor is not None else {},
|
||||||
ignore_keys=self._ignoreGlobalKeys,
|
ignore_keys=self._ignoreGlobalKeys,
|
||||||
remove_keys=self._removeGlobalKeys,
|
remove_keys=self._removeGlobalKeys,
|
||||||
)
|
)
|
||||||
@@ -183,8 +237,14 @@ class MediaWorkflowScreenBase(Screen):
|
|||||||
self._configure_tracks_table_columns()
|
self._configure_tracks_table_columns()
|
||||||
self._trackRowData = {}
|
self._trackRowData = {}
|
||||||
|
|
||||||
trackDescriptorList = self._sourceMediaDescriptor.getTrackDescriptors()
|
displayedMediaDescriptor = self.getDisplayedMediaDescriptor()
|
||||||
|
trackDescriptorList = (
|
||||||
|
displayedMediaDescriptor.getTrackDescriptors()
|
||||||
|
if displayedMediaDescriptor is not None
|
||||||
|
else []
|
||||||
|
)
|
||||||
typeCounter = {}
|
typeCounter = {}
|
||||||
|
applyNormalization = bool(getattr(self, "_applyNormalization", False))
|
||||||
|
|
||||||
for trackDescriptor in trackDescriptorList:
|
for trackDescriptor in trackDescriptorList:
|
||||||
trackType = trackDescriptor.getType()
|
trackType = trackDescriptor.getType()
|
||||||
@@ -193,19 +253,34 @@ class MediaWorkflowScreenBase(Screen):
|
|||||||
|
|
||||||
dispositionSet = trackDescriptor.getDispositionSet()
|
dispositionSet = trackDescriptor.getDispositionSet()
|
||||||
audioLayout = trackDescriptor.getAudioLayout()
|
audioLayout = trackDescriptor.getAudioLayout()
|
||||||
|
trackTitle = trackDescriptor.getTitle()
|
||||||
|
if (
|
||||||
|
applyNormalization
|
||||||
|
and not str(trackTitle).strip()
|
||||||
|
and trackType in (TrackType.VIDEO, TrackType.AUDIO, TrackType.SUBTITLE)
|
||||||
|
):
|
||||||
|
trackLanguage = trackDescriptor.getLanguage()
|
||||||
|
if trackLanguage != IsoLanguage.UNDEFINED:
|
||||||
|
trackTitle = trackLanguage.label()
|
||||||
row = (
|
row = (
|
||||||
trackDescriptor.getIndex(),
|
trackDescriptor.getIndex(),
|
||||||
t(trackType.label()),
|
t(trackType.label()),
|
||||||
typeCounter[trackType],
|
typeCounter[trackType],
|
||||||
trackDescriptor.getCodec().label(),
|
self._track_codec_cell_value(trackDescriptor),
|
||||||
t(audioLayout.label())
|
t(audioLayout.label())
|
||||||
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),
|
||||||
trackDescriptor.getTitle(),
|
trackTitle,
|
||||||
t("Yes") if TrackDisposition.DEFAULT in dispositionSet else t("No"),
|
self._track_disposition_cell_value(
|
||||||
t("Yes") if TrackDisposition.FORCED in dispositionSet else t("No"),
|
trackDescriptor,
|
||||||
|
TrackDisposition.DEFAULT,
|
||||||
|
),
|
||||||
|
self._track_disposition_cell_value(
|
||||||
|
trackDescriptor,
|
||||||
|
TrackDisposition.FORCED,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
row_key = self.tracksTable.add_row(*map(str, row))
|
row_key = self.tracksTable.add_row(*map(str, row))
|
||||||
@@ -355,7 +430,7 @@ class MediaWorkflowScreenBase(Screen):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def setSelectedTrackDefault(self):
|
def setSelectedTrackDefault(self):
|
||||||
selectedTrackDescriptor = self.getSelectedTrackDescriptor()
|
selectedTrackDescriptor = self.getTrackEditSourceDescriptor()
|
||||||
if selectedTrackDescriptor is None:
|
if selectedTrackDescriptor is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -366,7 +441,7 @@ class MediaWorkflowScreenBase(Screen):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def setSelectedTrackForced(self):
|
def setSelectedTrackForced(self):
|
||||||
selectedTrackDescriptor = self.getSelectedTrackDescriptor()
|
selectedTrackDescriptor = self.getTrackEditSourceDescriptor()
|
||||||
if selectedTrackDescriptor is None:
|
if selectedTrackDescriptor is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import click
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
DEFAULT_AC3_BANDWIDTH,
|
DEFAULT_AC3_BANDWIDTH,
|
||||||
DEFAULT_DTS_BANDWIDTH,
|
DEFAULT_DTS_BANDWIDTH,
|
||||||
DEFAULT_STEREO_BANDWIDTH,
|
DEFAULT_STEREO_BANDWIDTH,
|
||||||
|
FFMPEG_COMMAND_TOKENS,
|
||||||
)
|
)
|
||||||
from .ffx_controller import FfxController
|
|
||||||
from .media_descriptor import MediaDescriptor
|
from .media_descriptor import MediaDescriptor
|
||||||
|
from .media_descriptor_change_set import MediaDescriptorChangeSet
|
||||||
|
from .process import executeProcess, formatCommandSequence
|
||||||
from .video_encoder import VideoEncoder
|
from .video_encoder import VideoEncoder
|
||||||
|
|
||||||
|
from .helper import LogLevel
|
||||||
|
|
||||||
|
|
||||||
def create_temporary_output_path(source_path: str) -> str:
|
def create_temporary_output_path(source_path: str) -> str:
|
||||||
sourceDirectory = os.path.dirname(os.path.abspath(source_path)) or "."
|
sourceDirectory = os.path.dirname(os.path.abspath(source_path)) or "."
|
||||||
@@ -49,41 +55,123 @@ def build_metadata_edit_context(context: dict) -> dict:
|
|||||||
return editContext
|
return editContext
|
||||||
|
|
||||||
|
|
||||||
|
def build_metadata_edit_command(
|
||||||
|
context: dict,
|
||||||
|
source_path: str,
|
||||||
|
target_path: str,
|
||||||
|
baseline_descriptor: MediaDescriptor,
|
||||||
|
draft_descriptor: MediaDescriptor,
|
||||||
|
) -> list[str]:
|
||||||
|
changeSet = MediaDescriptorChangeSet(context, draft_descriptor, baseline_descriptor)
|
||||||
|
|
||||||
|
return (
|
||||||
|
list(FFMPEG_COMMAND_TOKENS)
|
||||||
|
+ ["-i", source_path, "-map", "0", "-c", "copy"]
|
||||||
|
+ changeSet.generateMetadataTokens()
|
||||||
|
+ changeSet.generateDispositionTokens()
|
||||||
|
+ [target_path]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_ffmpeg_invocation(
|
||||||
|
context: dict,
|
||||||
|
command_sequence: list[str],
|
||||||
|
*,
|
||||||
|
loggingHandler = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> None:
|
||||||
|
loggingCallback = loggingHandler or context.get("logging_handler")
|
||||||
|
if not callable(loggingCallback):
|
||||||
|
return
|
||||||
|
|
||||||
|
verbosity = int(context.get("verbosity", 0) or 0)
|
||||||
|
if verbosity > 0:
|
||||||
|
if dry_run:
|
||||||
|
loggingCallback(f"ffmpeg dry-run: {formatCommandSequence(command_sequence)}", level = LogLevel.DEBUG)
|
||||||
|
else:
|
||||||
|
loggingCallback(f"ffmpeg: {formatCommandSequence(command_sequence)}", level = LogLevel.DEBUG)
|
||||||
|
return
|
||||||
|
|
||||||
|
loggingCallback("ffmpeg dry-run prepared.") if dry_run else loggingCallback(
|
||||||
|
"ffmpeg metadata write started."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def apply_metadata_edits(
|
def apply_metadata_edits(
|
||||||
context: dict,
|
context: dict,
|
||||||
source_path: str,
|
source_path: str,
|
||||||
baseline_descriptor: MediaDescriptor,
|
baseline_descriptor: MediaDescriptor,
|
||||||
draft_descriptor: MediaDescriptor,
|
draft_descriptor: MediaDescriptor,
|
||||||
|
*,
|
||||||
|
loggingHandler = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
|
||||||
temporaryOutputPath = create_temporary_output_path(source_path)
|
temporaryOutputPath = create_temporary_output_path(source_path)
|
||||||
|
|
||||||
editContext = build_metadata_edit_context(context)
|
editContext = build_metadata_edit_context(context)
|
||||||
controller = FfxController(editContext, draft_descriptor, baseline_descriptor)
|
|
||||||
|
commandSequence = build_metadata_edit_command(
|
||||||
|
editContext,
|
||||||
|
source_path,
|
||||||
|
temporaryOutputPath,
|
||||||
|
baseline_descriptor,
|
||||||
|
draft_descriptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
ffmpegSeconds = 0.0
|
||||||
|
replaceSeconds = 0.0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
controller.runJob(
|
|
||||||
source_path,
|
|
||||||
temporaryOutputPath,
|
|
||||||
targetFormat="",
|
|
||||||
chainIteration=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
if editContext.get("dry_run", False):
|
if editContext.get("dry_run", False):
|
||||||
|
|
||||||
|
notify_ffmpeg_invocation(
|
||||||
|
editContext,
|
||||||
|
commandSequence,
|
||||||
|
loggingHandler = loggingHandler,
|
||||||
|
dry_run=True,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"applied": False,
|
"applied": False,
|
||||||
"dry_run": True,
|
"dry_run": True,
|
||||||
"target_path": temporaryOutputPath,
|
"target_path": temporaryOutputPath,
|
||||||
|
"command_sequence": commandSequence,
|
||||||
|
"timings": {
|
||||||
|
"ffmpeg_seconds": ffmpegSeconds,
|
||||||
|
"replace_seconds": replaceSeconds,
|
||||||
|
"write_seconds": ffmpegSeconds + replaceSeconds,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notify_ffmpeg_invocation(editContext,
|
||||||
|
commandSequence,
|
||||||
|
loggingHandler = loggingHandler)
|
||||||
|
|
||||||
|
ffmpegStart = monotonic()
|
||||||
|
_out, err, rc = executeProcess(commandSequence, context=editContext)
|
||||||
|
ffmpegSeconds = monotonic() - ffmpegStart
|
||||||
|
|
||||||
|
if rc:
|
||||||
|
raise click.ClickException(f"ffmpeg edit failed: rc={rc} error={err}")
|
||||||
|
|
||||||
|
replaceStart = monotonic()
|
||||||
os.replace(temporaryOutputPath, source_path)
|
os.replace(temporaryOutputPath, source_path)
|
||||||
|
replaceSeconds = monotonic() - replaceStart
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"applied": True,
|
"applied": True,
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
"target_path": source_path,
|
"target_path": source_path,
|
||||||
|
"command_sequence": commandSequence,
|
||||||
|
"timings": {
|
||||||
|
"ffmpeg_seconds": ffmpegSeconds,
|
||||||
|
"replace_seconds": replaceSeconds,
|
||||||
|
"write_seconds": ffmpegSeconds + replaceSeconds,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
if os.path.exists(temporaryOutputPath):
|
if os.path.exists(temporaryOutputPath):
|
||||||
os.remove(temporaryOutputPath)
|
os.remove(temporaryOutputPath)
|
||||||
raise
|
raise
|
||||||
finally:
|
|
||||||
if editContext.get("dry_run", False) and os.path.exists(temporaryOutputPath):
|
|
||||||
os.remove(temporaryOutputPath)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlalchemy.orm import relationship, declarative_base, sessionmaker
|
|||||||
|
|
||||||
from .show import Base
|
from .show import Base
|
||||||
|
|
||||||
|
from ffx.attachment_format import AttachmentFormat
|
||||||
from ffx.track_type import TrackType
|
from ffx.track_type import TrackType
|
||||||
|
|
||||||
from ffx.iso_language import IsoLanguage
|
from ffx.iso_language import IsoLanguage
|
||||||
@@ -35,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"))
|
||||||
@@ -132,9 +134,16 @@ class Track(Base):
|
|||||||
|
|
||||||
if trackType in [t.label() for t in TrackType]:
|
if trackType in [t.label() for t in TrackType]:
|
||||||
|
|
||||||
|
if trackType == TrackType.ATTACHMENT.label():
|
||||||
|
storedFormatIdentifier = AttachmentFormat.identifyFfprobeStream(streamObj).identifier()
|
||||||
|
else:
|
||||||
|
storedFormatIdentifier = TrackCodec.identify(
|
||||||
|
streamObj.get(TrackDescriptor.FFPROBE_CODEC_KEY)
|
||||||
|
).identifier()
|
||||||
|
|
||||||
return cls(pattern_id = patternId,
|
return cls(pattern_id = patternId,
|
||||||
track_type = trackType,
|
track_type = trackType,
|
||||||
codec_name = streamObj[TrackDescriptor.FFPROBE_CODEC_NAME_KEY],
|
codec_name = storedFormatIdentifier,
|
||||||
disposition_flags = sum([2**t.index() for (k,v) in streamObj[TrackDescriptor.FFPROBE_DISPOSITION_KEY].items()
|
disposition_flags = sum([2**t.index() for (k,v) in streamObj[TrackDescriptor.FFPROBE_DISPOSITION_KEY].items()
|
||||||
if v and (t := TrackDisposition.find(k)) is not None]),
|
if v and (t := TrackDisposition.find(k)) is not None]),
|
||||||
audio_layout = AudioLayout.identify(streamObj))
|
audio_layout = AudioLayout.identify(streamObj))
|
||||||
@@ -153,14 +162,29 @@ class Track(Base):
|
|||||||
return TrackType.fromIndex(self.track_type)
|
return TrackType.fromIndex(self.track_type)
|
||||||
|
|
||||||
def getCodec(self) -> TrackCodec:
|
def getCodec(self) -> TrackCodec:
|
||||||
|
if self.getType() == TrackType.ATTACHMENT:
|
||||||
|
return TrackCodec.UNKNOWN
|
||||||
return TrackCodec.identify(self.codec_name)
|
return TrackCodec.identify(self.codec_name)
|
||||||
|
|
||||||
|
def getAttachmentFormat(self) -> AttachmentFormat:
|
||||||
|
if self.getType() != TrackType.ATTACHMENT:
|
||||||
|
return AttachmentFormat.UNKNOWN
|
||||||
|
return AttachmentFormat.identify(self.codec_name)
|
||||||
|
|
||||||
|
def getFormatDescriptor(self):
|
||||||
|
if self.getType() == TrackType.ATTACHMENT:
|
||||||
|
return self.getAttachmentFormat()
|
||||||
|
return self.getCodec()
|
||||||
|
|
||||||
def getIndex(self):
|
def getIndex(self):
|
||||||
return int(self.index) if self.index is not None else -1
|
return int(self.index) if self.index is not None else -1
|
||||||
|
|
||||||
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
|
||||||
@@ -201,12 +225,16 @@ 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
|
||||||
|
|
||||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = self.getType()
|
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = self.getType()
|
||||||
kwargs[TrackDescriptor.CODEC_KEY] = self.getCodec()
|
if self.getType() == TrackType.ATTACHMENT:
|
||||||
|
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = self.getAttachmentFormat()
|
||||||
|
else:
|
||||||
|
kwargs[TrackDescriptor.CODEC_KEY] = self.getCodec()
|
||||||
|
|
||||||
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = self.getDispositionSet()
|
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = self.getDispositionSet()
|
||||||
kwargs[TrackDescriptor.TAGS_KEY] = self.getTags()
|
kwargs[TrackDescriptor.TAGS_KEY] = self.getTags()
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -86,22 +87,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:
|
||||||
@@ -134,9 +151,10 @@ class PatternController:
|
|||||||
def _build_track_row(self, trackDescriptor: TrackDescriptor) -> Track:
|
def _build_track_row(self, trackDescriptor: TrackDescriptor) -> Track:
|
||||||
track = Track(
|
track = Track(
|
||||||
track_type=int(trackDescriptor.getType().index()),
|
track_type=int(trackDescriptor.getType().index()),
|
||||||
codec_name=str(trackDescriptor.getCodec().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 +188,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,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from textual.containers import Grid
|
|||||||
from .i18n import t
|
from .i18n import t
|
||||||
from .show_controller import ShowController
|
from .show_controller import ShowController
|
||||||
from .pattern_controller import PatternController
|
from .pattern_controller import PatternController
|
||||||
from .screen_support import go_back_or_exit
|
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||||
|
|
||||||
from ffx.model.pattern import Pattern
|
from ffx.model.pattern import Pattern
|
||||||
|
|
||||||
@@ -68,6 +68,10 @@ class PatternDeleteScreen(Screen):
|
|||||||
|
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
if self.__showDescriptor:
|
if self.__showDescriptor:
|
||||||
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()})")
|
||||||
if not self.__pattern is None:
|
if not self.__pattern is None:
|
||||||
@@ -103,6 +107,7 @@ class PatternDeleteScreen(Screen):
|
|||||||
yield Button(t("Delete"), id="delete_button")
|
yield Button(t("Delete"), id="delete_button")
|
||||||
yield Button(t("Cancel"), id="cancel_button")
|
yield Button(t("Cancel"), id="cancel_button")
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import click, re
|
import click, re
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from textual import events
|
||||||
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
|
||||||
@@ -18,6 +19,7 @@ from .screen_support import (
|
|||||||
add_auto_table_column,
|
add_auto_table_column,
|
||||||
build_screen_bootstrap,
|
build_screen_bootstrap,
|
||||||
build_screen_controllers,
|
build_screen_controllers,
|
||||||
|
build_screen_log_pane,
|
||||||
go_back_or_exit,
|
go_back_or_exit,
|
||||||
populate_tag_table,
|
populate_tag_table,
|
||||||
)
|
)
|
||||||
@@ -86,6 +88,9 @@ class PatternDetailsScreen(Screen):
|
|||||||
.three {
|
.three {
|
||||||
column-span: 3;
|
column-span: 3;
|
||||||
}
|
}
|
||||||
|
.two {
|
||||||
|
column-span: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.four {
|
.four {
|
||||||
column-span: 4;
|
column-span: 4;
|
||||||
@@ -112,7 +117,7 @@ class PatternDetailsScreen(Screen):
|
|||||||
}
|
}
|
||||||
|
|
||||||
.yellow {
|
.yellow {
|
||||||
tint: yellow 40%;
|
color: yellow;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -173,13 +178,14 @@ class PatternDetailsScreen(Screen):
|
|||||||
row = (td.getIndex(),
|
row = (td.getIndex(),
|
||||||
t(trackType.label()),
|
t(trackType.label()),
|
||||||
typeCounter[trackType],
|
typeCounter[trackType],
|
||||||
td.getCodec().label(),
|
td.getFormatDescriptor().label(),
|
||||||
t(audioLayout.label()) if trackType == TrackType.AUDIO
|
t(audioLayout.label()) if trackType == TrackType.AUDIO
|
||||||
and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ',
|
and audioLayout != AudioLayout.LAYOUT_UNDEFINED else ' ',
|
||||||
trackLanguage.label() if trackLanguage != IsoLanguage.UNDEFINED else ' ',
|
trackLanguage.label() if trackLanguage != IsoLanguage.UNDEFINED else ' ',
|
||||||
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))
|
||||||
@@ -324,8 +330,12 @@ class PatternDetailsScreen(Screen):
|
|||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
@@ -341,6 +351,17 @@ class PatternDetailsScreen(Screen):
|
|||||||
self.updateTracks()
|
self.updateTracks()
|
||||||
self.updateShiftedSeasons()
|
self.updateShiftedSeasons()
|
||||||
|
|
||||||
|
def on_screen_resume(self, _event: events.ScreenResume) -> None:
|
||||||
|
if not hasattr(self, "tracksTable") or not hasattr(self, "tagsTable"):
|
||||||
|
return
|
||||||
|
|
||||||
|
self.updateShowQualityHint()
|
||||||
|
self.updateTags()
|
||||||
|
self.updateTracks()
|
||||||
|
|
||||||
|
if self.__pattern is not None and hasattr(self, "shiftedSeasonsTable"):
|
||||||
|
self.updateShiftedSeasons()
|
||||||
|
|
||||||
def compose(self):
|
def compose(self):
|
||||||
|
|
||||||
|
|
||||||
@@ -364,6 +385,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'
|
||||||
@@ -400,7 +422,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
|
||||||
@@ -429,9 +453,9 @@ class PatternDetailsScreen(Screen):
|
|||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
# Row 10
|
# Row 10
|
||||||
yield self.shiftedSeasonsTable
|
yield self.shiftedSeasonsTable
|
||||||
@@ -482,12 +506,30 @@ class PatternDetailsScreen(Screen):
|
|||||||
# Row 20
|
# Row 20
|
||||||
yield Static(" ", classes="seven")
|
yield Static(" ", classes="seven")
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Iterable, List
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Callable, Iterable, List
|
||||||
|
|
||||||
from .logging_utils import get_ffx_logger
|
from .logging_utils import get_ffx_logger
|
||||||
|
|
||||||
@@ -118,6 +121,8 @@ def executeProcess(
|
|||||||
directory: str = None,
|
directory: str = None,
|
||||||
context: dict = None,
|
context: dict = None,
|
||||||
timeoutSeconds: float = None,
|
timeoutSeconds: float = None,
|
||||||
|
stdoutLineHandler: Callable[[str], bool] | None = None,
|
||||||
|
stderrLineHandler: Callable[[str], bool] | None = None,
|
||||||
):
|
):
|
||||||
|
|
||||||
logger = context['logger'] if context is not None and 'logger' in context else get_ffx_logger()
|
logger = context['logger'] if context is not None and 'logger' in context else get_ffx_logger()
|
||||||
@@ -131,6 +136,16 @@ def executeProcess(
|
|||||||
formatCommandSequence(wrappedCommandSequence),
|
formatCommandSequence(wrappedCommandSequence),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if stdoutLineHandler is not None or stderrLineHandler is not None:
|
||||||
|
return executeStreamingProcess(
|
||||||
|
wrappedCommandSequence,
|
||||||
|
directory=directory,
|
||||||
|
logger=logger,
|
||||||
|
timeoutSeconds=timeoutSeconds,
|
||||||
|
stdoutLineHandler=stdoutLineHandler,
|
||||||
|
stderrLineHandler=stderrLineHandler,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
wrappedCommandSequence,
|
wrappedCommandSequence,
|
||||||
@@ -167,3 +182,162 @@ def executeProcess(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return completed.stdout, completed.stderr, completed.returncode
|
return completed.stdout, completed.stderr, completed.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def terminateProcess(process: subprocess.Popen, *, killAfterSeconds: float = 1.0) -> None:
|
||||||
|
if process.poll() is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(os, "killpg"):
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
else:
|
||||||
|
process.terminate()
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
|
||||||
|
deadline = time.monotonic() + killAfterSeconds
|
||||||
|
while process.poll() is None and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
if process.poll() is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(os, "killpg"):
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
else:
|
||||||
|
process.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def readProcessStream(
|
||||||
|
stream,
|
||||||
|
outputParts: list[str],
|
||||||
|
lineHandler: Callable[[str], bool] | None,
|
||||||
|
stopRequested: threading.Event,
|
||||||
|
logger,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
for line in iter(stream.readline, ''):
|
||||||
|
outputParts.append(line)
|
||||||
|
|
||||||
|
if lineHandler is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if lineHandler(line):
|
||||||
|
stopRequested.set()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Process line handler raised an exception")
|
||||||
|
finally:
|
||||||
|
stream.close()
|
||||||
|
|
||||||
|
|
||||||
|
def executeStreamingProcess(
|
||||||
|
commandSequence: List[str],
|
||||||
|
*,
|
||||||
|
directory: str = None,
|
||||||
|
logger = None,
|
||||||
|
timeoutSeconds: float = None,
|
||||||
|
stdoutLineHandler: Callable[[str], bool] | None = None,
|
||||||
|
stderrLineHandler: Callable[[str], bool] | None = None,
|
||||||
|
):
|
||||||
|
logger = logger or get_ffx_logger()
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
commandSequence,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
cwd=directory,
|
||||||
|
bufsize=1,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as ex:
|
||||||
|
error = (
|
||||||
|
"Command not found while running "
|
||||||
|
+ f"{formatCommandSequence(commandSequence)}: {ex.filename or ex}"
|
||||||
|
)
|
||||||
|
logger.error(error)
|
||||||
|
return '', error, COMMAND_NOT_FOUND_RETURN_CODE
|
||||||
|
|
||||||
|
stdoutParts: list[str] = []
|
||||||
|
stderrParts: list[str] = []
|
||||||
|
stopRequested = threading.Event()
|
||||||
|
timedOut = False
|
||||||
|
|
||||||
|
stdoutThread = threading.Thread(
|
||||||
|
target=readProcessStream,
|
||||||
|
args=(
|
||||||
|
process.stdout,
|
||||||
|
stdoutParts,
|
||||||
|
stdoutLineHandler,
|
||||||
|
stopRequested,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
stderrThread = threading.Thread(
|
||||||
|
target=readProcessStream,
|
||||||
|
args=(
|
||||||
|
process.stderr,
|
||||||
|
stderrParts,
|
||||||
|
stderrLineHandler,
|
||||||
|
stopRequested,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdoutThread.start()
|
||||||
|
stderrThread.start()
|
||||||
|
|
||||||
|
deadline = (
|
||||||
|
time.monotonic() + float(timeoutSeconds)
|
||||||
|
if timeoutSeconds is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
terminationRequested = False
|
||||||
|
|
||||||
|
while process.poll() is None:
|
||||||
|
if stopRequested.is_set():
|
||||||
|
terminationRequested = True
|
||||||
|
terminateProcess(process)
|
||||||
|
break
|
||||||
|
|
||||||
|
if deadline is not None and time.monotonic() >= deadline:
|
||||||
|
timedOut = True
|
||||||
|
terminationRequested = True
|
||||||
|
terminateProcess(process)
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
returnCode = process.wait()
|
||||||
|
stdoutThread.join()
|
||||||
|
stderrThread.join()
|
||||||
|
|
||||||
|
stdout = ''.join(stdoutParts)
|
||||||
|
stderr = ''.join(stderrParts)
|
||||||
|
|
||||||
|
if timedOut:
|
||||||
|
error = (
|
||||||
|
f"Command timed out after {timeoutSeconds} seconds while running "
|
||||||
|
+ formatCommandSequence(commandSequence)
|
||||||
|
)
|
||||||
|
if stderr:
|
||||||
|
error = f"{error}\n{stderr}"
|
||||||
|
logger.error(error)
|
||||||
|
return stdout, error, COMMAND_TIMED_OUT_RETURN_CODE
|
||||||
|
|
||||||
|
if returnCode != 0 and not terminationRequested:
|
||||||
|
logger.warning(
|
||||||
|
"executeProcess() rc=%s command=%s",
|
||||||
|
returnCode,
|
||||||
|
formatCommandSequence(commandSequence),
|
||||||
|
)
|
||||||
|
|
||||||
|
return stdout, stderr, returnCode
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import weakref
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from rich.cells import cell_len
|
from rich.cells import cell_len
|
||||||
from rich.measure import measure_renderables
|
from rich.measure import measure_renderables
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
from textual import events
|
||||||
|
from textual.widgets import Collapsible, RichLog, Static
|
||||||
|
|
||||||
from .helper import formatRichColor
|
from .helper import formatRichColor
|
||||||
|
from .i18n import t
|
||||||
from .pattern_controller import PatternController
|
from .pattern_controller import PatternController
|
||||||
from .show_controller import ShowController
|
from .show_controller import ShowController
|
||||||
from .shifted_season_controller import ShiftedSeasonController
|
from .shifted_season_controller import ShiftedSeasonController
|
||||||
@@ -16,6 +21,156 @@ from .tmdb_controller import TmdbController
|
|||||||
from .track_controller import TrackController
|
from .track_controller import TrackController
|
||||||
|
|
||||||
|
|
||||||
|
SCREEN_LOG_PANE_ID = "screen_log_pane"
|
||||||
|
SCREEN_LOG_VIEW_ID = "screen_log_view"
|
||||||
|
SCREEN_LOG_RESIZE_HANDLE_ID = "screen_log_resize_handle"
|
||||||
|
SCREEN_LOG_HANDLER_NAME = "ffx-screen-log"
|
||||||
|
SCREEN_LOG_DEFAULT_HEIGHT = 8
|
||||||
|
SCREEN_LOG_MIN_HEIGHT = 4
|
||||||
|
SCREEN_LOG_COMPONENT_WIDTH = 16
|
||||||
|
SCREEN_LOG_LEVEL_WIDTH = 8
|
||||||
|
|
||||||
|
_SCREEN_LOG_PANE_ENABLED = False
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenLogHandler(logging.Handler):
|
||||||
|
"""Mirror logger output into the active screen log pane when available."""
|
||||||
|
|
||||||
|
def __init__(self, app) -> None:
|
||||||
|
super().__init__(level=logging.DEBUG)
|
||||||
|
self.set_name(SCREEN_LOG_HANDLER_NAME)
|
||||||
|
self.set_app(app)
|
||||||
|
|
||||||
|
def set_app(self, app) -> None:
|
||||||
|
self._app_ref = weakref.ref(app) if app is not None else lambda: None
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
app = self._app_ref()
|
||||||
|
if app is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = str(self.format(record)).strip()
|
||||||
|
except Exception:
|
||||||
|
self.handleError(record)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
app.call_from_thread(write_screen_log, app.screen, message)
|
||||||
|
except RuntimeError:
|
||||||
|
write_screen_log(app.screen, message)
|
||||||
|
except Exception:
|
||||||
|
self.handleError(record)
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenLogResizeHandle(Static):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
ScreenLogResizeHandle {
|
||||||
|
width: 100%;
|
||||||
|
height: 1;
|
||||||
|
content-align: center middle;
|
||||||
|
color: $text-muted;
|
||||||
|
background: $panel-lighten-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreenLogResizeHandle:hover {
|
||||||
|
color: $text;
|
||||||
|
background: $panel-lighten-2;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(" drag to resize ", id=SCREEN_LOG_RESIZE_HANDLE_ID)
|
||||||
|
self._drag_active = False
|
||||||
|
self._drag_origin_screen_y = 0
|
||||||
|
self._drag_origin_height = SCREEN_LOG_DEFAULT_HEIGHT
|
||||||
|
|
||||||
|
def _get_log_pane(self):
|
||||||
|
return self.parent.parent if self.parent is not None else None
|
||||||
|
|
||||||
|
def on_mouse_down(self, event: events.MouseDown) -> None:
|
||||||
|
if event.button != 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
log_pane = self._get_log_pane()
|
||||||
|
if log_pane is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._drag_active = True
|
||||||
|
self._drag_origin_screen_y = event.screen_y
|
||||||
|
self._drag_origin_height = log_pane.get_log_height()
|
||||||
|
self.capture_mouse()
|
||||||
|
event.stop()
|
||||||
|
|
||||||
|
def on_mouse_move(self, event: events.MouseMove) -> None:
|
||||||
|
if not self._drag_active:
|
||||||
|
return
|
||||||
|
|
||||||
|
log_pane = self._get_log_pane()
|
||||||
|
if log_pane is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
next_height = self._drag_origin_height + (
|
||||||
|
self._drag_origin_screen_y - event.screen_y
|
||||||
|
)
|
||||||
|
log_pane.set_log_height(next_height)
|
||||||
|
event.stop()
|
||||||
|
|
||||||
|
def on_mouse_up(self, event: events.MouseUp) -> None:
|
||||||
|
if not self._drag_active:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._drag_active = False
|
||||||
|
self.release_mouse()
|
||||||
|
event.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class ResizableScreenLogPane(Collapsible):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._log_view = RichLog(
|
||||||
|
id=SCREEN_LOG_VIEW_ID,
|
||||||
|
wrap=True,
|
||||||
|
markup=False,
|
||||||
|
highlight=False,
|
||||||
|
auto_scroll=True,
|
||||||
|
)
|
||||||
|
self._log_height = SCREEN_LOG_DEFAULT_HEIGHT
|
||||||
|
self._apply_log_height()
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
ScreenLogResizeHandle(),
|
||||||
|
self._log_view,
|
||||||
|
title=t("Log"),
|
||||||
|
collapsed=True,
|
||||||
|
id=SCREEN_LOG_PANE_ID,
|
||||||
|
)
|
||||||
|
self.styles.width = "100%"
|
||||||
|
|
||||||
|
def _apply_log_height(self) -> None:
|
||||||
|
self._log_view.styles.height = self._log_height
|
||||||
|
self._log_view.styles.width = "100%"
|
||||||
|
|
||||||
|
def get_log_height(self) -> int:
|
||||||
|
return int(self._log_height)
|
||||||
|
|
||||||
|
def set_log_height(self, height: int) -> None:
|
||||||
|
next_height = max(SCREEN_LOG_MIN_HEIGHT, int(height))
|
||||||
|
|
||||||
|
try:
|
||||||
|
available_height = int(self.app.size.height) - 8
|
||||||
|
except Exception:
|
||||||
|
available_height = next_height
|
||||||
|
|
||||||
|
if available_height > 0:
|
||||||
|
next_height = min(next_height, available_height)
|
||||||
|
|
||||||
|
self._log_height = next_height
|
||||||
|
self._apply_log_height()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ScreenBootstrap:
|
class ScreenBootstrap:
|
||||||
context: dict
|
context: dict
|
||||||
@@ -46,6 +201,48 @@ def build_screen_bootstrap(context: dict) -> ScreenBootstrap:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_screen_log_pane_enabled(enabled: bool) -> None:
|
||||||
|
global _SCREEN_LOG_PANE_ENABLED
|
||||||
|
_SCREEN_LOG_PANE_ENABLED = bool(enabled)
|
||||||
|
|
||||||
|
|
||||||
|
def is_screen_log_pane_enabled() -> bool:
|
||||||
|
return bool(_SCREEN_LOG_PANE_ENABLED)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_screen_log_handler(logger, app, *, enabled: bool):
|
||||||
|
if logger is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
screen_log_handler = next(
|
||||||
|
(handler for handler in logger.handlers if handler.get_name() == SCREEN_LOG_HANDLER_NAME),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
if screen_log_handler is not None:
|
||||||
|
logger.removeHandler(screen_log_handler)
|
||||||
|
screen_log_handler.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
if screen_log_handler is None:
|
||||||
|
screen_log_handler = ScreenLogHandler(app)
|
||||||
|
logger.addHandler(screen_log_handler)
|
||||||
|
elif isinstance(screen_log_handler, ScreenLogHandler):
|
||||||
|
screen_log_handler.set_app(app)
|
||||||
|
|
||||||
|
screen_log_handler.setLevel(logging.DEBUG)
|
||||||
|
screen_log_handler.setFormatter(
|
||||||
|
logging.Formatter(
|
||||||
|
f"%(name)-{SCREEN_LOG_COMPONENT_WIDTH}s "
|
||||||
|
+ f"%(levelname)-{SCREEN_LOG_LEVEL_WIDTH}s "
|
||||||
|
+ "%(asctime)s | %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return screen_log_handler
|
||||||
|
|
||||||
|
|
||||||
def build_screen_controllers(
|
def build_screen_controllers(
|
||||||
context: dict,
|
context: dict,
|
||||||
*,
|
*,
|
||||||
@@ -143,6 +340,48 @@ def update_table_column_label(table, column_key, label) -> None:
|
|||||||
table.refresh()
|
table.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
def build_screen_log_pane() -> ResizableScreenLogPane | Static:
|
||||||
|
"""Create a shared collapsible log pane for screen-local diagnostics."""
|
||||||
|
|
||||||
|
if not is_screen_log_pane_enabled():
|
||||||
|
hidden = Static("", id=f"{SCREEN_LOG_PANE_ID}_disabled")
|
||||||
|
hidden.display = False
|
||||||
|
return hidden
|
||||||
|
|
||||||
|
return ResizableScreenLogPane()
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_screen_log_pane(screen) -> bool:
|
||||||
|
"""Toggle the current screen log pane when present."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
logPane = screen.query_one(f"#{SCREEN_LOG_PANE_ID}", Collapsible)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
logPane.collapsed = not bool(logPane.collapsed)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def write_screen_log(screen, message: str) -> bool:
|
||||||
|
"""Append a line to the current screen log pane when present."""
|
||||||
|
|
||||||
|
if message is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
text = str(message).strip()
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
logView = screen.query_one(f"#{SCREEN_LOG_VIEW_ID}", RichLog)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
logView.write(text)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def go_back_or_exit(screen) -> None:
|
def go_back_or_exit(screen) -> None:
|
||||||
"""Pop the current screen when possible, otherwise exit the app."""
|
"""Pop the current screen when possible, otherwise exit the app."""
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from textual.screen import Screen
|
|||||||
from textual.widgets import Footer, Placeholder
|
from textual.widgets import Footer, Placeholder
|
||||||
|
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
from .screen_support import go_back_or_exit
|
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||||
|
|
||||||
|
|
||||||
class SettingsScreen(Screen):
|
class SettingsScreen(Screen):
|
||||||
@@ -17,7 +17,15 @@ class SettingsScreen(Screen):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
# Row 1
|
# Row 1
|
||||||
yield Placeholder(t("Settings Screen"))
|
yield Placeholder(t("Settings Screen"))
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
|
||||||
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
|
|
||||||
def action_back(self):
|
def action_back(self):
|
||||||
go_back_or_exit(self)
|
go_back_or_exit(self)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from textual.containers import Grid
|
|||||||
|
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
from .shifted_season_controller import ShiftedSeasonController
|
from .shifted_season_controller import ShiftedSeasonController
|
||||||
from .screen_support import go_back_or_exit
|
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||||
|
|
||||||
from ffx.model.shifted_season import ShiftedSeason
|
from ffx.model.shifted_season import ShiftedSeason
|
||||||
|
|
||||||
@@ -67,6 +67,9 @@ class ShiftedSeasonDeleteScreen(Screen):
|
|||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
|
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
|
||||||
|
|
||||||
ownerLabel = (
|
ownerLabel = (
|
||||||
@@ -127,6 +130,7 @@ class ShiftedSeasonDeleteScreen(Screen):
|
|||||||
yield Button(t("Delete"), id="delete_button")
|
yield Button(t("Delete"), id="delete_button")
|
||||||
yield Button(t("Cancel"), id="cancel_button")
|
yield Button(t("Cancel"), id="cancel_button")
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from textual.containers import Grid
|
|||||||
|
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
from .shifted_season_controller import ShiftedSeasonController
|
from .shifted_season_controller import ShiftedSeasonController
|
||||||
from .screen_support import go_back_or_exit
|
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||||
|
|
||||||
from ffx.model.shifted_season import ShiftedSeason
|
from ffx.model.shifted_season import ShiftedSeason
|
||||||
|
|
||||||
@@ -109,6 +109,9 @@ class ShiftedSeasonDetailsScreen(Screen):
|
|||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
if self.__shiftedSeasonId is not None:
|
if self.__shiftedSeasonId is not None:
|
||||||
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
|
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
|
||||||
|
|
||||||
@@ -175,6 +178,7 @@ class ShiftedSeasonDetailsScreen(Screen):
|
|||||||
# Row 10
|
# Row 10
|
||||||
yield Static(" ", classes="three")
|
yield Static(" ", classes="three")
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from textual.containers import Grid
|
|||||||
|
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
from .show_controller import ShowController
|
from .show_controller import ShowController
|
||||||
from .screen_support import go_back_or_exit
|
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||||
|
|
||||||
# Screen[dict[int, str, int]]
|
# Screen[dict[int, str, int]]
|
||||||
class ShowDeleteScreen(Screen):
|
class ShowDeleteScreen(Screen):
|
||||||
@@ -89,6 +89,7 @@ class ShowDeleteScreen(Screen):
|
|||||||
yield Button(t("Cancel"), id="cancel_button")
|
yield Button(t("Cancel"), id="cancel_button")
|
||||||
|
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
|
||||||
@@ -108,5 +109,12 @@ class ShowDeleteScreen(Screen):
|
|||||||
if event.button.id == "cancel_button":
|
if event.button.id == "cancel_button":
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
|
||||||
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
|
|
||||||
def action_back(self):
|
def action_back(self):
|
||||||
go_back_or_exit(self)
|
go_back_or_exit(self)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from .screen_support import (
|
|||||||
add_auto_table_column,
|
add_auto_table_column,
|
||||||
build_screen_bootstrap,
|
build_screen_bootstrap,
|
||||||
build_screen_controllers,
|
build_screen_controllers,
|
||||||
|
build_screen_log_pane,
|
||||||
go_back_or_exit,
|
go_back_or_exit,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -174,6 +175,9 @@ class ShowDetailsScreen(Screen):
|
|||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
if self.__showDescriptor is not None:
|
if self.__showDescriptor is not None:
|
||||||
|
|
||||||
showId = int(self.__showDescriptor.getId())
|
showId = int(self.__showDescriptor.getId())
|
||||||
@@ -433,6 +437,7 @@ class ShowDetailsScreen(Screen):
|
|||||||
yield Button(t("Cancel"), id="cancel_button")
|
yield Button(t("Cancel"), id="cancel_button")
|
||||||
|
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ from rich.text import Text
|
|||||||
|
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
from .show_controller import ShowController
|
from .show_controller import ShowController
|
||||||
from .screen_support import add_auto_table_column, go_back_or_exit, update_table_column_label
|
from .screen_support import (
|
||||||
|
add_auto_table_column,
|
||||||
|
build_screen_log_pane,
|
||||||
|
go_back_or_exit,
|
||||||
|
update_table_column_label,
|
||||||
|
)
|
||||||
|
|
||||||
from .show_details_screen import ShowDetailsScreen
|
from .show_details_screen import ShowDetailsScreen
|
||||||
from .show_delete_screen import ShowDeleteScreen
|
from .show_delete_screen import ShowDeleteScreen
|
||||||
@@ -239,6 +244,10 @@ class ShowsScreen(Screen):
|
|||||||
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
for show in self.__sc.getAllShows():
|
for show in self.__sc.getAllShows():
|
||||||
self._add_show_row(show.getDescriptor(self.context))
|
self._add_show_row(show.getDescriptor(self.context))
|
||||||
|
|
||||||
@@ -278,4 +287,5 @@ class ShowsScreen(Screen):
|
|||||||
f = Footer()
|
f = Footer()
|
||||||
f.description = "yolo"
|
f.description = "yolo"
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield f
|
yield f
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from textual.widgets import Header, Footer, Static, Button
|
|||||||
from textual.containers import Grid
|
from textual.containers import Grid
|
||||||
|
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
from .screen_support import go_back_or_exit
|
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||||
|
|
||||||
|
|
||||||
# Screen[dict[int, str, int]]
|
# Screen[dict[int, str, int]]
|
||||||
@@ -64,6 +64,9 @@ class TagDeleteScreen(Screen):
|
|||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
self.query_one("#keylabel", Static).update(str(self.__key))
|
self.query_one("#keylabel", Static).update(str(self.__key))
|
||||||
self.query_one("#valuelabel", Static).update(str(self.__value))
|
self.query_one("#valuelabel", Static).update(str(self.__value))
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ class TagDeleteScreen(Screen):
|
|||||||
yield Button(t("Delete"), id="delete_button")
|
yield Button(t("Delete"), id="delete_button")
|
||||||
yield Button(t("Cancel"), id="cancel_button")
|
yield Button(t("Cancel"), id="cancel_button")
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from textual.widgets import Header, Footer, Static, Button, Input
|
|||||||
from textual.containers import Grid
|
from textual.containers import Grid
|
||||||
|
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
from .screen_support import go_back_or_exit
|
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||||
|
|
||||||
|
|
||||||
# Screen[dict[int, str, int]]
|
# Screen[dict[int, str, int]]
|
||||||
@@ -87,6 +87,9 @@ class TagDetailsScreen(Screen):
|
|||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
if self.__key is not None:
|
if self.__key is not None:
|
||||||
self.query_one("#key_input", Input).value = str(self.__key)
|
self.query_one("#key_input", Input).value = str(self.__key)
|
||||||
|
|
||||||
@@ -121,6 +124,7 @@ class TagDetailsScreen(Screen):
|
|||||||
# Row 6
|
# Row 6
|
||||||
yield Static(" ", classes="five", id="messagestatic")
|
yield Static(" ", classes="five", id="messagestatic")
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer(id="footer")
|
yield Footer(id="footer")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,22 @@ from enum import Enum
|
|||||||
|
|
||||||
class TrackCodec(Enum):
|
class TrackCodec(Enum):
|
||||||
|
|
||||||
H265 = {'identifier': 'hevc', 'format': 'h265', 'extension': 'h265' ,'label': 'H.265'}
|
VP9 = {'identifier': 'vp9', 'format': 'ivf', 'extension': 'ivf' , 'label': 'VP9'}
|
||||||
|
H265 = {'identifier': 'hevc', 'format': None, 'extension': 'h265' ,'label': 'H.265'}
|
||||||
H264 = {'identifier': 'h264', 'format': 'h264', 'extension': 'h264' ,'label': 'H.264'}
|
H264 = {'identifier': 'h264', 'format': 'h264', 'extension': 'h264' ,'label': 'H.264'}
|
||||||
MPEG4 = {'identifier': 'mpeg4', 'format': 'm4v', 'extension': 'm4v' ,'label': 'MPEG-4'}
|
MPEG4 = {'identifier': 'mpeg4', 'format': 'm4v', 'extension': 'm4v' ,'label': 'MPEG-4'}
|
||||||
MPEG2 = {'identifier': 'mpeg2video', 'format': 'mpeg2video', 'extension': 'mpg' ,'label': 'MPEG-2'}
|
MPEG2 = {'identifier': 'mpeg2video', 'format': 'mpeg2video', 'extension': 'mpg' ,'label': 'MPEG-2'}
|
||||||
|
|
||||||
|
OPUS = {'identifier': 'opus', 'format': 'opus', 'extension': 'opus' , 'label': 'Opus'}
|
||||||
AAC = {'identifier': 'aac', 'format': None, 'extension': 'aac' , 'label': 'AAC'}
|
AAC = {'identifier': 'aac', 'format': None, 'extension': 'aac' , 'label': 'AAC'}
|
||||||
AC3 = {'identifier': 'ac3', 'format': 'ac3', 'extension': 'ac3' , 'label': 'AC3'}
|
AC3 = {'identifier': 'ac3', 'format': 'ac3', 'extension': 'ac3' , 'label': 'AC3'}
|
||||||
EAC3 = {'identifier': 'eac3', 'format': 'eac3', 'extension': 'eac3' , 'label': 'EAC3'}
|
EAC3 = {'identifier': 'eac3', 'format': 'eac3', 'extension': 'eac3' , 'label': 'EAC3'}
|
||||||
DTS = {'identifier': 'dts', 'format': 'dts', 'extension': 'dts' , 'label': 'DTS'}
|
DTS = {'identifier': 'dts', 'format': 'dts', 'extension': 'dts' , 'label': 'DTS'}
|
||||||
MP3 = {'identifier': 'mp3', 'format': 'mp3', 'extension': 'mp3' , 'label': 'MP3'}
|
MP3 = {'identifier': 'mp3', 'format': 'mp3', 'extension': 'mp3' , 'label': 'MP3'}
|
||||||
|
|
||||||
|
WEBVTT = {'identifier': 'webvtt', 'format': 'webvtt', 'extension': 'vtt' , 'label': 'WebVTT'}
|
||||||
SRT = {'identifier': 'subrip', 'format': 'srt', 'extension': 'srt' , 'label': 'SRT'}
|
SRT = {'identifier': 'subrip', 'format': 'srt', 'extension': 'srt' , 'label': 'SRT'}
|
||||||
ASS = {'identifier': 'ass', 'format': 'ass', 'extension': 'ass' , 'label': 'ASS'}
|
ASS = {'identifier': 'ass', 'format': 'ass', 'extension': 'ass' , 'label': 'ASS'}
|
||||||
TTF = {'identifier': 'ttf', 'format': None, 'extension': 'ttf' , 'label': 'TTF'}
|
|
||||||
PGS = {'identifier': 'hdmv_pgs_subtitle', 'format': 'sup', 'extension': 'sup' , 'label': 'PGS'}
|
PGS = {'identifier': 'hdmv_pgs_subtitle', 'format': 'sup', 'extension': 'sup' , 'label': 'PGS'}
|
||||||
VOBSUB = {'identifier': 'dvd_subtitle', 'format': None, 'extension': 'mkv' , 'label': 'VobSub'}
|
VOBSUB = {'identifier': 'dvd_subtitle', 'format': None, 'extension': 'mkv' , 'label': 'VobSub'}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -43,9 +45,10 @@ class TrackController():
|
|||||||
s = self.Session()
|
s = self.Session()
|
||||||
track = Track(pattern_id = patId,
|
track = Track(pattern_id = patId,
|
||||||
track_type = int(trackDescriptor.getType().index()),
|
track_type = int(trackDescriptor.getType().index()),
|
||||||
codec_name = str(trackDescriptor.getCodec().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,10 +84,24 @@ 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.getCodec().identifier())
|
track.codec_name = str(trackDescriptor.getFormatDescriptor().identifier())
|
||||||
track.audio_layout = int(trackDescriptor.getAudioLayout().index())
|
track.audio_layout = int(trackDescriptor.getAudioLayout().index())
|
||||||
|
|
||||||
track.disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet()))
|
track.disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet()))
|
||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from textual.containers import Grid
|
|||||||
|
|
||||||
from ffx.track_descriptor import TrackDescriptor
|
from ffx.track_descriptor import TrackDescriptor
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
from .screen_support import go_back_or_exit
|
from .screen_support import build_screen_log_pane, go_back_or_exit
|
||||||
|
|
||||||
|
|
||||||
# Screen[dict[int, str, int]]
|
# Screen[dict[int, str, int]]
|
||||||
@@ -67,6 +67,9 @@ class TrackDeleteScreen(Screen):
|
|||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
self.query_one("#subindexlabel", Static).update(str(self.__trackDescriptor.getSubIndex()))
|
self.query_one("#subindexlabel", Static).update(str(self.__trackDescriptor.getSubIndex()))
|
||||||
self.query_one("#patternlabel", Static).update(str(self.__trackDescriptor.getPatternId()))
|
self.query_one("#patternlabel", Static).update(str(self.__trackDescriptor.getPatternId()))
|
||||||
self.query_one("#languagelabel", Static).update(str(self.__trackDescriptor.getLanguage().label()))
|
self.query_one("#languagelabel", Static).update(str(self.__trackDescriptor.getLanguage().label()))
|
||||||
@@ -118,6 +121,7 @@ class TrackDeleteScreen(Screen):
|
|||||||
yield Button(t("Delete"), id="delete_button")
|
yield Button(t("Delete"), id="delete_button")
|
||||||
yield Button(t("Cancel"), id="cancel_button")
|
yield Button(t("Cancel"), id="cancel_button")
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
|
from .attachment_format import AttachmentFormat
|
||||||
from .iso_language import IsoLanguage
|
from .iso_language import IsoLanguage
|
||||||
from .track_type import TrackType
|
from .track_type import TrackType
|
||||||
from .audio_layout import AudioLayout
|
from .audio_layout import AudioLayout
|
||||||
@@ -20,12 +21,14 @@ 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"
|
||||||
|
|
||||||
TRACK_TYPE_KEY = "track_type"
|
TRACK_TYPE_KEY = "track_type"
|
||||||
CODEC_KEY = "codec_name"
|
CODEC_KEY = "codec_name"
|
||||||
|
ATTACHMENT_FORMAT_KEY = "attachment_format"
|
||||||
AUDIO_LAYOUT_KEY = "audio_layout"
|
AUDIO_LAYOUT_KEY = "audio_layout"
|
||||||
|
|
||||||
FFPROBE_INDEX_KEY = "index"
|
FFPROBE_INDEX_KEY = "index"
|
||||||
@@ -75,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(
|
||||||
@@ -110,15 +115,6 @@ class TrackDescriptor:
|
|||||||
else:
|
else:
|
||||||
self.__trackType = TrackType.UNKNOWN
|
self.__trackType = TrackType.UNKNOWN
|
||||||
|
|
||||||
if TrackDescriptor.CODEC_KEY in kwargs.keys():
|
|
||||||
if type(kwargs[TrackDescriptor.CODEC_KEY]) is not TrackCodec:
|
|
||||||
raise TypeError(
|
|
||||||
f"TrackDesciptor.__init__(): Argument {TrackDescriptor.CODEC_KEY} is required to be of type TrackCodec"
|
|
||||||
)
|
|
||||||
self.__trackCodec = kwargs[TrackDescriptor.CODEC_KEY]
|
|
||||||
else:
|
|
||||||
self.__trackCodec = TrackCodec.UNKNOWN
|
|
||||||
|
|
||||||
if TrackDescriptor.TAGS_KEY in kwargs.keys():
|
if TrackDescriptor.TAGS_KEY in kwargs.keys():
|
||||||
if type(kwargs[TrackDescriptor.TAGS_KEY]) is not dict:
|
if type(kwargs[TrackDescriptor.TAGS_KEY]) is not dict:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
@@ -151,6 +147,34 @@ class TrackDescriptor:
|
|||||||
else:
|
else:
|
||||||
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED
|
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED
|
||||||
|
|
||||||
|
self.__trackCodec = TrackCodec.UNKNOWN
|
||||||
|
self.__attachmentFormat = AttachmentFormat.UNKNOWN
|
||||||
|
|
||||||
|
if self.__trackType == TrackType.ATTACHMENT:
|
||||||
|
if TrackDescriptor.ATTACHMENT_FORMAT_KEY in kwargs.keys():
|
||||||
|
if type(kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY]) is not AttachmentFormat:
|
||||||
|
raise TypeError(
|
||||||
|
f"TrackDesciptor.__init__(): Argument {TrackDescriptor.ATTACHMENT_FORMAT_KEY} is required to be of type AttachmentFormat"
|
||||||
|
)
|
||||||
|
self.__attachmentFormat = kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY]
|
||||||
|
elif TrackDescriptor.CODEC_KEY in kwargs.keys():
|
||||||
|
legacyCodec = kwargs[TrackDescriptor.CODEC_KEY]
|
||||||
|
if type(legacyCodec) is AttachmentFormat:
|
||||||
|
self.__attachmentFormat = legacyCodec
|
||||||
|
elif type(legacyCodec) is TrackCodec:
|
||||||
|
self.__attachmentFormat = AttachmentFormat.fromTrackCodec(legacyCodec)
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
f"TrackDesciptor.__init__(): Argument {TrackDescriptor.CODEC_KEY} is required to be of type TrackCodec for legacy attachment compatibility"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if TrackDescriptor.CODEC_KEY in kwargs.keys():
|
||||||
|
if type(kwargs[TrackDescriptor.CODEC_KEY]) is not TrackCodec:
|
||||||
|
raise TypeError(
|
||||||
|
f"TrackDesciptor.__init__(): Argument {TrackDescriptor.CODEC_KEY} is required to be of type TrackCodec"
|
||||||
|
)
|
||||||
|
self.__trackCodec = kwargs[TrackDescriptor.CODEC_KEY]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def fromFfprobe(cls, streamObj, subIndex: int = -1):
|
def fromFfprobe(cls, streamObj, subIndex: int = -1):
|
||||||
"""Processes ffprobe stream data as array with elements according to the following example
|
"""Processes ffprobe stream data as array with elements according to the following example
|
||||||
@@ -215,7 +239,12 @@ class TrackDescriptor:
|
|||||||
|
|
||||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = trackType
|
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = trackType
|
||||||
|
|
||||||
kwargs[TrackDescriptor.CODEC_KEY] = TrackCodec.identify(streamObj[TrackDescriptor.FFPROBE_CODEC_KEY])
|
if trackType == TrackType.ATTACHMENT:
|
||||||
|
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = AttachmentFormat.identifyFfprobeStream(streamObj)
|
||||||
|
else:
|
||||||
|
kwargs[TrackDescriptor.CODEC_KEY] = TrackCodec.identify(
|
||||||
|
streamObj.get(TrackDescriptor.FFPROBE_CODEC_KEY)
|
||||||
|
)
|
||||||
|
|
||||||
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = (
|
kwargs[TrackDescriptor.DISPOSITION_SET_KEY] = (
|
||||||
{
|
{
|
||||||
@@ -277,6 +306,14 @@ class TrackDescriptor:
|
|||||||
def getCodec(self) -> TrackCodec:
|
def getCodec(self) -> TrackCodec:
|
||||||
return self.__trackCodec
|
return self.__trackCodec
|
||||||
|
|
||||||
|
def getAttachmentFormat(self) -> AttachmentFormat:
|
||||||
|
return self.__attachmentFormat
|
||||||
|
|
||||||
|
def getFormatDescriptor(self):
|
||||||
|
if self.__trackType == TrackType.ATTACHMENT:
|
||||||
|
return self.__attachmentFormat
|
||||||
|
return self.__trackCodec
|
||||||
|
|
||||||
def getLanguage(self):
|
def getLanguage(self):
|
||||||
if "language" in self.__trackTags.keys():
|
if "language" in self.__trackTags.keys():
|
||||||
return IsoLanguage.findThreeLetter(self.__trackTags["language"])
|
return IsoLanguage.findThreeLetter(self.__trackTags["language"])
|
||||||
@@ -344,21 +381,32 @@ 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),
|
||||||
TrackDescriptor.TRACK_TYPE_KEY: self.__trackType,
|
TrackDescriptor.TRACK_TYPE_KEY: self.__trackType,
|
||||||
TrackDescriptor.CODEC_KEY: self.__trackCodec,
|
|
||||||
TrackDescriptor.TAGS_KEY: dict(self.__trackTags),
|
TrackDescriptor.TAGS_KEY: dict(self.__trackTags),
|
||||||
TrackDescriptor.DISPOSITION_SET_KEY: set(self.__dispositionSet),
|
TrackDescriptor.DISPOSITION_SET_KEY: set(self.__dispositionSet),
|
||||||
TrackDescriptor.AUDIO_LAYOUT_KEY: self.__audioLayout,
|
TrackDescriptor.AUDIO_LAYOUT_KEY: self.__audioLayout,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.__trackType == TrackType.ATTACHMENT:
|
||||||
|
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = self.__attachmentFormat
|
||||||
|
else:
|
||||||
|
kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec
|
||||||
|
|
||||||
if context is not None:
|
if context is not None:
|
||||||
kwargs[TrackDescriptor.CONTEXT_KEY] = context
|
kwargs[TrackDescriptor.CONTEXT_KEY] = context
|
||||||
elif self.__context:
|
elif self.__context:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from textual.widgets import Header, Footer, Static, Button, SelectionList, Selec
|
|||||||
from textual.containers import Grid
|
from textual.containers import Grid
|
||||||
from textual.widgets._data_table import CellDoesNotExist
|
from textual.widgets._data_table import CellDoesNotExist
|
||||||
|
|
||||||
|
from .attachment_format import AttachmentFormat
|
||||||
from .audio_layout import AudioLayout
|
from .audio_layout import AudioLayout
|
||||||
from .iso_language import IsoLanguage
|
from .iso_language import IsoLanguage
|
||||||
from .tag_delete_screen import TagDeleteScreen
|
from .tag_delete_screen import TagDeleteScreen
|
||||||
@@ -14,7 +15,13 @@ from .track_descriptor import TrackDescriptor
|
|||||||
from .track_disposition import TrackDisposition
|
from .track_disposition import TrackDisposition
|
||||||
from .track_type import TrackType
|
from .track_type import TrackType
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
from .screen_support import add_auto_table_column, build_screen_bootstrap, go_back_or_exit, populate_tag_table
|
from .screen_support import (
|
||||||
|
add_auto_table_column,
|
||||||
|
build_screen_bootstrap,
|
||||||
|
build_screen_log_pane,
|
||||||
|
go_back_or_exit,
|
||||||
|
populate_tag_table,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TrackDetailsScreen(Screen):
|
class TrackDetailsScreen(Screen):
|
||||||
@@ -128,22 +135,29 @@ class TrackDetailsScreen(Screen):
|
|||||||
self.__patternLabel = str(patternLabel)
|
self.__patternLabel = str(patternLabel)
|
||||||
self.__siblingTrackDescriptors = list(siblingTrackDescriptors or [])
|
self.__siblingTrackDescriptors = list(siblingTrackDescriptors or [])
|
||||||
self.__metadataOnly = bool(metadata_only)
|
self.__metadataOnly = bool(metadata_only)
|
||||||
|
self.__applyNormalization = bool(
|
||||||
|
self.context.get("apply_metadata_normalization", True)
|
||||||
|
)
|
||||||
|
|
||||||
if self.__isNew:
|
if self.__isNew:
|
||||||
self.__trackType = trackType
|
self.__trackType = trackType
|
||||||
self.__trackCodec = TrackCodec.UNKNOWN
|
self.__trackCodec = TrackCodec.UNKNOWN
|
||||||
|
self.__attachmentFormat = AttachmentFormat.UNKNOWN
|
||||||
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 = ""
|
||||||
else:
|
else:
|
||||||
self.__trackType = trackDescriptor.getType()
|
self.__trackType = trackDescriptor.getType()
|
||||||
self.__trackCodec = trackDescriptor.getCodec()
|
self.__trackCodec = trackDescriptor.getCodec()
|
||||||
|
self.__attachmentFormat = trackDescriptor.getAttachmentFormat()
|
||||||
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()
|
||||||
@@ -152,8 +166,13 @@ class TrackDetailsScreen(Screen):
|
|||||||
initial_language = trackDescriptor.getLanguage()
|
initial_language = trackDescriptor.getLanguage()
|
||||||
initial_title = trackDescriptor.getTitle()
|
initial_title = trackDescriptor.getTitle()
|
||||||
|
|
||||||
self.__titleAutoManaged = (
|
initialTitleEmpty = not str(initial_title).strip()
|
||||||
initial_language == IsoLanguage.UNDEFINED and not str(initial_title).strip()
|
self.__titleAutoManaged = bool(
|
||||||
|
initialTitleEmpty
|
||||||
|
and (
|
||||||
|
initial_language == IsoLanguage.UNDEFINED
|
||||||
|
or (self.__metadataOnly and self.__applyNormalization)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.__suppressTitleChanged = False
|
self.__suppressTitleChanged = False
|
||||||
self.__lastAutoTitle = ""
|
self.__lastAutoTitle = ""
|
||||||
@@ -222,6 +241,9 @@ class TrackDetailsScreen(Screen):
|
|||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
|
||||||
|
if getattr(self, 'context', {}).get('debug', False):
|
||||||
|
self.title = f"{self.app.title} - {self.__class__.__name__}"
|
||||||
|
|
||||||
self.query_one("#index_label", Static).update(
|
self.query_one("#index_label", Static).update(
|
||||||
str(self.__index) if self.__index is not None else "-"
|
str(self.__index) if self.__index is not None else "-"
|
||||||
)
|
)
|
||||||
@@ -234,6 +256,9 @@ class TrackDetailsScreen(Screen):
|
|||||||
self.query_one("#type_select", Select).value = self.__trackType
|
self.query_one("#type_select", Select).value = self.__trackType
|
||||||
|
|
||||||
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", SelectionList).add_option(
|
||||||
|
(t("Yes"), 0, self.__dropped)
|
||||||
|
)
|
||||||
|
|
||||||
for disposition in TrackDisposition:
|
for disposition in TrackDisposition:
|
||||||
|
|
||||||
@@ -256,11 +281,14 @@ class TrackDetailsScreen(Screen):
|
|||||||
self.__trackDescriptor.getLanguage()
|
self.__trackDescriptor.getLanguage()
|
||||||
)
|
)
|
||||||
self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle()
|
self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle()
|
||||||
|
if self.__titleAutoManaged and not self.__trackDescriptor.getTitle().strip():
|
||||||
|
self._apply_auto_title_for_language(self.__trackDescriptor.getLanguage())
|
||||||
self.updateTags()
|
self.updateTags()
|
||||||
|
|
||||||
if self.__metadataOnly:
|
if self.__metadataOnly:
|
||||||
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", SelectionList).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":
|
||||||
@@ -322,7 +350,8 @@ class TrackDetailsScreen(Screen):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Row 8
|
# Row 8
|
||||||
yield Static(" ", classes="five")
|
yield Static(t("Drop"))
|
||||||
|
yield SelectionList[int](id="drop_checkbox", classes="four")
|
||||||
|
|
||||||
# Row 9
|
# Row 9
|
||||||
yield Static(" ", classes="five")
|
yield Static(" ", classes="five")
|
||||||
@@ -387,6 +416,7 @@ class TrackDetailsScreen(Screen):
|
|||||||
# Row 24
|
# Row 24
|
||||||
yield Static(" ", classes="five", id="messagestatic")
|
yield Static(" ", classes="five", id="messagestatic")
|
||||||
|
|
||||||
|
yield build_screen_log_pane()
|
||||||
yield Footer(id="footer")
|
yield Footer(id="footer")
|
||||||
|
|
||||||
def getTrackDescriptorFromInput(self):
|
def getTrackDescriptorFromInput(self):
|
||||||
@@ -413,7 +443,10 @@ class TrackDetailsScreen(Screen):
|
|||||||
if not isinstance(selectedTrackType, TrackType):
|
if not isinstance(selectedTrackType, TrackType):
|
||||||
selectedTrackType = TrackType.UNKNOWN
|
selectedTrackType = TrackType.UNKNOWN
|
||||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = selectedTrackType
|
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = selectedTrackType
|
||||||
kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec
|
if selectedTrackType == TrackType.ATTACHMENT:
|
||||||
|
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = self.__attachmentFormat
|
||||||
|
else:
|
||||||
|
kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec
|
||||||
|
|
||||||
if selectedTrackType == TrackType.AUDIO:
|
if selectedTrackType == TrackType.AUDIO:
|
||||||
selectedAudioLayout = self.query_one("#audio_layout_select", Select).value
|
selectedAudioLayout = self.query_one("#audio_layout_select", Select).value
|
||||||
@@ -436,6 +469,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", SelectionList).selected
|
||||||
|
)
|
||||||
|
|
||||||
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]
|
||||||
@@ -478,6 +514,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(
|
||||||
@@ -501,10 +538,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
|
||||||
):
|
):
|
||||||
|
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -7,6 +7,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from functools import lru_cache
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
|
|
||||||
@@ -17,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
|
||||||
@@ -54,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:
|
||||||
@@ -95,8 +99,69 @@ def write_vtt(path: Path, lines: tuple[str, ...]) -> Path:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def create_source_fixture(workdir: Path, filename: str, tracks: list[SourceTrackSpec], duration_seconds: int = 1) -> Path:
|
@lru_cache(maxsize=None)
|
||||||
|
def _ffmpeg_encoder_is_available(encoder_name: str) -> bool:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["ffmpeg", "-encoders"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
encoder_label = str(encoder_name).strip()
|
||||||
|
for line in completed.stdout.splitlines():
|
||||||
|
if not line.startswith(" "):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tokens = line.split(maxsplit=2)
|
||||||
|
if len(tokens) >= 2 and tokens[1] == encoder_label:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_fixture_video_encoder(
|
||||||
|
video_encoder: str,
|
||||||
|
video_encoder_options: tuple[str, ...],
|
||||||
|
) -> tuple[str, tuple[str, ...]]:
|
||||||
|
if video_encoder != "libx264":
|
||||||
|
return video_encoder, video_encoder_options
|
||||||
|
|
||||||
|
if _ffmpeg_encoder_is_available("libx264"):
|
||||||
|
return video_encoder, video_encoder_options
|
||||||
|
|
||||||
|
if _ffmpeg_encoder_is_available("libopenh264"):
|
||||||
|
# Keep fixture generation software-based when libx264 is missing.
|
||||||
|
return "libopenh264", ("-pix_fmt", "yuv420p")
|
||||||
|
|
||||||
|
return video_encoder, video_encoder_options
|
||||||
|
|
||||||
|
|
||||||
|
def create_source_fixture(
|
||||||
|
workdir: Path,
|
||||||
|
filename: str,
|
||||||
|
tracks: list[SourceTrackSpec],
|
||||||
|
duration_seconds: int = 1,
|
||||||
|
*,
|
||||||
|
video_encoder: str = "libx264",
|
||||||
|
video_encoder_options: tuple[str, ...] = (
|
||||||
|
"-preset",
|
||||||
|
"ultrafast",
|
||||||
|
"-crf",
|
||||||
|
"35",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
),
|
||||||
|
audio_encoder: str = "aac",
|
||||||
|
audio_encoder_options: tuple[str, ...] = ("-b:a", "48k"),
|
||||||
|
subtitle_encoder: str = "webvtt",
|
||||||
|
) -> Path:
|
||||||
output_path = workdir / filename
|
output_path = workdir / filename
|
||||||
|
video_encoder, video_encoder_options = _resolve_fixture_video_encoder(
|
||||||
|
video_encoder,
|
||||||
|
video_encoder_options,
|
||||||
|
)
|
||||||
|
|
||||||
has_video = any(track.track_type == TrackType.VIDEO for track in tracks)
|
has_video = any(track.track_type == TrackType.VIDEO for track in tracks)
|
||||||
has_audio = any(track.track_type == TrackType.AUDIO for track in tracks)
|
has_audio = any(track.track_type == TrackType.AUDIO for track in tracks)
|
||||||
@@ -189,21 +254,16 @@ def create_source_fixture(workdir: Path, filename: str, tracks: list[SourceTrack
|
|||||||
command += map_tokens
|
command += map_tokens
|
||||||
command += metadata_tokens
|
command += metadata_tokens
|
||||||
command += disposition_tokens
|
command += disposition_tokens
|
||||||
|
if has_video:
|
||||||
|
command += ["-c:v", video_encoder] + list(video_encoder_options)
|
||||||
|
|
||||||
|
if has_audio:
|
||||||
|
command += ["-c:a", audio_encoder] + list(audio_encoder_options)
|
||||||
|
|
||||||
|
if subtitle_input_indices:
|
||||||
|
command += ["-c:s", subtitle_encoder]
|
||||||
|
|
||||||
command += [
|
command += [
|
||||||
"-c:v",
|
|
||||||
"libx264",
|
|
||||||
"-preset",
|
|
||||||
"ultrafast",
|
|
||||||
"-crf",
|
|
||||||
"35",
|
|
||||||
"-pix_fmt",
|
|
||||||
"yuv420p",
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-b:a",
|
|
||||||
"48k",
|
|
||||||
"-c:s",
|
|
||||||
"webvtt",
|
|
||||||
"-t",
|
"-t",
|
||||||
str(duration_seconds),
|
str(duration_seconds),
|
||||||
"-shortest",
|
"-shortest",
|
||||||
@@ -239,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(
|
||||||
|
|||||||
211
tests/unit/test_cli_convert_diagnostics.py
Normal file
211
tests/unit/test_cli_convert_diagnostics.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
|
||||||
|
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 import cli # noqa: E402
|
||||||
|
from ffx.diagnostics import FfmpegSkipFileWarning, recordUnremediedIssue # noqa: E402
|
||||||
|
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMediaDescriptor:
|
||||||
|
def getVideoTracks(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def getAudioTracks(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def getSubtitleTracks(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def getAttachmentTracks(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def applyOverrides(self, overrides):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeFileProperties:
|
||||||
|
def __init__(self, context, source_path):
|
||||||
|
self.source_path = source_path
|
||||||
|
|
||||||
|
def getShowId(self):
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def getSeason(self):
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def getEpisode(self):
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def getMediaDescriptor(self):
|
||||||
|
return _FakeMediaDescriptor()
|
||||||
|
|
||||||
|
def getPattern(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeShiftedSeasonController:
|
||||||
|
def __init__(self, context):
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
def shiftSeason(self, show_id, season, episode, patternId=None):
|
||||||
|
return season, episode
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeShowController:
|
||||||
|
def __init__(self, context):
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
def getShowDescriptor(self, show_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeFfxController:
|
||||||
|
calls: list[str] = []
|
||||||
|
mode = "skip_first"
|
||||||
|
|
||||||
|
def __init__(self, context, *args, **kwargs):
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
def runJob(self, sourcePath, *args, **kwargs):
|
||||||
|
self.calls.append(sourcePath)
|
||||||
|
if self.mode == "clean":
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.mode == "warn_unhandled" and sourcePath.endswith("episode1.avi"):
|
||||||
|
recordUnremediedIssue(
|
||||||
|
self.context,
|
||||||
|
sourcePath,
|
||||||
|
"unhandled-warning",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.mode == "skip_first" and sourcePath.endswith("episode1.avi"):
|
||||||
|
message = (
|
||||||
|
f"Skipping file {sourcePath}: ffmpeg still reported unset packet "
|
||||||
|
+ "timestamps after retry with -fflags +genpts."
|
||||||
|
)
|
||||||
|
recordUnremediedIssue(
|
||||||
|
self.context,
|
||||||
|
sourcePath,
|
||||||
|
"retry-with-generated-pts",
|
||||||
|
)
|
||||||
|
self.context["logger"].warning(message)
|
||||||
|
raise FfmpegSkipFileWarning(message)
|
||||||
|
|
||||||
|
|
||||||
|
class ConvertDiagnosticCliTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
logger = get_ffx_logger()
|
||||||
|
for handler in list(logger.handlers):
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
try:
|
||||||
|
handler.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.tempdir = tempfile.TemporaryDirectory()
|
||||||
|
self.home_dir = Path(self.tempdir.name) / "home"
|
||||||
|
self.home_dir.mkdir()
|
||||||
|
self.database_path = Path(self.tempdir.name) / "test.db"
|
||||||
|
self.source_dir = Path(self.tempdir.name) / "source"
|
||||||
|
self.source_dir.mkdir()
|
||||||
|
self.source_one = self.source_dir / "episode1.avi"
|
||||||
|
self.source_two = self.source_dir / "episode2.avi"
|
||||||
|
self.source_one.write_bytes(b"one")
|
||||||
|
self.source_two.write_bytes(b"two")
|
||||||
|
_FakeFfxController.calls = []
|
||||||
|
_FakeFfxController.mode = "skip_first"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tempdir.cleanup()
|
||||||
|
|
||||||
|
def test_convert_continues_after_skipping_one_file_due_to_ffmpeg_diagnostic(self):
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("ffx.file_properties.FileProperties", _FakeFileProperties),
|
||||||
|
patch("ffx.ffx_controller.FfxController", _FakeFfxController),
|
||||||
|
patch(
|
||||||
|
"ffx.shifted_season_controller.ShiftedSeasonController",
|
||||||
|
_FakeShiftedSeasonController,
|
||||||
|
),
|
||||||
|
patch("ffx.show_controller.ShowController", _FakeShowController),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
cli.ffx,
|
||||||
|
[
|
||||||
|
"--database-file",
|
||||||
|
str(self.database_path),
|
||||||
|
"convert",
|
||||||
|
"--no-tmdb",
|
||||||
|
"--no-pattern",
|
||||||
|
str(self.source_one),
|
||||||
|
str(self.source_two),
|
||||||
|
],
|
||||||
|
env={**os.environ, "HOME": str(self.home_dir)},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.exit_code, result.output)
|
||||||
|
self.assertEqual(
|
||||||
|
[str(self.source_one), str(self.source_two)],
|
||||||
|
_FakeFfxController.calls,
|
||||||
|
)
|
||||||
|
self.assertIn("Skipping file", result.output)
|
||||||
|
self.assertIn("-fflags +genpts", result.output)
|
||||||
|
self.assertIn("Files with ffmpeg findings that require review:", result.output)
|
||||||
|
self.assertIn(
|
||||||
|
"episode1.avi: retry-with-generated-pts",
|
||||||
|
result.output,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_convert_prints_clean_summary_when_no_unremedied_issues_were_seen(self):
|
||||||
|
runner = CliRunner()
|
||||||
|
_FakeFfxController.mode = "clean"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("ffx.file_properties.FileProperties", _FakeFileProperties),
|
||||||
|
patch("ffx.ffx_controller.FfxController", _FakeFfxController),
|
||||||
|
patch(
|
||||||
|
"ffx.shifted_season_controller.ShiftedSeasonController",
|
||||||
|
_FakeShiftedSeasonController,
|
||||||
|
),
|
||||||
|
patch("ffx.show_controller.ShowController", _FakeShowController),
|
||||||
|
):
|
||||||
|
result = runner.invoke(
|
||||||
|
cli.ffx,
|
||||||
|
[
|
||||||
|
"--database-file",
|
||||||
|
str(self.database_path),
|
||||||
|
"convert",
|
||||||
|
"--no-tmdb",
|
||||||
|
"--no-pattern",
|
||||||
|
str(self.source_one),
|
||||||
|
str(self.source_two),
|
||||||
|
],
|
||||||
|
env={**os.environ, "HOME": str(self.home_dir)},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.exit_code, result.output)
|
||||||
|
self.assertIn(
|
||||||
|
"All files converted with no issues.",
|
||||||
|
result.output,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -168,6 +168,40 @@ class CliLazyImportTests(unittest.TestCase):
|
|||||||
result["modules"],
|
result["modules"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_root_debug_flag_parses_without_loading_runtime_modules(self):
|
||||||
|
result = self.run_python(
|
||||||
|
textwrap.dedent(
|
||||||
|
f"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, {str(SRC_ROOT)!r})
|
||||||
|
|
||||||
|
import ffx.cli
|
||||||
|
|
||||||
|
context = ffx.cli.ffx.make_context(
|
||||||
|
"ffx",
|
||||||
|
["--debug", "help"],
|
||||||
|
resilient_parsing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps({{
|
||||||
|
"debug": context.params["debug"],
|
||||||
|
"modules": {{
|
||||||
|
module_name: module_name in sys.modules
|
||||||
|
for module_name in {HEAVY_MODULES!r}
|
||||||
|
}},
|
||||||
|
}}))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["debug"])
|
||||||
|
self.assertTrue(
|
||||||
|
all(not is_loaded for is_loaded in result["modules"].values()),
|
||||||
|
result["modules"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_convert_cut_option_supports_flag_duration_and_start_duration_forms(self):
|
def test_convert_cut_option_supports_flag_duration_and_start_duration_forms(self):
|
||||||
result = self.run_python(
|
result = self.run_python(
|
||||||
textwrap.dedent(
|
textwrap.dedent(
|
||||||
@@ -229,6 +263,47 @@ class CliLazyImportTests(unittest.TestCase):
|
|||||||
result["modules"],
|
result["modules"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_convert_copy_flags_parse_without_loading_runtime_modules(self):
|
||||||
|
result = self.run_python(
|
||||||
|
textwrap.dedent(
|
||||||
|
f"""
|
||||||
|
import click
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, {str(SRC_ROOT)!r})
|
||||||
|
|
||||||
|
import ffx.cli
|
||||||
|
|
||||||
|
context = ffx.cli.convert.make_context(
|
||||||
|
"convert",
|
||||||
|
["--copy-video", "--copy-audio"],
|
||||||
|
resilient_parsing=True,
|
||||||
|
)
|
||||||
|
help_output = ffx.cli.convert.get_help(click.Context(ffx.cli.convert))
|
||||||
|
|
||||||
|
print(json.dumps({{
|
||||||
|
"copy_video": context.params["copy_video"],
|
||||||
|
"copy_audio": context.params["copy_audio"],
|
||||||
|
"output": help_output,
|
||||||
|
"modules": {{
|
||||||
|
module_name: module_name in sys.modules
|
||||||
|
for module_name in {HEAVY_MODULES!r}
|
||||||
|
}},
|
||||||
|
}}))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["copy_video"])
|
||||||
|
self.assertTrue(result["copy_audio"])
|
||||||
|
self.assertIn("--copy-video", result["output"])
|
||||||
|
self.assertIn("--copy-audio", result["output"])
|
||||||
|
self.assertTrue(
|
||||||
|
all(not is_loaded for is_loaded in result["modules"].values()),
|
||||||
|
result["modules"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_edit_command_avoids_database_bootstrap(self):
|
def test_edit_command_avoids_database_bootstrap(self):
|
||||||
result = self.run_python(
|
result = self.run_python(
|
||||||
textwrap.dedent(
|
textwrap.dedent(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
89
tests/unit/test_cli_unmux_sequence.py
Normal file
89
tests/unit/test_cli_unmux_sequence.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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 import cli # 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 UnmuxSequenceTests(unittest.TestCase):
|
||||||
|
def test_h265_video_unmux_uses_annex_b_bitstream_filter_without_forced_format(self):
|
||||||
|
track_descriptor = TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.VIDEO,
|
||||||
|
codec_name=TrackCodec.H265,
|
||||||
|
tags={},
|
||||||
|
disposition_set=set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
sequence = cli.getUnmuxSequence(
|
||||||
|
track_descriptor,
|
||||||
|
"input.mp4",
|
||||||
|
"episode_0_eng",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
"input.mp4",
|
||||||
|
"-map",
|
||||||
|
"0:v:0",
|
||||||
|
"-c:v",
|
||||||
|
"copy",
|
||||||
|
"-bsf:v",
|
||||||
|
"hevc_mp4toannexb",
|
||||||
|
"episode_0_eng.h265",
|
||||||
|
],
|
||||||
|
sequence,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_h265_unmux_keeps_generic_copy_behavior(self):
|
||||||
|
track_descriptor = TrackDescriptor(
|
||||||
|
index=1,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.SRT,
|
||||||
|
tags={},
|
||||||
|
disposition_set=set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
sequence = cli.getUnmuxSequence(
|
||||||
|
track_descriptor,
|
||||||
|
"input.mkv",
|
||||||
|
"episode_1_eng",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
"input.mkv",
|
||||||
|
"-map",
|
||||||
|
"0:s:0",
|
||||||
|
"-c",
|
||||||
|
"copy",
|
||||||
|
"-f",
|
||||||
|
"srt",
|
||||||
|
"episode_1_eng.srt",
|
||||||
|
],
|
||||||
|
sequence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -68,11 +68,14 @@ class UpgradeCommandTests(unittest.TestCase):
|
|||||||
subprocess_calls.append((args, kwargs))
|
subprocess_calls.append((args, kwargs))
|
||||||
if args == ['git', 'status', '--porcelain', '--untracked-files=no']:
|
if args == ['git', 'status', '--porcelain', '--untracked-files=no']:
|
||||||
return self.make_completed(args, stdout="M src/ffx/constants.py\n")
|
return self.make_completed(args, stdout="M src/ffx/constants.py\n")
|
||||||
|
if args == ['git', 'rev-parse', '--abbrev-ref', 'HEAD']:
|
||||||
|
return self.make_completed(args, stdout="main\n")
|
||||||
return self.make_completed(args)
|
return self.make_completed(args)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(cli, "getBundleRepoPath", return_value=repo_path),
|
patch.object(cli, "getBundleRepoPath", return_value=repo_path),
|
||||||
patch.object(cli, "getBundlePipPath", return_value=pip_path),
|
patch.object(cli, "getBundlePipPath", return_value=pip_path),
|
||||||
|
patch.object(cli, "getBundleVersion", return_value="0.3.2"),
|
||||||
patch.object(cli.os.path, "isdir", return_value=True),
|
patch.object(cli.os.path, "isdir", return_value=True),
|
||||||
patch.object(cli.os.path, "isfile", return_value=True),
|
patch.object(cli.os.path, "isfile", return_value=True),
|
||||||
patch.object(cli.subprocess, "run", side_effect=fake_run),
|
patch.object(cli.subprocess, "run", side_effect=fake_run),
|
||||||
@@ -81,6 +84,7 @@ class UpgradeCommandTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(0, result.exit_code, result.output)
|
self.assertEqual(0, result.exit_code, result.output)
|
||||||
self.assertIn("Tracked local changes detected in the bundle repository:", result.output)
|
self.assertIn("Tracked local changes detected in the bundle repository:", result.output)
|
||||||
|
self.assertIn("Updated FFX to version 0.3.2 from branch main.", result.output)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[
|
[
|
||||||
['git', 'status', '--porcelain', '--untracked-files=no'],
|
['git', 'status', '--porcelain', '--untracked-files=no'],
|
||||||
@@ -89,6 +93,7 @@ class UpgradeCommandTests(unittest.TestCase):
|
|||||||
['git', 'checkout', '-B', 'main', 'FETCH_HEAD'],
|
['git', 'checkout', '-B', 'main', 'FETCH_HEAD'],
|
||||||
[pip_path, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
[pip_path, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
||||||
[pip_path, 'install', '--editable', '.'],
|
[pip_path, 'install', '--editable', '.'],
|
||||||
|
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
],
|
],
|
||||||
[call[0] for call in subprocess_calls],
|
[call[0] for call in subprocess_calls],
|
||||||
)
|
)
|
||||||
@@ -106,11 +111,14 @@ class UpgradeCommandTests(unittest.TestCase):
|
|||||||
subprocess_calls.append((args, kwargs))
|
subprocess_calls.append((args, kwargs))
|
||||||
if args == ['git', 'status', '--porcelain', '--untracked-files=no']:
|
if args == ['git', 'status', '--porcelain', '--untracked-files=no']:
|
||||||
return self.make_completed(args, stdout="")
|
return self.make_completed(args, stdout="")
|
||||||
|
if args == ['git', 'rev-parse', '--abbrev-ref', 'HEAD']:
|
||||||
|
return self.make_completed(args, stdout="develop\n")
|
||||||
return self.make_completed(args)
|
return self.make_completed(args)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(cli, "getBundleRepoPath", return_value=repo_path),
|
patch.object(cli, "getBundleRepoPath", return_value=repo_path),
|
||||||
patch.object(cli, "getBundlePipPath", return_value=pip_path),
|
patch.object(cli, "getBundlePipPath", return_value=pip_path),
|
||||||
|
patch.object(cli, "getBundleVersion", return_value="0.3.3"),
|
||||||
patch.object(cli.os.path, "isdir", return_value=True),
|
patch.object(cli.os.path, "isdir", return_value=True),
|
||||||
patch.object(cli.os.path, "isfile", return_value=True),
|
patch.object(cli.os.path, "isfile", return_value=True),
|
||||||
patch.object(cli.subprocess, "run", side_effect=fake_run),
|
patch.object(cli.subprocess, "run", side_effect=fake_run),
|
||||||
@@ -118,12 +126,14 @@ class UpgradeCommandTests(unittest.TestCase):
|
|||||||
result = runner.invoke(cli.ffx, ["upgrade"])
|
result = runner.invoke(cli.ffx, ["upgrade"])
|
||||||
|
|
||||||
self.assertEqual(0, result.exit_code, result.output)
|
self.assertEqual(0, result.exit_code, result.output)
|
||||||
|
self.assertIn("Updated FFX to version 0.3.3 from branch develop.", result.output)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[
|
[
|
||||||
['git', 'status', '--porcelain', '--untracked-files=no'],
|
['git', 'status', '--porcelain', '--untracked-files=no'],
|
||||||
['git', 'pull'],
|
['git', 'pull'],
|
||||||
[pip_path, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
[pip_path, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
||||||
[pip_path, 'install', '--editable', '.'],
|
[pip_path, 'install', '--editable', '.'],
|
||||||
|
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
],
|
],
|
||||||
[call[0] for call in subprocess_calls],
|
[call[0] for call in subprocess_calls],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
196
tests/unit/test_ffmpeg_diagnostics.py
Normal file
196
tests/unit/test_ffmpeg_diagnostics.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
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.diagnostics import ( # noqa: E402
|
||||||
|
FfmpegCommandRunner,
|
||||||
|
FfmpegDiagnosticMonitor,
|
||||||
|
FfmpegSkipFileWarning,
|
||||||
|
getUnremediedIssues,
|
||||||
|
iterUnremediedIssueSummaryLines,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingLogger:
|
||||||
|
def __init__(self):
|
||||||
|
self.messages: list[str] = []
|
||||||
|
|
||||||
|
def warning(self, message, *args, **kwargs):
|
||||||
|
if args:
|
||||||
|
message = message % args
|
||||||
|
self.messages.append(str(message))
|
||||||
|
|
||||||
|
|
||||||
|
class FfmpegDiagnosticsTests(unittest.TestCase):
|
||||||
|
def test_command_runner_retries_with_genpts_after_timestamp_warning(self):
|
||||||
|
logger = RecordingLogger()
|
||||||
|
context = {
|
||||||
|
"logger": logger,
|
||||||
|
"current_source_path": "tests/assets/avi/conan_S01E754_amalgam.avi",
|
||||||
|
}
|
||||||
|
runner = FfmpegCommandRunner(context)
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
def fake_execute(commandSequence, **kwargs):
|
||||||
|
commands.append(list(commandSequence))
|
||||||
|
stderrLineHandler = kwargs["stderrLineHandler"]
|
||||||
|
if len(commands) == 1:
|
||||||
|
self.assertTrue(
|
||||||
|
stderrLineHandler(
|
||||||
|
"[matroska @ 0x1] Timestamps are unset in a packet for stream 0. "
|
||||||
|
+ "This is deprecated and will stop working in the future."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return "", "timestamp warning\n", -15
|
||||||
|
|
||||||
|
return "done", "", 0
|
||||||
|
|
||||||
|
with patch("ffx.diagnostics.monitor.executeProcess", side_effect=fake_execute):
|
||||||
|
out, err, rc = runner.execute(["ffmpeg", "-y", "-i", "input.avi", "output.mkv"])
|
||||||
|
|
||||||
|
self.assertEqual("done", out)
|
||||||
|
self.assertEqual("", err)
|
||||||
|
self.assertEqual(0, rc)
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
["ffmpeg", "-y", "-i", "input.avi", "output.mkv"],
|
||||||
|
["ffmpeg", "-fflags", "+genpts", "-y", "-i", "input.avi", "output.mkv"],
|
||||||
|
],
|
||||||
|
commands,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"ffmpeg reported unset packet timestamps for tests/assets/avi/conan_S01E754_amalgam.avi. "
|
||||||
|
+ "Stopping early and retrying with -fflags +genpts."
|
||||||
|
],
|
||||||
|
logger.messages,
|
||||||
|
)
|
||||||
|
self.assertEqual({}, getUnremediedIssues(context))
|
||||||
|
|
||||||
|
def test_command_runner_skips_file_when_timestamp_warning_persists_after_genpts(self):
|
||||||
|
logger = RecordingLogger()
|
||||||
|
context = {
|
||||||
|
"logger": logger,
|
||||||
|
"current_source_path": "tests/assets/avi/conan_S01E754_amalgam.avi",
|
||||||
|
}
|
||||||
|
runner = FfmpegCommandRunner(context)
|
||||||
|
|
||||||
|
def fake_execute(commandSequence, **kwargs):
|
||||||
|
stderrLineHandler = kwargs["stderrLineHandler"]
|
||||||
|
self.assertTrue(
|
||||||
|
stderrLineHandler(
|
||||||
|
"[matroska @ 0x1] Timestamps are unset in a packet for stream 0. "
|
||||||
|
+ "This is deprecated and will stop working in the future."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return "", "timestamp warning\n", -15
|
||||||
|
|
||||||
|
with patch("ffx.diagnostics.monitor.executeProcess", side_effect=fake_execute):
|
||||||
|
with self.assertRaises(FfmpegSkipFileWarning):
|
||||||
|
runner.execute(
|
||||||
|
["ffmpeg", "-fflags", "+genpts", "-y", "-i", "input.avi", "output.mkv"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"Skipping file tests/assets/avi/conan_S01E754_amalgam.avi: ffmpeg still reported "
|
||||||
|
+ "unset packet timestamps after retry with -fflags +genpts."
|
||||||
|
],
|
||||||
|
logger.messages,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"tests/assets/avi/conan_S01E754_amalgam.avi": ["retry-with-generated-pts"]
|
||||||
|
},
|
||||||
|
getUnremediedIssues(context),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_monitor_tracks_non_harmless_corrupt_mpeg_audio_remedy_in_summary(self):
|
||||||
|
logger = RecordingLogger()
|
||||||
|
context = {
|
||||||
|
"logger": logger,
|
||||||
|
"current_source_path": "tests/assets/avi/conan_S01E763_amalgam.avi",
|
||||||
|
}
|
||||||
|
monitor = FfmpegDiagnosticMonitor(
|
||||||
|
context,
|
||||||
|
["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(
|
||||||
|
"[aist#0:1/mp3 @ 0x2] [dec:mp3float @ 0x3] Error submitting packet to decoder: "
|
||||||
|
+ "Invalid data found when processing input"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"ffmpeg reported damaged MPEG audio frames while converting "
|
||||||
|
+ "tests/assets/avi/conan_S01E763_amalgam.avi. FFX will continue, but the "
|
||||||
|
+ "output audio may contain gaps or glitches."
|
||||||
|
],
|
||||||
|
logger.messages,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"tests/assets/avi/conan_S01E763_amalgam.avi": ["warn-corrupt-mpeg-audio"]
|
||||||
|
},
|
||||||
|
getUnremediedIssues(context),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["conan_S01E763_amalgam.avi: warn-corrupt-mpeg-audio"],
|
||||||
|
iterUnremediedIssueSummaryLines(context),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_monitor_tracks_unhandled_diagnostic_for_summary(self):
|
||||||
|
context = {
|
||||||
|
"logger": RecordingLogger(),
|
||||||
|
"current_source_path": "tests/assets/avi/example.avi",
|
||||||
|
}
|
||||||
|
monitor = FfmpegDiagnosticMonitor(
|
||||||
|
context,
|
||||||
|
["ffmpeg", "-y", "-i", "input.avi", "output.mkv"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
monitor.handle_stderr_line(
|
||||||
|
"[avi @ 0x1] Strange warning with no automatic remedy is present"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"tests/assets/avi/example.avi": ["unhandled-warning"]
|
||||||
|
},
|
||||||
|
getUnremediedIssues(context),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["example.avi: unhandled-warning"],
|
||||||
|
iterUnremediedIssueSummaryLines(context),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"ffmpeg reported a diagnostic with no automatic remedy while converting "
|
||||||
|
+ "tests/assets/avi/example.avi. FFX will continue, but review the output "
|
||||||
|
+ "file. First unhandled line: [avi @ 0x1] Strange warning with no automatic remedy is present"
|
||||||
|
],
|
||||||
|
context["logger"].messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import click
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -14,6 +15,7 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
|
|
||||||
|
|
||||||
from ffx.ffx_controller import FfxController # noqa: E402
|
from ffx.ffx_controller import FfxController # noqa: E402
|
||||||
|
from ffx.audio_layout import AudioLayout # noqa: E402
|
||||||
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||||
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||||
from ffx.show_descriptor import ShowDescriptor # noqa: E402
|
from ffx.show_descriptor import ShowDescriptor # noqa: E402
|
||||||
@@ -32,6 +34,9 @@ class StaticConfig:
|
|||||||
|
|
||||||
|
|
||||||
class FfxControllerTests(unittest.TestCase):
|
class FfxControllerTests(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
FfxController.isFfmpegEncoderAvailable.cache_clear()
|
||||||
|
|
||||||
def make_context(self, video_encoder: VideoEncoder) -> dict:
|
def make_context(self, video_encoder: VideoEncoder) -> dict:
|
||||||
return {
|
return {
|
||||||
"logger": get_ffx_logger(),
|
"logger": get_ffx_logger(),
|
||||||
@@ -39,6 +44,8 @@ class FfxControllerTests(unittest.TestCase):
|
|||||||
"video_encoder": video_encoder,
|
"video_encoder": video_encoder,
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
"perform_cut": False,
|
"perform_cut": False,
|
||||||
|
"copy_video": False,
|
||||||
|
"copy_audio": False,
|
||||||
"bitrates": {
|
"bitrates": {
|
||||||
"stereo": "112k",
|
"stereo": "112k",
|
||||||
"ac3": "256k",
|
"ac3": "256k",
|
||||||
@@ -71,6 +78,56 @@ class FfxControllerTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
return descriptor, source_descriptor
|
return descriptor, source_descriptor
|
||||||
|
|
||||||
|
def make_media_descriptors_with_audio(
|
||||||
|
self,
|
||||||
|
audio_layout: AudioLayout = AudioLayout.LAYOUT_STEREO,
|
||||||
|
) -> tuple[MediaDescriptor, MediaDescriptor]:
|
||||||
|
descriptor = 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=1,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.AUDIO,
|
||||||
|
codec_name=TrackCodec.AAC,
|
||||||
|
audio_layout=audio_layout,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
source_descriptor = 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=1,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.AUDIO,
|
||||||
|
codec_name=TrackCodec.AAC,
|
||||||
|
audio_layout=audio_layout,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return descriptor, source_descriptor
|
||||||
|
|
||||||
|
def assert_token_pair(self, command: list[str], first: str, second: str):
|
||||||
|
self.assertTrue(
|
||||||
|
any(command[index:index + 2] == [first, second] for index in range(len(command) - 1)),
|
||||||
|
command,
|
||||||
|
)
|
||||||
|
|
||||||
def test_vp9_run_job_emits_file_level_encoding_quality_metadata(self):
|
def test_vp9_run_job_emits_file_level_encoding_quality_metadata(self):
|
||||||
context = self.make_context(VideoEncoder.VP9)
|
context = self.make_context(VideoEncoder.VP9)
|
||||||
target_descriptor, source_descriptor = self.make_media_descriptors()
|
target_descriptor, source_descriptor = self.make_media_descriptors()
|
||||||
@@ -104,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()
|
||||||
@@ -192,6 +298,135 @@ class FfxControllerTests(unittest.TestCase):
|
|||||||
self.assertIn("ENCODING_QUALITY=19", commands[0])
|
self.assertIn("ENCODING_QUALITY=19", commands[0])
|
||||||
mocked_info.assert_any_call("Setting quality 19 from pattern")
|
mocked_info.assert_any_call("Setting quality 19 from pattern")
|
||||||
|
|
||||||
|
def test_copy_video_uses_single_copy_command_without_video_encoding_options(self):
|
||||||
|
context = self.make_context(VideoEncoder.VP9)
|
||||||
|
context["copy_video"] = True
|
||||||
|
target_descriptor, source_descriptor = self.make_media_descriptors_with_audio()
|
||||||
|
controller = FfxController(context, target_descriptor, source_descriptor)
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
controller,
|
||||||
|
"executeCommandSequence",
|
||||||
|
side_effect=lambda command: commands.append(command) or ("", "", 0),
|
||||||
|
):
|
||||||
|
controller.runJob(
|
||||||
|
"input.mkv",
|
||||||
|
"output.mkv",
|
||||||
|
chainIteration=[
|
||||||
|
{
|
||||||
|
"identifier": "quality",
|
||||||
|
"parameters": {"quality": 27},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "nlmeans",
|
||||||
|
"parameters": {},
|
||||||
|
"tokens": ["nlmeans=s=2.0"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cropArguments={
|
||||||
|
"output_width": 1280,
|
||||||
|
"output_height": 720,
|
||||||
|
"x_offset": 0,
|
||||||
|
"y_offset": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(commands))
|
||||||
|
self.assert_token_pair(commands[0], "-c:v", "copy")
|
||||||
|
self.assertIn("libopus", commands[0])
|
||||||
|
self.assertNotIn("libvpx-vp9", commands[0])
|
||||||
|
self.assertNotIn("-pass", commands[0])
|
||||||
|
self.assertNotIn("-vf", commands[0])
|
||||||
|
self.assertFalse(any(token.startswith("ENCODING_QUALITY=") for token in commands[0]))
|
||||||
|
|
||||||
|
def test_copy_audio_uses_audio_copy_without_audio_encoding_options(self):
|
||||||
|
context = self.make_context(VideoEncoder.H264)
|
||||||
|
context["copy_audio"] = True
|
||||||
|
target_descriptor, source_descriptor = self.make_media_descriptors_with_audio(
|
||||||
|
AudioLayout.LAYOUT_5_1
|
||||||
|
)
|
||||||
|
controller = FfxController(context, target_descriptor, source_descriptor)
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
controller,
|
||||||
|
"executeCommandSequence",
|
||||||
|
side_effect=lambda command: commands.append(command) or ("", "", 0),
|
||||||
|
):
|
||||||
|
controller.runJob(
|
||||||
|
"input.mkv",
|
||||||
|
"output.mkv",
|
||||||
|
chainIteration=[
|
||||||
|
{
|
||||||
|
"identifier": "quality",
|
||||||
|
"parameters": {"quality": 21},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(commands))
|
||||||
|
self.assert_token_pair(commands[0], "-c:a", "copy")
|
||||||
|
self.assertIn("libx264", commands[0])
|
||||||
|
self.assertNotIn("libopus", commands[0])
|
||||||
|
self.assertFalse(any(token.startswith("-b:a") for token in commands[0]))
|
||||||
|
self.assertFalse(any(token.startswith("-filter:a") for token in commands[0]))
|
||||||
|
def test_generate_h264_tokens_prefers_libx264_when_available(self):
|
||||||
|
context = self.make_context(VideoEncoder.H264)
|
||||||
|
target_descriptor, source_descriptor = self.make_media_descriptors()
|
||||||
|
controller = FfxController(context, target_descriptor, source_descriptor)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
FfxController,
|
||||||
|
"getSupportedSoftwareH264Encoder",
|
||||||
|
return_value="libx264",
|
||||||
|
):
|
||||||
|
tokens = controller.generateH264Tokens(23)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["-c:v:0", "libx264", "-preset", "slow", "-crf", "23"],
|
||||||
|
tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_generate_h264_tokens_falls_back_to_libopenh264_and_logs_warning(self):
|
||||||
|
context = self.make_context(VideoEncoder.H264)
|
||||||
|
target_descriptor, source_descriptor = self.make_media_descriptors()
|
||||||
|
controller = FfxController(context, target_descriptor, source_descriptor)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
FfxController,
|
||||||
|
"getSupportedSoftwareH264Encoder",
|
||||||
|
return_value="libopenh264",
|
||||||
|
),
|
||||||
|
patch.object(context["logger"], "warning") as mocked_warning,
|
||||||
|
):
|
||||||
|
tokens = controller.generateH264Tokens(23)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["-c:v:0", "libopenh264", "-pix_fmt", "yuv420p"],
|
||||||
|
tokens,
|
||||||
|
)
|
||||||
|
mocked_warning.assert_called_once_with(
|
||||||
|
"libx264 encoder unavailable; falling back to libopenh264 for H.264 encoding."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_generate_h264_tokens_raises_when_no_supported_software_encoder_exists(self):
|
||||||
|
context = self.make_context(VideoEncoder.H264)
|
||||||
|
target_descriptor, source_descriptor = self.make_media_descriptors()
|
||||||
|
controller = FfxController(context, target_descriptor, source_descriptor)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
FfxController,
|
||||||
|
"getSupportedSoftwareH264Encoder",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
click.ClickException,
|
||||||
|
"no supported software H.264 encoder is available",
|
||||||
|
):
|
||||||
|
controller.generateH264Tokens(23)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
82
tests/unit/test_file_properties_asset_probe.py
Normal file
82
tests/unit/test_file_properties_asset_probe.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
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.file_properties import FileProperties # noqa: E402
|
||||||
|
from ffx.i18n import set_current_language # noqa: E402
|
||||||
|
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||||
|
from ffx.track_codec import TrackCodec # noqa: E402
|
||||||
|
from ffx.track_type import TrackType # noqa: E402
|
||||||
|
from tests.support.ffx_bundle import SourceTrackSpec, create_source_fixture # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class StaticConfig:
|
||||||
|
def __init__(self, data: dict):
|
||||||
|
self._data = data
|
||||||
|
|
||||||
|
def getData(self):
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
|
||||||
|
class FilePropertiesAssetProbeTests(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
set_current_language("de")
|
||||||
|
|
||||||
|
def test_boruto_webm_probe_recognizes_webm_stream_codecs(self):
|
||||||
|
context = {
|
||||||
|
"logger": get_ffx_logger(),
|
||||||
|
"config": StaticConfig({}),
|
||||||
|
"language": "de",
|
||||||
|
"use_pattern": False,
|
||||||
|
}
|
||||||
|
set_current_language("de")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
media_path = create_source_fixture(
|
||||||
|
Path(tmpdir),
|
||||||
|
"fixture.webm",
|
||||||
|
[
|
||||||
|
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||||
|
SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng"),
|
||||||
|
SourceTrackSpec(
|
||||||
|
TrackType.SUBTITLE,
|
||||||
|
identity="subtitle-2",
|
||||||
|
language="eng",
|
||||||
|
subtitle_lines=("Lorem ipsum dolor sit amet.",),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
duration_seconds=3,
|
||||||
|
video_encoder="libvpx-vp9",
|
||||||
|
video_encoder_options=("-b:v", "0", "-crf", "45"),
|
||||||
|
audio_encoder="libopus",
|
||||||
|
audio_encoder_options=("-b:a", "48k"),
|
||||||
|
subtitle_encoder="webvtt",
|
||||||
|
)
|
||||||
|
|
||||||
|
file_properties = FileProperties(context, str(media_path))
|
||||||
|
tracks = file_properties.getMediaDescriptor().getTrackDescriptors()
|
||||||
|
|
||||||
|
subtitle_codecs = [
|
||||||
|
track.getCodec()
|
||||||
|
for track in tracks
|
||||||
|
if track.getType() == TrackType.SUBTITLE
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertIn(TrackCodec.VP9, [track.getCodec() for track in tracks])
|
||||||
|
self.assertIn(TrackCodec.OPUS, [track.getCodec() for track in tracks])
|
||||||
|
self.assertTrue(subtitle_codecs)
|
||||||
|
self.assertTrue(all(codec == TrackCodec.WEBVTT for codec in subtitle_codecs))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -16,8 +16,10 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
from ffx.logging_utils import ( # noqa: E402
|
from ffx.logging_utils import ( # noqa: E402
|
||||||
CONSOLE_HANDLER_NAME,
|
CONSOLE_HANDLER_NAME,
|
||||||
FILE_HANDLER_NAME,
|
FILE_HANDLER_NAME,
|
||||||
|
MUTED_CONSOLE_LEVEL,
|
||||||
configure_ffx_logger,
|
configure_ffx_logger,
|
||||||
get_ffx_logger,
|
get_ffx_logger,
|
||||||
|
set_ffx_console_logging_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +83,33 @@ class LoggingUtilsTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.cleanup_logger(logger_name)
|
self.cleanup_logger(logger_name)
|
||||||
|
|
||||||
|
def test_set_ffx_console_logging_enabled_mutes_and_restores_console_handler(self):
|
||||||
|
logger_name = "ffx-test-console-mute"
|
||||||
|
self.cleanup_logger(logger_name)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
log_path = Path(tempdir) / "ffx.log"
|
||||||
|
|
||||||
|
logger = configure_ffx_logger(
|
||||||
|
str(log_path),
|
||||||
|
logging.DEBUG,
|
||||||
|
logging.INFO,
|
||||||
|
name=logger_name,
|
||||||
|
)
|
||||||
|
console_handler = next(
|
||||||
|
handler for handler in logger.handlers if handler.get_name() == CONSOLE_HANDLER_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(logging.INFO, console_handler.level)
|
||||||
|
|
||||||
|
set_ffx_console_logging_enabled(logger, enabled=False)
|
||||||
|
self.assertEqual(MUTED_CONSOLE_LEVEL, console_handler.level)
|
||||||
|
|
||||||
|
set_ffx_console_logging_enabled(logger, enabled=True)
|
||||||
|
self.assertEqual(logging.INFO, console_handler.level)
|
||||||
|
|
||||||
|
self.cleanup_logger(logger_name)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -247,7 +248,8 @@ class MediaDescriptorChangeSetTests(unittest.TestCase):
|
|||||||
self.assertIn("title=German", metadata_tokens)
|
self.assertIn("title=German", metadata_tokens)
|
||||||
self.assertNotIn("title=Deutsch", metadata_tokens)
|
self.assertNotIn("title=Deutsch", metadata_tokens)
|
||||||
|
|
||||||
def test_non_subtitle_track_without_title_does_not_get_language_name(self):
|
def test_audio_track_without_title_gets_language_name_when_normalization_enabled(self):
|
||||||
|
set_current_language("de")
|
||||||
context = {
|
context = {
|
||||||
"logger": get_ffx_logger(),
|
"logger": get_ffx_logger(),
|
||||||
"config": StaticConfig({}),
|
"config": StaticConfig({}),
|
||||||
@@ -278,6 +280,73 @@ class MediaDescriptorChangeSetTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("-metadata:s:a:0", metadata_tokens)
|
self.assertIn("-metadata:s:a:0", metadata_tokens)
|
||||||
self.assertIn("language=deu", metadata_tokens)
|
self.assertIn("language=deu", metadata_tokens)
|
||||||
|
self.assertIn("title=Deutsch", metadata_tokens)
|
||||||
|
|
||||||
|
def test_video_track_without_title_gets_language_name_when_normalization_enabled(self):
|
||||||
|
set_current_language("de")
|
||||||
|
context = {
|
||||||
|
"logger": get_ffx_logger(),
|
||||||
|
"config": StaticConfig({}),
|
||||||
|
}
|
||||||
|
|
||||||
|
source_track = TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.VIDEO,
|
||||||
|
tags={"language": "ger"},
|
||||||
|
)
|
||||||
|
target_track = TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.VIDEO,
|
||||||
|
tags={"language": "ger"},
|
||||||
|
)
|
||||||
|
|
||||||
|
change_set = MediaDescriptorChangeSet(
|
||||||
|
context,
|
||||||
|
MediaDescriptor(track_descriptors=[target_track]),
|
||||||
|
MediaDescriptor(track_descriptors=[source_track]),
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata_tokens = change_set.generateMetadataTokens()
|
||||||
|
|
||||||
|
self.assertIn("language=deu", metadata_tokens)
|
||||||
|
self.assertIn("title=Deutsch", metadata_tokens)
|
||||||
|
|
||||||
|
def test_changed_track_language_does_not_autofill_title_when_title_already_exists(self):
|
||||||
|
set_current_language("de")
|
||||||
|
context = {
|
||||||
|
"logger": get_ffx_logger(),
|
||||||
|
"config": StaticConfig({}),
|
||||||
|
}
|
||||||
|
|
||||||
|
source_track = TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
tags={"language": "ger", "title": "Deutsch [FN]"},
|
||||||
|
)
|
||||||
|
target_track = TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
tags={"language": "jpn", "title": "Deutsch [FN]"},
|
||||||
|
)
|
||||||
|
|
||||||
|
change_set = MediaDescriptorChangeSet(
|
||||||
|
context,
|
||||||
|
MediaDescriptor(track_descriptors=[target_track]),
|
||||||
|
MediaDescriptor(track_descriptors=[source_track]),
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata_tokens = change_set.generateMetadataTokens()
|
||||||
|
|
||||||
|
self.assertIn("language=jpn", metadata_tokens)
|
||||||
|
self.assertNotIn("title=Japanisch", metadata_tokens)
|
||||||
self.assertNotIn("title=Deutsch", metadata_tokens)
|
self.assertNotIn("title=Deutsch", metadata_tokens)
|
||||||
|
|
||||||
def test_target_only_tracks_still_emit_remove_tokens_for_configured_stream_keys(self):
|
def test_target_only_tracks_still_emit_remove_tokens_for_configured_stream_keys(self):
|
||||||
@@ -368,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()
|
||||||
@@ -15,9 +15,11 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
|
|
||||||
|
|
||||||
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||||
|
from ffx.helper import LogLevel # noqa: E402
|
||||||
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||||
from ffx.metadata_editor import ( # noqa: E402
|
from ffx.metadata_editor import ( # noqa: E402
|
||||||
apply_metadata_edits,
|
apply_metadata_edits,
|
||||||
|
build_metadata_edit_command,
|
||||||
build_metadata_edit_context,
|
build_metadata_edit_context,
|
||||||
create_temporary_output_path,
|
create_temporary_output_path,
|
||||||
)
|
)
|
||||||
@@ -32,6 +34,16 @@ class StaticConfig:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationCollector:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.messages: list[str] = []
|
||||||
|
self.levels: list[LogLevel | None] = []
|
||||||
|
|
||||||
|
def __call__(self, message: str, level: LogLevel | None = None) -> None:
|
||||||
|
self.messages.append(message)
|
||||||
|
self.levels.append(level)
|
||||||
|
|
||||||
|
|
||||||
def make_context(*, dry_run: bool = False) -> dict:
|
def make_context(*, dry_run: bool = False) -> dict:
|
||||||
return {
|
return {
|
||||||
"logger": get_ffx_logger(),
|
"logger": get_ffx_logger(),
|
||||||
@@ -77,15 +89,45 @@ class MetadataEditorTests(unittest.TestCase):
|
|||||||
self.assertEqual(".mkv", Path(temporary_path).suffix)
|
self.assertEqual(".mkv", Path(temporary_path).suffix)
|
||||||
self.assertEqual(Path(source_path).parent, Path(temporary_path).parent)
|
self.assertEqual(Path(source_path).parent, Path(temporary_path).parent)
|
||||||
|
|
||||||
|
def test_build_metadata_edit_command_maps_all_streams_and_uses_single_copy_codec(self):
|
||||||
|
context = build_metadata_edit_context(make_context())
|
||||||
|
baseline_descriptor = make_descriptor()
|
||||||
|
draft_descriptor = baseline_descriptor.clone(context=context)
|
||||||
|
|
||||||
|
command = build_metadata_edit_command(
|
||||||
|
context,
|
||||||
|
"/tmp/example.mkv",
|
||||||
|
"/tmp/.edit.mkv",
|
||||||
|
baseline_descriptor,
|
||||||
|
draft_descriptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, command.count("-map"))
|
||||||
|
self.assertEqual(1, command.count("-c"))
|
||||||
|
self.assertNotIn("-c:v:0", command)
|
||||||
|
self.assertNotIn("-c:a:0", command)
|
||||||
|
self.assertNotIn("-c:s:0", command)
|
||||||
|
self.assertEqual(
|
||||||
|
["-map", "0", "-c", "copy"],
|
||||||
|
command[command.index("-map"):command.index("-c") + 2],
|
||||||
|
)
|
||||||
|
|
||||||
def test_apply_metadata_edits_rewrites_via_temporary_file_then_replaces_source(self):
|
def test_apply_metadata_edits_rewrites_via_temporary_file_then_replaces_source(self):
|
||||||
context = make_context()
|
context = make_context()
|
||||||
baseline_descriptor = make_descriptor()
|
baseline_descriptor = make_descriptor()
|
||||||
draft_descriptor = baseline_descriptor.clone(context=context)
|
draft_descriptor = baseline_descriptor.clone(context=context)
|
||||||
source_path = "/tmp/example.mkv"
|
source_path = "/tmp/example.mkv"
|
||||||
|
expected_command = build_metadata_edit_command(
|
||||||
|
build_metadata_edit_context(context),
|
||||||
|
source_path,
|
||||||
|
"/tmp/.edit.mkv",
|
||||||
|
baseline_descriptor,
|
||||||
|
draft_descriptor,
|
||||||
|
)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"),
|
patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"),
|
||||||
patch("ffx.metadata_editor.FfxController.runJob") as mocked_run_job,
|
patch("ffx.metadata_editor.executeProcess", return_value=("", "", 0)) as mocked_execute,
|
||||||
patch("ffx.metadata_editor.os.replace") as mocked_replace,
|
patch("ffx.metadata_editor.os.replace") as mocked_replace,
|
||||||
):
|
):
|
||||||
result = apply_metadata_edits(
|
result = apply_metadata_edits(
|
||||||
@@ -95,32 +137,43 @@ class MetadataEditorTests(unittest.TestCase):
|
|||||||
draft_descriptor,
|
draft_descriptor,
|
||||||
)
|
)
|
||||||
|
|
||||||
mocked_run_job.assert_called_once_with(
|
mocked_execute.assert_called_once_with(expected_command, context=build_metadata_edit_context(context))
|
||||||
source_path,
|
|
||||||
"/tmp/.edit.mkv",
|
|
||||||
targetFormat="",
|
|
||||||
chainIteration=[],
|
|
||||||
)
|
|
||||||
mocked_replace.assert_called_once_with("/tmp/.edit.mkv", source_path)
|
mocked_replace.assert_called_once_with("/tmp/.edit.mkv", source_path)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{
|
{
|
||||||
"applied": True,
|
"applied": True,
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
"target_path": source_path,
|
"target_path": source_path,
|
||||||
|
"command_sequence": expected_command,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"applied": result["applied"],
|
||||||
|
"dry_run": result["dry_run"],
|
||||||
|
"target_path": result["target_path"],
|
||||||
|
"command_sequence": result["command_sequence"],
|
||||||
},
|
},
|
||||||
result,
|
|
||||||
)
|
)
|
||||||
|
self.assertIn("timings", result)
|
||||||
|
self.assertIn("ffmpeg_seconds", result["timings"])
|
||||||
|
self.assertIn("replace_seconds", result["timings"])
|
||||||
|
self.assertIn("write_seconds", result["timings"])
|
||||||
|
|
||||||
def test_apply_metadata_edits_dry_run_skips_replace_and_cleans_temp_path(self):
|
def test_apply_metadata_edits_dry_run_skips_replace_and_cleans_temp_path(self):
|
||||||
context = make_context(dry_run=True)
|
context = make_context(dry_run=True)
|
||||||
baseline_descriptor = make_descriptor()
|
baseline_descriptor = make_descriptor()
|
||||||
draft_descriptor = baseline_descriptor.clone(context=context)
|
draft_descriptor = baseline_descriptor.clone(context=context)
|
||||||
|
notifications = NotificationCollector()
|
||||||
|
expected_command = build_metadata_edit_command(
|
||||||
|
build_metadata_edit_context(context),
|
||||||
|
"/tmp/example.mkv",
|
||||||
|
"/tmp/.edit.mkv",
|
||||||
|
baseline_descriptor,
|
||||||
|
draft_descriptor,
|
||||||
|
)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"),
|
patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"),
|
||||||
patch("ffx.metadata_editor.FfxController.runJob") as mocked_run_job,
|
patch("ffx.metadata_editor.executeProcess") as mocked_execute,
|
||||||
patch("ffx.metadata_editor.os.path.exists", return_value=True),
|
|
||||||
patch("ffx.metadata_editor.os.remove") as mocked_remove,
|
|
||||||
patch("ffx.metadata_editor.os.replace") as mocked_replace,
|
patch("ffx.metadata_editor.os.replace") as mocked_replace,
|
||||||
):
|
):
|
||||||
result = apply_metadata_edits(
|
result = apply_metadata_edits(
|
||||||
@@ -128,19 +181,59 @@ class MetadataEditorTests(unittest.TestCase):
|
|||||||
"/tmp/example.mkv",
|
"/tmp/example.mkv",
|
||||||
baseline_descriptor,
|
baseline_descriptor,
|
||||||
draft_descriptor,
|
draft_descriptor,
|
||||||
|
loggingHandler = notifications,
|
||||||
)
|
)
|
||||||
|
|
||||||
mocked_run_job.assert_called_once()
|
mocked_execute.assert_not_called()
|
||||||
mocked_replace.assert_not_called()
|
mocked_replace.assert_not_called()
|
||||||
mocked_remove.assert_called_once_with("/tmp/.edit.mkv")
|
self.assertEqual(["ffmpeg dry-run prepared."], notifications.messages)
|
||||||
|
self.assertEqual([None], notifications.levels)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{
|
{
|
||||||
"applied": False,
|
"applied": False,
|
||||||
"dry_run": True,
|
"dry_run": True,
|
||||||
"target_path": "/tmp/.edit.mkv",
|
"target_path": "/tmp/.edit.mkv",
|
||||||
|
"command_sequence": expected_command,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"applied": result["applied"],
|
||||||
|
"dry_run": result["dry_run"],
|
||||||
|
"target_path": result["target_path"],
|
||||||
|
"command_sequence": result["command_sequence"],
|
||||||
},
|
},
|
||||||
result,
|
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"ffmpeg_seconds": 0.0,
|
||||||
|
"replace_seconds": 0.0,
|
||||||
|
"write_seconds": 0.0,
|
||||||
|
},
|
||||||
|
result["timings"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_apply_metadata_edits_notifies_with_command_when_verbose(self):
|
||||||
|
context = make_context()
|
||||||
|
context["verbosity"] = 1
|
||||||
|
baseline_descriptor = make_descriptor()
|
||||||
|
draft_descriptor = baseline_descriptor.clone(context=context)
|
||||||
|
notifications = NotificationCollector()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"),
|
||||||
|
patch("ffx.metadata_editor.executeProcess", return_value=("", "", 0)),
|
||||||
|
patch("ffx.metadata_editor.os.replace"),
|
||||||
|
):
|
||||||
|
apply_metadata_edits(
|
||||||
|
context,
|
||||||
|
"/tmp/example.mkv",
|
||||||
|
baseline_descriptor,
|
||||||
|
draft_descriptor,
|
||||||
|
loggingHandler = notifications,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(notifications.messages))
|
||||||
|
self.assertTrue(notifications.messages[0].startswith("ffmpeg: ffmpeg "))
|
||||||
|
self.assertEqual([LogLevel.DEBUG], notifications.levels)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -65,6 +65,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 +73,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
|
||||||
@@ -193,6 +195,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$")
|
||||||
|
|
||||||
@@ -227,6 +300,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$")
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -51,6 +52,33 @@ class ProcessTests(unittest.TestCase):
|
|||||||
self.assertIn("Command timed out", err)
|
self.assertIn("Command timed out", err)
|
||||||
self.assertIn(sys.executable, err)
|
self.assertIn(sys.executable, err)
|
||||||
|
|
||||||
|
def test_execute_process_can_stop_early_while_streaming_stderr(self):
|
||||||
|
start = time.monotonic()
|
||||||
|
observed_lines = []
|
||||||
|
|
||||||
|
out, err, rc = executeProcess(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-c",
|
||||||
|
(
|
||||||
|
"import sys, time; "
|
||||||
|
"sys.stderr.write('fatal warning\\n'); sys.stderr.flush(); "
|
||||||
|
"time.sleep(2); "
|
||||||
|
"sys.stderr.write('late line\\n'); sys.stderr.flush()"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
stderrLineHandler=lambda line: observed_lines.append(line) or ("fatal warning" in line),
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
|
||||||
|
self.assertLess(elapsed, 1.5)
|
||||||
|
self.assertNotEqual(0, rc)
|
||||||
|
self.assertEqual("", out)
|
||||||
|
self.assertIn("fatal warning", err)
|
||||||
|
self.assertNotIn("late line", err)
|
||||||
|
self.assertEqual(["fatal warning\n"], observed_lines)
|
||||||
|
|
||||||
def test_get_wrapped_command_sequence_leaves_command_unwrapped_when_limits_disabled(self):
|
def test_get_wrapped_command_sequence_leaves_command_unwrapped_when_limits_disabled(self):
|
||||||
wrapped = getWrappedCommandSequence(
|
wrapped = getWrappedCommandSequence(
|
||||||
["ffmpeg", "-i", "input.mkv"],
|
["ffmpeg", "-i", "input.mkv"],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -57,9 +58,38 @@ class FakeScreen:
|
|||||||
self.app = FakeApp(screen_stack)
|
self.app = FakeApp(screen_stack)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRichLog:
|
||||||
|
def __init__(self):
|
||||||
|
self.messages = []
|
||||||
|
|
||||||
|
def write(self, message):
|
||||||
|
self.messages.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScreenWithLog:
|
||||||
|
def __init__(self):
|
||||||
|
self.log_view = FakeRichLog()
|
||||||
|
|
||||||
|
def query_one(self, selector, _widget_type=None):
|
||||||
|
if selector == f"#{screen_support.SCREEN_LOG_VIEW_ID}":
|
||||||
|
return self.log_view
|
||||||
|
raise LookupError(selector)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeThreadedApp:
|
||||||
|
def __init__(self, screen):
|
||||||
|
self.screen = screen
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def call_from_thread(self, func, *args):
|
||||||
|
self.calls.append((func, args))
|
||||||
|
return func(*args)
|
||||||
|
|
||||||
|
|
||||||
class ScreenSupportTests(unittest.TestCase):
|
class ScreenSupportTests(unittest.TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
set_current_language("de")
|
set_current_language("de")
|
||||||
|
screen_support.set_screen_log_pane_enabled(False)
|
||||||
|
|
||||||
def make_context(self):
|
def make_context(self):
|
||||||
return {
|
return {
|
||||||
@@ -168,6 +198,63 @@ class ScreenSupportTests(unittest.TestCase):
|
|||||||
self.assertGreater(len(translated), 8)
|
self.assertGreater(len(translated), 8)
|
||||||
self.assertEqual(len(translated) + 2, screen_support.localized_column_width(translated, 8))
|
self.assertEqual(len(translated) + 2, screen_support.localized_column_width(translated, 8))
|
||||||
|
|
||||||
|
def test_build_screen_log_pane_is_hidden_when_debug_mode_is_disabled(self):
|
||||||
|
screen_support.set_screen_log_pane_enabled(False)
|
||||||
|
|
||||||
|
log_pane = screen_support.build_screen_log_pane()
|
||||||
|
|
||||||
|
self.assertFalse(log_pane.display)
|
||||||
|
|
||||||
|
def test_build_screen_log_pane_is_collapsed_when_debug_mode_is_enabled(self):
|
||||||
|
screen_support.set_screen_log_pane_enabled(True)
|
||||||
|
|
||||||
|
log_pane = screen_support.build_screen_log_pane()
|
||||||
|
|
||||||
|
self.assertIsInstance(log_pane, screen_support.ResizableScreenLogPane)
|
||||||
|
self.assertEqual(screen_support.SCREEN_LOG_PANE_ID, log_pane.id)
|
||||||
|
self.assertTrue(log_pane.collapsed)
|
||||||
|
|
||||||
|
def test_resizable_screen_log_pane_clamps_height_to_minimum(self):
|
||||||
|
log_pane = screen_support.ResizableScreenLogPane()
|
||||||
|
|
||||||
|
log_pane.set_log_height(1)
|
||||||
|
|
||||||
|
self.assertEqual(screen_support.SCREEN_LOG_MIN_HEIGHT, log_pane.get_log_height())
|
||||||
|
|
||||||
|
def test_configure_screen_log_handler_routes_logger_messages_to_active_screen(self):
|
||||||
|
logger_name = "ffx-test-screen-log-handler"
|
||||||
|
logger = logging.getLogger(logger_name)
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
for handler in list(logger.handlers):
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
|
||||||
|
screen = FakeScreenWithLog()
|
||||||
|
app = FakeThreadedApp(screen)
|
||||||
|
|
||||||
|
try:
|
||||||
|
handler = screen_support.configure_screen_log_handler(
|
||||||
|
logger,
|
||||||
|
app,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(handler)
|
||||||
|
|
||||||
|
logger.info("hello pane")
|
||||||
|
|
||||||
|
self.assertEqual(1, len(screen.log_view.messages))
|
||||||
|
self.assertRegex(
|
||||||
|
screen.log_view.messages[0],
|
||||||
|
r"^ffx-test-screen-log-handler\s+INFO\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \| hello pane$",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
screen_support.configure_screen_log_handler(logger, app, enabled=False)
|
||||||
|
for handler in list(logger.handlers):
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ 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.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_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
|
||||||
@@ -89,16 +93,21 @@ class FakeTagTable:
|
|||||||
|
|
||||||
|
|
||||||
class FakeMediaDescriptor:
|
class FakeMediaDescriptor:
|
||||||
def __init__(self, track_descriptors):
|
def __init__(self, track_descriptors, tags=None):
|
||||||
self._track_descriptors = list(track_descriptors)
|
self._track_descriptors = list(track_descriptors)
|
||||||
|
self._tags = dict(tags or {})
|
||||||
|
|
||||||
def getTrackDescriptors(self):
|
def getTrackDescriptors(self):
|
||||||
return list(self._track_descriptors)
|
return list(self._track_descriptors)
|
||||||
|
|
||||||
|
def getTags(self):
|
||||||
|
return dict(self._tags)
|
||||||
|
|
||||||
|
|
||||||
class FakeValueWidget:
|
class FakeValueWidget:
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
self.value = value
|
self.value = value
|
||||||
|
self.disabled = False
|
||||||
|
|
||||||
|
|
||||||
class FakeInputWidget:
|
class FakeInputWidget:
|
||||||
@@ -106,18 +115,31 @@ class FakeInputWidget:
|
|||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStaticWidget:
|
||||||
|
def __init__(self, value=""):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
def update(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -168,6 +190,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": FakeSelectionListWidget({0}),
|
||||||
"#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}),
|
||||||
@@ -181,6 +204,34 @@ 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):
|
||||||
|
screen = object.__new__(TrackDetailsScreen)
|
||||||
|
screen.context = {"logger": get_ffx_logger()}
|
||||||
|
screen._TrackDetailsScreen__trackDescriptor = None
|
||||||
|
screen._TrackDetailsScreen__patternId = 5
|
||||||
|
screen._TrackDetailsScreen__index = 4
|
||||||
|
screen._TrackDetailsScreen__subIndex = 0
|
||||||
|
screen._TrackDetailsScreen__trackCodec = TrackCodec.UNKNOWN
|
||||||
|
screen._TrackDetailsScreen__attachmentFormat = AttachmentFormat.TTF
|
||||||
|
screen._TrackDetailsScreen__draftTrackTags = {"filename": "font.ttf", "mimetype": "font/ttf"}
|
||||||
|
|
||||||
|
widgets = {
|
||||||
|
"#type_select": FakeValueWidget(TrackType.ATTACHMENT),
|
||||||
|
"#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_UNDEFINED),
|
||||||
|
"#drop_checkbox": FakeSelectionListWidget(set()),
|
||||||
|
"#language_select": FakeValueWidget(Select.NULL),
|
||||||
|
"#title_input": FakeInputWidget(""),
|
||||||
|
"#dispositions_selection_list": FakeSelectionListWidget(set()),
|
||||||
|
}
|
||||||
|
screen.query_one = lambda selector, _widget_type=None: widgets[selector]
|
||||||
|
|
||||||
|
descriptor = screen.getTrackDescriptorFromInput()
|
||||||
|
|
||||||
|
self.assertEqual(TrackType.ATTACHMENT, descriptor.getType())
|
||||||
|
self.assertEqual(AttachmentFormat.TTF, descriptor.getAttachmentFormat())
|
||||||
|
self.assertEqual(TrackCodec.UNKNOWN, descriptor.getCodec())
|
||||||
|
|
||||||
def test_track_details_screen_auto_sets_localized_title_from_selected_language(self):
|
def test_track_details_screen_auto_sets_localized_title_from_selected_language(self):
|
||||||
set_current_language("de")
|
set_current_language("de")
|
||||||
@@ -244,6 +295,51 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual("Preset", widgets["#title_input"].value)
|
self.assertEqual("Preset", widgets["#title_input"].value)
|
||||||
|
|
||||||
|
def test_track_details_screen_metadata_only_mount_shows_normalized_title_preview(self):
|
||||||
|
set_current_language("de")
|
||||||
|
screen = object.__new__(TrackDetailsScreen)
|
||||||
|
screen._TrackDetailsScreen__index = 2
|
||||||
|
screen._TrackDetailsScreen__subIndex = 0
|
||||||
|
screen._TrackDetailsScreen__patternLabel = "demo"
|
||||||
|
screen._TrackDetailsScreen__trackType = TrackType.AUDIO
|
||||||
|
screen._TrackDetailsScreen__audioLayout = AudioLayout.LAYOUT_STEREO
|
||||||
|
screen._TrackDetailsScreen__trackDescriptor = TrackDescriptor(
|
||||||
|
index=2,
|
||||||
|
source_index=2,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.AUDIO,
|
||||||
|
codec_name=TrackCodec.DTS,
|
||||||
|
audio_layout=AudioLayout.LAYOUT_STEREO,
|
||||||
|
tags={"language": "ger"},
|
||||||
|
)
|
||||||
|
screen._TrackDetailsScreen__dropped = False
|
||||||
|
screen._TrackDetailsScreen__metadataOnly = True
|
||||||
|
screen._TrackDetailsScreen__titleAutoManaged = True
|
||||||
|
screen._TrackDetailsScreen__suppressTitleChanged = False
|
||||||
|
screen._TrackDetailsScreen__lastAutoTitle = ""
|
||||||
|
screen._TrackDetailsScreen__removeTrackKeys = []
|
||||||
|
screen._TrackDetailsScreen__ignoreTrackKeys = []
|
||||||
|
screen._TrackDetailsScreen__draftTrackTags = {}
|
||||||
|
screen._TrackDetailsScreen__tagRowData = {}
|
||||||
|
screen.updateTags = lambda: None
|
||||||
|
|
||||||
|
widgets = {
|
||||||
|
"#index_label": FakeStaticWidget(),
|
||||||
|
"#subindex_label": FakeStaticWidget(),
|
||||||
|
"#pattern_label": FakeStaticWidget(),
|
||||||
|
"#type_select": FakeValueWidget(None),
|
||||||
|
"#audio_layout_select": FakeValueWidget(None),
|
||||||
|
"#drop_checkbox": FakeSelectionListWidget(set()),
|
||||||
|
"#dispositions_selection_list": FakeSelectionListWidget(set()),
|
||||||
|
"#language_select": FakeValueWidget(None),
|
||||||
|
"#title_input": FakeInputWidget(""),
|
||||||
|
}
|
||||||
|
screen.query_one = lambda selector, _widget_type=None: widgets[selector]
|
||||||
|
|
||||||
|
screen.on_mount()
|
||||||
|
|
||||||
|
self.assertEqual("Deutsch", widgets["#title_input"].value)
|
||||||
|
|
||||||
def test_track_details_screen_language_options_are_sorted_by_localized_label(self):
|
def test_track_details_screen_language_options_are_sorted_by_localized_label(self):
|
||||||
set_current_language("de")
|
set_current_language("de")
|
||||||
|
|
||||||
@@ -266,7 +362,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()
|
||||||
@@ -278,6 +374,7 @@ 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_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)
|
||||||
@@ -326,12 +423,224 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
screen.tracksTable = FakeTagTable()
|
screen.tracksTable = FakeTagTable()
|
||||||
screen._sourceMediaDescriptor = FakeMediaDescriptor([first_track])
|
screen._sourceMediaDescriptor = FakeMediaDescriptor([first_track])
|
||||||
screen._trackRowData = {}
|
screen._trackRowData = {}
|
||||||
|
screen._applyNormalization = False
|
||||||
|
|
||||||
screen.updateTracks()
|
screen.updateTracks()
|
||||||
|
|
||||||
self.assertEqual(9, len(screen.tracksTable.columns))
|
self.assertEqual(9, len(screen.tracksTable.columns))
|
||||||
self.assertIn("A much longer updated title", screen.tracksTable.rows["row-0"])
|
self.assertIn("A much longer updated title", screen.tracksTable.rows["row-0"])
|
||||||
|
|
||||||
|
def test_media_edit_screen_shows_normalized_audio_title_preview(self):
|
||||||
|
set_current_language("de")
|
||||||
|
audio_track = TrackDescriptor(
|
||||||
|
index=1,
|
||||||
|
source_index=1,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.AUDIO,
|
||||||
|
codec_name=TrackCodec.DTS,
|
||||||
|
audio_layout=AudioLayout.LAYOUT_STEREO,
|
||||||
|
tags={"language": "ger"},
|
||||||
|
)
|
||||||
|
|
||||||
|
screen = object.__new__(MediaEditScreen)
|
||||||
|
screen.tracksTable = FakeTagTable()
|
||||||
|
screen._sourceMediaDescriptor = FakeMediaDescriptor([audio_track])
|
||||||
|
screen._trackRowData = {}
|
||||||
|
screen._applyNormalization = True
|
||||||
|
|
||||||
|
screen.updateTracks()
|
||||||
|
|
||||||
|
self.assertIn("Deutsch", screen.tracksTable.rows["row-0"])
|
||||||
|
|
||||||
|
def test_media_edit_screen_shows_normalized_video_title_preview(self):
|
||||||
|
set_current_language("de")
|
||||||
|
video_track = TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.VIDEO,
|
||||||
|
codec_name=TrackCodec.H264,
|
||||||
|
tags={"language": "ger"},
|
||||||
|
)
|
||||||
|
|
||||||
|
screen = object.__new__(MediaEditScreen)
|
||||||
|
screen.tracksTable = FakeTagTable()
|
||||||
|
screen._sourceMediaDescriptor = FakeMediaDescriptor([video_track])
|
||||||
|
screen._trackRowData = {}
|
||||||
|
screen._applyNormalization = True
|
||||||
|
|
||||||
|
screen.updateTracks()
|
||||||
|
|
||||||
|
self.assertIn("Deutsch", screen.tracksTable.rows["row-0"])
|
||||||
|
|
||||||
|
def test_media_edit_screen_toggle_normalization_refreshes_tracks(self):
|
||||||
|
screen = object.__new__(MediaEditScreen)
|
||||||
|
screen._applyNormalization = False
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
screen.setApplyNormalization = lambda enabled: (
|
||||||
|
setattr(screen, "_applyNormalization", bool(enabled)),
|
||||||
|
calls.append("setApplyNormalization"),
|
||||||
|
)
|
||||||
|
screen.updateToggleButtons = lambda: calls.append("updateToggleButtons")
|
||||||
|
screen.updateTracks = lambda: calls.append("updateTracks")
|
||||||
|
screen.updateDifferences = lambda: calls.append("updateDifferences")
|
||||||
|
screen.setMessage = lambda _message: calls.append("setMessage")
|
||||||
|
|
||||||
|
screen.action_toggle_normalization()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"setApplyNormalization",
|
||||||
|
"updateToggleButtons",
|
||||||
|
"updateTracks",
|
||||||
|
"updateDifferences",
|
||||||
|
"setMessage",
|
||||||
|
],
|
||||||
|
calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_media_edit_screen_handle_edit_track_updates_draft_descriptor(self):
|
||||||
|
original_track = TrackDescriptor(
|
||||||
|
index=1,
|
||||||
|
source_index=1,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.UNKNOWN,
|
||||||
|
tags={"language": "ger"},
|
||||||
|
)
|
||||||
|
context = {"logger": get_ffx_logger()}
|
||||||
|
updated_track = original_track.clone(context=context)
|
||||||
|
updated_track.getTags()["language"] = "eng"
|
||||||
|
|
||||||
|
screen = object.__new__(MediaEditScreen)
|
||||||
|
screen.context = context
|
||||||
|
screen._sourceMediaDescriptor = MediaDescriptor(
|
||||||
|
context=context,
|
||||||
|
track_descriptors=[original_track],
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
screen.setMessage = lambda _message: calls.append("setMessage")
|
||||||
|
screen.refreshAfterDraftChange = lambda: calls.append("refreshAfterDraftChange")
|
||||||
|
|
||||||
|
screen.handle_edit_track(updated_track)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
"eng",
|
||||||
|
screen._sourceMediaDescriptor.getTrackDescriptors()[0].getTags()["language"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["setMessage", "refreshAfterDraftChange"],
|
||||||
|
calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_media_edit_screen_screen_resume_refreshes_draft_tables(self):
|
||||||
|
screen = object.__new__(MediaEditScreen)
|
||||||
|
screen.tracksTable = FakeTagTable()
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
screen.refreshAfterDraftChange = lambda: calls.append("refreshAfterDraftChange")
|
||||||
|
screen.updateToggleButtons = lambda: calls.append("updateToggleButtons")
|
||||||
|
|
||||||
|
screen.on_screen_resume(None)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["refreshAfterDraftChange", "updateToggleButtons"],
|
||||||
|
calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pattern_details_screen_screen_resume_refreshes_tables(self):
|
||||||
|
screen = object.__new__(PatternDetailsScreen)
|
||||||
|
screen.tracksTable = FakeTagTable()
|
||||||
|
screen.tagsTable = FakeTagTable()
|
||||||
|
screen.shiftedSeasonsTable = FakeTagTable()
|
||||||
|
screen._PatternDetailsScreen__pattern = object()
|
||||||
|
screen._PatternDetailsScreen__showDescriptor = None
|
||||||
|
widgets = {
|
||||||
|
"#show_quality_hint": FakeStaticWidget(),
|
||||||
|
}
|
||||||
|
screen.query_one = lambda selector, _type=None: widgets[selector]
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
screen.updateTags = lambda: calls.append("updateTags")
|
||||||
|
screen.updateTracks = lambda: calls.append("updateTracks")
|
||||||
|
screen.updateShiftedSeasons = lambda: calls.append("updateShiftedSeasons")
|
||||||
|
|
||||||
|
screen.on_screen_resume(None)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["updateTags", "updateTracks", "updateShiftedSeasons"],
|
||||||
|
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):
|
||||||
|
screen = object.__new__(InspectDetailsScreen)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
screen.reloadProperties = lambda reset_draft=True: calls.append(
|
||||||
|
("reloadProperties", reset_draft)
|
||||||
|
)
|
||||||
|
screen._currentPattern = None
|
||||||
|
screen.updateMediaTags = lambda: calls.append("updateMediaTags")
|
||||||
|
screen.updateTracks = lambda: calls.append("updateTracks")
|
||||||
|
screen.updateDifferences = lambda: calls.append("updateDifferences")
|
||||||
|
|
||||||
|
screen.handle_edit_pattern(None)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
("reloadProperties", True),
|
||||||
|
"updateMediaTags",
|
||||||
|
"updateTracks",
|
||||||
|
"updateDifferences",
|
||||||
|
],
|
||||||
|
calls,
|
||||||
|
)
|
||||||
|
|
||||||
def test_pattern_details_screen_reads_selected_shifted_season_from_row_mapping(self):
|
def test_pattern_details_screen_reads_selected_shifted_season_from_row_mapping(self):
|
||||||
screen = object.__new__(PatternDetailsScreen)
|
screen = object.__new__(PatternDetailsScreen)
|
||||||
screen.shiftedSeasonsTable = FakeTagTable()
|
screen.shiftedSeasonsTable = FakeTagTable()
|
||||||
@@ -438,6 +747,263 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
self.assertNotIn(placeholder_key, screen._showRowData)
|
self.assertNotIn(placeholder_key, screen._showRowData)
|
||||||
self.assertEqual(0, screen.getRowIndexFromShowId(8))
|
self.assertEqual(0, screen.getRowIndexFromShowId(8))
|
||||||
|
|
||||||
|
def test_inspect_details_screen_update_tracks_shows_target_pattern_tracks(self):
|
||||||
|
source_track = TrackDescriptor(
|
||||||
|
index=1,
|
||||||
|
source_index=1,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.UNKNOWN,
|
||||||
|
tags={"language": "ger", "title": "German Full"},
|
||||||
|
)
|
||||||
|
target_track = TrackDescriptor(
|
||||||
|
index=1,
|
||||||
|
source_index=1,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.UNKNOWN,
|
||||||
|
tags={"language": "eng", "title": "English Full"},
|
||||||
|
)
|
||||||
|
|
||||||
|
screen = object.__new__(InspectDetailsScreen)
|
||||||
|
screen.tracksTable = FakeTagTable()
|
||||||
|
screen._sourceMediaDescriptor = FakeMediaDescriptor([source_track])
|
||||||
|
screen._targetMediaDescriptor = FakeMediaDescriptor([target_track])
|
||||||
|
screen._currentPattern = object()
|
||||||
|
screen._trackRowData = {}
|
||||||
|
screen._applyNormalization = False
|
||||||
|
|
||||||
|
screen.updateTracks()
|
||||||
|
|
||||||
|
self.assertIn("English Full", screen.tracksTable.rows["row-0"])
|
||||||
|
self.assertIs(target_track, screen.getSelectedTrackDescriptor())
|
||||||
|
|
||||||
|
def test_inspect_details_screen_update_tracks_shows_attachment_format_and_blanks_language(self):
|
||||||
|
attachment_track = TrackDescriptor(
|
||||||
|
index=4,
|
||||||
|
source_index=4,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
tags={"filename": "font.ttf", "mimetype": "font/ttf"},
|
||||||
|
)
|
||||||
|
|
||||||
|
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("4", row[0])
|
||||||
|
self.assertEqual("TTF", row[3])
|
||||||
|
self.assertEqual(" ", row[5])
|
||||||
|
self.assertEqual(" ", row[7])
|
||||||
|
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):
|
||||||
|
source_track = TrackDescriptor(
|
||||||
|
index=3,
|
||||||
|
source_index=7,
|
||||||
|
sub_index=1,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.UNKNOWN,
|
||||||
|
tags={"language": "ger"},
|
||||||
|
)
|
||||||
|
target_track = TrackDescriptor(
|
||||||
|
index=1,
|
||||||
|
source_index=7,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.UNKNOWN,
|
||||||
|
tags={"language": "eng"},
|
||||||
|
)
|
||||||
|
|
||||||
|
screen = object.__new__(InspectDetailsScreen)
|
||||||
|
screen.tracksTable = FakeTagTable()
|
||||||
|
screen._sourceMediaDescriptor = FakeMediaDescriptor([source_track])
|
||||||
|
screen._targetMediaDescriptor = FakeMediaDescriptor([target_track])
|
||||||
|
screen._currentPattern = object()
|
||||||
|
screen._trackRowData = {}
|
||||||
|
screen._applyNormalization = False
|
||||||
|
|
||||||
|
screen.updateTracks()
|
||||||
|
|
||||||
|
self.assertIs(source_track, screen.getTrackEditSourceDescriptor())
|
||||||
|
|
||||||
|
def test_inspect_details_screen_action_update_pattern_uses_existing_change_set_before_reload(self):
|
||||||
|
class _FakePattern:
|
||||||
|
def getPattern(self):
|
||||||
|
return r"demo_(s[0-9]+e[0-9]+)\.mkv"
|
||||||
|
|
||||||
|
def getId(self):
|
||||||
|
return 9
|
||||||
|
|
||||||
|
class _FakeTagController:
|
||||||
|
def __init__(self, calls):
|
||||||
|
self._calls = calls
|
||||||
|
|
||||||
|
def deleteMediaTagByKey(self, pattern_id, key):
|
||||||
|
self._calls.append(("deleteMediaTagByKey", pattern_id, key))
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
screen = object.__new__(InspectDetailsScreen)
|
||||||
|
screen._currentPattern = _FakePattern()
|
||||||
|
screen._mediaChangeSetObj = {
|
||||||
|
"tags": {
|
||||||
|
DIFF_ADDED_KEY: {"TITLE": "Demo"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
screen._tac = _FakeTagController(calls)
|
||||||
|
screen._tc = type(
|
||||||
|
"_FakeTrackController",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"addTrack": staticmethod(lambda *_args, **_kwargs: None),
|
||||||
|
"deleteTrack": staticmethod(lambda *_args, **_kwargs: None),
|
||||||
|
"setDispositionState": staticmethod(lambda *_args, **_kwargs: None),
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
screen._sourceMediaDescriptor = FakeMediaDescriptor([], tags={})
|
||||||
|
screen._targetMediaDescriptor = FakeMediaDescriptor([])
|
||||||
|
screen.getPatternObjFromInput = lambda: {
|
||||||
|
"show_id": 1,
|
||||||
|
"pattern": r"demo_(s[0-9]+e[0-9]+)\.mkv",
|
||||||
|
}
|
||||||
|
screen.reloadProperties = lambda reset_draft=True: calls.append(
|
||||||
|
("reloadProperties", reset_draft)
|
||||||
|
)
|
||||||
|
screen.updateMediaTags = lambda: calls.append("updateMediaTags")
|
||||||
|
screen.updateTracks = lambda: calls.append("updateTracks")
|
||||||
|
screen.updateDifferences = lambda: calls.append("updateDifferences")
|
||||||
|
|
||||||
|
screen.action_update_pattern()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
("deleteMediaTagByKey", 9, "TITLE"),
|
||||||
|
("reloadProperties", True),
|
||||||
|
"updateMediaTags",
|
||||||
|
"updateTracks",
|
||||||
|
"updateDifferences",
|
||||||
|
],
|
||||||
|
calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
25
tests/unit/test_track_codec_identification.py
Normal file
25
tests/unit/test_track_codec_identification.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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.track_codec import TrackCodec # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TrackCodecIdentificationTests(unittest.TestCase):
|
||||||
|
def test_identify_modern_webm_codecs(self):
|
||||||
|
self.assertEqual(TrackCodec.VP9, TrackCodec.identify("vp9"))
|
||||||
|
self.assertEqual(TrackCodec.OPUS, TrackCodec.identify("opus"))
|
||||||
|
self.assertEqual(TrackCodec.WEBVTT, TrackCodec.identify("webvtt"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
61
tests/unit/test_track_descriptor_probe.py
Normal file
61
tests/unit/test_track_descriptor_probe.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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.track_codec import TrackCodec # noqa: E402
|
||||||
|
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
||||||
|
from ffx.track_type import TrackType # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TrackDescriptorProbeTests(unittest.TestCase):
|
||||||
|
def test_attachment_without_codec_name_uses_font_metadata_to_identify_ttf(self):
|
||||||
|
descriptor = TrackDescriptor.fromFfprobe(
|
||||||
|
{
|
||||||
|
"index": 4,
|
||||||
|
"codec_type": "attachment",
|
||||||
|
"disposition": {"default": 0},
|
||||||
|
"tags": {
|
||||||
|
"filename": "AmazonEmberTanuki-Italic.ttf",
|
||||||
|
"mimetype": "font/ttf",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subIndex=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(descriptor)
|
||||||
|
self.assertEqual(TrackType.ATTACHMENT, descriptor.getType())
|
||||||
|
self.assertEqual(AttachmentFormat.TTF, descriptor.getAttachmentFormat())
|
||||||
|
self.assertEqual(AttachmentFormat.TTF, descriptor.getFormatDescriptor())
|
||||||
|
self.assertEqual(TrackCodec.UNKNOWN, descriptor.getCodec())
|
||||||
|
|
||||||
|
def test_attachment_without_codec_name_still_probes_as_unknown_when_not_font(self):
|
||||||
|
descriptor = TrackDescriptor.fromFfprobe(
|
||||||
|
{
|
||||||
|
"index": 9,
|
||||||
|
"codec_type": "attachment",
|
||||||
|
"disposition": {"default": 0},
|
||||||
|
"tags": {
|
||||||
|
"filename": "cover.bin",
|
||||||
|
"mimetype": "application/octet-stream",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subIndex=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(descriptor)
|
||||||
|
self.assertEqual(TrackType.ATTACHMENT, descriptor.getType())
|
||||||
|
self.assertEqual(AttachmentFormat.UNKNOWN, descriptor.getAttachmentFormat())
|
||||||
|
self.assertEqual(TrackCodec.UNKNOWN, descriptor.getCodec())
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -172,20 +172,84 @@ fetch_remote_state() {
|
|||||||
git fetch "${ORIGIN_REMOTE}" "${DEV_BRANCH}" "${MAIN_BRANCH}" --tags >/dev/null
|
git fetch "${ORIGIN_REMOTE}" "${DEV_BRANCH}" "${MAIN_BRANCH}" --tags >/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
require_branch_matches_remote() {
|
branch_divergence_counts() {
|
||||||
local branch="$1"
|
local branch="$1"
|
||||||
local local_sha=""
|
local remote_only=""
|
||||||
local remote_sha=""
|
local local_only=""
|
||||||
|
|
||||||
if ! git show-ref --verify --quiet "refs/remotes/${ORIGIN_REMOTE}/${branch}"; then
|
if ! git show-ref --verify --quiet "refs/remotes/${ORIGIN_REMOTE}/${branch}"; then
|
||||||
fail "Remote branch '${ORIGIN_REMOTE}/${branch}' does not exist."
|
fail "Remote branch '${ORIGIN_REMOTE}/${branch}' does not exist."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local_sha="$(git rev-parse "refs/heads/${branch}")"
|
read -r remote_only local_only < <(
|
||||||
remote_sha="$(git rev-parse "refs/remotes/${ORIGIN_REMOTE}/${branch}")"
|
git rev-list --left-right --count \
|
||||||
|
"refs/remotes/${ORIGIN_REMOTE}/${branch}...refs/heads/${branch}"
|
||||||
|
)
|
||||||
|
|
||||||
if [ "${local_sha}" != "${remote_sha}" ]; then
|
printf '%s %s\n' "${remote_only}" "${local_only}"
|
||||||
fail "Local branch '${branch}' is not up to date with '${ORIGIN_REMOTE}/${branch}'. Pull, rebase, or push first."
|
}
|
||||||
|
|
||||||
|
fast_forward_branch_to_remote() {
|
||||||
|
local branch="$1"
|
||||||
|
local remote_ref="refs/remotes/${ORIGIN_REMOTE}/${branch}"
|
||||||
|
local current_head=""
|
||||||
|
|
||||||
|
current_head="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
|
||||||
|
printf "Fast-forwarding local branch '%s' to '%s/%s'...\n" \
|
||||||
|
"${branch}" \
|
||||||
|
"${ORIGIN_REMOTE}" \
|
||||||
|
"${branch}"
|
||||||
|
|
||||||
|
if [ "${current_head}" = "${branch}" ]; then
|
||||||
|
git merge --ff-only "${remote_ref}" >/dev/null
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git branch -f "${branch}" "${remote_ref}" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_release_source_branch() {
|
||||||
|
local branch="$1"
|
||||||
|
local remote_only=""
|
||||||
|
local local_only=""
|
||||||
|
|
||||||
|
read -r remote_only local_only < <(branch_divergence_counts "${branch}")
|
||||||
|
|
||||||
|
if [ "${remote_only}" -ne 0 ] && [ "${local_only}" -ne 0 ]; then
|
||||||
|
fail "Local branch '${branch}' has diverged from '${ORIGIN_REMOTE}/${branch}' (${local_only} local-only commit(s), ${remote_only} remote-only commit(s)). Reconcile the branches first."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${remote_only}" -ne 0 ]; then
|
||||||
|
fast_forward_branch_to_remote "${branch}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${local_only}" -ne 0 ]; then
|
||||||
|
printf "Notice: local branch '%s' is ahead of '%s/%s' by %s commit(s); release will use the local tip.\n" \
|
||||||
|
"${branch}" \
|
||||||
|
"${ORIGIN_REMOTE}" \
|
||||||
|
"${branch}" \
|
||||||
|
"${local_only}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_release_target_branch() {
|
||||||
|
local branch="$1"
|
||||||
|
local remote_only=""
|
||||||
|
local local_only=""
|
||||||
|
|
||||||
|
read -r remote_only local_only < <(branch_divergence_counts "${branch}")
|
||||||
|
|
||||||
|
if [ "${remote_only}" -ne 0 ] && [ "${local_only}" -ne 0 ]; then
|
||||||
|
fail "Local branch '${branch}' has diverged from '${ORIGIN_REMOTE}/${branch}' (${local_only} local-only commit(s), ${remote_only} remote-only commit(s)). Reconcile the branches first."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${local_only}" -ne 0 ]; then
|
||||||
|
fail "Local branch '${branch}' is ahead of '${ORIGIN_REMOTE}/${branch}' by ${local_only} commit(s). Push or reconcile first so the release starts from the published ${branch} tip."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${remote_only}" -ne 0 ]; then
|
||||||
|
fast_forward_branch_to_remote "${branch}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,13 +313,11 @@ print_release_plan() {
|
|||||||
|
|
||||||
printf 'Dry run only. Planned steps:\n'
|
printf 'Dry run only. Planned steps:\n'
|
||||||
printf '1. Ensure current branch is %s and the worktree is clean.\n' "${DEV_BRANCH}"
|
printf '1. Ensure current branch is %s and the worktree is clean.\n' "${DEV_BRANCH}"
|
||||||
printf '2. Fetch %s and verify local %s and %s exactly match %s/%s and %s/%s.\n' \
|
printf '2. Fetch %s, fast-forward local %s and %s from %s when safe, and fail on divergence or unpublished local %s commits.\n' \
|
||||||
"${ORIGIN_REMOTE}" \
|
"${ORIGIN_REMOTE}" \
|
||||||
"${DEV_BRANCH}" \
|
"${DEV_BRANCH}" \
|
||||||
"${MAIN_BRANCH}" \
|
"${MAIN_BRANCH}" \
|
||||||
"${ORIGIN_REMOTE}" \
|
"${ORIGIN_REMOTE}" \
|
||||||
"${DEV_BRANCH}" \
|
|
||||||
"${ORIGIN_REMOTE}" \
|
|
||||||
"${MAIN_BRANCH}"
|
"${MAIN_BRANCH}"
|
||||||
if [ "${SKIP_TESTS}" -eq 1 ]; then
|
if [ "${SKIP_TESTS}" -eq 1 ]; then
|
||||||
printf '3. Skip the pre-release test gate.\n'
|
printf '3. Skip the pre-release test gate.\n'
|
||||||
@@ -304,8 +366,8 @@ require_repo_state
|
|||||||
require_dev_checkout
|
require_dev_checkout
|
||||||
require_clean_worktree
|
require_clean_worktree
|
||||||
fetch_remote_state
|
fetch_remote_state
|
||||||
require_branch_matches_remote "${DEV_BRANCH}"
|
sync_release_source_branch "${DEV_BRANCH}"
|
||||||
require_branch_matches_remote "${MAIN_BRANCH}"
|
sync_release_target_branch "${MAIN_BRANCH}"
|
||||||
|
|
||||||
RELEASE_VERSION="$(resolve_release_version)"
|
RELEASE_VERSION="$(resolve_release_version)"
|
||||||
RELEASE_TAG="v${RELEASE_VERSION}"
|
RELEASE_TAG="v${RELEASE_VERSION}"
|
||||||
@@ -341,8 +403,8 @@ fi
|
|||||||
run_pre_release_tests
|
run_pre_release_tests
|
||||||
require_clean_worktree
|
require_clean_worktree
|
||||||
fetch_remote_state
|
fetch_remote_state
|
||||||
require_branch_matches_remote "${DEV_BRANCH}"
|
sync_release_source_branch "${DEV_BRANCH}"
|
||||||
require_branch_matches_remote "${MAIN_BRANCH}"
|
sync_release_target_branch "${MAIN_BRANCH}"
|
||||||
require_release_tag_available "${RELEASE_VERSION}"
|
require_release_tag_available "${RELEASE_VERSION}"
|
||||||
|
|
||||||
git switch "${MAIN_BRANCH}" >/dev/null
|
git switch "${MAIN_BRANCH}" >/dev/null
|
||||||
|
|||||||
Reference in New Issue
Block a user