6 Commits

Author SHA1 Message Date
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
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
31 changed files with 1458 additions and 188 deletions

2
.gitignore vendored
View File

@@ -1,7 +1,6 @@
__pycache__/
*.py[cod]
junk/
.vscode
.ipynb_checkpoints/
tools/ansible/inventory/hawaii.yml
tools/ansible/inventory/peppermint.yml
@@ -17,6 +16,7 @@ dist/
*.egg-info/
.venv/
venv/
docs/_build/
.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,6 +99,12 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
## 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
- 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

View File

@@ -69,3 +69,7 @@
## Delete When
- 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
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/

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

75
docs/usage.rst Normal file
View 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.

View File

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

View File

@@ -1139,6 +1139,7 @@ def convert(ctx,
from ffx.tmdb_controller import TmdbController
from ffx.track_codec import TrackCodec
from ffx.track_disposition import TrackDisposition
from ffx.track_type import TrackType
from ffx.video_encoder import VideoEncoder
startTime = time.perf_counter()
@@ -1393,13 +1394,29 @@ def convert(ctx,
from ffx.attachment_format import AttachmentFormat
if ([smd for smd in sourceMediaDescriptor.getSubtitleTracks()
if smd.getCodec() == TrackCodec.ASS]
and [amd for amd in sourceMediaDescriptor.getAttachmentTracks()
if amd.getAttachmentFormat() == AttachmentFormat.TTF]):
styledAssDetector = getattr(
sourceMediaDescriptor,
"hasStyledAssSubtitlesWithFontAttachments",
None,
)
styledAssSourceDetected = (
bool(styledAssDetector())
if callable(styledAssDetector)
else False
)
if styledAssSourceDetected:
styledAssMessage = (
"Styled ASS subtitles with embedded font attachments detected; "
+ "preserving source font attachments."
)
click.echo(styledAssMessage)
targetFormat = ''
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
@@ -1426,6 +1443,12 @@ def convert(ctx,
else:
targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj)
if styledAssSourceDetected:
targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks(
sourceMediaDescriptor,
AttachmentFormat.TTF,
context=ctx.obj,
)
checkUniqueDispositions(context, targetMediaDescriptor)
currentShowDescriptor = currentPattern.getShowDescriptor(ctx.obj)
@@ -1435,6 +1458,8 @@ def convert(ctx,
targetTrackDescriptorList = targetMediaDescriptor.getTrackDescriptors()
for ttd in targetTrackDescriptorList:
if ttd.getType() == TrackType.ATTACHMENT:
continue
tti = ttd.getIndex()
ttsi = ttd.getSourceIndex()

View File

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

View File

@@ -329,6 +329,96 @@ class MediaDescriptor:
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):
"""Generate ffmpeg import options for external stream files"""

View File

@@ -56,8 +56,24 @@ class MediaDescriptorChangeSet():
and 'ignore' in metadataConfiguration['streams'].keys() else [])
self.__targetTrackDescriptors = targetMediaDescriptor.getTrackDescriptors() if targetMediaDescriptor is not None else []
self.__sourceTrackDescriptors = sourceMediaDescriptor.getTrackDescriptors() if sourceMediaDescriptor is not None else []
self.__targetTrackDescriptors = (
[
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 = {
trackDescriptor.getIndex(): trackDescriptor
for trackDescriptor in self.__targetTrackDescriptors

View File

@@ -166,10 +166,9 @@ class MediaWorkflowScreenBase(Screen):
self._baselineMediaDescriptor = probedMediaDescriptor
self._sourceMediaDescriptor = probedMediaDescriptor
self._currentPattern = self._mediaFileProperties.getPattern()
self._targetMediaDescriptor = (
self._currentPattern.getMediaDescriptor(self.context)
if self._currentPattern is not None
else None
self._targetMediaDescriptor = self._resolve_target_media_descriptor(
self._currentPattern,
self._sourceMediaDescriptor,
)
self.rebuildChangeSet()
@@ -205,6 +204,25 @@ class MediaWorkflowScreenBase(Screen):
def getTrackEditSourceDescriptor(self) -> TrackDescriptor | None:
return self.getSelectedTrackDescriptor()
def _resolve_target_media_descriptor(self, currentPattern, sourceMediaDescriptor):
if currentPattern is None:
return None
targetMediaDescriptor = currentPattern.getMediaDescriptor(self.context)
styledAssDetector = getattr(
sourceMediaDescriptor,
"hasStyledAssSubtitlesWithFontAttachments",
None,
)
if callable(styledAssDetector) and styledAssDetector():
targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks(
sourceMediaDescriptor,
AttachmentFormat.TTF,
context=self.context,
)
return targetMediaDescriptor
def updateMediaTags(self):
displayedMediaDescriptor = self.getDisplayedMediaDescriptor()
self._sourceMediaTagRowData = populate_tag_table(

View File

@@ -7,6 +7,7 @@ from .show import Base, Show
from ffx.media_descriptor import MediaDescriptor
from ffx.show_descriptor import ShowDescriptor
from ffx.track_type import TrackType
class Pattern(Base):
@@ -76,6 +77,8 @@ class Pattern(Base):
subIndexCounter = {}
for track in self.tracks:
trackType = track.getType()
if trackType == TrackType.ATTACHMENT:
continue
if not trackType in subIndexCounter.keys():
subIndexCounter[trackType] = 0
kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY].append(track.getDescriptor(context, subIndex = subIndexCounter[trackType]))

View File

@@ -8,6 +8,7 @@ from ffx.model.track import Track
from ffx.model.track_tag import TrackTag
from ffx.track_descriptor import TrackDescriptor
from ffx.track_disposition import TrackDisposition
from ffx.track_type import TrackType
class DuplicatePatternMatchError(click.ClickException):
@@ -86,12 +87,16 @@ class PatternController:
)
normalized_descriptors = []
filtered_attachments = False
for trackDescriptor in trackDescriptors:
if type(trackDescriptor) is not TrackDescriptor:
raise TypeError(
"PatternController: All track descriptors are required to be of type TrackDescriptor"
)
normalized_descriptors.append(trackDescriptor)
if trackDescriptor.getType() == TrackType.ATTACHMENT:
filtered_attachments = True
continue
normalized_descriptors.append(trackDescriptor.clone())
if not normalized_descriptors:
raise InvalidPatternSchemaError(
@@ -102,6 +107,10 @@ class PatternController:
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}
expected_indexes = set(range(len(normalized_descriptors)))
if index_set != expected_indexes:
@@ -170,7 +179,7 @@ class PatternController:
pattern.tracks.append(self._build_track_row(trackDescriptor))
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(
f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no tracks."
)

View File

@@ -35,6 +35,8 @@ class TrackController():
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
patId = int(trackDescriptor.getPatternId() if patternId is None else patternId)
@@ -72,6 +74,8 @@ class TrackController():
if type(trackDescriptor) is not TrackDescriptor:
raise TypeError('TrackController.updateTrack(): Argument trackDescriptor is required to be of type TrackDescriptor')
if trackDescriptor.getType() == TrackType.ATTACHMENT:
return False
try:
s = self.Session()

View File

@@ -18,6 +18,7 @@ from tests.support.ffx_bundle import (
write_vtt,
)
from ffx.attachment_format import AttachmentFormat
from ffx.track_type import TrackType
try:
@@ -280,6 +281,72 @@ class SubtrackMappingBundleTests(unittest.TestCase):
self.assertIn("non-existent source track #99", error_output)
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):
source_filename = "substitute_s01e01.mkv"
self.write_config(

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))
from ffx.attachment_format import AttachmentFormat
from ffx.audio_layout import AudioLayout
from ffx.database import databaseContext
from ffx.pattern_controller import PatternController
@@ -56,6 +57,7 @@ class PatternTrackSpec:
tags: Mapping[str, str] = field(default_factory=dict)
dispositions: tuple[TrackDisposition, ...] = ()
audio_layout: AudioLayout = AudioLayout.LAYOUT_STEREO
attachment_format: AttachmentFormat = AttachmentFormat.UNKNOWN
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:
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))
pattern_id = PatternController(context).savePatternSchema(

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_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_type import TrackType # 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("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):
context = {
"logger": get_ffx_logger(),

View File

@@ -193,6 +193,36 @@ class PatternManagementTests(unittest.TestCase):
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):
self.insert_trackless_pattern_row(1, r"^invalid_(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.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.logging_utils import get_ffx_logger # noqa: E402
from ffx.inspect_details_screen import InspectDetailsScreen # noqa: E402
from ffx.i18n import set_current_language # noqa: E402
from ffx.media_descriptor import MediaDescriptor # noqa: E402
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402
from ffx.media_edit_screen import MediaEditScreen # noqa: E402
from ffx.pattern_details_screen import PatternDetailsScreen # 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(" ", row[5])
def test_inspect_details_screen_uses_source_font_attachments_for_styled_ass(self):
class _Config:
def getData(self):
return {}
class _Pattern:
def __init__(self, media_descriptor):
self._media_descriptor = media_descriptor
def getMediaDescriptor(self, _context):
return self._media_descriptor
source_descriptor = MediaDescriptor(
track_descriptors=[
TrackDescriptor(
index=0,
source_index=0,
sub_index=0,
track_type=TrackType.SUBTITLE,
codec_name=TrackCodec.ASS,
tags={"title": "Styled Subtitle"},
),
TrackDescriptor(
index=1,
source_index=1,
sub_index=0,
track_type=TrackType.ATTACHMENT,
attachment_format=AttachmentFormat.TTF,
tags={"filename": "current.ttf", "mimetype": "font/ttf"},
),
]
)
pattern_descriptor = MediaDescriptor(
track_descriptors=[
TrackDescriptor(
index=0,
source_index=0,
sub_index=0,
track_type=TrackType.SUBTITLE,
codec_name=TrackCodec.ASS,
tags={"title": "Styled Subtitle"},
),
TrackDescriptor(
index=1,
source_index=1,
sub_index=0,
track_type=TrackType.ATTACHMENT,
attachment_format=AttachmentFormat.TTF,
tags={"filename": "old.ttf", "mimetype": "font/ttf"},
),
TrackDescriptor(
index=2,
source_index=2,
sub_index=1,
track_type=TrackType.ATTACHMENT,
attachment_format=AttachmentFormat.TTF,
tags={"filename": "missing.ttf", "mimetype": "font/ttf"},
),
]
)
screen = object.__new__(InspectDetailsScreen)
screen.context = {"logger": get_ffx_logger(), "config": _Config()}
resolved_descriptor = screen._resolve_target_media_descriptor(
_Pattern(pattern_descriptor),
source_descriptor,
)
attachment_tracks = resolved_descriptor.getAttachmentTracks()
self.assertEqual(1, len(attachment_tracks))
self.assertEqual({"filename": "current.ttf", "mimetype": "font/ttf"}, attachment_tracks[0].getTags())
change_set = MediaDescriptorChangeSet(
screen.context,
resolved_descriptor,
source_descriptor,
).getChangeSetObj()
self.assertNotIn(
1,
change_set.get("tracks", {}).get(DIFF_REMOVED_KEY, {}),
)
def test_inspect_details_screen_maps_target_selection_back_to_source_track(self):
source_track = TrackDescriptor(
index=3,