Compare commits
6 Commits
v0.4.2
...
f794f822f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f794f822f2 | ||
|
|
1a11710df7 | ||
|
|
93d19629dc | ||
|
|
db43501ce2 | ||
|
|
87568989fe | ||
|
|
20ab08626b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,7 +1,6 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
junk/
|
junk/
|
||||||
.vscode
|
|
||||||
.ipynb_checkpoints/
|
.ipynb_checkpoints/
|
||||||
tools/ansible/inventory/hawaii.yml
|
tools/ansible/inventory/hawaii.yml
|
||||||
tools/ansible/inventory/peppermint.yml
|
tools/ansible/inventory/peppermint.yml
|
||||||
@@ -17,6 +16,7 @@ dist/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
docs/_build/
|
||||||
.codex
|
.codex
|
||||||
|
|
||||||
|
|
||||||
@@ -24,4 +24,4 @@ venv/
|
|||||||
*.webm
|
*.webm
|
||||||
*.mp4
|
*.mp4
|
||||||
ffmpeg2pass-0.log
|
ffmpeg2pass-0.log
|
||||||
*.sup
|
*.sup
|
||||||
|
|||||||
11
.vscode/extensions.json
vendored
Normal file
11
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"swyddfa.esbonio",
|
||||||
|
"ms-python.python",
|
||||||
|
"ms-python.vscode-pylance",
|
||||||
|
"ms-python.debugpy",
|
||||||
|
"tamasfe.even-better-toml",
|
||||||
|
"redhat.vscode-yaml",
|
||||||
|
"DavidAnson.vscode-markdownlint"
|
||||||
|
]
|
||||||
|
}
|
||||||
18
.vscode/settings.json
vendored
Normal file
18
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"esbonio.sphinx.pythonCommand": "${venv:.venv}/bin/python",
|
||||||
|
"esbonio.sphinx.buildCommand": [
|
||||||
|
"sphinx-build",
|
||||||
|
"-b",
|
||||||
|
"html",
|
||||||
|
"docs",
|
||||||
|
"docs/_build/html"
|
||||||
|
],
|
||||||
|
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||||
|
"python.testing.pytestEnabled": true,
|
||||||
|
"python.testing.pytestArgs": [
|
||||||
|
"--ignore=tests/legacy",
|
||||||
|
"--ignore=tests/support",
|
||||||
|
"tests"
|
||||||
|
],
|
||||||
|
"restructuredtext.confPath": "${workspaceFolder}/docs"
|
||||||
|
}
|
||||||
@@ -99,6 +99,12 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
|
|||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
|
### 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
|
### 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
|
- 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
|
||||||
|
|||||||
@@ -69,3 +69,7 @@
|
|||||||
## Delete When
|
## Delete When
|
||||||
|
|
||||||
- Delete this scratchpad once the optimization backlog is either converted into issues/work items or distilled into durable project guidance.
|
- Delete this scratchpad once the optimization backlog is either converted into issues/work items or distilled into durable project guidance.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## TODO: Review styled ASS separate handling
|
||||||
|
|||||||
21
docs/Makefile
Normal file
21
docs/Makefile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
SPHINXOPTS ?=
|
||||||
|
VENV_SPHINXBUILD = ../.venv/bin/sphinx-build
|
||||||
|
SPHINXBUILD ?= $(if $(wildcard $(VENV_SPHINXBUILD)),$(VENV_SPHINXBUILD),sphinx-build)
|
||||||
|
SOURCEDIR = .
|
||||||
|
BUILDDIR = _build
|
||||||
|
|
||||||
|
.PHONY: help clean html linkcheck
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Please use 'make <target>' where <target> is one of"
|
||||||
|
@echo " html to make standalone HTML files"
|
||||||
|
@echo " linkcheck to check all external links for integrity"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf "$(BUILDDIR)"
|
||||||
|
|
||||||
|
html:
|
||||||
|
@$(SPHINXBUILD) -b html "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS)
|
||||||
|
|
||||||
|
linkcheck:
|
||||||
|
@$(SPHINXBUILD) -b linkcheck "$(SOURCEDIR)" "$(BUILDDIR)/linkcheck" $(SPHINXOPTS)
|
||||||
31
docs/api.rst
Normal file
31
docs/api.rst
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
API Reference
|
||||||
|
=============
|
||||||
|
|
||||||
|
This section exposes selected modules that are useful when working on tests,
|
||||||
|
diagnostics, process execution, metadata editing, and file probing.
|
||||||
|
|
||||||
|
CLI Helpers
|
||||||
|
-----------
|
||||||
|
|
||||||
|
.. automodule:: ffx.cli
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
Process Helpers
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: ffx.process
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
File Probing
|
||||||
|
------------
|
||||||
|
|
||||||
|
.. automodule:: ffx.file_properties
|
||||||
|
|
||||||
|
Metadata Editing
|
||||||
|
----------------
|
||||||
|
|
||||||
|
.. automodule:: ffx.metadata_editor
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
44
docs/conf.py
Normal file
44
docs/conf.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
SRC_DIR = ROOT_DIR / "src"
|
||||||
|
sys.path.insert(0, str(SRC_DIR))
|
||||||
|
|
||||||
|
project = "FFX"
|
||||||
|
author = "javanaut@maveno.de"
|
||||||
|
copyright = "2026, Maveno"
|
||||||
|
|
||||||
|
try:
|
||||||
|
release = package_version("ffx")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
release = "0.0.0"
|
||||||
|
version = release
|
||||||
|
|
||||||
|
extensions = [
|
||||||
|
"sphinx.ext.autodoc",
|
||||||
|
"sphinx.ext.napoleon",
|
||||||
|
"sphinx.ext.viewcode",
|
||||||
|
"sphinx_copybutton",
|
||||||
|
]
|
||||||
|
|
||||||
|
source_suffix = {
|
||||||
|
".rst": "restructuredtext",
|
||||||
|
}
|
||||||
|
|
||||||
|
templates_path = ["_templates"]
|
||||||
|
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||||
|
|
||||||
|
html_theme = "sphinx_rtd_theme"
|
||||||
|
html_title = "FFX"
|
||||||
|
html_static_path = []
|
||||||
|
|
||||||
|
autodoc_typehints = "description"
|
||||||
|
autodoc_member_order = "bysource"
|
||||||
|
napoleon_google_docstring = True
|
||||||
|
napoleon_numpy_docstring = True
|
||||||
|
|
||||||
50
docs/development.rst
Normal file
50
docs/development.rst
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
Development
|
||||||
|
===========
|
||||||
|
|
||||||
|
The repo-local ``.venv`` is the preferred environment for contributors working
|
||||||
|
on tests or documentation:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
tests/prepare.sh
|
||||||
|
|
||||||
|
The preparation script installs the package in editable mode with both test and
|
||||||
|
documentation extras:
|
||||||
|
|
||||||
|
.. code-block:: text
|
||||||
|
|
||||||
|
.[test,docs]
|
||||||
|
|
||||||
|
Run Tests
|
||||||
|
---------
|
||||||
|
|
||||||
|
Run the modern pytest suite:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
.venv/bin/python -m pytest --ignore=tests/legacy --ignore=tests/support tests
|
||||||
|
|
||||||
|
The legacy harness remains available separately and is intentionally not part of
|
||||||
|
the default pytest run.
|
||||||
|
|
||||||
|
Build Docs
|
||||||
|
----------
|
||||||
|
|
||||||
|
Build HTML documentation:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
.venv/bin/sphinx-build -b html docs docs/_build/html
|
||||||
|
|
||||||
|
The same command is wrapped by the Sphinx ``Makefile``:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
make -C docs html
|
||||||
|
|
||||||
|
VS Code
|
||||||
|
-------
|
||||||
|
|
||||||
|
The repository includes ``.vscode/extensions.json`` with recommended
|
||||||
|
extensions, including Esbonio for Sphinx language-server support. The workspace
|
||||||
|
settings point Python tooling and Esbonio at the repo-local ``.venv``.
|
||||||
BIN
docs/esbonio.db
Normal file
BIN
docs/esbonio.db
Normal file
Binary file not shown.
@@ -1,170 +0,0 @@
|
|||||||
# File Formats
|
|
||||||
|
|
||||||
This document captures source-file-format notes that complement the normative
|
|
||||||
requirements in `requirements/source_file_formats.md`.
|
|
||||||
|
|
||||||
The first documented format is a Matroska source that carries styled ASS/SSA
|
|
||||||
subtitle streams together with embedded font attachments.
|
|
||||||
|
|
||||||
## Styled ASS In Matroska With Embedded Fonts
|
|
||||||
|
|
||||||
These files are typically `.mkv` releases where subtitle rendering quality
|
|
||||||
depends on keeping both parts of the subtitle package together:
|
|
||||||
|
|
||||||
- one or more subtitle streams with codec `ass`
|
|
||||||
- one or more attachment streams that embed font files used by those subtitles
|
|
||||||
|
|
||||||
This matters because ASS subtitles are not plain text subtitles in the narrow
|
|
||||||
WebVTT sense. They can carry layout, styling, positioning, karaoke, signs, and
|
|
||||||
other typesetting effects. If the matching embedded fonts are lost, consumers
|
|
||||||
can still see subtitle text but the intended styling and sometimes glyph
|
|
||||||
coverage can be degraded.
|
|
||||||
|
|
||||||
For FFX this format is special because the ASS subtitle streams should remain
|
|
||||||
normally editable and mappable, while the related font attachments should be
|
|
||||||
transported unchanged.
|
|
||||||
|
|
||||||
## Observed Sample
|
|
||||||
|
|
||||||
Assessment date: `2026-04-17`
|
|
||||||
|
|
||||||
Observed sample file:
|
|
||||||
|
|
||||||
- `tests/assets/boruto_s01e283_ssa.mkv`
|
|
||||||
|
|
||||||
Commands used for assessment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ffprobe tests/assets/boruto_s01e283_ssa.mkv
|
|
||||||
ffprobe -hide_banner -show_format -show_streams -of json tests/assets/boruto_s01e283_ssa.mkv
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed stream layout:
|
|
||||||
|
|
||||||
| Stream index | Kind | Key details |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `0` | video | `codec_name=h264` |
|
|
||||||
| `1` | audio | `codec_name=aac`, `language=jpn` |
|
|
||||||
| `2` | subtitle | `codec_name=ass`, `language=ger`, default |
|
|
||||||
| `3` | subtitle | `codec_name=ass`, `language=eng` |
|
|
||||||
| `4`-`13` | attachment | `tags.mimetype=font/ttf`, `.ttf` filenames |
|
|
||||||
|
|
||||||
Observed attachment filenames:
|
|
||||||
|
|
||||||
- `AmazonEmberTanuki-Italic.ttf`
|
|
||||||
- `AmazonEmberTanuki-Regular.ttf`
|
|
||||||
- `Arial.ttf`
|
|
||||||
- `Arial Bold.ttf`
|
|
||||||
- `Georgia.ttf`
|
|
||||||
- `Times New Roman.ttf`
|
|
||||||
- `Times New Roman Bold.ttf`
|
|
||||||
- `Trebuchet MS.ttf`
|
|
||||||
- `Verdana.ttf`
|
|
||||||
- `Verdana Bold.ttf`
|
|
||||||
|
|
||||||
Important probe behavior from the real sample:
|
|
||||||
|
|
||||||
- Plain `ffprobe` lists the font streams as `Attachment: none`.
|
|
||||||
- Plain `ffprobe` also prints warnings such as `Could not find codec
|
|
||||||
parameters for stream 4 (Attachment: none): unknown codec` and later
|
|
||||||
`Unsupported codec with id 0 for input stream ...`.
|
|
||||||
- The JSON produced by `FileProperties.FFPROBE_COMMAND_TOKENS`
|
|
||||||
(`ffprobe -hide_banner -show_format -show_streams -of json`) still exposes
|
|
||||||
the attachment streams clearly through `codec_type="attachment"` and the
|
|
||||||
attachment tags.
|
|
||||||
- In that JSON, the attachment streams do not expose `codec_name`.
|
|
||||||
|
|
||||||
This last point is important for FFX: robust detection must not depend on
|
|
||||||
attachment `codec_name` being present.
|
|
||||||
|
|
||||||
## Detection Guidance
|
|
||||||
|
|
||||||
Current known indicators for this format are:
|
|
||||||
|
|
||||||
- one or more subtitle streams with `codec_type="subtitle"` and
|
|
||||||
`codec_name="ass"`
|
|
||||||
- one or more attachment streams with `codec_type="attachment"`
|
|
||||||
- attachment tags that identify embedded fonts, especially
|
|
||||||
`tags.mimetype="font/ttf"`
|
|
||||||
- attachment filenames that end in `.ttf`
|
|
||||||
|
|
||||||
The pattern can vary. FFX should therefore treat the above as a cluster of
|
|
||||||
signals rather than an exact signature tied to one file.
|
|
||||||
|
|
||||||
Inference from the observed sample plus FFmpeg documentation:
|
|
||||||
|
|
||||||
- MIME matching should not be limited to `font/ttf` alone.
|
|
||||||
- The Boruto sample uses `font/ttf`.
|
|
||||||
- FFmpeg's Matroska attachment example uses
|
|
||||||
`mimetype=application/x-truetype-font` for a `.ttf` attachment.
|
|
||||||
- Detection should therefore normalize multiple TTF-like MIME values rather
|
|
||||||
than depend on a single exact string.
|
|
||||||
|
|
||||||
## Processing Expectations In FFX
|
|
||||||
|
|
||||||
The format-specific requirements live in
|
|
||||||
`requirements/source_file_formats.md`. In practical terms, FFX should:
|
|
||||||
|
|
||||||
- recognize the ASS-plus-font-attachment pattern even when attachment probe
|
|
||||||
data is incomplete
|
|
||||||
- tell the operator that the pattern was detected and that special handling is
|
|
||||||
being used
|
|
||||||
- reject sidecar subtitle import for such sources, because converting or
|
|
||||||
replacing these subtitle tracks with ordinary external text subtitles would
|
|
||||||
break the intended subtitle package
|
|
||||||
- continue to allow normal manipulation of the ASS subtitle tracks themselves
|
|
||||||
- preserve the font attachment streams unchanged
|
|
||||||
|
|
||||||
## FFmpeg Notes
|
|
||||||
|
|
||||||
Relevant FFmpeg documentation confirms several behaviors that line up with
|
|
||||||
FFX's needs:
|
|
||||||
|
|
||||||
- FFmpeg documents `-attach` as adding an attachment stream to the output, and
|
|
||||||
explicitly names Matroska fonts used in subtitle rendering as an example.
|
|
||||||
- FFmpeg documents attachment streams as regular streams that are created after
|
|
||||||
the mapped media streams.
|
|
||||||
- FFmpeg documents `-dump_attachment` for extracting attachment streams, which
|
|
||||||
is useful for debugging or validating a source file's embedded fonts.
|
|
||||||
- FFmpeg's Matroska example requires a `mimetype` metadata tag for attached
|
|
||||||
fonts, which is consistent with using attachment tags as detection signals.
|
|
||||||
- FFmpeg also notes that attachments are implemented as codec extradata. That
|
|
||||||
helps explain why probe output for attachment streams can look different from
|
|
||||||
ordinary audio, video, and subtitle streams.
|
|
||||||
|
|
||||||
Implication for FFX:
|
|
||||||
|
|
||||||
- Attachment preservation is not an optional cosmetic feature for this format.
|
|
||||||
It is part of preserving the subtitle package correctly.
|
|
||||||
|
|
||||||
## Jellyfin Notes
|
|
||||||
|
|
||||||
Jellyfin's documentation also supports keeping this format intact:
|
|
||||||
|
|
||||||
- Jellyfin's subtitle compatibility table lists `ASS/SSA` as supported in
|
|
||||||
`MKV` and not supported in `MP4`.
|
|
||||||
- Jellyfin notes that when subtitles must be transcoded, they are either
|
|
||||||
converted to a supported format or burned into the video, and burning them in
|
|
||||||
is the most CPU-intensive path.
|
|
||||||
- Jellyfin's subtitle-extraction example for `SSA/ASS` first dumps attachment
|
|
||||||
streams and then extracts the ASS subtitle stream, which reflects the real
|
|
||||||
relationship between ASS subtitles and embedded fonts in MKV releases.
|
|
||||||
- Jellyfin's font documentation says text-based subtitles require fonts to
|
|
||||||
render properly.
|
|
||||||
- Jellyfin's configuration documentation says the web client uses configured
|
|
||||||
fallback fonts for ASS subtitles when other fonts such as MKV attachments or
|
|
||||||
client-side fonts are not available.
|
|
||||||
|
|
||||||
Inference from the Jellyfin compatibility tables:
|
|
||||||
|
|
||||||
- Keeping this subtitle format in Matroska is the safest interoperability
|
|
||||||
choice for Jellyfin consumers.
|
|
||||||
- Converting the subtitle payload to WebVTT would lose styled ASS behavior.
|
|
||||||
- Dropping the attachment streams would force client or fallback font
|
|
||||||
substitution and can change appearance or glyph coverage.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- FFmpeg documentation: https://ffmpeg.org/ffmpeg.html
|
|
||||||
- Jellyfin codec support: https://jellyfin.org/docs/general/clients/codec-support/
|
|
||||||
- Jellyfin configuration and fonts: https://jellyfin.org/docs/general/administration/configuration/
|
|
||||||
192
docs/file_formats.rst
Normal file
192
docs/file_formats.rst
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
File Formats
|
||||||
|
============
|
||||||
|
|
||||||
|
This document captures source-file-format notes that complement the normative
|
||||||
|
requirements in ``requirements/source_file_formats.md``.
|
||||||
|
|
||||||
|
The first documented format is a Matroska source that carries styled ASS/SSA
|
||||||
|
subtitle streams together with embedded font attachments.
|
||||||
|
|
||||||
|
Styled ASS In Matroska With Embedded Fonts
|
||||||
|
------------------------------------------
|
||||||
|
|
||||||
|
These files are typically ``.mkv`` releases where subtitle rendering quality
|
||||||
|
depends on keeping both parts of the subtitle package together:
|
||||||
|
|
||||||
|
* one or more subtitle streams with codec ``ass``
|
||||||
|
* one or more attachment streams that embed font files used by those subtitles
|
||||||
|
|
||||||
|
This matters because ASS subtitles are not plain text subtitles in the narrow
|
||||||
|
WebVTT sense. They can carry layout, styling, positioning, karaoke, signs, and
|
||||||
|
other typesetting effects. If the matching embedded fonts are lost, consumers
|
||||||
|
can still see subtitle text but the intended styling and sometimes glyph
|
||||||
|
coverage can be degraded.
|
||||||
|
|
||||||
|
For FFX this format is special because the ASS subtitle streams should remain
|
||||||
|
normally editable and mappable, while the related font attachments should be
|
||||||
|
transported unchanged.
|
||||||
|
|
||||||
|
Observed Sample
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Assessment date: ``2026-04-17``
|
||||||
|
|
||||||
|
Observed sample file:
|
||||||
|
|
||||||
|
* ``tests/assets/boruto_s01e283_ssa.mkv``
|
||||||
|
|
||||||
|
Commands used for assessment:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
ffprobe tests/assets/boruto_s01e283_ssa.mkv
|
||||||
|
ffprobe -hide_banner -show_format -show_streams -of json tests/assets/boruto_s01e283_ssa.mkv
|
||||||
|
|
||||||
|
Observed stream layout:
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Stream index
|
||||||
|
- Kind
|
||||||
|
- Key details
|
||||||
|
* - ``0``
|
||||||
|
- video
|
||||||
|
- ``codec_name=h264``
|
||||||
|
* - ``1``
|
||||||
|
- audio
|
||||||
|
- ``codec_name=aac``, ``language=jpn``
|
||||||
|
* - ``2``
|
||||||
|
- subtitle
|
||||||
|
- ``codec_name=ass``, ``language=ger``, default
|
||||||
|
* - ``3``
|
||||||
|
- subtitle
|
||||||
|
- ``codec_name=ass``, ``language=eng``
|
||||||
|
* - ``4``-``13``
|
||||||
|
- attachment
|
||||||
|
- ``tags.mimetype=font/ttf``, ``.ttf`` filenames
|
||||||
|
|
||||||
|
Observed attachment filenames:
|
||||||
|
|
||||||
|
* ``AmazonEmberTanuki-Italic.ttf``
|
||||||
|
* ``AmazonEmberTanuki-Regular.ttf``
|
||||||
|
* ``Arial.ttf``
|
||||||
|
* ``Arial Bold.ttf``
|
||||||
|
* ``Georgia.ttf``
|
||||||
|
* ``Times New Roman.ttf``
|
||||||
|
* ``Times New Roman Bold.ttf``
|
||||||
|
* ``Trebuchet MS.ttf``
|
||||||
|
* ``Verdana.ttf``
|
||||||
|
* ``Verdana Bold.ttf``
|
||||||
|
|
||||||
|
Important probe behavior from the real sample:
|
||||||
|
|
||||||
|
* Plain ``ffprobe`` lists the font streams as ``Attachment: none``.
|
||||||
|
* Plain ``ffprobe`` also prints warnings such as ``Could not find codec
|
||||||
|
parameters for stream 4 (Attachment: none): unknown codec`` and later
|
||||||
|
``Unsupported codec with id 0 for input stream ...``.
|
||||||
|
* The JSON produced by ``FileProperties.FFPROBE_COMMAND_TOKENS``
|
||||||
|
(``ffprobe -hide_banner -show_format -show_streams -of json``) still exposes
|
||||||
|
the attachment streams clearly through ``codec_type="attachment"`` and the
|
||||||
|
attachment tags.
|
||||||
|
* In that JSON, the attachment streams do not expose ``codec_name``.
|
||||||
|
|
||||||
|
This last point is important for FFX: robust detection must not depend on
|
||||||
|
attachment ``codec_name`` being present.
|
||||||
|
|
||||||
|
Detection Guidance
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Current known indicators for this format are:
|
||||||
|
|
||||||
|
* one or more subtitle streams with ``codec_type="subtitle"`` and
|
||||||
|
``codec_name="ass"``
|
||||||
|
* one or more attachment streams with ``codec_type="attachment"``
|
||||||
|
* attachment tags that identify embedded fonts, especially
|
||||||
|
``tags.mimetype="font/ttf"``
|
||||||
|
* attachment filenames that end in ``.ttf``
|
||||||
|
|
||||||
|
The pattern can vary. FFX should therefore treat the above as a cluster of
|
||||||
|
signals rather than an exact signature tied to one file.
|
||||||
|
|
||||||
|
Inference from the observed sample plus FFmpeg documentation:
|
||||||
|
|
||||||
|
* MIME matching should not be limited to ``font/ttf`` alone.
|
||||||
|
* The Boruto sample uses ``font/ttf``.
|
||||||
|
* FFmpeg's Matroska attachment example uses
|
||||||
|
``mimetype=application/x-truetype-font`` for a ``.ttf`` attachment.
|
||||||
|
* Detection should therefore normalize multiple TTF-like MIME values rather
|
||||||
|
than depend on a single exact string.
|
||||||
|
|
||||||
|
Processing Expectations In FFX
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
The format-specific requirements live in
|
||||||
|
``requirements/source_file_formats.md``. In practical terms, FFX should:
|
||||||
|
|
||||||
|
* recognize the ASS-plus-font-attachment pattern even when attachment probe data
|
||||||
|
is incomplete
|
||||||
|
* tell the operator that the pattern was detected and that special handling is
|
||||||
|
being used
|
||||||
|
* reject sidecar subtitle import for such sources, because converting or
|
||||||
|
replacing these subtitle tracks with ordinary external text subtitles would
|
||||||
|
break the intended subtitle package
|
||||||
|
* continue to allow normal manipulation of the ASS subtitle tracks themselves
|
||||||
|
* preserve the font attachment streams unchanged
|
||||||
|
|
||||||
|
FFmpeg Notes
|
||||||
|
------------
|
||||||
|
|
||||||
|
Relevant FFmpeg documentation confirms several behaviors that line up with
|
||||||
|
FFX's needs:
|
||||||
|
|
||||||
|
* FFmpeg documents ``-attach`` as adding an attachment stream to the output, and
|
||||||
|
explicitly names Matroska fonts used in subtitle rendering as an example.
|
||||||
|
* FFmpeg documents attachment streams as regular streams that are created after
|
||||||
|
the mapped media streams.
|
||||||
|
* FFmpeg documents ``-dump_attachment`` for extracting attachment streams, which
|
||||||
|
is useful for debugging or validating a source file's embedded fonts.
|
||||||
|
* FFmpeg's Matroska example requires a ``mimetype`` metadata tag for attached
|
||||||
|
fonts, which is consistent with using attachment tags as detection signals.
|
||||||
|
* FFmpeg also notes that attachments are implemented as codec extradata. That
|
||||||
|
helps explain why probe output for attachment streams can look different from
|
||||||
|
ordinary audio, video, and subtitle streams.
|
||||||
|
|
||||||
|
Implication for FFX:
|
||||||
|
|
||||||
|
* Attachment preservation is not an optional cosmetic feature for this format.
|
||||||
|
It is part of preserving the subtitle package correctly.
|
||||||
|
|
||||||
|
Jellyfin Notes
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Jellyfin's documentation also supports keeping this format intact:
|
||||||
|
|
||||||
|
* Jellyfin's subtitle compatibility table lists ``ASS/SSA`` as supported in
|
||||||
|
``MKV`` and not supported in ``MP4``.
|
||||||
|
* Jellyfin notes that when subtitles must be transcoded, they are either
|
||||||
|
converted to a supported format or burned into the video, and burning them in
|
||||||
|
is the most CPU-intensive path.
|
||||||
|
* Jellyfin's subtitle-extraction example for ``SSA/ASS`` first dumps attachment
|
||||||
|
streams and then extracts the ASS subtitle stream, which reflects the real
|
||||||
|
relationship between ASS subtitles and embedded fonts in MKV releases.
|
||||||
|
* Jellyfin's font documentation says text-based subtitles require fonts to
|
||||||
|
render properly.
|
||||||
|
* Jellyfin's configuration documentation says the web client uses configured
|
||||||
|
fallback fonts for ASS subtitles when other fonts such as MKV attachments or
|
||||||
|
client-side fonts are not available.
|
||||||
|
|
||||||
|
Inference from the Jellyfin compatibility tables:
|
||||||
|
|
||||||
|
* Keeping this subtitle format in Matroska is the safest interoperability choice
|
||||||
|
for Jellyfin consumers.
|
||||||
|
* Converting the subtitle payload to WebVTT would lose styled ASS behavior.
|
||||||
|
* Dropping the attachment streams would force client or fallback font
|
||||||
|
substitution and can change appearance or glyph coverage.
|
||||||
|
|
||||||
|
References
|
||||||
|
----------
|
||||||
|
|
||||||
|
* FFmpeg documentation: https://ffmpeg.org/ffmpeg.html
|
||||||
|
* Jellyfin codec support: https://jellyfin.org/docs/general/clients/codec-support/
|
||||||
|
* Jellyfin configuration and fonts: https://jellyfin.org/docs/general/administration/configuration/
|
||||||
25
docs/index.rst
Normal file
25
docs/index.rst
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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: 2
|
||||||
|
:caption: Contributor Guide
|
||||||
|
|
||||||
|
development
|
||||||
|
api
|
||||||
52
docs/installation.rst
Normal file
52
docs/installation.rst
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
FFX is designed for a Linux-like workstation with local command execution. The
|
||||||
|
runtime media tools must be available on ``PATH``:
|
||||||
|
|
||||||
|
* ``ffmpeg``
|
||||||
|
* ``ffprobe``
|
||||||
|
* ``cpulimit``
|
||||||
|
|
||||||
|
User Bundle
|
||||||
|
-----------
|
||||||
|
|
||||||
|
The persistent user installation is prepared with the two-step flow described in
|
||||||
|
the project README:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
bash tools/setup.sh
|
||||||
|
bash tools/configure_workstation.sh
|
||||||
|
|
||||||
|
``tools/setup.sh`` creates the long-lived bundle virtualenv at
|
||||||
|
``~/.local/share/ffx.venv`` and exposes the ``ffx`` command. The workstation
|
||||||
|
script checks system tools and seeds local config directories.
|
||||||
|
|
||||||
|
Local Test And Docs Environment
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
Contributor test and documentation work uses the repo-local virtualenv:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
tests/prepare.sh
|
||||||
|
|
||||||
|
The script creates ``.venv``, installs FFX in editable mode with test and docs
|
||||||
|
extras, and verifies the Sphinx toolchain. Use check-only mode when you only
|
||||||
|
want to inspect readiness:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
tests/prepare.sh --check
|
||||||
|
|
||||||
|
Documentation Build
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
After preparation, build the documentation with:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
.venv/bin/sphinx-build -b html docs docs/_build/html
|
||||||
|
|
||||||
|
The generated site starts at ``docs/_build/html/index.html``.
|
||||||
42
docs/make.bat
Normal file
42
docs/make.bat
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
@ECHO OFF
|
||||||
|
|
||||||
|
pushd %~dp0
|
||||||
|
|
||||||
|
if "%SPHINXBUILD%" == "" if exist ..\.venv\Scripts\sphinx-build.exe (
|
||||||
|
set SPHINXBUILD=..\.venv\Scripts\sphinx-build.exe
|
||||||
|
)
|
||||||
|
if "%SPHINXBUILD%" == "" set SPHINXBUILD=sphinx-build
|
||||||
|
set SOURCEDIR=.
|
||||||
|
set BUILDDIR=_build
|
||||||
|
|
||||||
|
%SPHINXBUILD% >NUL 2>NUL
|
||||||
|
if errorlevel 9009 (
|
||||||
|
echo.
|
||||||
|
echo The 'sphinx-build' command was not found. Make sure Sphinx is installed,
|
||||||
|
echo then set SPHINXBUILD to the full path if needed.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "" goto help
|
||||||
|
if "%1" == "html" goto html
|
||||||
|
if "%1" == "linkcheck" goto linkcheck
|
||||||
|
echo.
|
||||||
|
echo Unknown target "%1".
|
||||||
|
goto help
|
||||||
|
|
||||||
|
:html
|
||||||
|
%SPHINXBUILD% -b html %SOURCEDIR% %BUILDDIR%\html %SPHINXOPTS%
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:linkcheck
|
||||||
|
%SPHINXBUILD% -b linkcheck %SOURCEDIR% %BUILDDIR%\linkcheck %SPHINXOPTS%
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:help
|
||||||
|
echo.
|
||||||
|
echo Please use 'make.bat ^<target^>' where ^<target^> is one of
|
||||||
|
echo html to make standalone HTML files
|
||||||
|
echo linkcheck to check all external links for integrity
|
||||||
|
|
||||||
|
:end
|
||||||
|
popd
|
||||||
75
docs/usage.rst
Normal file
75
docs/usage.rst
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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`` and ``--subtitle-prefix`` for sidecar subtitle
|
||||||
|
imports
|
||||||
|
* ``--copy-video`` or ``--copy-audio`` to preserve selected stream types
|
||||||
|
* ``--rename-only`` for filename normalization without media rewriting
|
||||||
|
|
||||||
|
Manage Shows And Patterns
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
Open the Textual interface for show and pattern management:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx shows
|
||||||
|
|
||||||
|
Extract Streams
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Extract streams from a file:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx unmux /path/to/episode.mkv
|
||||||
|
|
||||||
|
For subtitle-only extraction:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx unmux --subtitles-only --label show-name /path/to/episode.mkv
|
||||||
|
|
||||||
|
Detect Crop
|
||||||
|
-----------
|
||||||
|
|
||||||
|
Ask FFmpeg to suggest crop parameters:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx cropdetect /path/to/episode.mkv
|
||||||
|
|
||||||
|
The default sampling window is controlled by the application defaults and can be
|
||||||
|
overridden with command options.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ffx"
|
name = "ffx"
|
||||||
description = "FFX recoding and metadata managing tool"
|
description = "FFX recoding and metadata managing tool"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -1139,6 +1139,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()
|
||||||
@@ -1393,13 +1394,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
|
||||||
@@ -1426,6 +1443,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 +1458,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()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
VERSION='0.4.2'
|
VERSION='0.4.3'
|
||||||
DATABASE_VERSION = 3
|
DATABASE_VERSION = 3
|
||||||
|
|
||||||
DEFAULT_QUALITY = 32
|
DEFAULT_QUALITY = 32
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -76,6 +77,8 @@ class Pattern(Base):
|
|||||||
subIndexCounter = {}
|
subIndexCounter = {}
|
||||||
for track in self.tracks:
|
for track in self.tracks:
|
||||||
trackType = track.getType()
|
trackType = track.getType()
|
||||||
|
if trackType == TrackType.ATTACHMENT:
|
||||||
|
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]))
|
kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY].append(track.getDescriptor(context, subIndex = subIndexCounter[trackType]))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from ffx.model.track import Track
|
|||||||
from ffx.model.track_tag import TrackTag
|
from ffx.model.track_tag import TrackTag
|
||||||
from ffx.track_descriptor import TrackDescriptor
|
from ffx.track_descriptor import TrackDescriptor
|
||||||
from ffx.track_disposition import TrackDisposition
|
from ffx.track_disposition import TrackDisposition
|
||||||
|
from ffx.track_type import TrackType
|
||||||
|
|
||||||
|
|
||||||
class DuplicatePatternMatchError(click.ClickException):
|
class DuplicatePatternMatchError(click.ClickException):
|
||||||
@@ -86,12 +87,16 @@ 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(
|
||||||
@@ -102,6 +107,10 @@ class PatternController:
|
|||||||
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:
|
||||||
@@ -170,7 +179,7 @@ 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:
|
if not any(track.getType() != TrackType.ATTACHMENT for track in pattern.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."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -72,6 +74,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()
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -280,6 +281,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(
|
||||||
|
|||||||
471
tests/prepare.sh
Executable file
471
tests/prepare.sh
Executable file
@@ -0,0 +1,471 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
VENV_DIR="${FFX_TEST_VENV_DIR:-${ROOT_DIR}/.venv}"
|
||||||
|
VENV_BIN_DIR="${VENV_DIR}/bin"
|
||||||
|
VENV_PYTHON="${VENV_BIN_DIR}/python"
|
||||||
|
VENV_PIP="${VENV_BIN_DIR}/pip"
|
||||||
|
|
||||||
|
CHECK_ONLY=0
|
||||||
|
READINESS_FAILURES=0
|
||||||
|
INSTALL_FAILURES=0
|
||||||
|
|
||||||
|
MISSING_REQUIRED_SYSTEM=()
|
||||||
|
|
||||||
|
COLOR_RESET=""
|
||||||
|
COLOR_GREEN=""
|
||||||
|
COLOR_YELLOW=""
|
||||||
|
COLOR_RED=""
|
||||||
|
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
COLOR_RESET="$(printf '\033[0m')"
|
||||||
|
COLOR_GREEN="$(printf '\033[32m')"
|
||||||
|
COLOR_YELLOW="$(printf '\033[33m')"
|
||||||
|
COLOR_RED="$(printf '\033[31m')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [--check] [--help]
|
||||||
|
|
||||||
|
Prepare the repo-local FFX test environment at:
|
||||||
|
${VENV_DIR}
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- verify or install required system commands for tests
|
||||||
|
- create or reuse the repo-local test virtualenv
|
||||||
|
- install this repository into the venv with Python test and docs extras
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--check Report readiness only. Do not create, install, or modify.
|
||||||
|
--help Show this help text.
|
||||||
|
|
||||||
|
Environment overrides:
|
||||||
|
FFX_TEST_VENV_DIR Override the test virtualenv path. Defaults to ${ROOT_DIR}/.venv.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This script prepares a project-local test environment, not the persistent user bundle.
|
||||||
|
- The persistent bundle setup remains owned by tools/setup.sh.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
status_ok() {
|
||||||
|
printf '%sok%s' "${COLOR_GREEN}" "${COLOR_RESET}"
|
||||||
|
}
|
||||||
|
|
||||||
|
status_warn() {
|
||||||
|
printf '%swarn%s' "${COLOR_YELLOW}" "${COLOR_RESET}"
|
||||||
|
}
|
||||||
|
|
||||||
|
status_fail() {
|
||||||
|
printf '%sfailed%s' "${COLOR_RED}" "${COLOR_RESET}"
|
||||||
|
}
|
||||||
|
|
||||||
|
report_component() {
|
||||||
|
local level="$1"
|
||||||
|
local label="$2"
|
||||||
|
local detail="$3"
|
||||||
|
local rendered_status=""
|
||||||
|
|
||||||
|
case "${level}" in
|
||||||
|
ok)
|
||||||
|
rendered_status="$(status_ok)"
|
||||||
|
;;
|
||||||
|
warn)
|
||||||
|
rendered_status="$(status_warn)"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
rendered_status="$(status_fail)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
printf '[%s] %s%s\n' "${rendered_status}" "${label}" "${detail:+: $detail}"
|
||||||
|
}
|
||||||
|
|
||||||
|
command_exists() {
|
||||||
|
command -v "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_python_venv_support() {
|
||||||
|
python3 -m venv --help >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_system_command() {
|
||||||
|
command_exists "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_venv_python() {
|
||||||
|
[ -x "${VENV_PYTHON}" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
check_venv_pip() {
|
||||||
|
check_venv_python && "${VENV_PIP}" --version >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_venv_ffx() {
|
||||||
|
check_venv_python && "${VENV_PYTHON}" -m ffx version >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_venv_pytest() {
|
||||||
|
check_venv_python && "${VENV_PYTHON}" -m pytest --version >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_venv_sphinx() {
|
||||||
|
check_venv_python && "${VENV_BIN_DIR}/sphinx-build" --version >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_venv_docs_packages() {
|
||||||
|
check_venv_python && "${VENV_PYTHON}" - <<'PY' >/dev/null 2>&1
|
||||||
|
import esbonio
|
||||||
|
import sphinx
|
||||||
|
import sphinx_rtd_theme
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
check_editable_install() {
|
||||||
|
check_venv_python && FFX_REPO_ROOT="${ROOT_DIR}" "${VENV_PYTHON}" - <<'PY' >/dev/null 2>&1
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import ffx
|
||||||
|
|
||||||
|
repo_root = Path(os.environ["FFX_REPO_ROOT"]).resolve()
|
||||||
|
package_path = Path(ffx.__file__).resolve()
|
||||||
|
|
||||||
|
raise SystemExit(0 if repo_root in package_path.parents else 1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
check_python_environment_ready() {
|
||||||
|
check_venv_python &&
|
||||||
|
check_venv_pip &&
|
||||||
|
check_venv_pytest &&
|
||||||
|
check_venv_sphinx &&
|
||||||
|
check_venv_docs_packages &&
|
||||||
|
check_venv_ffx &&
|
||||||
|
check_editable_install
|
||||||
|
}
|
||||||
|
|
||||||
|
command_detail() {
|
||||||
|
command -v "$1" || printf "command '%s' not found" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
python_venv_detail() {
|
||||||
|
if check_python_venv_support; then
|
||||||
|
printf 'python3 -m venv is available'
|
||||||
|
else
|
||||||
|
printf 'python3 venv support is unavailable'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
venv_python_detail() {
|
||||||
|
if check_venv_python; then
|
||||||
|
printf '%s' "${VENV_PYTHON}"
|
||||||
|
else
|
||||||
|
printf 'missing %s' "${VENV_PYTHON}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
venv_pip_detail() {
|
||||||
|
if check_venv_pip; then
|
||||||
|
"${VENV_PIP}" --version
|
||||||
|
else
|
||||||
|
printf 'missing pip in %s' "${VENV_DIR}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
venv_ffx_detail() {
|
||||||
|
if check_venv_ffx; then
|
||||||
|
printf 'ffx import and CLI entry are available'
|
||||||
|
else
|
||||||
|
printf 'ffx is not installed in %s' "${VENV_DIR}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
venv_pytest_detail() {
|
||||||
|
if check_venv_pytest; then
|
||||||
|
"${VENV_PYTHON}" -m pytest --version 2>/dev/null | head -n 1
|
||||||
|
else
|
||||||
|
printf 'pytest is not installed in %s' "${VENV_DIR}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
venv_sphinx_detail() {
|
||||||
|
if check_venv_sphinx; then
|
||||||
|
"${VENV_BIN_DIR}/sphinx-build" --version 2>&1
|
||||||
|
else
|
||||||
|
printf 'sphinx-build is not installed in %s' "${VENV_DIR}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
venv_docs_packages_detail() {
|
||||||
|
if check_venv_docs_packages; then
|
||||||
|
printf 'Sphinx, Read the Docs theme, and Esbonio packages are importable'
|
||||||
|
else
|
||||||
|
printf 'one or more docs packages are missing in %s' "${VENV_DIR}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
editable_install_detail() {
|
||||||
|
if check_editable_install; then
|
||||||
|
printf 'ffx resolves from %s' "${ROOT_DIR}"
|
||||||
|
else
|
||||||
|
printf 'ffx does not resolve from the project source tree'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
report_required_command() {
|
||||||
|
local label="$1"
|
||||||
|
local command_name="$2"
|
||||||
|
|
||||||
|
if check_system_command "${command_name}"; then
|
||||||
|
report_component ok "${label}" "$(command_detail "${command_name}")"
|
||||||
|
else
|
||||||
|
report_component failed "${label}" "$(command_detail "${command_name}")"
|
||||||
|
MISSING_REQUIRED_SYSTEM+=("${command_name}")
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_system_status() {
|
||||||
|
MISSING_REQUIRED_SYSTEM=()
|
||||||
|
|
||||||
|
echo "System toolchain status:"
|
||||||
|
report_required_command "git" "git"
|
||||||
|
report_required_command "python3" "python3"
|
||||||
|
|
||||||
|
if check_system_command "python3" && check_python_venv_support; then
|
||||||
|
report_component ok "python3 venv" "$(python_venv_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "python3 venv" "$(python_venv_detail)"
|
||||||
|
MISSING_REQUIRED_SYSTEM+=("python3-venv")
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
report_required_command "ffmpeg" "ffmpeg"
|
||||||
|
report_required_command "ffprobe" "ffprobe"
|
||||||
|
report_required_command "cpulimit" "cpulimit"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_python_status() {
|
||||||
|
echo "Repo test and docs virtualenv status:"
|
||||||
|
|
||||||
|
if check_venv_python; then
|
||||||
|
report_component ok "test virtualenv" "$(venv_python_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "test virtualenv" "$(venv_python_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_venv_pip; then
|
||||||
|
report_component ok "test pip" "$(venv_pip_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "test pip" "$(venv_pip_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_venv_pytest; then
|
||||||
|
report_component ok "test pytest" "$(venv_pytest_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "test pytest" "$(venv_pytest_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_venv_sphinx; then
|
||||||
|
report_component ok "docs sphinx" "$(venv_sphinx_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "docs sphinx" "$(venv_sphinx_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_venv_docs_packages; then
|
||||||
|
report_component ok "docs packages" "$(venv_docs_packages_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "docs packages" "$(venv_docs_packages_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_venv_ffx; then
|
||||||
|
report_component ok "test ffx" "$(venv_ffx_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "test ffx" "$(venv_ffx_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_editable_install; then
|
||||||
|
report_component ok "editable source" "$(editable_install_detail)"
|
||||||
|
else
|
||||||
|
report_component failed "editable source" "$(editable_install_detail)"
|
||||||
|
READINESS_FAILURES=$((READINESS_FAILURES + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_status_report() {
|
||||||
|
READINESS_FAILURES=0
|
||||||
|
|
||||||
|
print_system_status
|
||||||
|
echo
|
||||||
|
print_python_status
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_package_manager() {
|
||||||
|
if command_exists apt-get; then
|
||||||
|
printf 'apt-get\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if command_exists pacman; then
|
||||||
|
printf 'pacman\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_root_command() {
|
||||||
|
if [ "${EUID}" -eq 0 ]; then
|
||||||
|
"$@"
|
||||||
|
elif command_exists sudo; then
|
||||||
|
sudo -n "$@"
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_system_requirements() {
|
||||||
|
local package_manager
|
||||||
|
|
||||||
|
if [ "${#MISSING_REQUIRED_SYSTEM[@]}" -eq 0 ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! package_manager="$(detect_package_manager)"; then
|
||||||
|
printf 'No supported package manager found for automatic system preparation.\n' >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${package_manager}" in
|
||||||
|
apt-get)
|
||||||
|
printf 'Installing required system dependencies via apt-get...\n'
|
||||||
|
if ! run_root_command apt-get update; then
|
||||||
|
printf 'apt-get update failed or requires interactive sudo.\n' >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! run_root_command apt-get install -y git python3 python3-venv ffmpeg cpulimit; then
|
||||||
|
printf 'apt-get install failed or requires interactive sudo.\n' >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
pacman)
|
||||||
|
printf 'Installing required system dependencies via pacman...\n'
|
||||||
|
if ! run_root_command pacman -Sy --noconfirm git python ffmpeg cpulimit; then
|
||||||
|
printf 'pacman install failed or requires interactive sudo.\n' >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_test_venv() {
|
||||||
|
if ! check_venv_python; then
|
||||||
|
printf 'Creating repo test virtualenv at %s...\n' "${VENV_DIR}"
|
||||||
|
if ! python3 -m venv "${VENV_DIR}"; then
|
||||||
|
printf 'Failed to create test virtualenv at %s.\n' "${VENV_DIR}" >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! check_venv_pip; then
|
||||||
|
printf 'Missing pip in %s.\n' "${VENV_DIR}" >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Installing FFX package with test and docs extras into %s...\n' "${VENV_DIR}"
|
||||||
|
if ! (
|
||||||
|
cd "${ROOT_DIR}" &&
|
||||||
|
"${VENV_PIP}" install --editable '.[test,docs]'
|
||||||
|
); then
|
||||||
|
printf 'Failed to install FFX package with test and docs extras into %s.\n' "${VENV_DIR}" >&2
|
||||||
|
INSTALL_FAILURES=$((INSTALL_FAILURES + 1))
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--check)
|
||||||
|
CHECK_ONLY=1
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'Unknown option: %s\n\n' "$1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
parse_args "$@"
|
||||||
|
|
||||||
|
print_status_report
|
||||||
|
|
||||||
|
if [ "${CHECK_ONLY}" -eq 0 ]; then
|
||||||
|
if [ "${#MISSING_REQUIRED_SYSTEM[@]}" -gt 0 ]; then
|
||||||
|
echo
|
||||||
|
install_system_requirements
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_python_environment_ready; then
|
||||||
|
echo
|
||||||
|
report_component ok "Python package install" "repo test and docs virtualenv is already ready"
|
||||||
|
elif check_system_command "python3" && check_python_venv_support; then
|
||||||
|
echo
|
||||||
|
ensure_test_venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
print_status_report
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "${INSTALL_FAILURES}" -gt 0 ]; then
|
||||||
|
echo "One or more test preparation steps failed; see the status checks above." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${READINESS_FAILURES}" -gt 0 ]; then
|
||||||
|
if [ "${CHECK_ONLY}" -eq 1 ]; then
|
||||||
|
echo "The FFX test and docs environment is incomplete." >&2
|
||||||
|
else
|
||||||
|
echo "Required test or docs components are still missing after preparation." >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${CHECK_ONLY}" -eq 1 ]; then
|
||||||
|
echo "The FFX test and docs environment is ready."
|
||||||
|
else
|
||||||
|
echo "The FFX test and docs environment is prepared."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -18,6 +18,7 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(SRC_ROOT))
|
sys.path.insert(0, str(SRC_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
from ffx.attachment_format import AttachmentFormat
|
||||||
from ffx.audio_layout import AudioLayout
|
from ffx.audio_layout import AudioLayout
|
||||||
from ffx.database import databaseContext
|
from ffx.database import databaseContext
|
||||||
from ffx.pattern_controller import PatternController
|
from ffx.pattern_controller import PatternController
|
||||||
@@ -56,6 +57,7 @@ class PatternTrackSpec:
|
|||||||
tags: Mapping[str, str] = field(default_factory=dict)
|
tags: Mapping[str, str] = field(default_factory=dict)
|
||||||
dispositions: tuple[TrackDisposition, ...] = ()
|
dispositions: tuple[TrackDisposition, ...] = ()
|
||||||
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:
|
||||||
@@ -299,6 +301,8 @@ def prepare_pattern_database(database_path: Path, filename_pattern: str, track_s
|
|||||||
}
|
}
|
||||||
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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -193,6 +193,36 @@ 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_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$")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -822,6 +823,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,
|
||||||
|
|||||||
Reference in New Issue
Block a user