30 Commits

Author SHA1 Message Date
Javanaut
eaa3fc35a3 Release v0.4.5 2026-07-08 11:10:00 +02:00
Javanaut
f9432ec564 v0.4.5ff 2026-07-08 11:09:29 +02:00
Javanaut
8f5873fbab v0.4.5 2026-07-08 10:54:51 +02:00
Javanaut
12e5438d22 fix capture group 2026-07-08 10:46:08 +02:00
Javanaut
6f09c80aaf ffcb 2026-07-08 09:47:07 +02:00
Javanaut
f49d6f4838 ff 2026-07-08 09:37:11 +02:00
Javanaut
2aeeaac204 ffcb 2026-07-08 09:29:12 +02:00
Javanaut
8c2a4cae96 cb 2026-07-08 09:28:52 +02:00
Javanaut
cdc01062d3 ff 2026-07-08 09:24:44 +02:00
Javanaut
aa864dfd4d drop flag 2026-07-08 07:18:03 +02:00
Javanaut
697645be25 TF deleted subtracks 2026-07-07 17:53:43 +02:00
Javanaut
c0056b6c3c Extending deinterlace subparams to hex range 2026-06-22 13:58:55 +02:00
Javanaut
dd810ed41f Adds deinterlacer after-filters 2026-06-22 12:57:29 +02:00
Javanaut
f9d9db5871 adds up_down_temp mode for deinterlace 2026-06-22 11:46:45 +02:00
Javanaut
56f801631f adds aa filter to deinterlacer 2026-06-22 07:35:19 +02:00
Javanaut
89409907f9 fix deinterlace 2026-06-21 21:52:42 +02:00
Javanaut
68c4b23b2a Release v0.4.4 2026-06-21 12:13:18 +02:00
Javanaut
0415087e75 test fixes 0.4.4 2026-06-21 12:12:52 +02:00
Javanaut
20a9bb36b8 v.0.4.4 2026-06-21 12:09:04 +02:00
Javanaut
3ac139a2dc Impl --yes flag 2026-06-21 12:03:37 +02:00
Javanaut
912db3c39a fix M 2026-06-19 08:22:52 +02:00
Javanaut
8a375ccce1 Unmux output dir generation 2026-06-19 08:19:43 +02:00
Javanaut
176cfa06eb Prefixless subtitle sidecar files 2026-06-15 12:43:34 +02:00
Javanaut
f794f822f2 Merge branch 'dev' of gitea.maveno.de:Javanaut/ffx into dev 2026-06-15 11:17:21 +02:00
Javanaut
1a11710df7 Convert docs to sphinx 2026-06-15 11:14:21 +02:00
Javanaut
ac6e3020b2 Release v0.4.3 2026-05-22 21:25:19 +02:00
Javanaut
93d19629dc v0.4.3 2026-05-22 21:24:48 +02:00
Javanaut
db43501ce2 v0.4.3 2026-05-22 21:13:48 +02:00
Javanaut
87568989fe fix styled ASS 2026-05-22 21:04:50 +02:00
Javanaut
20ab08626b TF Fix styled ASS font tracks 2026-05-22 20:11:05 +02:00
49 changed files with 3632 additions and 447 deletions

2
.gitignore vendored
View File

@@ -1,7 +1,6 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
junk/ junk/
.vscode
.ipynb_checkpoints/ .ipynb_checkpoints/
tools/ansible/inventory/hawaii.yml tools/ansible/inventory/hawaii.yml
tools/ansible/inventory/peppermint.yml tools/ansible/inventory/peppermint.yml
@@ -17,6 +16,7 @@ dist/
*.egg-info/ *.egg-info/
.venv/ .venv/
venv/ venv/
docs/_build/
.codex .codex

11
.vscode/extensions.json vendored Normal file
View 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
View 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"
}

View File

@@ -99,95 +99,4 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
## Version History ## Version History
### 0.4.2 See the [version history](docs/history.rst) for release notes.
- 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

21
docs/Makefile Normal file
View 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
View 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
View 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
View 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

Binary file not shown.

View File

@@ -1,170 +0,0 @@
# File Formats
This document captures source-file-format notes that complement the normative
requirements in `requirements/source_file_formats.md`.
The first documented format is a Matroska source that carries styled ASS/SSA
subtitle streams together with embedded font attachments.
## Styled ASS In Matroska With Embedded Fonts
These files are typically `.mkv` releases where subtitle rendering quality
depends on keeping both parts of the subtitle package together:
- one or more subtitle streams with codec `ass`
- one or more attachment streams that embed font files used by those subtitles
This matters because ASS subtitles are not plain text subtitles in the narrow
WebVTT sense. They can carry layout, styling, positioning, karaoke, signs, and
other typesetting effects. If the matching embedded fonts are lost, consumers
can still see subtitle text but the intended styling and sometimes glyph
coverage can be degraded.
For FFX this format is special because the ASS subtitle streams should remain
normally editable and mappable, while the related font attachments should be
transported unchanged.
## Observed Sample
Assessment date: `2026-04-17`
Observed sample file:
- `tests/assets/boruto_s01e283_ssa.mkv`
Commands used for assessment:
```bash
ffprobe tests/assets/boruto_s01e283_ssa.mkv
ffprobe -hide_banner -show_format -show_streams -of json tests/assets/boruto_s01e283_ssa.mkv
```
Observed stream layout:
| Stream index | Kind | Key details |
| --- | --- | --- |
| `0` | video | `codec_name=h264` |
| `1` | audio | `codec_name=aac`, `language=jpn` |
| `2` | subtitle | `codec_name=ass`, `language=ger`, default |
| `3` | subtitle | `codec_name=ass`, `language=eng` |
| `4`-`13` | attachment | `tags.mimetype=font/ttf`, `.ttf` filenames |
Observed attachment filenames:
- `AmazonEmberTanuki-Italic.ttf`
- `AmazonEmberTanuki-Regular.ttf`
- `Arial.ttf`
- `Arial Bold.ttf`
- `Georgia.ttf`
- `Times New Roman.ttf`
- `Times New Roman Bold.ttf`
- `Trebuchet MS.ttf`
- `Verdana.ttf`
- `Verdana Bold.ttf`
Important probe behavior from the real sample:
- Plain `ffprobe` lists the font streams as `Attachment: none`.
- Plain `ffprobe` also prints warnings such as `Could not find codec
parameters for stream 4 (Attachment: none): unknown codec` and later
`Unsupported codec with id 0 for input stream ...`.
- The JSON produced by `FileProperties.FFPROBE_COMMAND_TOKENS`
(`ffprobe -hide_banner -show_format -show_streams -of json`) still exposes
the attachment streams clearly through `codec_type="attachment"` and the
attachment tags.
- In that JSON, the attachment streams do not expose `codec_name`.
This last point is important for FFX: robust detection must not depend on
attachment `codec_name` being present.
## Detection Guidance
Current known indicators for this format are:
- one or more subtitle streams with `codec_type="subtitle"` and
`codec_name="ass"`
- one or more attachment streams with `codec_type="attachment"`
- attachment tags that identify embedded fonts, especially
`tags.mimetype="font/ttf"`
- attachment filenames that end in `.ttf`
The pattern can vary. FFX should therefore treat the above as a cluster of
signals rather than an exact signature tied to one file.
Inference from the observed sample plus FFmpeg documentation:
- MIME matching should not be limited to `font/ttf` alone.
- The Boruto sample uses `font/ttf`.
- FFmpeg's Matroska attachment example uses
`mimetype=application/x-truetype-font` for a `.ttf` attachment.
- Detection should therefore normalize multiple TTF-like MIME values rather
than depend on a single exact string.
## Processing Expectations In FFX
The format-specific requirements live in
`requirements/source_file_formats.md`. In practical terms, FFX should:
- recognize the ASS-plus-font-attachment pattern even when attachment probe
data is incomplete
- tell the operator that the pattern was detected and that special handling is
being used
- reject sidecar subtitle import for such sources, because converting or
replacing these subtitle tracks with ordinary external text subtitles would
break the intended subtitle package
- continue to allow normal manipulation of the ASS subtitle tracks themselves
- preserve the font attachment streams unchanged
## FFmpeg Notes
Relevant FFmpeg documentation confirms several behaviors that line up with
FFX's needs:
- FFmpeg documents `-attach` as adding an attachment stream to the output, and
explicitly names Matroska fonts used in subtitle rendering as an example.
- FFmpeg documents attachment streams as regular streams that are created after
the mapped media streams.
- FFmpeg documents `-dump_attachment` for extracting attachment streams, which
is useful for debugging or validating a source file's embedded fonts.
- FFmpeg's Matroska example requires a `mimetype` metadata tag for attached
fonts, which is consistent with using attachment tags as detection signals.
- FFmpeg also notes that attachments are implemented as codec extradata. That
helps explain why probe output for attachment streams can look different from
ordinary audio, video, and subtitle streams.
Implication for FFX:
- Attachment preservation is not an optional cosmetic feature for this format.
It is part of preserving the subtitle package correctly.
## Jellyfin Notes
Jellyfin's documentation also supports keeping this format intact:
- Jellyfin's subtitle compatibility table lists `ASS/SSA` as supported in
`MKV` and not supported in `MP4`.
- Jellyfin notes that when subtitles must be transcoded, they are either
converted to a supported format or burned into the video, and burning them in
is the most CPU-intensive path.
- Jellyfin's subtitle-extraction example for `SSA/ASS` first dumps attachment
streams and then extracts the ASS subtitle stream, which reflects the real
relationship between ASS subtitles and embedded fonts in MKV releases.
- Jellyfin's font documentation says text-based subtitles require fonts to
render properly.
- Jellyfin's configuration documentation says the web client uses configured
fallback fonts for ASS subtitles when other fonts such as MKV attachments or
client-side fonts are not available.
Inference from the Jellyfin compatibility tables:
- Keeping this subtitle format in Matroska is the safest interoperability
choice for Jellyfin consumers.
- Converting the subtitle payload to WebVTT would lose styled ASS behavior.
- Dropping the attachment streams would force client or fallback font
substitution and can change appearance or glyph coverage.
## References
- FFmpeg documentation: https://ffmpeg.org/ffmpeg.html
- Jellyfin codec support: https://jellyfin.org/docs/general/clients/codec-support/
- Jellyfin configuration and fonts: https://jellyfin.org/docs/general/administration/configuration/

192
docs/file_formats.rst Normal file
View File

@@ -0,0 +1,192 @@
File Formats
============
This document captures source-file-format notes that complement the normative
requirements in ``requirements/source_file_formats.md``.
The first documented format is a Matroska source that carries styled ASS/SSA
subtitle streams together with embedded font attachments.
Styled ASS In Matroska With Embedded Fonts
------------------------------------------
These files are typically ``.mkv`` releases where subtitle rendering quality
depends on keeping both parts of the subtitle package together:
* one or more subtitle streams with codec ``ass``
* one or more attachment streams that embed font files used by those subtitles
This matters because ASS subtitles are not plain text subtitles in the narrow
WebVTT sense. They can carry layout, styling, positioning, karaoke, signs, and
other typesetting effects. If the matching embedded fonts are lost, consumers
can still see subtitle text but the intended styling and sometimes glyph
coverage can be degraded.
For FFX this format is special because the ASS subtitle streams should remain
normally editable and mappable, while the related font attachments should be
transported unchanged.
Observed Sample
---------------
Assessment date: ``2026-04-17``
Observed sample file:
* ``tests/assets/boruto_s01e283_ssa.mkv``
Commands used for assessment:
.. code-block:: bash
ffprobe tests/assets/boruto_s01e283_ssa.mkv
ffprobe -hide_banner -show_format -show_streams -of json tests/assets/boruto_s01e283_ssa.mkv
Observed stream layout:
.. list-table::
:header-rows: 1
* - Stream index
- Kind
- Key details
* - ``0``
- video
- ``codec_name=h264``
* - ``1``
- audio
- ``codec_name=aac``, ``language=jpn``
* - ``2``
- subtitle
- ``codec_name=ass``, ``language=ger``, default
* - ``3``
- subtitle
- ``codec_name=ass``, ``language=eng``
* - ``4``-``13``
- attachment
- ``tags.mimetype=font/ttf``, ``.ttf`` filenames
Observed attachment filenames:
* ``AmazonEmberTanuki-Italic.ttf``
* ``AmazonEmberTanuki-Regular.ttf``
* ``Arial.ttf``
* ``Arial Bold.ttf``
* ``Georgia.ttf``
* ``Times New Roman.ttf``
* ``Times New Roman Bold.ttf``
* ``Trebuchet MS.ttf``
* ``Verdana.ttf``
* ``Verdana Bold.ttf``
Important probe behavior from the real sample:
* Plain ``ffprobe`` lists the font streams as ``Attachment: none``.
* Plain ``ffprobe`` also prints warnings such as ``Could not find codec
parameters for stream 4 (Attachment: none): unknown codec`` and later
``Unsupported codec with id 0 for input stream ...``.
* The JSON produced by ``FileProperties.FFPROBE_COMMAND_TOKENS``
(``ffprobe -hide_banner -show_format -show_streams -of json``) still exposes
the attachment streams clearly through ``codec_type="attachment"`` and the
attachment tags.
* In that JSON, the attachment streams do not expose ``codec_name``.
This last point is important for FFX: robust detection must not depend on
attachment ``codec_name`` being present.
Detection Guidance
------------------
Current known indicators for this format are:
* one or more subtitle streams with ``codec_type="subtitle"`` and
``codec_name="ass"``
* one or more attachment streams with ``codec_type="attachment"``
* attachment tags that identify embedded fonts, especially
``tags.mimetype="font/ttf"``
* attachment filenames that end in ``.ttf``
The pattern can vary. FFX should therefore treat the above as a cluster of
signals rather than an exact signature tied to one file.
Inference from the observed sample plus FFmpeg documentation:
* MIME matching should not be limited to ``font/ttf`` alone.
* The Boruto sample uses ``font/ttf``.
* FFmpeg's Matroska attachment example uses
``mimetype=application/x-truetype-font`` for a ``.ttf`` attachment.
* Detection should therefore normalize multiple TTF-like MIME values rather
than depend on a single exact string.
Processing Expectations In FFX
------------------------------
The format-specific requirements live in
``requirements/source_file_formats.md``. In practical terms, FFX should:
* recognize the ASS-plus-font-attachment pattern even when attachment probe data
is incomplete
* tell the operator that the pattern was detected and that special handling is
being used
* reject sidecar subtitle import for such sources, because converting or
replacing these subtitle tracks with ordinary external text subtitles would
break the intended subtitle package
* continue to allow normal manipulation of the ASS subtitle tracks themselves
* preserve the font attachment streams unchanged
FFmpeg Notes
------------
Relevant FFmpeg documentation confirms several behaviors that line up with
FFX's needs:
* FFmpeg documents ``-attach`` as adding an attachment stream to the output, and
explicitly names Matroska fonts used in subtitle rendering as an example.
* FFmpeg documents attachment streams as regular streams that are created after
the mapped media streams.
* FFmpeg documents ``-dump_attachment`` for extracting attachment streams, which
is useful for debugging or validating a source file's embedded fonts.
* FFmpeg's Matroska example requires a ``mimetype`` metadata tag for attached
fonts, which is consistent with using attachment tags as detection signals.
* FFmpeg also notes that attachments are implemented as codec extradata. That
helps explain why probe output for attachment streams can look different from
ordinary audio, video, and subtitle streams.
Implication for FFX:
* Attachment preservation is not an optional cosmetic feature for this format.
It is part of preserving the subtitle package correctly.
Jellyfin Notes
--------------
Jellyfin's documentation also supports keeping this format intact:
* Jellyfin's subtitle compatibility table lists ``ASS/SSA`` as supported in
``MKV`` and not supported in ``MP4``.
* Jellyfin notes that when subtitles must be transcoded, they are either
converted to a supported format or burned into the video, and burning them in
is the most CPU-intensive path.
* Jellyfin's subtitle-extraction example for ``SSA/ASS`` first dumps attachment
streams and then extracts the ASS subtitle stream, which reflects the real
relationship between ASS subtitles and embedded fonts in MKV releases.
* Jellyfin's font documentation says text-based subtitles require fonts to
render properly.
* Jellyfin's configuration documentation says the web client uses configured
fallback fonts for ASS subtitles when other fonts such as MKV attachments or
client-side fonts are not available.
Inference from the Jellyfin compatibility tables:
* Keeping this subtitle format in Matroska is the safest interoperability choice
for Jellyfin consumers.
* Converting the subtitle payload to WebVTT would lose styled ASS behavior.
* Dropping the attachment streams would force client or fallback font
substitution and can change appearance or glyph coverage.
References
----------
* FFmpeg documentation: https://ffmpeg.org/ffmpeg.html
* Jellyfin codec support: https://jellyfin.org/docs/general/clients/codec-support/
* Jellyfin configuration and fonts: https://jellyfin.org/docs/general/administration/configuration/

128
docs/history.rst Normal file
View File

@@ -0,0 +1,128 @@
Version History
===============
0.4.5
-----
- Release version bumped to 0.4.5.
0.4.4
-----
- External subtitle imports can now match prefixless sidecar files against the source basename, choose their extension, and confirm partial substitutions.
- Unmux now creates missing output directories with confirmation or the new ``--yes`` option.
- Project documentation is now built with Sphinx and includes installation, usage, development, file-format, and API references.
0.4.3
-----
- Styled ASS subtitle sources with embedded font attachments are now detected explicitly, keep MKV output, preserve current source font attachments, and reject incompatible sidecar subtitle import.
- Attachment descriptors are now treated as source-runtime data instead of pattern schema data, so pattern persistence skips them and source-vs-pattern validation ignores them.
- Inspect differences no longer report planned changes for attachment filename/count drift while still showing attachment streams in the stream table.
0.4.2
-----
- Pattern details now show an inline ``Show: <quality>`` hint next to the quality field when the pattern itself has no stored quality but the selected show does.
- Inspect stream tables now show attachment format labels like ``TTF`` in the codec column and keep attachment language cells blank instead of showing an undefined language.
- FFmpeg damaged-MP3 diagnostics now recognize additional corruption lines such as ``invalid new backstep``, keeping them grouped under the ``warn-corrupt-mpeg-audio`` review summary.
0.4.1
-----
- ``convert`` now supports ``--copy-video`` and ``--copy-audio`` to keep the selected stream type in copy mode without applying the corresponding reencode flags, filters, or formatting options.
- FFmpeg conversions now monitor diagnostics while the process is running, retry unset AVI packet timestamps once with ``-fflags +genpts``, and stop early when a file should be skipped instead of waiting for the full job to finish.
- End-of-run convert summaries now list only FFmpeg findings that still require review, including named remedy identifiers such as ``warn-corrupt-mpeg-audio``.
- ``upgrade`` now finishes by reporting the installed FFX version together with the active bundle branch.
0.3.1
-----
- Debug mode screen titles now append the active Textual screen class name, making screen-specific troubleshooting easier during inspect and edit flows.
- ``--cut`` again works as a combined flag/option: omitted disables cutting, bare ``--cut`` applies the default ``60,180``, and explicit duration or ``START,DURATION`` values stay supported.
- H.265 unmux commands no longer force an invalid ``-f h265`` output format, keeping FFmpeg copy extraction aligned with the required Annex B bitstream filter.
- H.264 encoding now falls back from ``libx264`` to ``libopenh264`` with a warning when needed, and the test fixtures use the same encoder fallback so the suite remains portable across FFmpeg builds.
0.3.0
-----
- Inspect and edit screens now refresh nested track and pattern changes more reliably, with inspect-mode tables aligned to the target pattern view shown in the differences pane.
- Metadata editing got a follow-up polish pass with clearer FFmpeg notifications, a shared in-screen log pane, safer apply/reload handling, and expanded cleanup and normalization coverage.
- Track and asset probing recognize additional codecs, and the modern test suite now covers more metadata-editor, change-set, screen-state, and asset-probe behavior.
- Textual now requires version ``8.0`` or newer to match the UI APIs used by the current screens.
0.2.6
-----
- DB-free ``ffx edit`` workflow for in-place metadata editing via temporary-file rewrite.
- Inspect and edit workflows split into dedicated Textual screens with shared media-workflow support.
- Textual tables and row actions now separate raw data from rendered labels to avoid markup leaking into stored metadata.
- Responsive screen layout pass, ``Esc`` back handling, sortable show/inspect tables, and improved edit-screen notifications/toggles.
- Application-wide UTF-8 i18n catalogs with language precedence from CLI over config over system over German default.
- Metadata normalization extended for localized subtitle titles, ISO language cleanup, and smarter track editor language/title helpers.
0.2.5
-----
- Show-level quality and notes fields.
- Pattern-over-show-over-default season-shift resolution with dynamic DB migration loading.
- Migration prompt now reports the upgrade path and creates an in-place DB backup before applying schema changes.
- ``upgrade --branch <name>`` now fetches remote-only branches before switching.
- ``unmux`` now applies season shifting to subtitle output filenames.
- Convert now keeps DB-defined target subtitle dispositions authoritative over sidecar filename disposition flags when a pattern definition exists.
- Focused modern tests added around migrations, unmux, upgrade, and subtitle-disposition import precedence.
0.2.4
-----
- Lightweight CLI commands now stay import-light via lazy runtime loading.
- Setup/config templating moved to ``assets/ffx.json.j2``.
- Aligned two-step local setup wrappers: ``ffx setup`` and ``ffx configure_workstation``.
- Combined ``ffprobe`` payload reuse in ``FileProperties``.
- Configurable crop-detect sampling plus per-process crop result caching.
- Single-query controller accessors and conditional DB schema bootstrap.
- Shared screen bootstrap/controller wiring for large detail screens.
- Configurable default season/episode digit lengths.
- Digit-aware ``rename`` and padded ``unmux`` filename markers.
0.2.3
-----
- PyPI packaging.
- Output filename templating.
- Season shifting.
- DB versioning.
0.2.2
-----
- CLI overrides.
0.2.1
-----
- Signature handling.
- Tag cleanup.
- Bugfixes and refactoring.
0.2.0
-----
- Tests.
- Config file.
0.1.3
-----
- Subtitle file imports.
0.1.2
-----
- Bugfixes.
0.1.1
-----
- Bugfixes.
- TMDB show identification.

31
docs/index.rst Normal file
View 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
View 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
View 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
View 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.

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "ffx" name = "ffx"
description = "FFX recoding and metadata managing tool" description = "FFX recoding and metadata managing tool"
version = "0.4.2" version = "0.4.5"
license = {file = "LICENSE.md"} license = {file = "LICENSE.md"}
dependencies = [ dependencies = [
"requests", "requests",
@@ -31,6 +31,12 @@ Issues = "https://gitea.maveno.de/Javanaut/ffx/issues"
test = [ test = [
"pytest", "pytest",
] ]
docs = [
"esbonio",
"sphinx",
"sphinx-copybutton",
"sphinx-rtd-theme",
]
[build-system] [build-system]
requires = [ requires = [

View File

@@ -41,13 +41,17 @@ CPU_OPTION_HELP = (
+ "Omit to disable; 0 also disables." + "Omit to disable; 0 also disables."
) )
SUBTITLE_DIRECTORY_OPTION_HELP = ( SUBTITLE_DIRECTORY_OPTION_HELP = (
"Load subtitles from here. When omitted and --subtitle-prefix is set, " "Load subtitles from here. Without --subtitle-prefix, match the source filename "
+ "basename. When omitted and --subtitle-prefix is set, "
+ "FFX uses the configured subtitlesDirectory base path plus the prefix as a subdirectory." + "FFX uses the configured subtitlesDirectory base path plus the prefix as a subdirectory."
) )
SUBTITLE_PREFIX_OPTION_HELP = ( SUBTITLE_PREFIX_OPTION_HELP = (
"Subtitle filename prefix. Requires --subtitle-directory, or a configured " "Subtitle filename prefix. Requires --subtitle-directory, or a configured "
+ "subtitlesDirectory base path that contains a matching <prefix>/ subdirectory." + "subtitlesDirectory base path that contains a matching <prefix>/ subdirectory."
) )
SUBTITLE_EXTENSION_OPTION_HELP = (
"External subtitle filename extension. A leading dot is optional."
)
UNMUX_OUTPUT_DIRECTORY_OPTION_HELP = ( UNMUX_OUTPUT_DIRECTORY_OPTION_HELP = (
"Write extracted streams here. When omitted together with --subtitles-only and " "Write extracted streams here. When omitted together with --subtitles-only and "
+ "--label, FFX uses the configured subtitlesDirectory base path plus the label." + "--label, FFX uses the configured subtitlesDirectory base path plus the label."
@@ -96,6 +100,31 @@ def normalizeCpuOption(ctx, param, value):
raise click.BadParameter(str(ex)) from ex raise click.BadParameter(str(ex)) from ex
def normalizeDeinterlaceOption(ctx, param, value):
if value == 'none':
return value
from ffx.filter.deinterlace_filter import DeinterlaceFilter
try:
DeinterlaceFilter.getModeTokens(value)
except ValueError as ex:
raise click.BadParameter(str(ex)) from ex
return value
def normalizeSubtitleExtension(ctx, param, value):
normalizedExtension = str(value).strip().lower()
if normalizedExtension.startswith('.'):
normalizedExtension = normalizedExtension[1:]
if not normalizedExtension or not normalizedExtension.isalnum():
raise click.BadParameter(
"Subtitle extension must contain only letters and numbers, "
+ "with an optional leading dot."
)
return normalizedExtension
def parseCutOptionValue(value) -> tuple[int, int] | None: def parseCutOptionValue(value) -> tuple[int, int] | None:
if value is None: if value is None:
return None return None
@@ -146,11 +175,21 @@ def resolveSubtitleImportOptions(context, subtitleDirectory, subtitlePrefix):
else '' else ''
) )
if not resolvedSubtitlePrefix:
return False, resolvedSubtitleDirectory, resolvedSubtitlePrefix
if resolvedSubtitleDirectory: if resolvedSubtitleDirectory:
return True, resolvedSubtitleDirectory, resolvedSubtitlePrefix if not os.path.isdir(resolvedSubtitleDirectory):
raise click.ClickException(
"The provided subtitle directory does not exist: "
+ resolvedSubtitleDirectory
)
return (
True,
resolvedSubtitleDirectory,
resolvedSubtitlePrefix,
not resolvedSubtitlePrefix,
)
if not resolvedSubtitlePrefix:
return False, resolvedSubtitleDirectory, resolvedSubtitlePrefix, False
configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath() configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath()
if not configuredSubtitlesBaseDirectory: if not configuredSubtitlesBaseDirectory:
@@ -170,7 +209,85 @@ def resolveSubtitleImportOptions(context, subtitleDirectory, subtitlePrefix):
+ resolvedSubtitleDirectory + resolvedSubtitleDirectory
) )
return True, resolvedSubtitleDirectory, resolvedSubtitlePrefix return True, resolvedSubtitleDirectory, resolvedSubtitlePrefix, False
def importExternalSubtitles(
context,
mediaDescriptor,
sourceFileBasename,
season,
episode,
preserveDispositions=False,
):
matchSourceBasename = context['subtitle_match_source_basename']
subtitlePrefix = (
sourceFileBasename
if matchSourceBasename
else context['subtitle_prefix']
)
try:
importResult = mediaDescriptor.importSubtitles(
context['subtitle_directory'],
subtitlePrefix,
season,
episode,
preserve_dispositions=preserveDispositions,
extension=context['subtitle_extension'],
strict=matchSourceBasename,
)
except (OSError, ValueError) as ex:
raise click.ClickException(
f"External subtitle discovery failed for '{sourceFileBasename}': {ex}"
) from ex
if not matchSourceBasename:
return importResult
importedTrackIndices = importResult['imported_track_indices']
missingTrackIndices = importResult['missing_track_indices']
extension = context['subtitle_extension']
importedDescription = (
', '.join(f"#{index}" for index in importedTrackIndices)
if importedTrackIndices
else 'none'
)
click.echo(
f"External subtitle scan for '{sourceFileBasename}': found "
+ f"{importResult['candidate_count']} .{extension} file(s); "
+ f"matched subtitle tracks {importedDescription}."
)
if not missingTrackIndices:
return importResult
missingDescription = ', '.join(f"#{index}" for index in missingTrackIndices)
incompleteMessage = (
f"External subtitle files are missing for subtitle tracks "
+ f"{missingDescription} in '{sourceFileBasename}'."
)
if context.get('yes', False):
click.echo(
incompleteMessage
+ " Continuing with the matching subtitle files because --yes is set."
)
return importResult
if context['no_prompt']:
raise click.ClickException(
incompleteMessage
+ " Partial subtitle substitution requires confirmation, but --no-prompt is set."
)
click.echo(incompleteMessage)
if not click.confirm(
"Continue and substitute only the subtitle tracks with matching files?",
default=False,
):
raise click.ClickException("External subtitle substitution aborted by user.")
return importResult
def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label): def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label):
@@ -181,7 +298,10 @@ def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label):
) )
resolvedLabel = str(label).strip() resolvedLabel = str(label).strip()
if resolvedOutputDirectory or not subtitlesOnly or not resolvedLabel: if resolvedOutputDirectory:
return resolvedOutputDirectory, True
if not subtitlesOnly or not resolvedLabel:
return resolvedOutputDirectory, False return resolvedOutputDirectory, False
configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath() configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath()
@@ -194,6 +314,63 @@ def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label):
return os.path.join(configuredSubtitlesBaseDirectory, resolvedLabel), True return os.path.join(configuredSubtitlesBaseDirectory, resolvedLabel), True
def confirmUnmuxOutputDirectoryCreation(outputDirectory):
message = (
"Create unmux output directory and missing parents: "
+ str(outputDirectory)
)
if not sys.stdin.isatty():
return click.confirm(message, default=True)
click.echo(f"{message} [Y/n]: ", nl=False)
while True:
char = click.getchar()
if char in ('\r', '\n'):
click.echo()
return True
normalizedChar = char.lower()
if normalizedChar == 'y':
click.echo(char)
return True
if normalizedChar == 'n':
click.echo(char)
return False
if char in ('\x03', '\x04'):
raise click.Abort()
click.echo("\nPlease respond with 'y' or 'n': ", nl=False)
def ensureUnmuxOutputDirectory(context, outputDirectory):
resolvedOutputDirectory = os.path.expanduser(str(outputDirectory).strip())
if not resolvedOutputDirectory:
return False
if os.path.isdir(resolvedOutputDirectory):
return False
if os.path.exists(resolvedOutputDirectory):
raise click.ClickException(
"Unmux output path exists but is not a directory: "
+ resolvedOutputDirectory
)
if context.get('dry_run', False):
return False
if context.get('yes', False):
os.makedirs(resolvedOutputDirectory, exist_ok=True)
return True
if not confirmUnmuxOutputDirectoryCreation(resolvedOutputDirectory):
raise click.ClickException("Unmux output directory creation aborted by user.")
os.makedirs(resolvedOutputDirectory, exist_ok=True)
return True
def resolveIndicatorDigitLengths(context=None, showDescriptor=None): def resolveIndicatorDigitLengths(context=None, showDescriptor=None):
from ffx.show_descriptor import ShowDescriptor from ffx.show_descriptor import ShowDescriptor
@@ -716,6 +893,12 @@ def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix,
@click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix') @click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix')
@click.option("-o", "--output-directory", type=str, default='', help=UNMUX_OUTPUT_DIRECTORY_OPTION_HELP) @click.option("-o", "--output-directory", type=str, default='', help=UNMUX_OUTPUT_DIRECTORY_OPTION_HELP)
@click.option("-s", "--subtitles-only", is_flag=True, default=False) @click.option("-s", "--subtitles-only", is_flag=True, default=False)
@click.option(
"--yes",
is_flag=True,
default=False,
help="Create a missing unmux output directory without prompting.",
)
@click.option( @click.option(
'--nice', '--nice',
type=int, type=int,
@@ -737,6 +920,7 @@ def unmux(ctx,
label, label,
output_directory, output_directory,
subtitles_only, subtitles_only,
yes,
nice, nice,
cpu): cpu):
from ffx.file_properties import FileProperties from ffx.file_properties import FileProperties
@@ -752,15 +936,16 @@ def unmux(ctx,
ctx.obj['resource_limits']['niceness'] = nice ctx.obj['resource_limits']['niceness'] = nice
ctx.obj['resource_limits']['cpu_limit'] = cpu ctx.obj['resource_limits']['cpu_limit'] = cpu
ctx.obj['resource_limits']['cpu_percent'] = cpu ctx.obj['resource_limits']['cpu_percent'] = cpu
ctx.obj['yes'] = bool(yes)
output_directory, create_output_directory = resolveUnmuxOutputDirectory( output_directory, requires_output_directory = resolveUnmuxOutputDirectory(
ctx.obj, ctx.obj,
output_directory, output_directory,
subtitles_only, subtitles_only,
label, label,
) )
if create_output_directory and existingSourcePaths and not ctx.obj.get('dry_run', False): if requires_output_directory and existingSourcePaths:
os.makedirs(output_directory, exist_ok=True) ensureUnmuxOutputDirectory(ctx.obj, output_directory)
shiftedSeasonController = ShiftedSeasonController(ctx.obj) shiftedSeasonController = ShiftedSeasonController(ctx.obj)
@@ -974,6 +1159,14 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
@click.option('--subtitle-directory', type=str, default='', help=SUBTITLE_DIRECTORY_OPTION_HELP) @click.option('--subtitle-directory', type=str, default='', help=SUBTITLE_DIRECTORY_OPTION_HELP)
@click.option('--subtitle-prefix', type=str, default='', help=SUBTITLE_PREFIX_OPTION_HELP) @click.option('--subtitle-prefix', type=str, default='', help=SUBTITLE_PREFIX_OPTION_HELP)
@click.option(
'--subtitle-extension',
type=str,
default='vtt',
callback=normalizeSubtitleExtension,
show_default=True,
help=SUBTITLE_EXTENSION_OPTION_HELP,
)
@click.option('--language', type=str, multiple=True, help='Set stream language. Use format <stream index>:<3 letter iso code>') @click.option('--language', type=str, multiple=True, help='Set stream language. Use format <stream index>:<3 letter iso code>')
@click.option('--title', type=str, multiple=True, help='Set stream title. Use format <stream index>:<title>') @click.option('--title', type=str, multiple=True, help='Set stream title. Use format <stream index>:<title>')
@@ -1014,7 +1207,21 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
@click.option("--output-directory", type=str, default='') @click.option("--output-directory", type=str, default='')
@click.option("--deinterlace", is_flag=False, flag_value="default", default="none") @click.option(
"--deinterlace",
type=str,
metavar="SPEC",
is_flag=False,
flag_value="default",
default="none",
show_default=True,
callback=normalizeDeinterlaceOption,
help=(
"Deinterlace with bwdif followed by ordered upN, downN, and tempHHHH "
"components. Scale factors N range from 2 to 8; H is hexadecimal. "
"For example: up2_temp3366_down2."
),
)
@click.option("--denoise", is_flag=False, flag_value="default", default="none") @click.option("--denoise", is_flag=False, flag_value="default", default="none")
@click.option("--denoise-use-hw", is_flag=True, default=False) @click.option("--denoise-use-hw", is_flag=True, default=False)
@@ -1034,6 +1241,12 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
@click.option("--dont-pass-dispositions", is_flag=True, default=False) @click.option("--dont-pass-dispositions", is_flag=True, default=False)
@click.option("--no-prompt", is_flag=True, default=False) @click.option("--no-prompt", is_flag=True, default=False)
@click.option(
"--yes",
is_flag=True,
default=False,
help="Confirm partial external subtitle substitution without prompting.",
)
@click.option("--no-signature", is_flag=True, default=False) @click.option("--no-signature", is_flag=True, default=False)
@click.option("--keep-mkvmerge-metadata", is_flag=True, default=False) @click.option("--keep-mkvmerge-metadata", is_flag=True, default=False)
@@ -1070,6 +1283,7 @@ def convert(ctx,
subtitle_directory, subtitle_directory,
subtitle_prefix, subtitle_prefix,
subtitle_extension,
language, language,
title, title,
@@ -1108,6 +1322,7 @@ def convert(ctx,
no_pattern, no_pattern,
dont_pass_dispositions, dont_pass_dispositions,
no_prompt, no_prompt,
yes,
no_signature, no_signature,
keep_mkvmerge_metadata, keep_mkvmerge_metadata,
@@ -1139,6 +1354,7 @@ def convert(ctx,
from ffx.tmdb_controller import TmdbController from ffx.tmdb_controller import TmdbController
from ffx.track_codec import TrackCodec from ffx.track_codec import TrackCodec
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
from ffx.track_type import TrackType
from ffx.video_encoder import VideoEncoder from ffx.video_encoder import VideoEncoder
startTime = time.perf_counter() startTime = time.perf_counter()
@@ -1161,6 +1377,7 @@ def convert(ctx,
context['use_tmdb'] = not no_tmdb context['use_tmdb'] = not no_tmdb
context['use_pattern'] = not no_pattern context['use_pattern'] = not no_pattern
context['no_prompt'] = no_prompt context['no_prompt'] = no_prompt
context['yes'] = yes
context['no_signature'] = no_signature context['no_signature'] = no_signature
context['keep_mkvmerge_metadata'] = keep_mkvmerge_metadata context['keep_mkvmerge_metadata'] = keep_mkvmerge_metadata
@@ -1179,6 +1396,7 @@ def convert(ctx,
context['import_subtitles'], context['import_subtitles'],
resolvedSubtitleDirectory, resolvedSubtitleDirectory,
resolvedSubtitlePrefix, resolvedSubtitlePrefix,
context['subtitle_match_source_basename'],
) = resolveSubtitleImportOptions( ) = resolveSubtitleImportOptions(
context, context,
subtitle_directory, subtitle_directory,
@@ -1187,6 +1405,7 @@ def convert(ctx,
if context['import_subtitles']: if context['import_subtitles']:
context['subtitle_directory'] = resolvedSubtitleDirectory context['subtitle_directory'] = resolvedSubtitleDirectory
context['subtitle_prefix'] = resolvedSubtitlePrefix context['subtitle_prefix'] = resolvedSubtitlePrefix
context['subtitle_extension'] = subtitle_extension
existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in SUPPORTED_INPUT_FILE_EXTENSIONS] existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in SUPPORTED_INPUT_FILE_EXTENSIONS]
@@ -1332,7 +1551,7 @@ def convert(ctx,
NlmeansFilter(**denoiseKwargs) NlmeansFilter(**denoiseKwargs)
if deinterlace != 'none' and not copyVideoEffective: if deinterlace != 'none' and not copyVideoEffective:
DeinterlaceFilter() DeinterlaceFilter(mode=deinterlace)
chainYield = list(qf.getChainYield()) chainYield = list(qf.getChainYield())
@@ -1393,13 +1612,29 @@ def convert(ctx,
from ffx.attachment_format import AttachmentFormat from ffx.attachment_format import AttachmentFormat
if ([smd for smd in sourceMediaDescriptor.getSubtitleTracks() styledAssDetector = getattr(
if smd.getCodec() == TrackCodec.ASS] sourceMediaDescriptor,
and [amd for amd in sourceMediaDescriptor.getAttachmentTracks() "hasStyledAssSubtitlesWithFontAttachments",
if amd.getAttachmentFormat() == AttachmentFormat.TTF]): None,
)
styledAssSourceDetected = (
bool(styledAssDetector())
if callable(styledAssDetector)
else False
)
if styledAssSourceDetected:
styledAssMessage = (
"Styled ASS subtitles with embedded font attachments detected; "
+ "preserving source font attachments."
)
click.echo(styledAssMessage)
targetFormat = '' targetFormat = ''
targetExtension = 'mkv' targetExtension = 'mkv'
if context['import_subtitles']:
raise click.ClickException(
"External subtitle import is incompatible with styled ASS "
+ "sources that carry embedded font attachments."
)
#HINT: This is None if the filename did not match anything in database #HINT: This is None if the filename did not match anything in database
@@ -1414,10 +1649,13 @@ def convert(ctx,
currentShowDescriptor = None currentShowDescriptor = None
if context['import_subtitles']: if context['import_subtitles']:
sourceMediaDescriptor.importSubtitles(context['subtitle_directory'], importExternalSubtitles(
context['subtitle_prefix'], context,
showSeason, sourceMediaDescriptor,
showEpisode) sourceFileBasename,
showSeason,
showEpisode,
)
if cliOverrides: if cliOverrides:
sourceMediaDescriptor.applyOverrides(cliOverrides) sourceMediaDescriptor.applyOverrides(cliOverrides)
@@ -1426,6 +1664,12 @@ def convert(ctx,
else: else:
targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj) targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj)
if styledAssSourceDetected:
targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks(
sourceMediaDescriptor,
AttachmentFormat.TTF,
context=ctx.obj,
)
checkUniqueDispositions(context, targetMediaDescriptor) checkUniqueDispositions(context, targetMediaDescriptor)
currentShowDescriptor = currentPattern.getShowDescriptor(ctx.obj) currentShowDescriptor = currentPattern.getShowDescriptor(ctx.obj)
@@ -1435,6 +1679,8 @@ def convert(ctx,
targetTrackDescriptorList = targetMediaDescriptor.getTrackDescriptors() targetTrackDescriptorList = targetMediaDescriptor.getTrackDescriptors()
for ttd in targetTrackDescriptorList: for ttd in targetTrackDescriptorList:
if ttd.getType() == TrackType.ATTACHMENT:
continue
tti = ttd.getIndex() tti = ttd.getIndex()
ttsi = ttd.getSourceIndex() ttsi = ttd.getSourceIndex()
@@ -1453,11 +1699,14 @@ def convert(ctx,
if context['import_subtitles']: if context['import_subtitles']:
targetMediaDescriptor.importSubtitles(context['subtitle_directory'], importExternalSubtitles(
context['subtitle_prefix'], context,
showSeason, targetMediaDescriptor,
showEpisode, sourceFileBasename,
preserve_dispositions=True) showSeason,
showEpisode,
preserveDispositions=True,
)
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}") # ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}") ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")
@@ -1468,7 +1717,10 @@ def convert(ctx,
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}") # ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}") ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")
ctx.obj['logger'].debug(f"Input mapping tokens (2nd pass): {targetMediaDescriptor.getInputMappingTokens()}") ctx.obj['logger'].debug(
"Input mapping tokens (2nd pass): "
+ f"{targetMediaDescriptor.getInputMappingTokens(sourceMediaDescriptor=sourceMediaDescriptor)}"
)
fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor) fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor)

View File

@@ -1,4 +1,4 @@
VERSION='0.4.2' VERSION='0.4.5'
DATABASE_VERSION = 3 DATABASE_VERSION = 3
DEFAULT_QUALITY = 32 DEFAULT_QUALITY = 32

View File

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

View File

@@ -1,9 +1,11 @@
from textual.app import App from textual.app import App
from .i18n import set_current_language, t from .i18n import set_current_language, t
from .confirm_screen import ConfirmScreen
from .shows_screen import ShowsScreen from .shows_screen import ShowsScreen
from .inspect_details_screen import InspectDetailsScreen from .inspect_details_screen import InspectDetailsScreen
from .media_edit_screen import MediaEditScreen from .media_edit_screen import MediaEditScreen
from .pattern_controller import InvalidPatternCaptureGroupError, PatternController
from .screen_support import configure_screen_log_handler, set_screen_log_pane_enabled from .screen_support import configure_screen_log_handler, set_screen_log_pane_enabled
@@ -40,7 +42,20 @@ class FfxApp(App):
self.push_screen(ShowsScreen()) self.push_screen(ShowsScreen())
if self.context['command'] == 'inspect': if self.context['command'] == 'inspect':
self.push_screen(InspectDetailsScreen()) try:
self.push_screen(InspectDetailsScreen())
except InvalidPatternCaptureGroupError as ex:
self._invalid_pattern_error = ex
self.push_screen(
ConfirmScreen(
t(
"Pattern #{pattern_id} matches the filename but has no capture group for the season/episode indicator. Delete this pattern and quit?"
).format(pattern_id=ex.pattern_id),
confirm_label=t("Delete"),
cancel_label=t("Quit"),
),
self._handle_invalid_pattern_confirmation,
)
if self.context['command'] == 'edit': if self.context['command'] == 'edit':
self.push_screen(MediaEditScreen()) self.push_screen(MediaEditScreen())
@@ -49,3 +64,13 @@ class FfxApp(App):
def getContext(self): def getContext(self):
"""Data 'output' method""" """Data 'output' method"""
return self.context return self.context
def _handle_invalid_pattern_confirmation(self, confirmed: bool):
invalid_pattern_error = getattr(self, "_invalid_pattern_error", None)
if confirmed and invalid_pattern_error is not None:
PatternController(self.context).deletePattern(
invalid_pattern_error.pattern_id
)
self.exit()

View File

@@ -501,12 +501,7 @@ class FfxController():
commandSequence1 = (commandTokens commandSequence1 = (commandTokens
+ self.__targetMediaDescriptor.getInputMappingTokens(only_video=True)) + self.__targetMediaDescriptor.getInputMappingTokens(only_video=True))
# Optional tokens commandSequence1 += filterTokens
#NOTE: Filters and so needs to run on the first pass as well, as here
# the required bitrate for the second run is determined and recorded
# TODO: Results seems to be slightly better with first pass omitted,
# Confirm or find better filter settings for 2-pass
# commandSequence1 += self.__context['denoiser'].generatefilterTokens()
for td in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.VIDEO): for td in self.__targetMediaDescriptor.getTrackDescriptors(trackType=TrackType.VIDEO):
#HINT: Attached thumbnails are not supported by .webm container format #HINT: Attached thumbnails are not supported by .webm container format

View File

@@ -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,6 +29,11 @@ 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)
@@ -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

View File

@@ -10,6 +10,7 @@ from textual.widgets._data_table import CellDoesNotExist
from ffx.file_properties import FileProperties from ffx.file_properties import FileProperties
from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
from ffx.pattern_controller import InvalidPatternCaptureGroupError
from ffx.show_descriptor import ShowDescriptor from ffx.show_descriptor import ShowDescriptor
from ffx.track_descriptor import TrackDescriptor from ffx.track_descriptor import TrackDescriptor
@@ -472,11 +473,15 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
): ):
mediaTags[tagKey] = tagValue mediaTags[tagKey] = tagValue
patternId = self._pc.savePatternSchema( try:
patternObj, patternId = self._pc.savePatternSchema(
trackDescriptors=self._sourceMediaDescriptor.getTrackDescriptors(), patternObj,
mediaTags=mediaTags, trackDescriptors=self._sourceMediaDescriptor.getTrackDescriptors(),
) mediaTags=mediaTags,
)
except InvalidPatternCaptureGroupError as ex:
self.notify(str(ex), severity="error")
return
if patternId: if patternId:
self.reloadProperties(reset_draft=True) self.reloadProperties(reset_draft=True)
self.updateMediaTags() self.updateMediaTags()
@@ -498,10 +503,14 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
patternObj patternObj
and self._currentPattern.getPattern() != patternObj["pattern"] and self._currentPattern.getPattern() != patternObj["pattern"]
): ):
updated = self._pc.updatePattern( try:
self._currentPattern.getId(), updated = self._pc.updatePattern(
patternObj, self._currentPattern.getId(),
) patternObj,
)
except InvalidPatternCaptureGroupError as ex:
self.notify(str(ex), severity="error")
return False
if updated: if updated:
self.reloadProperties(reset_draft=True) self.reloadProperties(reset_draft=True)
self.updateMediaTags() self.updateMediaTags()

View File

@@ -329,6 +329,96 @@ class MediaDescriptor:
if s.getType() == TrackType.ATTACHMENT if s.getType() == TrackType.ATTACHMENT
] ]
def hasStyledAssSubtitlesWithFontAttachments(self) -> bool:
return (
any(
trackDescriptor.getCodec() == TrackCodec.ASS
for trackDescriptor in self.getSubtitleTracks()
)
and any(
trackDescriptor.getAttachmentFormat() == AttachmentFormat.TTF
for trackDescriptor in self.getAttachmentTracks()
)
)
def withoutAttachmentTracks(
self,
attachmentFormat: AttachmentFormat | None = None,
context: dict | None = None,
):
filteredTrackDescriptors = []
for trackDescriptor in self.__trackDescriptors:
if trackDescriptor.getType() == TrackType.ATTACHMENT and (
attachmentFormat is None
or trackDescriptor.getAttachmentFormat() == attachmentFormat
):
continue
filteredTrackDescriptors.append(
trackDescriptor.clone(
context=context if context is not None else self.__context
)
)
kwargs = {
MediaDescriptor.TAGS_KEY: dict(self.__mediaTags),
MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY: filteredTrackDescriptors,
}
if context is not None:
kwargs[MediaDescriptor.CONTEXT_KEY] = context
elif self.__context:
kwargs[MediaDescriptor.CONTEXT_KEY] = self.__context
filteredMediaDescriptor = MediaDescriptor(**kwargs)
filteredMediaDescriptor.reindexSubIndices()
return filteredMediaDescriptor
def withoutAttachmentsForComparison(self):
return self.withoutAttachmentTracks(context=self.__context)
def withSourceAttachmentTracks(
self,
sourceMediaDescriptor: Self,
attachmentFormat: AttachmentFormat | None = None,
context: dict | None = None,
):
trackDescriptors = []
for trackDescriptor in self.__trackDescriptors:
if trackDescriptor.getType() == TrackType.ATTACHMENT and (
attachmentFormat is None
or trackDescriptor.getAttachmentFormat() == attachmentFormat
):
continue
trackDescriptors.append(
trackDescriptor.clone(
context=context if context is not None else self.__context
)
)
for sourceTrackDescriptor in sourceMediaDescriptor.getAttachmentTracks():
if (
attachmentFormat is not None
and sourceTrackDescriptor.getAttachmentFormat() != attachmentFormat
):
continue
attachmentClone = sourceTrackDescriptor.clone(
context=context if context is not None else self.__context
)
attachmentClone.setIndex(len(trackDescriptors))
trackDescriptors.append(attachmentClone)
kwargs = {
MediaDescriptor.TAGS_KEY: dict(self.__mediaTags),
MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY: trackDescriptors,
}
if context is not None:
kwargs[MediaDescriptor.CONTEXT_KEY] = context
elif self.__context:
kwargs[MediaDescriptor.CONTEXT_KEY] = self.__context
mergedMediaDescriptor = MediaDescriptor(**kwargs)
mergedMediaDescriptor.reindexSubIndices()
return mergedMediaDescriptor
def getImportFileTokens(self, use_sub_index: bool = True): def getImportFileTokens(self, use_sub_index: bool = True):
"""Generate ffmpeg import options for external stream files""" """Generate ffmpeg import options for external stream files"""
@@ -341,10 +431,13 @@ class MediaDescriptor:
importedFilePath = td.getExternalSourceFilePath() importedFilePath = td.getExternalSourceFilePath()
if importedFilePath: if importedFilePath:
substitutionMessage = (
self.__logger.info(f"Substituting subtitle stream #{td.getIndex()} " f"Substituting subtitle stream #{td.getIndex()} "
+ f"({td.getType().label()}:{td.getSubIndex()}) " + f"({td.getType().label()}:{td.getSubIndex()}) "
+ f"with import from file {td.getExternalSourceFilePath()}") + f"with import from file {td.getExternalSourceFilePath()}"
)
click.echo(substitutionMessage)
self.__logger.debug(substitutionMessage)
importFileTokens += [ importFileTokens += [
"-i", "-i",
@@ -380,45 +473,48 @@ class MediaDescriptor:
td: TrackDescriptor = sortedTrackDescriptors[trackIndex] td: TrackDescriptor = sortedTrackDescriptors[trackIndex]
#HINT: Attached thumbnails are not supported by .webm container format #HINT: Attached thumbnails are not supported by .webm container format
if td.getCodec() != TrackCodec.PNG: trackType = td.getType()
trackCodec = td.getCodec()
if (
trackCodec == TrackCodec.PNG
or trackType == TrackType.ATTACHMENT
or (only_video and trackType != TrackType.VIDEO)
):
continue
sourceTrackDescriptor = sourceTrackDescriptorsByIndex.get(td.getSourceIndex()) sourceTrackDescriptor = sourceTrackDescriptorsByIndex.get(td.getSourceIndex())
if sourceTrackDescriptor is None: if sourceTrackDescriptor is None:
raise ValueError(f"No source track descriptor found for source index {td.getSourceIndex()}") raise ValueError(f"No source track descriptor found for source index {td.getSourceIndex()}")
stdi = sourceTrackDescriptor.getIndex() stdi = sourceTrackDescriptor.getIndex()
stdsi = sourceTrackDescriptor.getSubIndex() stdsi = sourceTrackDescriptor.getSubIndex()
trackType = td.getType() importedFilePath = td.getExternalSourceFilePath()
trackCodec = td.getCodec()
if (trackType != TrackType.ATTACHMENT if use_sub_index:
and (trackType == TrackType.VIDEO or not only_video)):
if importedFilePath:
importedFilePath = td.getExternalSourceFilePath() inputMappingTokens += [
"-map",
f"{filePointer}:{trackType.indicator()}:0",
]
filePointer += 1
if use_sub_index: else:
if importedFilePath: if trackCodec not in [TrackCodec.PGS, TrackCodec.VOBSUB]:
inputMappingTokens += [
"-map",
f"0:{trackType.indicator()}:{stdsi}",
]
inputMappingTokens += [ else:
"-map", if trackCodec not in [TrackCodec.PGS, TrackCodec.VOBSUB]:
f"{filePointer}:{trackType.indicator()}:0", inputMappingTokens += ["-map", f"0:{stdi}"]
]
filePointer += 1
else: if only_video:
return inputMappingTokens
if not trackCodec in [TrackCodec.PGS, TrackCodec.VOBSUB]:
inputMappingTokens += [
"-map",
f"0:{trackType.indicator()}:{stdsi}",
]
else:
if not trackCodec in [TrackCodec.PGS, TrackCodec.VOBSUB]:
inputMappingTokens += ["-map", f"0:{stdi}"]
if sourceMediaDescriptor: if sourceMediaDescriptor:
fontDescriptors = [ftd for ftd in sourceMediaDescriptor.getAttachmentTracks() fontDescriptors = [ftd for ftd in sourceMediaDescriptor.getAttachmentTracks()
@@ -429,71 +525,161 @@ class MediaDescriptor:
and ftd.getAttachmentFormat() == AttachmentFormat.TTF] and ftd.getAttachmentFormat() == AttachmentFormat.TTF]
for ad in sorted(fontDescriptors, key=lambda d: d.getIndex()): for ad in sorted(fontDescriptors, key=lambda d: d.getIndex()):
inputMappingTokens += ["-map", f"0:{ad.getIndex()}"] sourceIndex = ad.getIndex()
if not sourceMediaDescriptor and ad.getSourceIndex() > -1:
sourceIndex = ad.getSourceIndex()
inputMappingTokens += ["-map", f"0:{sourceIndex}"]
return inputMappingTokens return inputMappingTokens
def searchSubtitleFiles(self, searchDirectory, prefix): def searchSubtitleFiles(
self,
searchDirectory,
prefix,
extension=SUBTITLE_FILE_EXTENSION,
strict=False,
):
sesld_match = re.compile(f"{prefix}_{MediaDescriptor.SEASON_EPISODE_STREAM_LANGUAGE_DISPOSITIONS_MATCH}") normalizedExtension = str(extension).strip().lower()
sld_match = re.compile(f"{prefix}_{MediaDescriptor.STREAM_LANGUAGE_DISPOSITIONS_MATCH}") if normalizedExtension.startswith('.'):
normalizedExtension = normalizedExtension[1:]
escapedPrefix = re.escape(prefix)
sesld_match = re.compile(
f"{escapedPrefix}_{MediaDescriptor.SEASON_EPISODE_STREAM_LANGUAGE_DISPOSITIONS_MATCH}"
)
sld_match = re.compile(
f"{escapedPrefix}_{MediaDescriptor.STREAM_LANGUAGE_DISPOSITIONS_MATCH}"
)
subtitleFileDescriptors = [] subtitleFileDescriptors = []
subtitleFilenames = []
for subtitleFilename in os.listdir(searchDirectory): for subtitleFilename in sorted(os.listdir(searchDirectory)):
if subtitleFilename.startswith(prefix) and subtitleFilename.endswith( subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
"." + MediaDescriptor.SUBTITLE_FILE_EXTENSION subtitleFilenameStem, subtitleFilenameExtension = os.path.splitext(
subtitleFilename
)
if (
os.path.isfile(subtitleFilePath)
and subtitleFilenameStem.startswith(prefix + '_')
and subtitleFilenameExtension.lower() == '.' + normalizedExtension
): ):
subtitleFilenames.append(subtitleFilename)
sesld_result = sesld_match.search(subtitleFilename) expectedSubtitleTrackIndices = {
sld_result = None if not sesld_result is None else sld_match.search(subtitleFilename) subtitleTrack.getIndex()
for subtitleTrack in self.getSubtitleTracks()
}
if strict and len(subtitleFilenames) > len(expectedSubtitleTrackIndices):
raise ValueError(
f"Found {len(subtitleFilenames)} matching .{normalizedExtension} files "
+ f"for {len(expectedSubtitleTrackIndices)} subtitle tracks."
)
if not sesld_result is None: for subtitleFilename in subtitleFilenames:
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)
)
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename) if strict and sesld_result is None and sld_result is None:
if os.path.isfile(subtitleFilePath): raise ValueError(
f"Subtitle filename does not match the expected pattern: "
+ subtitleFilename
)
subtitleFileDescriptor = {} if sesld_result is not None:
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)
dispSet = set() subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
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) 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)
if not sld_result is None: dispSet = set()
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
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename) subtitleFileDescriptors.append(subtitleFileDescriptor)
if os.path.isfile(subtitleFilePath):
subtitleFileDescriptor = {} if sld_result is not None:
subtitleFileDescriptor["path"] = subtitleFilePath
subtitleFileDescriptor["index"] = int(sld_result.group(1))
subtitleFileDescriptor["language"] = sld_result.group(2)
dispSet = set() subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
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) subtitleFileDescriptor = {}
subtitleFileDescriptor["path"] = subtitleFilePath
subtitleFileDescriptor["index"] = int(sld_result.group(1))
subtitleFileDescriptor["language"] = sld_result.group(2)
dispSet = set()
dispCaptGroups = sld_result.groups()
numCaptGroups = len(dispCaptGroups)
if numCaptGroups > 2:
for groupIndex in range(numCaptGroups - 2):
disp = TrackDisposition.fromIndicator(
dispCaptGroups[groupIndex + 2]
)
if disp is not None:
dispSet.add(disp)
subtitleFileDescriptor["disposition_set"] = dispSet
subtitleFileDescriptors.append(subtitleFileDescriptor)
if strict:
discoveredTrackIndices = [
descriptor['index'] for descriptor in subtitleFileDescriptors
]
duplicateTrackIndices = sorted(
{
trackIndex
for trackIndex in discoveredTrackIndices
if discoveredTrackIndices.count(trackIndex) > 1
}
)
if duplicateTrackIndices:
duplicateDescription = ', '.join(
f"#{index}" for index in duplicateTrackIndices
)
raise ValueError(
"Multiple external subtitle files refer to subtitle track(s) "
+ duplicateDescription
+ "."
)
unexpectedTrackIndices = sorted(
set(discoveredTrackIndices) - expectedSubtitleTrackIndices
)
if unexpectedTrackIndices:
unexpectedDescription = ', '.join(
f"#{index}" for index in unexpectedTrackIndices
)
expectedDescription = ', '.join(
f"#{index}" for index in sorted(expectedSubtitleTrackIndices)
) or 'none'
raise ValueError(
"External subtitle track index pattern does not match the media "
+ f"subtitle tracks: found {unexpectedDescription}; "
+ f"expected a subset of {expectedDescription}."
)
self.__logger.debug(f"searchSubtitleFiles(): Available subtitle files {subtitleFileDescriptors}") self.__logger.debug(f"searchSubtitleFiles(): Available subtitle files {subtitleFileDescriptors}")
@@ -508,12 +694,19 @@ class MediaDescriptor:
season: int = -1, season: int = -1,
episode: int = -1, episode: int = -1,
preserve_dispositions: bool = False, preserve_dispositions: bool = False,
extension: str = SUBTITLE_FILE_EXTENSION,
strict: bool = False,
): ):
# click.echo(f"Season: {season} Episode: {episode}") # click.echo(f"Season: {season} Episode: {episode}")
self.__logger.debug(f"importSubtitles(): Season: {season} Episode: {episode}") self.__logger.debug(f"importSubtitles(): Season: {season} Episode: {episode}")
availableFileSubtitleDescriptors = self.searchSubtitleFiles(searchDirectory, prefix) availableFileSubtitleDescriptors = self.searchSubtitleFiles(
searchDirectory,
prefix,
extension=extension,
strict=strict,
)
self.__logger.debug(f"importSubtitles(): availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}") self.__logger.debug(f"importSubtitles(): availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}")
@@ -526,7 +719,8 @@ class MediaDescriptor:
[ [
d d
for d in availableFileSubtitleDescriptors for d in availableFileSubtitleDescriptors
if ((season == -1 and episode == -1) if (strict
or (season == -1 and episode == -1)
or ( or (
d.get("season") == int(season) d.get("season") == int(season)
and d.get("episode") == int(episode) and d.get("episode") == int(episode)
@@ -540,6 +734,7 @@ class MediaDescriptor:
self.__logger.debug(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}") self.__logger.debug(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}")
importedTrackIndices = []
for msfd in matchingSubtitleFileDescriptors: for msfd in matchingSubtitleFileDescriptors:
matchingSubtitleTrackDescriptor = [s for s in subtitleTracks if s.getIndex() == msfd["index"]] matchingSubtitleTrackDescriptor = [s for s in subtitleTracks if s.getIndex() == msfd["index"]]
if matchingSubtitleTrackDescriptor: if matchingSubtitleTrackDescriptor:
@@ -553,6 +748,19 @@ class MediaDescriptor:
matchingTrack.getTags()["language"] = msfd["language"] matchingTrack.getTags()["language"] = msfd["language"]
if msfd["disposition_set"] and not preserve_dispositions: if msfd["disposition_set"] and not preserve_dispositions:
matchingTrack.setDispositionSet(msfd["disposition_set"]) matchingTrack.setDispositionSet(msfd["disposition_set"])
importedTrackIndices.append(matchingTrack.getIndex())
expectedTrackIndices = sorted(
subtitleTrack.getIndex() for subtitleTrack in subtitleTracks
)
importedTrackIndices = sorted(set(importedTrackIndices))
return {
"candidate_count": len(availableFileSubtitleDescriptors),
"imported_track_indices": importedTrackIndices,
"missing_track_indices": sorted(
set(expectedTrackIndices) - set(importedTrackIndices)
),
}
def getConfiguration(self, label: str = ''): def getConfiguration(self, label: str = ''):

View File

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

View File

@@ -166,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()
@@ -205,6 +204,25 @@ class MediaWorkflowScreenBase(Screen):
def getTrackEditSourceDescriptor(self) -> TrackDescriptor | None: def getTrackEditSourceDescriptor(self) -> TrackDescriptor | None:
return self.getSelectedTrackDescriptor() return self.getSelectedTrackDescriptor()
def _resolve_target_media_descriptor(self, currentPattern, sourceMediaDescriptor):
if currentPattern is None:
return None
targetMediaDescriptor = currentPattern.getMediaDescriptor(self.context)
styledAssDetector = getattr(
sourceMediaDescriptor,
"hasStyledAssSubtitlesWithFontAttachments",
None,
)
if callable(styledAssDetector) and styledAssDetector():
targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks(
sourceMediaDescriptor,
AttachmentFormat.TTF,
context=self.context,
)
return targetMediaDescriptor
def updateMediaTags(self): def updateMediaTags(self):
displayedMediaDescriptor = self.getDisplayedMediaDescriptor() displayedMediaDescriptor = self.getDisplayedMediaDescriptor()
self._sourceMediaTagRowData = populate_tag_table( self._sourceMediaTagRowData = populate_tag_table(

View File

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

View File

@@ -36,6 +36,7 @@ class Track(Base):
index = Column(Integer) index = Column(Integer)
source_index = Column(Integer) source_index = Column(Integer)
dropped = Column(Integer, default=0)
# v1.x # v1.x
pattern_id = Column(Integer, ForeignKey('patterns.id', ondelete="CASCADE")) pattern_id = Column(Integer, ForeignKey('patterns.id', ondelete="CASCADE"))
@@ -181,6 +182,9 @@ class Track(Base):
def getSourceIndex(self): def getSourceIndex(self):
return int(self.source_index) if self.source_index is not None else -1 return int(self.source_index) if self.source_index is not None else -1
def isDropped(self) -> bool:
return bool(self.dropped)
def getLanguage(self): def getLanguage(self):
tags = {t.key:t.value for t in self.track_tags} tags = {t.key:t.value for t in self.track_tags}
return IsoLanguage.findThreeLetter(tags['language']) if 'language' in tags.keys() else IsoLanguage.UNDEFINED return IsoLanguage.findThreeLetter(tags['language']) if 'language' in tags.keys() else IsoLanguage.UNDEFINED
@@ -221,6 +225,7 @@ class Track(Base):
kwargs[TrackDescriptor.INDEX_KEY] = self.getIndex() kwargs[TrackDescriptor.INDEX_KEY] = self.getIndex()
kwargs[TrackDescriptor.SOURCE_INDEX_KEY] = self.getSourceIndex() kwargs[TrackDescriptor.SOURCE_INDEX_KEY] = self.getSourceIndex()
kwargs[TrackDescriptor.DROPPED_KEY] = self.isDropped()
if subIndex > -1: if subIndex > -1:
kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex

View File

@@ -8,6 +8,7 @@ from ffx.model.track import Track
from ffx.model.track_tag import TrackTag from ffx.model.track_tag import TrackTag
from ffx.track_descriptor import TrackDescriptor from ffx.track_descriptor import TrackDescriptor
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
from ffx.track_type import TrackType
class DuplicatePatternMatchError(click.ClickException): class DuplicatePatternMatchError(click.ClickException):
@@ -18,6 +19,21 @@ class InvalidPatternSchemaError(click.ClickException):
pass pass
class InvalidPatternCaptureGroupError(click.ClickException):
def __init__(self, pattern_id: int, pattern_expression: str):
self.pattern_id = int(pattern_id)
self.pattern_expression = str(pattern_expression)
pattern_label = (
f"Pattern #{self.pattern_id}"
if self.pattern_id >= 0
else "Pattern"
)
super().__init__(
f"{pattern_label} must contain at least one capture group "
f"for the season/episode indicator: {self.pattern_expression!r}"
)
class PatternController: class PatternController:
_compiled_regex_cache: dict[str, re.Pattern] = {} _compiled_regex_cache: dict[str, re.Pattern] = {}
@@ -65,6 +81,13 @@ class PatternController:
cls._compiled_regex_cache[expression_text] = compiled cls._compiled_regex_cache[expression_text] = compiled
return compiled return compiled
@classmethod
def _validate_pattern_capture_groups(cls, pattern_id: int, expression: str):
compiled = cls._compile_pattern_expression(pattern_id, expression)
if compiled.groups < 1:
raise InvalidPatternCaptureGroupError(pattern_id, expression)
return compiled
def _coerce_pattern_fields(self, patternObj): def _coerce_pattern_fields(self, patternObj):
return { return {
"show_id": int(patternObj["show_id"]), "show_id": int(patternObj["show_id"]),
@@ -86,22 +109,38 @@ class PatternController:
) )
normalized_descriptors = [] normalized_descriptors = []
filtered_attachments = False
for trackDescriptor in trackDescriptors: for trackDescriptor in trackDescriptors:
if type(trackDescriptor) is not TrackDescriptor: if type(trackDescriptor) is not TrackDescriptor:
raise TypeError( raise TypeError(
"PatternController: All track descriptors are required to be of type TrackDescriptor" "PatternController: All track descriptors are required to be of type TrackDescriptor"
) )
normalized_descriptors.append(trackDescriptor) if trackDescriptor.getType() == TrackType.ATTACHMENT:
filtered_attachments = True
continue
normalized_descriptors.append(trackDescriptor.clone())
if not normalized_descriptors: if not normalized_descriptors:
raise InvalidPatternSchemaError( raise InvalidPatternSchemaError(
"Patterns must define at least one track before they can be stored." "Patterns must define at least one track before they can be stored."
) )
if not any(
not descriptor.isDropped()
for descriptor in normalized_descriptors
):
raise InvalidPatternSchemaError(
"Patterns must define at least one non-dropped track before they can be stored."
)
normalized_descriptors = sorted( normalized_descriptors = sorted(
normalized_descriptors, key=lambda descriptor: descriptor.getIndex() normalized_descriptors, key=lambda descriptor: descriptor.getIndex()
) )
if filtered_attachments:
for index, descriptor in enumerate(normalized_descriptors):
descriptor.setIndex(index)
index_set = {descriptor.getIndex() for descriptor in normalized_descriptors} index_set = {descriptor.getIndex() for descriptor in normalized_descriptors}
expected_indexes = set(range(len(normalized_descriptors))) expected_indexes = set(range(len(normalized_descriptors)))
if index_set != expected_indexes: if index_set != expected_indexes:
@@ -137,6 +176,7 @@ class PatternController:
codec_name=str(trackDescriptor.getFormatDescriptor().identifier()), codec_name=str(trackDescriptor.getFormatDescriptor().identifier()),
index=int(trackDescriptor.getIndex()), index=int(trackDescriptor.getIndex()),
source_index=int(trackDescriptor.getSourceIndex()), source_index=int(trackDescriptor.getSourceIndex()),
dropped=1 if trackDescriptor.isDropped() else 0,
disposition_flags=int( disposition_flags=int(
TrackDisposition.toFlags(trackDescriptor.getDispositionSet()) TrackDisposition.toFlags(trackDescriptor.getDispositionSet())
), ),
@@ -170,10 +210,19 @@ class PatternController:
pattern.tracks.append(self._build_track_row(trackDescriptor)) pattern.tracks.append(self._build_track_row(trackDescriptor))
def _validate_persisted_pattern(self, pattern: Pattern): def _validate_persisted_pattern(self, pattern: Pattern):
if not pattern.tracks: persisted_tracks = [
track
for track in pattern.tracks
if track.getType() != TrackType.ATTACHMENT
]
if not persisted_tracks:
raise InvalidPatternSchemaError( raise InvalidPatternSchemaError(
f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no tracks." f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no tracks."
) )
if not any(not track.isDropped() for track in persisted_tracks):
raise InvalidPatternSchemaError(
f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no non-dropped tracks."
)
def savePatternSchema( def savePatternSchema(
self, self,
@@ -185,6 +234,10 @@ class PatternController:
fields = self._coerce_pattern_fields(patternObj) fields = self._coerce_pattern_fields(patternObj)
normalized_tracks = self._normalize_track_descriptors(trackDescriptors) normalized_tracks = self._normalize_track_descriptors(trackDescriptors)
normalized_tags = self._coerce_media_tags(mediaTags) normalized_tags = self._coerce_media_tags(mediaTags)
self._validate_pattern_capture_groups(
patternId if patternId is not None else -1,
fields["pattern"],
)
session = None session = None
try: try:
@@ -247,6 +300,7 @@ class PatternController:
def updatePattern(self, patternId, patternObj): def updatePattern(self, patternId, patternObj):
fields = self._coerce_pattern_fields(patternObj) fields = self._coerce_pattern_fields(patternObj)
self._validate_pattern_capture_groups(patternId, fields["pattern"])
session = None session = None
try: try:
@@ -382,6 +436,11 @@ class PatternController:
patternMatch = compiled.search(str(filename)) patternMatch = compiled.search(str(filename))
if patternMatch is None: if patternMatch is None:
continue continue
if not patternMatch.groups():
raise InvalidPatternCaptureGroupError(
pattern.getId(),
pattern.getPattern(),
)
self._validate_persisted_pattern(pattern) self._validate_persisted_pattern(pattern)
matches.append({"match": patternMatch, "pattern": pattern}) matches.append({"match": patternMatch, "pattern": pattern})

View File

@@ -2,14 +2,15 @@ import click, re
from typing import List from typing import List
from textual import events from textual import events
from textual.css.query import NoMatches
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, Input, DataTable, TextArea from textual.widgets import Header, Footer, Static, Button, Input, DataTable, TextArea
from textual.containers import Grid from textual.containers import Grid
from ffx.model.pattern import Pattern from ffx.model.pattern import Pattern
from ffx.pattern_controller import InvalidPatternCaptureGroupError
from .track_details_screen import TrackDetailsScreen from .track_details_screen import TrackDetailsScreen
from .track_delete_screen import TrackDeleteScreen
from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen
from .shifted_season_details_screen import ShiftedSeasonDetailsScreen from .shifted_season_details_screen import ShiftedSeasonDetailsScreen
@@ -154,6 +155,13 @@ class PatternDetailsScreen(Screen):
def updateTracks(self): def updateTracks(self):
selectedTrack = self.getSelectedTrackDescriptor()
selectedTrackIndex = (
self._track_row_index(selectedTrack)
if selectedTrack is not None
else None
)
selectedCursorColumn = getattr(self.tracksTable.cursor_coordinate, "column", 0)
self.tracksTable.clear() self.tracksTable.clear()
self.__trackRowData = {} self.__trackRowData = {}
@@ -161,6 +169,7 @@ class PatternDetailsScreen(Screen):
tracks = self.getCurrentTrackDescriptors() tracks = self.getCurrentTrackDescriptors()
typeCounter = {} typeCounter = {}
restoredRowIndex = None
td: TrackDescriptor td: TrackDescriptor
for td in tracks: for td in tracks:
@@ -185,13 +194,77 @@ class PatternDetailsScreen(Screen):
td.getTitle(), td.getTitle(),
t('Yes') if TrackDisposition.DEFAULT in dispoSet else t('No'), t('Yes') if TrackDisposition.DEFAULT in dispoSet else t('No'),
t('Yes') if TrackDisposition.FORCED in dispoSet else t('No'), t('Yes') if TrackDisposition.FORCED in dispoSet else t('No'),
t('Yes') if td.isDropped() else t('No'),
td.getSourceIndex()) td.getSourceIndex())
row_key = self.tracksTable.add_row(*map(str, row)) row_key = self.tracksTable.add_row(*map(str, row))
self.__trackRowData[row_key] = td self.__trackRowData[row_key] = td
if (
selectedTrack is not None
and self._descriptor_refs_same_track(td, selectedTrack)
):
restoredRowIndex = len(self.__trackRowData) - 1
typeCounter[trackType] += 1 typeCounter[trackType] += 1
if restoredRowIndex is None and selectedTrackIndex is not None and self.__trackRowData:
restoredRowIndex = min(selectedTrackIndex, len(self.__trackRowData) - 1)
if restoredRowIndex is not None:
self.tracksTable.move_cursor(
row=restoredRowIndex,
column=selectedCursorColumn,
animate=False,
scroll=False,
)
self.updateTrackDropButtonState()
def _track_row_index(self, trackDescriptor: TrackDescriptor | None):
if trackDescriptor is None:
return None
for rowIndex, descriptor in enumerate(self.__trackRowData.values()):
if self._descriptor_refs_same_track(descriptor, trackDescriptor):
return rowIndex
return None
def _descriptor_refs_same_track(
self,
descriptor: TrackDescriptor,
otherDescriptor: TrackDescriptor,
) -> bool:
if descriptor.getId() != -1 and otherDescriptor.getId() != -1:
return descriptor.getId() == otherDescriptor.getId()
return (
descriptor.getPatternId() == otherDescriptor.getPatternId()
and descriptor.getIndex() == otherDescriptor.getIndex()
and descriptor.getSubIndex() == otherDescriptor.getSubIndex()
and descriptor.getType() == otherDescriptor.getType()
)
def updateTrackDropButtonState(self):
try:
button = self.query_one("#button_toggle_track_drop", Button)
except (NoMatches, AttributeError):
return
selectedTrack = self.getSelectedTrackDescriptor()
button.disabled = selectedTrack is None
button.variant = (
"warning"
if selectedTrack is not None and selectedTrack.isDropped()
else "default"
)
def on_data_table_row_highlighted(
self,
event: DataTable.RowHighlighted,
) -> None:
if event.data_table.id == "tracks_table":
self.updateTrackDropButtonState()
def getCurrentTrackDescriptors(self) -> List[TrackDescriptor]: def getCurrentTrackDescriptors(self) -> List[TrackDescriptor]:
if self.__pattern is not None: if self.__pattern is not None:
@@ -384,6 +457,7 @@ class PatternDetailsScreen(Screen):
self.column_key_track_title = add_auto_table_column(self.tracksTable, t("Title")) self.column_key_track_title = add_auto_table_column(self.tracksTable, t("Title"))
self.column_key_track_default = add_auto_table_column(self.tracksTable, t("Default")) self.column_key_track_default = add_auto_table_column(self.tracksTable, t("Default"))
self.column_key_track_forced = add_auto_table_column(self.tracksTable, t("Forced")) self.column_key_track_forced = add_auto_table_column(self.tracksTable, t("Forced"))
self.column_key_track_drop = add_auto_table_column(self.tracksTable, t("Drop"))
self.column_key_track_source_index = add_auto_table_column(self.tracksTable, t("SrcIndex")) self.column_key_track_source_index = add_auto_table_column(self.tracksTable, t("SrcIndex"))
self.tracksTable.cursor_type = 'row' self.tracksTable.cursor_type = 'row'
@@ -481,7 +555,7 @@ class PatternDetailsScreen(Screen):
yield Static(t("Streams")) yield Static(t("Streams"))
yield Button(t("Add"), id="button_add_track") yield Button(t("Add"), id="button_add_track")
yield Button(t("Edit"), id="button_edit_track") yield Button(t("Edit"), id="button_edit_track")
yield Button(t("Delete"), id="button_delete_track") yield Button(t("Drop"), id="button_toggle_track_drop")
yield Static(" ") yield Static(" ")
yield Button(t("Up"), id="button_track_up") yield Button(t("Up"), id="button_track_up")
@@ -502,7 +576,7 @@ class PatternDetailsScreen(Screen):
yield Static(" ", classes="five") yield Static(" ", classes="five")
# Row 20 # Row 20
yield Static(" ", classes="seven") yield Static(" ", id="messagestatic", classes="seven")
yield build_screen_log_pane() yield build_screen_log_pane()
yield Footer() yield Footer()
@@ -537,6 +611,12 @@ class PatternDetailsScreen(Screen):
def getNotesFromInput(self): def getNotesFromInput(self):
return str(self.query_one("#notes_textarea", TextArea).text) return str(self.query_one("#notes_textarea", TextArea).text)
def setMessage(self, message: str):
try:
self.query_one("#messagestatic", Static).update(str(message) or " ")
except (NoMatches, AttributeError):
pass
def getSelectedTrackDescriptor(self): def getSelectedTrackDescriptor(self):
@@ -584,25 +664,26 @@ class PatternDetailsScreen(Screen):
patternDescriptor['quality'] = self.getQualityFromInput() patternDescriptor['quality'] = self.getQualityFromInput()
patternDescriptor['notes'] = self.getNotesFromInput() patternDescriptor['notes'] = self.getNotesFromInput()
if self.__pattern is not None: try:
if self.__pattern is not None:
if self.__pc.updatePattern(self.__pattern.getId(), patternDescriptor): if self.__pc.updatePattern(self.__pattern.getId(), patternDescriptor):
self.dismiss(patternDescriptor) self.dismiss(patternDescriptor)
else: else:
#TODO: Meldung self.setMessage(t("Unable to update pattern."))
self.app.pop_screen()
else:
patternId = self.__pc.savePatternSchema(
patternDescriptor,
trackDescriptors=self.__draftTracks,
mediaTags=self.__draftTags,
)
if patternId:
self.dismiss(patternDescriptor)
else: else:
#TODO: Meldung patternId = self.__pc.savePatternSchema(
self.app.pop_screen() patternDescriptor,
trackDescriptors=self.__draftTracks,
mediaTags=self.__draftTags,
)
if patternId:
self.dismiss(patternDescriptor)
else:
self.setMessage(t("Unable to save pattern."))
except InvalidPatternCaptureGroupError as ex:
self.setMessage(str(ex))
if event.button.id == "cancel_button": if event.button.id == "cancel_button":
@@ -663,11 +744,8 @@ class PatternDetailsScreen(Screen):
), ),
self.handle_edit_track, self.handle_edit_track,
) )
if event.button.id == "button_delete_track": if event.button.id == "button_toggle_track_drop":
self.app.push_screen( self.toggleSelectedTrackDropped(selectedTrack)
TrackDeleteScreen(trackDescriptor = selectedTrack),
self.handle_delete_track,
)
if event.button.id == "button_add_tag": if event.button.id == "button_add_tag":
@@ -751,6 +829,27 @@ class PatternDetailsScreen(Screen):
self.updateTracks() self.updateTracks()
def toggleSelectedTrackDropped(self, trackDescriptor: TrackDescriptor):
if trackDescriptor is None:
return
updatedTrack = trackDescriptor.clone(context=self.context)
updatedTrack.setDropped(not trackDescriptor.isDropped())
if self.__pattern is not None:
if not self.__tc.updateTrack(updatedTrack.getId(), updatedTrack):
raise click.ClickException("PatternDetailsScreen.toggleSelectedTrackDropped(): track update failed")
else:
for currentTrack in self.__draftTracks:
if (
currentTrack.getIndex() == trackDescriptor.getIndex()
and currentTrack.getSubIndex() == trackDescriptor.getSubIndex()
):
currentTrack.setDropped(updatedTrack.isDropped())
break
self.updateTracks()
def handle_delete_track(self, trackDescriptor : TrackDescriptor): def handle_delete_track(self, trackDescriptor : TrackDescriptor):
if trackDescriptor is None: if trackDescriptor is None:

View File

@@ -35,6 +35,8 @@ class TrackController():
def addTrack(self, trackDescriptor : TrackDescriptor, patternId = None): def addTrack(self, trackDescriptor : TrackDescriptor, patternId = None):
if trackDescriptor.getType() == TrackType.ATTACHMENT:
return False
# option to override pattern id in case track descriptor has not set it # option to override pattern id in case track descriptor has not set it
patId = int(trackDescriptor.getPatternId() if patternId is None else patternId) patId = int(trackDescriptor.getPatternId() if patternId is None else patternId)
@@ -46,6 +48,7 @@ class TrackController():
codec_name = str(trackDescriptor.getFormatDescriptor().identifier()), codec_name = str(trackDescriptor.getFormatDescriptor().identifier()),
index = int(trackDescriptor.getIndex()), index = int(trackDescriptor.getIndex()),
source_index = int(trackDescriptor.getSourceIndex()), source_index = int(trackDescriptor.getSourceIndex()),
dropped = 1 if trackDescriptor.isDropped() else 0,
disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())), disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())),
audio_layout = trackDescriptor.getAudioLayout().index()) audio_layout = trackDescriptor.getAudioLayout().index())
@@ -72,6 +75,8 @@ class TrackController():
if type(trackDescriptor) is not TrackDescriptor: if type(trackDescriptor) is not TrackDescriptor:
raise TypeError('TrackController.updateTrack(): Argument trackDescriptor is required to be of type TrackDescriptor') raise TypeError('TrackController.updateTrack(): Argument trackDescriptor is required to be of type TrackDescriptor')
if trackDescriptor.getType() == TrackType.ATTACHMENT:
return False
try: try:
s = self.Session() s = self.Session()
@@ -79,7 +84,21 @@ class TrackController():
if track is not None: if track is not None:
patternId = int(track.pattern_id)
if trackDescriptor.isDropped() and not track.isDropped():
activeSiblingCount = (
s.query(Track)
.filter(Track.pattern_id == patternId, Track.dropped == 0)
.count()
)
if activeSiblingCount <= 1:
raise click.ClickException(
f"Cannot drop the last active track from pattern #{patternId}. Patterns must define at least one non-dropped track."
)
track.index = int(trackDescriptor.getIndex()) track.index = int(trackDescriptor.getIndex())
track.source_index = int(trackDescriptor.getSourceIndex())
track.dropped = 1 if trackDescriptor.isDropped() else 0
track.track_type = int(trackDescriptor.getType().index()) track.track_type = int(trackDescriptor.getType().index())
track.codec_name = str(trackDescriptor.getFormatDescriptor().identifier()) track.codec_name = str(trackDescriptor.getFormatDescriptor().identifier())
@@ -251,6 +270,17 @@ class TrackController():
f"Cannot delete the last track from pattern #{patternId}. Patterns must define at least one track." f"Cannot delete the last track from pattern #{patternId}. Patterns must define at least one track."
) )
if not track.isDropped():
activeSiblingTracks = [
siblingTrack
for siblingTrack in siblingTracks
if not siblingTrack.isDropped()
]
if len(activeSiblingTracks) <= 1:
raise click.ClickException(
f"Cannot delete the last active track from pattern #{patternId}. Patterns must define at least one non-dropped track."
)
index = 0 index = 0
for track in siblingTracks: for track in siblingTracks:

View File

@@ -21,6 +21,7 @@ class TrackDescriptor:
SUB_INDEX_KEY = "sub_index" SUB_INDEX_KEY = "sub_index"
PATTERN_ID_KEY = "pattern_id" PATTERN_ID_KEY = "pattern_id"
EXTERNAL_SOURCE_FILE_PATH_KEY = "external_source_file" EXTERNAL_SOURCE_FILE_PATH_KEY = "external_source_file"
DROPPED_KEY = "dropped"
DISPOSITION_SET_KEY = "disposition_set" DISPOSITION_SET_KEY = "disposition_set"
TAGS_KEY = "tags" TAGS_KEY = "tags"
@@ -77,6 +78,8 @@ class TrackDescriptor:
else: else:
self.__externalSourceFilePath = '' self.__externalSourceFilePath = ''
self.__dropped = bool(kwargs.get(TrackDescriptor.DROPPED_KEY, False))
if TrackDescriptor.INDEX_KEY in kwargs.keys(): if TrackDescriptor.INDEX_KEY in kwargs.keys():
if type(kwargs[TrackDescriptor.INDEX_KEY]) is not int: if type(kwargs[TrackDescriptor.INDEX_KEY]) is not int:
raise TypeError( raise TypeError(
@@ -378,11 +381,18 @@ class TrackDescriptor:
def getExternalSourceFilePath(self): def getExternalSourceFilePath(self):
return self.__externalSourceFilePath return self.__externalSourceFilePath
def isDropped(self) -> bool:
return self.__dropped
def setDropped(self, dropped: bool):
self.__dropped = bool(dropped)
def clone(self, context: dict | None = None): def clone(self, context: dict | None = None):
kwargs = { kwargs = {
TrackDescriptor.ID_KEY: int(self.__trackId), TrackDescriptor.ID_KEY: int(self.__trackId),
TrackDescriptor.PATTERN_ID_KEY: int(self.__patternId), TrackDescriptor.PATTERN_ID_KEY: int(self.__patternId),
TrackDescriptor.EXTERNAL_SOURCE_FILE_PATH_KEY: str(self.__externalSourceFilePath), TrackDescriptor.EXTERNAL_SOURCE_FILE_PATH_KEY: str(self.__externalSourceFilePath),
TrackDescriptor.DROPPED_KEY: bool(self.__dropped),
TrackDescriptor.INDEX_KEY: int(self.__index), TrackDescriptor.INDEX_KEY: int(self.__index),
TrackDescriptor.SOURCE_INDEX_KEY: int(self.__sourceIndex), TrackDescriptor.SOURCE_INDEX_KEY: int(self.__sourceIndex),
TrackDescriptor.SUB_INDEX_KEY: int(self.__subIndex), TrackDescriptor.SUB_INDEX_KEY: int(self.__subIndex),

View File

@@ -1,7 +1,7 @@
import click import click
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, SelectionList, Select, DataTable, Input from textual.widgets import Header, Footer, Static, Button, SelectionList, Select, DataTable, Input, Checkbox
from textual.containers import Grid from textual.containers import Grid
from textual.widgets._data_table import CellDoesNotExist from textual.widgets._data_table import CellDoesNotExist
@@ -34,7 +34,7 @@ class TrackDetailsScreen(Screen):
Grid { Grid {
grid-size: 5 24; grid-size: 5 24;
grid-rows: 2 2 2 2 2 3 3 2 2 3 2 2 2 2 2 6 2 2 6 2 2 2; grid-rows: 2 2 2 2 2 3 3 3 2 3 2 2 2 2 2 6 2 2 6 2 2 2;
grid-columns: 18 1fr 1fr 1fr 4fr; grid-columns: 18 1fr 1fr 1fr 4fr;
height: 100%; height: 100%;
width: 100%; width: 100%;
@@ -146,6 +146,7 @@ class TrackDetailsScreen(Screen):
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED
self.__index = index self.__index = index
self.__subIndex = subIndex self.__subIndex = subIndex
self.__dropped = False
self.__draftTrackTags = {} self.__draftTrackTags = {}
initial_language = IsoLanguage.UNDEFINED initial_language = IsoLanguage.UNDEFINED
initial_title = "" initial_title = ""
@@ -156,6 +157,7 @@ class TrackDetailsScreen(Screen):
self.__audioLayout = trackDescriptor.getAudioLayout() self.__audioLayout = trackDescriptor.getAudioLayout()
self.__index = trackDescriptor.getIndex() self.__index = trackDescriptor.getIndex()
self.__subIndex = trackDescriptor.getSubIndex() self.__subIndex = trackDescriptor.getSubIndex()
self.__dropped = trackDescriptor.isDropped()
self.__draftTrackTags = { self.__draftTrackTags = {
key: value key: value
for key, value in trackDescriptor.getTags().items() for key, value in trackDescriptor.getTags().items()
@@ -255,6 +257,8 @@ class TrackDetailsScreen(Screen):
self.query_one("#audio_layout_select", Select).value = self.__audioLayout self.query_one("#audio_layout_select", Select).value = self.__audioLayout
self.query_one("#drop_checkbox", Checkbox).value = self.__dropped
for disposition in TrackDisposition: for disposition in TrackDisposition:
dispositionIsSet = ( dispositionIsSet = (
@@ -284,6 +288,8 @@ class TrackDetailsScreen(Screen):
self.query_one("#type_select", Select).disabled = True self.query_one("#type_select", Select).disabled = True
self.query_one("#audio_layout_select", Select).disabled = True self.query_one("#audio_layout_select", Select).disabled = True
self.query_one("#drop_checkbox", Checkbox).disabled = True
def on_select_changed(self, event: Select.Changed) -> None: def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id == "language_select": if event.select.id == "language_select":
self._handle_language_selection_changed(event.value) self._handle_language_selection_changed(event.value)
@@ -344,7 +350,9 @@ class TrackDetailsScreen(Screen):
) )
# Row 8 # Row 8
yield Static(" ", classes="five") yield Static(t("Drop"))
yield Checkbox(id="drop_checkbox", classes="four")
# Row 9 # Row 9
yield Static(" ", classes="five") yield Static(" ", classes="five")
@@ -462,6 +470,9 @@ class TrackDetailsScreen(Screen):
trackTags["title"] = title trackTags["title"] = title
kwargs[TrackDescriptor.TAGS_KEY] = trackTags kwargs[TrackDescriptor.TAGS_KEY] = trackTags
kwargs[TrackDescriptor.DROPPED_KEY] = bool(
self.query_one("#drop_checkbox", Checkbox).value
)
dispositionFlags = sum( dispositionFlags = sum(
[2 ** flag for flag in self.query_one("#dispositions_selection_list", SelectionList).selected] [2 ** flag for flag in self.query_one("#dispositions_selection_list", SelectionList).selected]
@@ -504,6 +515,7 @@ class TrackDetailsScreen(Screen):
descriptor descriptor
for descriptor in siblingTrackList for descriptor in siblingTrackList
if descriptor.getType() == trackDescriptor.getType() if descriptor.getType() == trackDescriptor.getType()
and not descriptor.isDropped()
] ]
numDefaultTracks = len( numDefaultTracks = len(
@@ -527,10 +539,12 @@ class TrackDetailsScreen(Screen):
trackDescriptor.setSubIndex(int(self.__subIndex)) trackDescriptor.setSubIndex(int(self.__subIndex))
if ( if (
TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet() not trackDescriptor.isDropped()
and TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet()
and numDefaultTracks and numDefaultTracks
) or ( ) or (
TrackDisposition.FORCED in trackDescriptor.getDispositionSet() not trackDescriptor.isDropped()
and TrackDisposition.FORCED in trackDescriptor.getDispositionSet()
and numForcedTracks and numForcedTracks
): ):

View File

@@ -66,7 +66,7 @@ class PatternManagementCliTests(unittest.TestCase):
[to_track_descriptor(track_descriptors[0])], [to_track_descriptor(track_descriptors[0])],
) )
controller.savePatternSchema( controller.savePatternSchema(
{"show_id": 2, "pattern": r"^dup_.*$"}, {"show_id": 2, "pattern": r"^dup_(s[0-9]+e[0-9]+).*$"},
[to_track_descriptor(track_descriptors[0])], [to_track_descriptor(track_descriptors[0])],
) )
finally: finally:

View File

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

View File

@@ -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
View 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 "$@"

View File

@@ -18,6 +18,7 @@ if str(SRC_ROOT) not in sys.path:
sys.path.insert(0, str(SRC_ROOT)) sys.path.insert(0, str(SRC_ROOT))
from ffx.attachment_format import AttachmentFormat
from ffx.audio_layout import AudioLayout from ffx.audio_layout import AudioLayout
from ffx.database import databaseContext from ffx.database import databaseContext
from ffx.pattern_controller import PatternController from ffx.pattern_controller import PatternController
@@ -55,7 +56,9 @@ class PatternTrackSpec:
track_type: TrackType track_type: TrackType
tags: Mapping[str, str] = field(default_factory=dict) tags: Mapping[str, str] = field(default_factory=dict)
dispositions: tuple[TrackDisposition, ...] = () dispositions: tuple[TrackDisposition, ...] = ()
dropped: bool = False
audio_layout: AudioLayout = AudioLayout.LAYOUT_STEREO audio_layout: AudioLayout = AudioLayout.LAYOUT_STEREO
attachment_format: AttachmentFormat = AttachmentFormat.UNKNOWN
def make_logger(name: str) -> logging.Logger: def make_logger(name: str) -> logging.Logger:
@@ -296,9 +299,12 @@ def prepare_pattern_database(database_path: Path, filename_pattern: str, track_s
TrackDescriptor.TRACK_TYPE_KEY: track.track_type, TrackDescriptor.TRACK_TYPE_KEY: track.track_type,
TrackDescriptor.TAGS_KEY: dict(track.tags), TrackDescriptor.TAGS_KEY: dict(track.tags),
TrackDescriptor.DISPOSITION_SET_KEY: set(track.dispositions), TrackDescriptor.DISPOSITION_SET_KEY: set(track.dispositions),
TrackDescriptor.DROPPED_KEY: track.dropped,
} }
if track.track_type == TrackType.AUDIO: if track.track_type == TrackType.AUDIO:
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = track.audio_layout kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = track.audio_layout
if track.track_type == TrackType.ATTACHMENT:
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = track.attachment_format
track_descriptors.append(TrackDescriptor(**kwargs)) track_descriptors.append(TrackDescriptor(**kwargs))
pattern_id = PatternController(context).savePatternSchema( pattern_id = PatternController(context).savePatternSchema(

View File

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

View File

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

View File

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

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

View File

@@ -161,6 +161,55 @@ class FfxControllerTests(unittest.TestCase):
any(token.startswith("ENCODING_PRESET=") for token in commands[1]) any(token.startswith("ENCODING_PRESET=") for token in commands[1])
) )
def test_vp9_run_job_applies_identical_video_filters_to_both_passes(self):
context = self.make_context(VideoEncoder.VP9)
target_descriptor, source_descriptor = self.make_media_descriptors()
controller = FfxController(context, target_descriptor, source_descriptor)
commands = []
with (
patch.object(
controller,
"executeCommandSequence",
side_effect=lambda command: commands.append(command) or ("", "", 0),
),
patch("ffx.ffx_controller.os.path.exists", return_value=False),
):
controller.runJob(
"input.mkv",
"output.webm",
targetFormat="webm",
chainIteration=[
{
"identifier": "quality",
"parameters": {"quality": 27},
},
{
"identifier": "nlmeans",
"parameters": {},
"tokens": ["nlmeans=s=2.8"],
},
{
"identifier": "bwdif",
"parameters": {},
"tokens": ["bwdif=mode=1"],
},
],
cropArguments={
"output_width": 704,
"output_height": 576,
"x_offset": 8,
"y_offset": 0,
},
)
self.assertEqual(2, len(commands))
expectedFilterChain = (
"crop=704:576:8:0, nlmeans=s=2.8, bwdif=mode=1"
)
self.assert_token_pair(commands[0], "-vf", expectedFilterChain)
self.assert_token_pair(commands[1], "-vf", expectedFilterChain)
def test_av1_run_job_emits_file_level_quality_and_preset_metadata(self): def test_av1_run_job_emits_file_level_quality_and_preset_metadata(self):
context = self.make_context(VideoEncoder.AV1) context = self.make_context(VideoEncoder.AV1)
target_descriptor, source_descriptor = self.make_media_descriptors() target_descriptor, source_descriptor = self.make_media_descriptors()

View File

@@ -13,6 +13,7 @@ if str(SRC_ROOT) not in sys.path:
from ffx.media_descriptor import MediaDescriptor # noqa: E402 from ffx.media_descriptor import MediaDescriptor # noqa: E402
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402 from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402
from ffx.attachment_format import AttachmentFormat # noqa: E402
from ffx.track_descriptor import TrackDescriptor # noqa: E402 from ffx.track_descriptor import TrackDescriptor # noqa: E402
from ffx.track_type import TrackType # noqa: E402 from ffx.track_type import TrackType # noqa: E402
from ffx.i18n import set_current_language # noqa: E402 from ffx.i18n import set_current_language # noqa: E402
@@ -436,6 +437,47 @@ class MediaDescriptorChangeSetTests(unittest.TestCase):
self.assertNotIn("creation_time=", metadata_tokens) self.assertNotIn("creation_time=", metadata_tokens)
self.assertNotIn("BPS=", metadata_tokens) self.assertNotIn("BPS=", metadata_tokens)
def test_attachment_tracks_are_ignored_for_pattern_comparison(self):
context = {
"logger": get_ffx_logger(),
"config": StaticConfig({}),
}
source_track = TrackDescriptor(
index=0,
source_index=0,
sub_index=0,
track_type=TrackType.ATTACHMENT,
attachment_format=AttachmentFormat.TTF,
tags={"filename": "current.ttf", "mimetype": "font/ttf"},
)
target_track = TrackDescriptor(
index=0,
source_index=0,
sub_index=0,
track_type=TrackType.ATTACHMENT,
attachment_format=AttachmentFormat.TTF,
tags={"filename": "stored.ttf", "mimetype": "font/ttf"},
)
stale_target_track = TrackDescriptor(
index=1,
source_index=1,
sub_index=1,
track_type=TrackType.ATTACHMENT,
attachment_format=AttachmentFormat.TTF,
tags={"filename": "missing.ttf", "mimetype": "font/ttf"},
)
change_set = MediaDescriptorChangeSet(
context,
MediaDescriptor(track_descriptors=[target_track, stale_target_track]),
MediaDescriptor(track_descriptors=[source_track]),
)
self.assertEqual({}, change_set.getChangeSetObj())
self.assertEqual([], change_set.generateMetadataTokens())
self.assertEqual([], change_set.generateDispositionTokens())
def test_normalization_can_be_disabled_per_context(self): def test_normalization_can_be_disabled_per_context(self):
context = { context = {
"logger": get_ffx_logger(), "logger": get_ffx_logger(),

View File

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

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

View File

@@ -21,6 +21,7 @@ from ffx.file_properties import FileProperties # noqa: E402
from ffx.model.pattern import Pattern # noqa: E402 from ffx.model.pattern import Pattern # noqa: E402
from ffx.pattern_controller import ( # noqa: E402 from ffx.pattern_controller import ( # noqa: E402
DuplicatePatternMatchError, DuplicatePatternMatchError,
InvalidPatternCaptureGroupError,
InvalidPatternSchemaError, InvalidPatternSchemaError,
PatternController, PatternController,
) )
@@ -65,6 +66,7 @@ def make_track_descriptor(
track_type: TrackType = TrackType.VIDEO, track_type: TrackType = TrackType.VIDEO,
title: str = "", title: str = "",
dispositions: set[TrackDisposition] | None = None, dispositions: set[TrackDisposition] | None = None,
dropped: bool = False,
) -> TrackDescriptor: ) -> TrackDescriptor:
kwargs = { kwargs = {
TrackDescriptor.INDEX_KEY: index, TrackDescriptor.INDEX_KEY: index,
@@ -72,6 +74,7 @@ def make_track_descriptor(
TrackDescriptor.TRACK_TYPE_KEY: track_type, TrackDescriptor.TRACK_TYPE_KEY: track_type,
TrackDescriptor.TAGS_KEY: {"title": title} if title else {}, TrackDescriptor.TAGS_KEY: {"title": title} if title else {},
TrackDescriptor.DISPOSITION_SET_KEY: dispositions or set(), TrackDescriptor.DISPOSITION_SET_KEY: dispositions or set(),
TrackDescriptor.DROPPED_KEY: dropped,
} }
if track_type == TrackType.AUDIO: if track_type == TrackType.AUDIO:
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_STEREO kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_STEREO
@@ -142,7 +145,7 @@ class PatternManagementTests(unittest.TestCase):
def test_match_filename_raises_for_duplicate_matches_in_same_show(self): def test_match_filename_raises_for_duplicate_matches_in_same_show(self):
self.save_pattern(1, r"^same_(s[0-9]+e[0-9]+)\.mkv$") self.save_pattern(1, r"^same_(s[0-9]+e[0-9]+)\.mkv$")
self.save_pattern(1, r"^same_.*$") self.save_pattern(1, r"^same_(.*)$")
with self.assertRaises(DuplicatePatternMatchError) as caught: with self.assertRaises(DuplicatePatternMatchError) as caught:
self.pattern_controller.matchFilename("same_s01e01.mkv") self.pattern_controller.matchFilename("same_s01e01.mkv")
@@ -152,7 +155,7 @@ class PatternManagementTests(unittest.TestCase):
def test_match_filename_raises_for_duplicate_matches_across_shows(self): def test_match_filename_raises_for_duplicate_matches_across_shows(self):
self.save_pattern(1, r"^cross_(s[0-9]+e[0-9]+)\.mkv$") self.save_pattern(1, r"^cross_(s[0-9]+e[0-9]+)\.mkv$")
self.save_pattern(2, r"^cross_.*$") self.save_pattern(2, r"^cross_(.*)$")
with self.assertRaises(DuplicatePatternMatchError) as caught: with self.assertRaises(DuplicatePatternMatchError) as caught:
self.pattern_controller.matchFilename("cross_s01e01.mkv") self.pattern_controller.matchFilename("cross_s01e01.mkv")
@@ -179,6 +182,48 @@ class PatternManagementTests(unittest.TestCase):
match = self.pattern_controller.matchFilename("after_s01e01.mkv") match = self.pattern_controller.matchFilename("after_s01e01.mkv")
self.assertEqual(pattern_id, match["pattern"].getId()) self.assertEqual(pattern_id, match["pattern"].getId())
def test_save_pattern_schema_rejects_pattern_without_capture_group(self):
self.add_show(1, "No Capture Pattern Show")
with self.assertRaises(InvalidPatternCaptureGroupError) as caught:
self.pattern_controller.savePatternSchema(
{
"show_id": 1,
"pattern": r"^nocapture_s[0-9]+e[0-9]+\.mkv$",
},
trackDescriptors=[make_track_descriptor(0)],
)
self.assertIn("capture group", str(caught.exception))
def test_update_pattern_rejects_pattern_without_capture_group(self):
pattern_id = self.save_pattern(1, r"^valid_(s[0-9]+e[0-9]+)\.mkv$")
with self.assertRaises(InvalidPatternCaptureGroupError):
self.pattern_controller.updatePattern(
pattern_id,
{
"show_id": 1,
"pattern": r"^invalid_s[0-9]+e[0-9]+\.mkv$",
"quality": 0,
"notes": "",
},
)
match = self.pattern_controller.matchFilename("valid_s01e01.mkv")
self.assertEqual(pattern_id, match["pattern"].getId())
def test_match_filename_raises_specific_error_for_legacy_pattern_without_capture_group(self):
pattern_id = self.insert_trackless_pattern_row(
1,
r"^legacy_s[0-9]+e[0-9]+\.mkv$",
)
with self.assertRaises(InvalidPatternCaptureGroupError) as caught:
self.pattern_controller.matchFilename("legacy_s01e01.mkv")
self.assertEqual(pattern_id, caught.exception.pattern_id)
def test_save_pattern_schema_rejects_zero_track_patterns(self): def test_save_pattern_schema_rejects_zero_track_patterns(self):
self.add_show(1, "Empty Pattern Show") self.add_show(1, "Empty Pattern Show")
@@ -193,6 +238,77 @@ class PatternManagementTests(unittest.TestCase):
self.assertIn("at least one track", str(caught.exception)) self.assertIn("at least one track", str(caught.exception))
def test_save_pattern_schema_rejects_all_dropped_patterns(self):
self.add_show(1, "Dropped Pattern Show")
with self.assertRaises(InvalidPatternSchemaError) as caught:
self.pattern_controller.savePatternSchema(
{
"show_id": 1,
"pattern": r"^dropped_(s[0-9]+e[0-9]+)\.mkv$",
},
trackDescriptors=[make_track_descriptor(0, dropped=True)],
)
self.assertIn("non-dropped track", str(caught.exception))
def test_dropped_pattern_track_is_not_in_conversion_media_descriptor(self):
pattern_id = self.save_pattern(
1,
r"^active_(s[0-9]+e[0-9]+)\.mkv$",
tracks=[
make_track_descriptor(0, track_type=TrackType.VIDEO),
make_track_descriptor(1, track_type=TrackType.AUDIO, dropped=True),
make_track_descriptor(2, track_type=TrackType.SUBTITLE),
],
)
Session = self.context["database"]["session"]
session = Session()
try:
pattern = session.query(Pattern).filter(Pattern.id == pattern_id).first()
media_descriptor = pattern.getMediaDescriptor(self.context)
tracks = media_descriptor.getTrackDescriptors()
finally:
session.close()
self.assertEqual(
[TrackType.VIDEO, TrackType.SUBTITLE],
[track.getType() for track in tracks],
)
self.assertEqual([0, 1], [track.getIndex() for track in tracks])
self.assertEqual([0, 2], [track.getSourceIndex() for track in tracks])
def test_save_pattern_schema_does_not_persist_attachment_tracks(self):
pattern_id = self.save_pattern(
1,
r"^noattachments_(s[0-9]+e[0-9]+)\.mkv$",
tracks=[
make_track_descriptor(0, track_type=TrackType.VIDEO),
make_track_descriptor(1, track_type=TrackType.ATTACHMENT),
],
)
Session = self.context["database"]["session"]
session = Session()
try:
tracks = session.query(Pattern).filter(Pattern.id == pattern_id).first().tracks
self.assertEqual(1, len(tracks))
self.assertEqual(TrackType.VIDEO, tracks[0].getType())
finally:
session.close()
def test_track_controller_does_not_add_attachment_tracks_to_patterns(self):
pattern_id = self.save_pattern(1, r"^skipadd_(s[0-9]+e[0-9]+)\.mkv$")
added = self.track_controller.addTrack(
make_track_descriptor(1, track_type=TrackType.ATTACHMENT),
patternId=pattern_id,
)
self.assertFalse(added)
self.assertEqual(1, len(self.track_controller.findTracks(pattern_id)))
def test_match_filename_rejects_existing_trackless_pattern_rows(self): def test_match_filename_rejects_existing_trackless_pattern_rows(self):
self.insert_trackless_pattern_row(1, r"^invalid_(s[0-9]+e[0-9]+)\.mkv$") self.insert_trackless_pattern_row(1, r"^invalid_(s[0-9]+e[0-9]+)\.mkv$")
@@ -203,7 +319,7 @@ class PatternManagementTests(unittest.TestCase):
def test_file_properties_skips_pattern_matching_when_disabled(self): def test_file_properties_skips_pattern_matching_when_disabled(self):
self.save_pattern(1, r"^nopattern_(s[0-9]+e[0-9]+)\.mkv$") self.save_pattern(1, r"^nopattern_(s[0-9]+e[0-9]+)\.mkv$")
self.save_pattern(2, r"^nopattern_.*$") self.save_pattern(2, r"^nopattern_(.*)$")
no_pattern_context = dict(self.context) no_pattern_context = dict(self.context)
no_pattern_context["use_pattern"] = False no_pattern_context["use_pattern"] = False
@@ -227,6 +343,17 @@ class PatternManagementTests(unittest.TestCase):
self.assertIn("last track", str(caught.exception)) self.assertIn("last track", str(caught.exception))
def test_track_controller_refuses_to_drop_last_active_track(self):
pattern_id = self.save_pattern(1, r"^drop_(s[0-9]+e[0-9]+)\.mkv$")
track = self.track_controller.getTrack(pattern_id, 0)
descriptor = track.getDescriptor()
descriptor.setDropped(True)
with self.assertRaises(click.ClickException) as caught:
self.track_controller.updateTrack(track.getId(), descriptor)
self.assertIn("last active track", str(caught.exception))
def test_exact_duplicate_pattern_definition_is_rejected(self): def test_exact_duplicate_pattern_definition_is_rejected(self):
self.save_pattern(1, r"^unique_(s[0-9]+e[0-9]+)\.mkv$") self.save_pattern(1, r"^unique_(s[0-9]+e[0-9]+)\.mkv$")

View File

@@ -15,12 +15,13 @@ if str(SRC_ROOT) not in sys.path:
from ffx.audio_layout import AudioLayout # noqa: E402 from ffx.audio_layout import AudioLayout # noqa: E402
from ffx.attachment_format import AttachmentFormat # noqa: E402 from ffx.attachment_format import AttachmentFormat # noqa: E402
from ffx.helper import DIFF_ADDED_KEY # noqa: E402 from ffx.helper import DIFF_ADDED_KEY, DIFF_REMOVED_KEY # noqa: E402
from ffx.iso_language import IsoLanguage # noqa: E402 from ffx.iso_language import IsoLanguage # noqa: E402
from ffx.logging_utils import get_ffx_logger # noqa: E402 from ffx.logging_utils import get_ffx_logger # noqa: E402
from ffx.inspect_details_screen import InspectDetailsScreen # noqa: E402 from ffx.inspect_details_screen import InspectDetailsScreen # noqa: E402
from ffx.i18n import set_current_language # noqa: E402 from ffx.i18n import set_current_language # noqa: E402
from ffx.media_descriptor import MediaDescriptor # noqa: E402 from ffx.media_descriptor import MediaDescriptor # noqa: E402
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402
from ffx.media_edit_screen import MediaEditScreen # noqa: E402 from ffx.media_edit_screen import MediaEditScreen # noqa: E402
from ffx.pattern_details_screen import PatternDetailsScreen # noqa: E402 from ffx.pattern_details_screen import PatternDetailsScreen # noqa: E402
from ffx.show_descriptor import ShowDescriptor # noqa: E402 from ffx.show_descriptor import ShowDescriptor # noqa: E402
@@ -75,6 +76,12 @@ class FakeTagTable:
def select_row(self, row_key): def select_row(self, row_key):
self._selected_row_key = row_key self._selected_row_key = row_key
def move_cursor(self, *, row=None, column=None, animate=False, scroll=True):
if row is not None and self._row_order:
self._selected_row_key = self._row_order[int(row)]
if column is not None:
self.cursor_coordinate = (self.cursor_coordinate[0], int(column))
def get_row_index(self, row_key): def get_row_index(self, row_key):
return self._row_order.index(row_key) return self._row_order.index(row_key)
@@ -109,6 +116,12 @@ class FakeValueWidget:
self.disabled = False self.disabled = False
class FakeButtonWidget:
def __init__(self):
self.disabled = False
self.variant = "default"
class FakeInputWidget: class FakeInputWidget:
def __init__(self, value): def __init__(self, value):
self.value = value self.value = value
@@ -125,18 +138,20 @@ class FakeStaticWidget:
class FakeSelectionListWidget: class FakeSelectionListWidget:
def __init__(self, selected): def __init__(self, selected):
self.selected = selected self.selected = selected
self.disabled = False
def add_option(self, _option): def add_option(self, _option):
return None return None
def make_track_descriptor(index, sub_index, track_type): def make_track_descriptor(index, sub_index, track_type, *, dropped=False):
return TrackDescriptor( return TrackDescriptor(
index=index, index=index,
sub_index=sub_index, sub_index=sub_index,
track_type=track_type, track_type=track_type,
codec_name=TrackCodec.UNKNOWN, codec_name=TrackCodec.UNKNOWN,
audio_layout=AudioLayout.LAYOUT_UNDEFINED, audio_layout=AudioLayout.LAYOUT_UNDEFINED,
dropped=dropped,
) )
@@ -187,6 +202,7 @@ class TagTableScreenStateTests(unittest.TestCase):
widgets = { widgets = {
"#type_select": FakeValueWidget(TrackType.AUDIO), "#type_select": FakeValueWidget(TrackType.AUDIO),
"#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_STEREO), "#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_STEREO),
"#drop_checkbox": FakeValueWidget(True),
"#language_select": FakeValueWidget(IsoLanguage.GERMAN), "#language_select": FakeValueWidget(IsoLanguage.GERMAN),
"#title_input": FakeInputWidget("German Audio"), "#title_input": FakeInputWidget("German Audio"),
"#dispositions_selection_list": FakeSelectionListWidget({0, 6}), "#dispositions_selection_list": FakeSelectionListWidget({0, 6}),
@@ -200,6 +216,7 @@ class TagTableScreenStateTests(unittest.TestCase):
self.assertEqual("deu", descriptor.getTags()["language"]) self.assertEqual("deu", descriptor.getTags()["language"])
self.assertEqual("German Audio", descriptor.getTitle()) self.assertEqual("German Audio", descriptor.getTitle())
self.assertEqual("value", descriptor.getTags()["KEEP"]) self.assertEqual("value", descriptor.getTags()["KEEP"])
self.assertTrue(descriptor.isDropped())
def test_track_details_screen_preserves_attachment_format_for_attachment_tracks(self): def test_track_details_screen_preserves_attachment_format_for_attachment_tracks(self):
screen = object.__new__(TrackDetailsScreen) screen = object.__new__(TrackDetailsScreen)
@@ -215,6 +232,7 @@ class TagTableScreenStateTests(unittest.TestCase):
widgets = { widgets = {
"#type_select": FakeValueWidget(TrackType.ATTACHMENT), "#type_select": FakeValueWidget(TrackType.ATTACHMENT),
"#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_UNDEFINED), "#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_UNDEFINED),
"#drop_checkbox": FakeValueWidget(False),
"#language_select": FakeValueWidget(Select.NULL), "#language_select": FakeValueWidget(Select.NULL),
"#title_input": FakeInputWidget(""), "#title_input": FakeInputWidget(""),
"#dispositions_selection_list": FakeSelectionListWidget(set()), "#dispositions_selection_list": FakeSelectionListWidget(set()),
@@ -306,6 +324,7 @@ class TagTableScreenStateTests(unittest.TestCase):
audio_layout=AudioLayout.LAYOUT_STEREO, audio_layout=AudioLayout.LAYOUT_STEREO,
tags={"language": "ger"}, tags={"language": "ger"},
) )
screen._TrackDetailsScreen__dropped = False
screen._TrackDetailsScreen__metadataOnly = True screen._TrackDetailsScreen__metadataOnly = True
screen._TrackDetailsScreen__titleAutoManaged = True screen._TrackDetailsScreen__titleAutoManaged = True
screen._TrackDetailsScreen__suppressTitleChanged = False screen._TrackDetailsScreen__suppressTitleChanged = False
@@ -322,6 +341,7 @@ class TagTableScreenStateTests(unittest.TestCase):
"#pattern_label": FakeStaticWidget(), "#pattern_label": FakeStaticWidget(),
"#type_select": FakeValueWidget(None), "#type_select": FakeValueWidget(None),
"#audio_layout_select": FakeValueWidget(None), "#audio_layout_select": FakeValueWidget(None),
"#drop_checkbox": FakeValueWidget(False),
"#dispositions_selection_list": FakeSelectionListWidget(set()), "#dispositions_selection_list": FakeSelectionListWidget(set()),
"#language_select": FakeValueWidget(None), "#language_select": FakeValueWidget(None),
"#title_input": FakeInputWidget(""), "#title_input": FakeInputWidget(""),
@@ -354,7 +374,7 @@ class TagTableScreenStateTests(unittest.TestCase):
def test_pattern_details_screen_reads_selected_track_from_row_mapping(self): def test_pattern_details_screen_reads_selected_track_from_row_mapping(self):
first_track = make_track_descriptor(0, 0, TrackType.VIDEO) first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE) second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE, dropped=True)
screen = object.__new__(PatternDetailsScreen) screen = object.__new__(PatternDetailsScreen)
screen.tracksTable = FakeTagTable() screen.tracksTable = FakeTagTable()
@@ -366,6 +386,101 @@ class TagTableScreenStateTests(unittest.TestCase):
screen.tracksTable.select_row("row-1") screen.tracksTable.select_row("row-1")
self.assertIs(second_track, screen.getSelectedTrackDescriptor()) self.assertIs(second_track, screen.getSelectedTrackDescriptor())
self.assertEqual("Ja", screen.tracksTable.rows["row-1"][9])
def test_pattern_details_screen_toggles_draft_track_drop_state(self):
first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE)
drop_button = FakeButtonWidget()
screen = object.__new__(PatternDetailsScreen)
screen.context = {"logger": get_ffx_logger()}
screen.tracksTable = FakeTagTable()
screen._PatternDetailsScreen__draftTracks = [first_track, second_track]
screen._PatternDetailsScreen__pattern = None
screen._PatternDetailsScreen__trackRowData = {}
screen.query_one = lambda selector, _widget_type=None: drop_button
screen.updateTracks()
screen.tracksTable.select_row("row-1")
screen.toggleSelectedTrackDropped(screen.getSelectedTrackDescriptor())
self.assertFalse(first_track.isDropped())
self.assertTrue(second_track.isDropped())
self.assertEqual("Ja", list(screen.tracksTable.rows.values())[1][9])
self.assertIs(second_track, screen.getSelectedTrackDescriptor())
self.assertEqual("warning", drop_button.variant)
def test_pattern_details_screen_updates_drop_button_for_selected_track(self):
first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
second_track = make_track_descriptor(
1,
0,
TrackType.SUBTITLE,
dropped=True,
)
drop_button = FakeButtonWidget()
screen = object.__new__(PatternDetailsScreen)
screen.tracksTable = FakeTagTable()
screen._PatternDetailsScreen__draftTracks = [first_track, second_track]
screen._PatternDetailsScreen__pattern = None
screen._PatternDetailsScreen__trackRowData = {}
screen.query_one = lambda selector, _widget_type=None: drop_button
screen.updateTracks()
self.assertEqual("default", drop_button.variant)
screen.tracksTable.select_row("row-1")
screen.updateTrackDropButtonState()
self.assertEqual("warning", drop_button.variant)
screen.tracksTable.select_row("row-0")
screen.updateTrackDropButtonState()
self.assertEqual("default", drop_button.variant)
def test_pattern_details_screen_toggles_persisted_track_drop_state(self):
class _FakePattern:
def getId(self):
return 5
class _FakeTrackController:
def __init__(self):
self.updated = []
def updateTrack(self, track_id, track_descriptor):
self.updated.append((track_id, track_descriptor))
return True
track = TrackDescriptor(
id=17,
pattern_id=5,
index=1,
source_index=1,
sub_index=0,
track_type=TrackType.SUBTITLE,
codec_name=TrackCodec.UNKNOWN,
dropped=False,
)
track_controller = _FakeTrackController()
screen = object.__new__(PatternDetailsScreen)
screen.context = {"logger": get_ffx_logger()}
screen._PatternDetailsScreen__pattern = _FakePattern()
screen._PatternDetailsScreen__tc = track_controller
screen.updateTracks = lambda: None
screen.toggleSelectedTrackDropped(track)
self.assertEqual(1, len(track_controller.updated))
track_id, updated_track = track_controller.updated[0]
self.assertEqual(17, track_id)
self.assertFalse(track.isDropped())
self.assertTrue(updated_track.isDropped())
def test_pattern_details_screen_reads_selected_tag_from_raw_row_mapping(self): def test_pattern_details_screen_reads_selected_tag_from_raw_row_mapping(self):
screen = object.__new__(PatternDetailsScreen) screen = object.__new__(PatternDetailsScreen)
@@ -822,6 +937,89 @@ class TagTableScreenStateTests(unittest.TestCase):
self.assertEqual("unknown", row[3]) self.assertEqual("unknown", row[3])
self.assertEqual(" ", row[5]) self.assertEqual(" ", row[5])
def test_inspect_details_screen_uses_source_font_attachments_for_styled_ass(self):
class _Config:
def getData(self):
return {}
class _Pattern:
def __init__(self, media_descriptor):
self._media_descriptor = media_descriptor
def getMediaDescriptor(self, _context):
return self._media_descriptor
source_descriptor = MediaDescriptor(
track_descriptors=[
TrackDescriptor(
index=0,
source_index=0,
sub_index=0,
track_type=TrackType.SUBTITLE,
codec_name=TrackCodec.ASS,
tags={"title": "Styled Subtitle"},
),
TrackDescriptor(
index=1,
source_index=1,
sub_index=0,
track_type=TrackType.ATTACHMENT,
attachment_format=AttachmentFormat.TTF,
tags={"filename": "current.ttf", "mimetype": "font/ttf"},
),
]
)
pattern_descriptor = MediaDescriptor(
track_descriptors=[
TrackDescriptor(
index=0,
source_index=0,
sub_index=0,
track_type=TrackType.SUBTITLE,
codec_name=TrackCodec.ASS,
tags={"title": "Styled Subtitle"},
),
TrackDescriptor(
index=1,
source_index=1,
sub_index=0,
track_type=TrackType.ATTACHMENT,
attachment_format=AttachmentFormat.TTF,
tags={"filename": "old.ttf", "mimetype": "font/ttf"},
),
TrackDescriptor(
index=2,
source_index=2,
sub_index=1,
track_type=TrackType.ATTACHMENT,
attachment_format=AttachmentFormat.TTF,
tags={"filename": "missing.ttf", "mimetype": "font/ttf"},
),
]
)
screen = object.__new__(InspectDetailsScreen)
screen.context = {"logger": get_ffx_logger(), "config": _Config()}
resolved_descriptor = screen._resolve_target_media_descriptor(
_Pattern(pattern_descriptor),
source_descriptor,
)
attachment_tracks = resolved_descriptor.getAttachmentTracks()
self.assertEqual(1, len(attachment_tracks))
self.assertEqual({"filename": "current.ttf", "mimetype": "font/ttf"}, attachment_tracks[0].getTags())
change_set = MediaDescriptorChangeSet(
screen.context,
resolved_descriptor,
source_descriptor,
).getChangeSetObj()
self.assertNotIn(
1,
change_set.get("tracks", {}).get(DIFF_REMOVED_KEY, {}),
)
def test_inspect_details_screen_maps_target_selection_back_to_source_track(self): def test_inspect_details_screen_maps_target_selection_back_to_source_track(self):
source_track = TrackDescriptor( source_track = TrackDescriptor(
index=3, index=3,