Compare commits
6 Commits
f794f822f2
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0415087e75 | ||
|
|
20a9bb36b8 | ||
|
|
3ac139a2dc | ||
|
|
912db3c39a | ||
|
|
8a375ccce1 | ||
|
|
176cfa06eb |
99
README.md
99
README.md
@@ -99,101 +99,4 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
|
|||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
### 0.4.3
|
See the [version history](docs/history.rst) for release notes.
|
||||||
|
|
||||||
- styled ASS subtitle sources with embedded font attachments are now detected explicitly, keep MKV output, preserve current source font attachments, and reject incompatible sidecar subtitle import
|
|
||||||
- attachment descriptors are now treated as source-runtime data instead of pattern schema data, so pattern persistence skips them and source-vs-pattern validation ignores them
|
|
||||||
- inspect differences no longer report planned changes for attachment filename/count drift while still showing attachment streams in the stream table
|
|
||||||
|
|
||||||
### 0.4.2
|
|
||||||
|
|
||||||
- pattern details now show an inline `Show: <quality>` hint next to the quality field when the pattern itself has no stored quality but the selected show does
|
|
||||||
- inspect stream tables now show attachment format labels like `TTF` in the codec column and keep attachment language cells blank instead of showing an undefined language
|
|
||||||
- ffmpeg damaged-MP3 diagnostics now recognize additional corruption lines such as `invalid new backstep`, keeping them grouped under the `warn-corrupt-mpeg-audio` review summary
|
|
||||||
|
|
||||||
### 0.4.1
|
|
||||||
|
|
||||||
- `convert` now supports `--copy-video` and `--copy-audio` to keep the selected stream type in copy mode without applying the corresponding reencode flags, filters, or formatting options
|
|
||||||
- ffmpeg conversions now monitor diagnostics while the process is running, retry unset AVI packet timestamps once with `-fflags +genpts`, and stop early when a file should be skipped instead of waiting for the full job to finish
|
|
||||||
- end-of-run convert summaries now list only ffmpeg findings that still require review, including named remedy identifiers such as `warn-corrupt-mpeg-audio`
|
|
||||||
- `upgrade` now finishes by reporting the installed FFX version together with the active bundle branch
|
|
||||||
|
|
||||||
### 0.3.1
|
|
||||||
|
|
||||||
- debug mode screen titles now append the active Textual screen class name, making screen-specific troubleshooting easier during inspect and edit flows
|
|
||||||
- `--cut` again works as a combined flag/option: omitted disables cutting, bare `--cut` applies the default `60,180`, and explicit duration or `START,DURATION` values stay supported
|
|
||||||
- H.265 unmux commands no longer force an invalid `-f h265` output format, keeping ffmpeg copy extraction aligned with the required Annex B bitstream filter
|
|
||||||
- H.264 encoding now falls back from `libx264` to `libopenh264` with a warning when needed, and the test fixtures use the same encoder fallback so the suite remains portable across ffmpeg builds
|
|
||||||
|
|
||||||
### 0.3.0
|
|
||||||
|
|
||||||
- inspect and edit screens now refresh nested track and pattern changes more reliably, with inspect-mode tables aligned to the target pattern view shown in the differences pane
|
|
||||||
- metadata editing got a follow-up polish pass with clearer ffmpeg notifications, a shared in-screen log pane, safer apply/reload handling, and expanded cleanup and normalization coverage
|
|
||||||
- track and asset probing recognize additional codecs, and the modern test suite now covers more metadata-editor, change-set, screen-state, and asset-probe behavior
|
|
||||||
- Textual now requires version `8.0` or newer to match the UI APIs used by the current screens
|
|
||||||
|
|
||||||
### 0.2.6
|
|
||||||
|
|
||||||
- DB-free `ffx edit` workflow for in-place metadata editing via temporary-file rewrite
|
|
||||||
- inspect and edit workflows split into dedicated Textual screens with shared media-workflow support
|
|
||||||
- Textual tables and row actions now separate raw data from rendered labels to avoid markup leaking into stored metadata
|
|
||||||
- responsive screen layout pass, `Esc` back handling, sortable show/inspect tables, and improved edit-screen notifications/toggles
|
|
||||||
- application-wide UTF-8 i18n catalogs with language precedence from CLI over config over system over German default
|
|
||||||
- metadata normalization extended for localized subtitle titles, ISO language cleanup, and smarter track editor language/title helpers
|
|
||||||
|
|
||||||
### 0.2.5
|
|
||||||
|
|
||||||
- show-level quality and notes fields
|
|
||||||
- pattern-over-show-over-default season-shift resolution with dynamic DB migration loading
|
|
||||||
- migration prompt now reports the upgrade path and creates an in-place DB backup before applying schema changes
|
|
||||||
- `upgrade --branch <name>` now fetches remote-only branches before switching
|
|
||||||
- `unmux` now applies season shifting to subtitle output filenames
|
|
||||||
- convert now keeps DB-defined target subtitle dispositions authoritative over sidecar filename disposition flags when a pattern definition exists
|
|
||||||
- focused modern tests added around migrations, unmux, upgrade, and subtitle-disposition import precedence
|
|
||||||
|
|
||||||
### 0.2.4
|
|
||||||
|
|
||||||
- lightweight CLI commands now stay import-light via lazy runtime loading
|
|
||||||
- setup/config templating moved to `assets/ffx.json.j2`
|
|
||||||
- aligned two-step local setup wrappers: `ffx setup` and `ffx configure_workstation`
|
|
||||||
- combined `ffprobe` payload reuse in `FileProperties`
|
|
||||||
- configurable crop-detect sampling plus per-process crop result caching
|
|
||||||
- single-query controller accessors and conditional DB schema bootstrap
|
|
||||||
- shared screen bootstrap/controller wiring for large detail screens
|
|
||||||
- configurable default season/episode digit lengths
|
|
||||||
- digit-aware `rename` and padded `unmux` filename markers
|
|
||||||
|
|
||||||
### 0.2.3
|
|
||||||
|
|
||||||
- PyPI packaging
|
|
||||||
- output filename templating
|
|
||||||
- season shifting
|
|
||||||
- DB versioning
|
|
||||||
|
|
||||||
### 0.2.2
|
|
||||||
|
|
||||||
- CLI overrides
|
|
||||||
|
|
||||||
### 0.2.1
|
|
||||||
|
|
||||||
- signature handling
|
|
||||||
- tag cleanup
|
|
||||||
- bugfixes and refactoring
|
|
||||||
|
|
||||||
### 0.2.0
|
|
||||||
|
|
||||||
- tests
|
|
||||||
- config file
|
|
||||||
|
|
||||||
### 0.1.3
|
|
||||||
|
|
||||||
- subtitle file imports
|
|
||||||
|
|
||||||
### 0.1.2
|
|
||||||
|
|
||||||
- bugfixes
|
|
||||||
|
|
||||||
### 0.1.1
|
|
||||||
|
|
||||||
- bugfixes
|
|
||||||
- TMDB show identification
|
|
||||||
|
|||||||
123
docs/history.rst
Normal file
123
docs/history.rst
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
Version History
|
||||||
|
===============
|
||||||
|
|
||||||
|
0.4.4
|
||||||
|
-----
|
||||||
|
|
||||||
|
- External subtitle imports can now match prefixless sidecar files against the source basename, choose their extension, and confirm partial substitutions.
|
||||||
|
- Unmux now creates missing output directories with confirmation or the new ``--yes`` option.
|
||||||
|
- Project documentation is now built with Sphinx and includes installation, usage, development, file-format, and API references.
|
||||||
|
|
||||||
|
0.4.3
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Styled ASS subtitle sources with embedded font attachments are now detected explicitly, keep MKV output, preserve current source font attachments, and reject incompatible sidecar subtitle import.
|
||||||
|
- Attachment descriptors are now treated as source-runtime data instead of pattern schema data, so pattern persistence skips them and source-vs-pattern validation ignores them.
|
||||||
|
- Inspect differences no longer report planned changes for attachment filename/count drift while still showing attachment streams in the stream table.
|
||||||
|
|
||||||
|
0.4.2
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Pattern details now show an inline ``Show: <quality>`` hint next to the quality field when the pattern itself has no stored quality but the selected show does.
|
||||||
|
- Inspect stream tables now show attachment format labels like ``TTF`` in the codec column and keep attachment language cells blank instead of showing an undefined language.
|
||||||
|
- FFmpeg damaged-MP3 diagnostics now recognize additional corruption lines such as ``invalid new backstep``, keeping them grouped under the ``warn-corrupt-mpeg-audio`` review summary.
|
||||||
|
|
||||||
|
0.4.1
|
||||||
|
-----
|
||||||
|
|
||||||
|
- ``convert`` now supports ``--copy-video`` and ``--copy-audio`` to keep the selected stream type in copy mode without applying the corresponding reencode flags, filters, or formatting options.
|
||||||
|
- FFmpeg conversions now monitor diagnostics while the process is running, retry unset AVI packet timestamps once with ``-fflags +genpts``, and stop early when a file should be skipped instead of waiting for the full job to finish.
|
||||||
|
- End-of-run convert summaries now list only FFmpeg findings that still require review, including named remedy identifiers such as ``warn-corrupt-mpeg-audio``.
|
||||||
|
- ``upgrade`` now finishes by reporting the installed FFX version together with the active bundle branch.
|
||||||
|
|
||||||
|
0.3.1
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Debug mode screen titles now append the active Textual screen class name, making screen-specific troubleshooting easier during inspect and edit flows.
|
||||||
|
- ``--cut`` again works as a combined flag/option: omitted disables cutting, bare ``--cut`` applies the default ``60,180``, and explicit duration or ``START,DURATION`` values stay supported.
|
||||||
|
- H.265 unmux commands no longer force an invalid ``-f h265`` output format, keeping FFmpeg copy extraction aligned with the required Annex B bitstream filter.
|
||||||
|
- H.264 encoding now falls back from ``libx264`` to ``libopenh264`` with a warning when needed, and the test fixtures use the same encoder fallback so the suite remains portable across FFmpeg builds.
|
||||||
|
|
||||||
|
0.3.0
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Inspect and edit screens now refresh nested track and pattern changes more reliably, with inspect-mode tables aligned to the target pattern view shown in the differences pane.
|
||||||
|
- Metadata editing got a follow-up polish pass with clearer FFmpeg notifications, a shared in-screen log pane, safer apply/reload handling, and expanded cleanup and normalization coverage.
|
||||||
|
- Track and asset probing recognize additional codecs, and the modern test suite now covers more metadata-editor, change-set, screen-state, and asset-probe behavior.
|
||||||
|
- Textual now requires version ``8.0`` or newer to match the UI APIs used by the current screens.
|
||||||
|
|
||||||
|
0.2.6
|
||||||
|
-----
|
||||||
|
|
||||||
|
- DB-free ``ffx edit`` workflow for in-place metadata editing via temporary-file rewrite.
|
||||||
|
- Inspect and edit workflows split into dedicated Textual screens with shared media-workflow support.
|
||||||
|
- Textual tables and row actions now separate raw data from rendered labels to avoid markup leaking into stored metadata.
|
||||||
|
- Responsive screen layout pass, ``Esc`` back handling, sortable show/inspect tables, and improved edit-screen notifications/toggles.
|
||||||
|
- Application-wide UTF-8 i18n catalogs with language precedence from CLI over config over system over German default.
|
||||||
|
- Metadata normalization extended for localized subtitle titles, ISO language cleanup, and smarter track editor language/title helpers.
|
||||||
|
|
||||||
|
0.2.5
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Show-level quality and notes fields.
|
||||||
|
- Pattern-over-show-over-default season-shift resolution with dynamic DB migration loading.
|
||||||
|
- Migration prompt now reports the upgrade path and creates an in-place DB backup before applying schema changes.
|
||||||
|
- ``upgrade --branch <name>`` now fetches remote-only branches before switching.
|
||||||
|
- ``unmux`` now applies season shifting to subtitle output filenames.
|
||||||
|
- Convert now keeps DB-defined target subtitle dispositions authoritative over sidecar filename disposition flags when a pattern definition exists.
|
||||||
|
- Focused modern tests added around migrations, unmux, upgrade, and subtitle-disposition import precedence.
|
||||||
|
|
||||||
|
0.2.4
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Lightweight CLI commands now stay import-light via lazy runtime loading.
|
||||||
|
- Setup/config templating moved to ``assets/ffx.json.j2``.
|
||||||
|
- Aligned two-step local setup wrappers: ``ffx setup`` and ``ffx configure_workstation``.
|
||||||
|
- Combined ``ffprobe`` payload reuse in ``FileProperties``.
|
||||||
|
- Configurable crop-detect sampling plus per-process crop result caching.
|
||||||
|
- Single-query controller accessors and conditional DB schema bootstrap.
|
||||||
|
- Shared screen bootstrap/controller wiring for large detail screens.
|
||||||
|
- Configurable default season/episode digit lengths.
|
||||||
|
- Digit-aware ``rename`` and padded ``unmux`` filename markers.
|
||||||
|
|
||||||
|
0.2.3
|
||||||
|
-----
|
||||||
|
|
||||||
|
- PyPI packaging.
|
||||||
|
- Output filename templating.
|
||||||
|
- Season shifting.
|
||||||
|
- DB versioning.
|
||||||
|
|
||||||
|
0.2.2
|
||||||
|
-----
|
||||||
|
|
||||||
|
- CLI overrides.
|
||||||
|
|
||||||
|
0.2.1
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Signature handling.
|
||||||
|
- Tag cleanup.
|
||||||
|
- Bugfixes and refactoring.
|
||||||
|
|
||||||
|
0.2.0
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Tests.
|
||||||
|
- Config file.
|
||||||
|
|
||||||
|
0.1.3
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Subtitle file imports.
|
||||||
|
|
||||||
|
0.1.2
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Bugfixes.
|
||||||
|
|
||||||
|
0.1.1
|
||||||
|
-----
|
||||||
|
|
||||||
|
- Bugfixes.
|
||||||
|
- TMDB show identification.
|
||||||
@@ -17,6 +17,12 @@ utility modules.
|
|||||||
usage
|
usage
|
||||||
file_formats
|
file_formats
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 1
|
||||||
|
:caption: Release Notes
|
||||||
|
|
||||||
|
history
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
:caption: Contributor Guide
|
:caption: Contributor Guide
|
||||||
|
|||||||
@@ -33,11 +33,33 @@ Useful overrides include:
|
|||||||
* ``--no-pattern`` to skip database pattern matching
|
* ``--no-pattern`` to skip database pattern matching
|
||||||
* ``--show``, ``--season``, and ``--episode`` for explicit episode identity
|
* ``--show``, ``--season``, and ``--episode`` for explicit episode identity
|
||||||
* ``--output-directory`` for generated output placement
|
* ``--output-directory`` for generated output placement
|
||||||
* ``--subtitle-directory`` and ``--subtitle-prefix`` for sidecar subtitle
|
* ``--subtitle-directory`` for source-basename sidecar subtitle imports
|
||||||
imports
|
* ``--subtitle-prefix`` for explicit or configured-prefix subtitle imports
|
||||||
|
* ``--subtitle-extension`` to select the imported sidecar format (default:
|
||||||
|
``vtt``)
|
||||||
|
* ``--yes`` to accept a valid partial sidecar set without prompting
|
||||||
* ``--copy-video`` or ``--copy-audio`` to preserve selected stream types
|
* ``--copy-video`` or ``--copy-audio`` to preserve selected stream types
|
||||||
* ``--rename-only`` for filename normalization without media rewriting
|
* ``--rename-only`` for filename normalization without media rewriting
|
||||||
|
|
||||||
|
Directory-only subtitle import matches the source basename. For example,
|
||||||
|
``A2_t01.mkv`` discovers files such as ``A2_t01_2_deu_DEF.vtt`` in the
|
||||||
|
provided directory:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx convert --subtitle-directory /path/to/subtitles A2_t01.mkv
|
||||||
|
|
||||||
|
Select a different sidecar set by extension, with or without the leading dot:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
ffx convert --subtitle-directory /path/to/subtitles \
|
||||||
|
--subtitle-extension .mkv A2_t01.mkv
|
||||||
|
|
||||||
|
When only some source subtitle tracks have matching sidecar files, conversion
|
||||||
|
asks for confirmation. Use ``--yes`` to substitute that valid subset without
|
||||||
|
prompting. ``--yes`` also permits this case when ``--no-prompt`` is set.
|
||||||
|
|
||||||
Manage Shows And Patterns
|
Manage Shows And Patterns
|
||||||
-------------------------
|
-------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -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.3"
|
version = "0.4.4"
|
||||||
license = {file = "LICENSE.md"}
|
license = {file = "LICENSE.md"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"requests",
|
"requests",
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ Secondary source: `tests/legacy/`, used only to clarify intent and reveal gaps.
|
|||||||
- `SUBTRACK_MAPPING-0016`: Metadata for a substituted target track shall be merged from the regular source track and the separate source file when available.
|
- `SUBTRACK_MAPPING-0016`: Metadata for a substituted target track shall be merged from the regular source track and the separate source file when available.
|
||||||
- `SUBTRACK_MAPPING-0017`: If the separate source file provides a metadata field that is also present on the regular source track, the separate source file value shall win in the target output.
|
- `SUBTRACK_MAPPING-0017`: If the separate source file provides a metadata field that is also present on the regular source track, the separate source file value shall win in the target output.
|
||||||
- `SUBTRACK_MAPPING-0018`: If a metadata field is absent from the separate source file, the system shall fall back to the corresponding metadata from the regular source track or target schema rewrite rules.
|
- `SUBTRACK_MAPPING-0018`: If a metadata field is absent from the separate source file, the system shall fall back to the corresponding metadata from the regular source track or target schema rewrite rules.
|
||||||
|
- `SUBTRACK_MAPPING-0019`: When `ffx convert` receives an explicit subtitle directory without a subtitle prefix, it shall discover sidecar files using the source media basename as the filename prefix.
|
||||||
|
- `SUBTRACK_MAPPING-0020`: Basename-driven subtitle discovery shall first filter regular files by the exact `<source-basename>_` filename prefix and the configured subtitle extension.
|
||||||
|
- `SUBTRACK_MAPPING-0021`: `--subtitle-extension` shall accept an extension with or without a leading dot, default to `vtt`, and apply to both basename-driven and explicit-prefix subtitle discovery.
|
||||||
|
- `SUBTRACK_MAPPING-0022`: Basename-driven sidecar filenames shall identify the target subtitle track using the existing `<prefix>_<stream-index>_<language>[_<disposition>].<extension>` filename contract.
|
||||||
|
- `SUBTRACK_MAPPING-0023`: A complete, valid basename-driven sidecar set shall proceed without confirmation and shall report the discovered substitutions to the operator.
|
||||||
|
- `SUBTRACK_MAPPING-0024`: An incomplete but otherwise valid basename-driven sidecar set shall require confirmation before substituting only the represented subtitle tracks. `--yes` shall supply that confirmation without prompting. With `--no-prompt` and without `--yes`, conversion shall fail with an explanation instead.
|
||||||
|
- `SUBTRACK_MAPPING-0025`: Basename-driven discovery shall fail before conversion when the filtered set contains too many files, malformed filenames, duplicate stream indices, or stream indices that do not identify subtitle tracks in the active media descriptor.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
@@ -57,6 +64,9 @@ Secondary source: `tests/legacy/`, used only to clarify intent and reveal gaps.
|
|||||||
- If target-track metadata is rewritten after reordering, it is written onto the correct source-derived logical track rather than the track that merely occupies the same final output position.
|
- If target-track metadata is rewritten after reordering, it is written onto the correct source-derived logical track rather than the track that merely occupies the same final output position.
|
||||||
- Invalid target-to-source references fail deterministically before the conversion job is launched.
|
- Invalid target-to-source references fail deterministically before the conversion job is launched.
|
||||||
- If a separate source file substitutes one target track, that track keeps its target slot and ordering while metadata is merged with separate-file values taking precedence when both sides provide the same field.
|
- If a separate source file substitutes one target track, that track keeps its target slot and ordering while metadata is merged with separate-file values taking precedence when both sides provide the same field.
|
||||||
|
- Given `A2_t01.mkv` and an explicit subtitle directory containing `A2_t01_2_deu_DEF.vtt`, `A2_t01_3_eng.vtt`, and `A2_t01_4_eng.vtt`, directory-only subtitle import recognizes and substitutes all three tracks without prompting.
|
||||||
|
- Selecting `--subtitle-extension mkv` or `--subtitle-extension .mkv` selects the equivalent basename-matched `.mkv` sidecar set instead of the default `.vtt` set.
|
||||||
|
- Given an incomplete but valid basename-matched sidecar set, `--yes` proceeds with only the represented subtitle substitutions, including when `--no-prompt` is also set.
|
||||||
- A test proving subtrack mapping must assert at least one of: exact `source_index` to output-order mapping, omission of named source tracks, or preservation of per-track metadata after reorder.
|
- A test proving subtrack mapping must assert at least one of: exact `source_index` to output-order mapping, omission of named source tracks, or preservation of per-track metadata after reorder.
|
||||||
|
|
||||||
## Test Notes
|
## Test Notes
|
||||||
|
|||||||
229
src/ffx/cli.py
229
src/ffx/cli.py
@@ -41,13 +41,17 @@ CPU_OPTION_HELP = (
|
|||||||
+ "Omit to disable; 0 also disables."
|
+ "Omit to disable; 0 also disables."
|
||||||
)
|
)
|
||||||
SUBTITLE_DIRECTORY_OPTION_HELP = (
|
SUBTITLE_DIRECTORY_OPTION_HELP = (
|
||||||
"Load subtitles from here. When omitted and --subtitle-prefix is set, "
|
"Load subtitles from here. Without --subtitle-prefix, match the source filename "
|
||||||
|
+ "basename. When omitted and --subtitle-prefix is set, "
|
||||||
+ "FFX uses the configured subtitlesDirectory base path plus the prefix as a subdirectory."
|
+ "FFX uses the configured subtitlesDirectory base path plus the prefix as a subdirectory."
|
||||||
)
|
)
|
||||||
SUBTITLE_PREFIX_OPTION_HELP = (
|
SUBTITLE_PREFIX_OPTION_HELP = (
|
||||||
"Subtitle filename prefix. Requires --subtitle-directory, or a configured "
|
"Subtitle filename prefix. Requires --subtitle-directory, or a configured "
|
||||||
+ "subtitlesDirectory base path that contains a matching <prefix>/ subdirectory."
|
+ "subtitlesDirectory base path that contains a matching <prefix>/ subdirectory."
|
||||||
)
|
)
|
||||||
|
SUBTITLE_EXTENSION_OPTION_HELP = (
|
||||||
|
"External subtitle filename extension. A leading dot is optional."
|
||||||
|
)
|
||||||
UNMUX_OUTPUT_DIRECTORY_OPTION_HELP = (
|
UNMUX_OUTPUT_DIRECTORY_OPTION_HELP = (
|
||||||
"Write extracted streams here. When omitted together with --subtitles-only and "
|
"Write extracted streams here. When omitted together with --subtitles-only and "
|
||||||
+ "--label, FFX uses the configured subtitlesDirectory base path plus the label."
|
+ "--label, FFX uses the configured subtitlesDirectory base path plus the label."
|
||||||
@@ -96,6 +100,18 @@ def normalizeCpuOption(ctx, param, value):
|
|||||||
raise click.BadParameter(str(ex)) from ex
|
raise click.BadParameter(str(ex)) from ex
|
||||||
|
|
||||||
|
|
||||||
|
def normalizeSubtitleExtension(ctx, param, value):
|
||||||
|
normalizedExtension = str(value).strip().lower()
|
||||||
|
if normalizedExtension.startswith('.'):
|
||||||
|
normalizedExtension = normalizedExtension[1:]
|
||||||
|
if not normalizedExtension or not normalizedExtension.isalnum():
|
||||||
|
raise click.BadParameter(
|
||||||
|
"Subtitle extension must contain only letters and numbers, "
|
||||||
|
+ "with an optional leading dot."
|
||||||
|
)
|
||||||
|
return normalizedExtension
|
||||||
|
|
||||||
|
|
||||||
def parseCutOptionValue(value) -> tuple[int, int] | None:
|
def parseCutOptionValue(value) -> tuple[int, int] | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -146,11 +162,21 @@ def resolveSubtitleImportOptions(context, subtitleDirectory, subtitlePrefix):
|
|||||||
else ''
|
else ''
|
||||||
)
|
)
|
||||||
|
|
||||||
if not resolvedSubtitlePrefix:
|
|
||||||
return False, resolvedSubtitleDirectory, resolvedSubtitlePrefix
|
|
||||||
|
|
||||||
if resolvedSubtitleDirectory:
|
if resolvedSubtitleDirectory:
|
||||||
return True, resolvedSubtitleDirectory, resolvedSubtitlePrefix
|
if not os.path.isdir(resolvedSubtitleDirectory):
|
||||||
|
raise click.ClickException(
|
||||||
|
"The provided subtitle directory does not exist: "
|
||||||
|
+ resolvedSubtitleDirectory
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
True,
|
||||||
|
resolvedSubtitleDirectory,
|
||||||
|
resolvedSubtitlePrefix,
|
||||||
|
not resolvedSubtitlePrefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not resolvedSubtitlePrefix:
|
||||||
|
return False, resolvedSubtitleDirectory, resolvedSubtitlePrefix, False
|
||||||
|
|
||||||
configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath()
|
configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath()
|
||||||
if not configuredSubtitlesBaseDirectory:
|
if not configuredSubtitlesBaseDirectory:
|
||||||
@@ -170,7 +196,85 @@ def resolveSubtitleImportOptions(context, subtitleDirectory, subtitlePrefix):
|
|||||||
+ resolvedSubtitleDirectory
|
+ resolvedSubtitleDirectory
|
||||||
)
|
)
|
||||||
|
|
||||||
return True, resolvedSubtitleDirectory, resolvedSubtitlePrefix
|
return True, resolvedSubtitleDirectory, resolvedSubtitlePrefix, False
|
||||||
|
|
||||||
|
|
||||||
|
def importExternalSubtitles(
|
||||||
|
context,
|
||||||
|
mediaDescriptor,
|
||||||
|
sourceFileBasename,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
preserveDispositions=False,
|
||||||
|
):
|
||||||
|
matchSourceBasename = context['subtitle_match_source_basename']
|
||||||
|
subtitlePrefix = (
|
||||||
|
sourceFileBasename
|
||||||
|
if matchSourceBasename
|
||||||
|
else context['subtitle_prefix']
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
importResult = mediaDescriptor.importSubtitles(
|
||||||
|
context['subtitle_directory'],
|
||||||
|
subtitlePrefix,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
preserve_dispositions=preserveDispositions,
|
||||||
|
extension=context['subtitle_extension'],
|
||||||
|
strict=matchSourceBasename,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError) as ex:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"External subtitle discovery failed for '{sourceFileBasename}': {ex}"
|
||||||
|
) from ex
|
||||||
|
|
||||||
|
if not matchSourceBasename:
|
||||||
|
return importResult
|
||||||
|
|
||||||
|
importedTrackIndices = importResult['imported_track_indices']
|
||||||
|
missingTrackIndices = importResult['missing_track_indices']
|
||||||
|
extension = context['subtitle_extension']
|
||||||
|
importedDescription = (
|
||||||
|
', '.join(f"#{index}" for index in importedTrackIndices)
|
||||||
|
if importedTrackIndices
|
||||||
|
else 'none'
|
||||||
|
)
|
||||||
|
click.echo(
|
||||||
|
f"External subtitle scan for '{sourceFileBasename}': found "
|
||||||
|
+ f"{importResult['candidate_count']} .{extension} file(s); "
|
||||||
|
+ f"matched subtitle tracks {importedDescription}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not missingTrackIndices:
|
||||||
|
return importResult
|
||||||
|
|
||||||
|
missingDescription = ', '.join(f"#{index}" for index in missingTrackIndices)
|
||||||
|
incompleteMessage = (
|
||||||
|
f"External subtitle files are missing for subtitle tracks "
|
||||||
|
+ f"{missingDescription} in '{sourceFileBasename}'."
|
||||||
|
)
|
||||||
|
if context.get('yes', False):
|
||||||
|
click.echo(
|
||||||
|
incompleteMessage
|
||||||
|
+ " Continuing with the matching subtitle files because --yes is set."
|
||||||
|
)
|
||||||
|
return importResult
|
||||||
|
|
||||||
|
if context['no_prompt']:
|
||||||
|
raise click.ClickException(
|
||||||
|
incompleteMessage
|
||||||
|
+ " Partial subtitle substitution requires confirmation, but --no-prompt is set."
|
||||||
|
)
|
||||||
|
|
||||||
|
click.echo(incompleteMessage)
|
||||||
|
if not click.confirm(
|
||||||
|
"Continue and substitute only the subtitle tracks with matching files?",
|
||||||
|
default=False,
|
||||||
|
):
|
||||||
|
raise click.ClickException("External subtitle substitution aborted by user.")
|
||||||
|
|
||||||
|
return importResult
|
||||||
|
|
||||||
|
|
||||||
def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label):
|
def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label):
|
||||||
@@ -181,7 +285,10 @@ def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label):
|
|||||||
)
|
)
|
||||||
resolvedLabel = str(label).strip()
|
resolvedLabel = str(label).strip()
|
||||||
|
|
||||||
if resolvedOutputDirectory or not subtitlesOnly or not resolvedLabel:
|
if resolvedOutputDirectory:
|
||||||
|
return resolvedOutputDirectory, True
|
||||||
|
|
||||||
|
if not subtitlesOnly or not resolvedLabel:
|
||||||
return resolvedOutputDirectory, False
|
return resolvedOutputDirectory, False
|
||||||
|
|
||||||
configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath()
|
configuredSubtitlesBaseDirectory = context['config'].getSubtitlesDirectoryPath()
|
||||||
@@ -194,6 +301,63 @@ def resolveUnmuxOutputDirectory(context, outputDirectory, subtitlesOnly, label):
|
|||||||
return os.path.join(configuredSubtitlesBaseDirectory, resolvedLabel), True
|
return os.path.join(configuredSubtitlesBaseDirectory, resolvedLabel), True
|
||||||
|
|
||||||
|
|
||||||
|
def confirmUnmuxOutputDirectoryCreation(outputDirectory):
|
||||||
|
message = (
|
||||||
|
"Create unmux output directory and missing parents: "
|
||||||
|
+ str(outputDirectory)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
return click.confirm(message, default=True)
|
||||||
|
|
||||||
|
click.echo(f"{message} [Y/n]: ", nl=False)
|
||||||
|
while True:
|
||||||
|
char = click.getchar()
|
||||||
|
if char in ('\r', '\n'):
|
||||||
|
click.echo()
|
||||||
|
return True
|
||||||
|
|
||||||
|
normalizedChar = char.lower()
|
||||||
|
if normalizedChar == 'y':
|
||||||
|
click.echo(char)
|
||||||
|
return True
|
||||||
|
if normalizedChar == 'n':
|
||||||
|
click.echo(char)
|
||||||
|
return False
|
||||||
|
if char in ('\x03', '\x04'):
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
click.echo("\nPlease respond with 'y' or 'n': ", nl=False)
|
||||||
|
|
||||||
|
|
||||||
|
def ensureUnmuxOutputDirectory(context, outputDirectory):
|
||||||
|
resolvedOutputDirectory = os.path.expanduser(str(outputDirectory).strip())
|
||||||
|
if not resolvedOutputDirectory:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if os.path.isdir(resolvedOutputDirectory):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if os.path.exists(resolvedOutputDirectory):
|
||||||
|
raise click.ClickException(
|
||||||
|
"Unmux output path exists but is not a directory: "
|
||||||
|
+ resolvedOutputDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
if context.get('dry_run', False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if context.get('yes', False):
|
||||||
|
os.makedirs(resolvedOutputDirectory, exist_ok=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not confirmUnmuxOutputDirectoryCreation(resolvedOutputDirectory):
|
||||||
|
raise click.ClickException("Unmux output directory creation aborted by user.")
|
||||||
|
|
||||||
|
os.makedirs(resolvedOutputDirectory, exist_ok=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def resolveIndicatorDigitLengths(context=None, showDescriptor=None):
|
def resolveIndicatorDigitLengths(context=None, showDescriptor=None):
|
||||||
from ffx.show_descriptor import ShowDescriptor
|
from ffx.show_descriptor import ShowDescriptor
|
||||||
|
|
||||||
@@ -716,6 +880,12 @@ def getUnmuxSequence(trackDescriptor: TrackDescriptor, sourcePath, targetPrefix,
|
|||||||
@click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix')
|
@click.option('-l', '--label', type=str, default='', help='Label to be used as filename prefix')
|
||||||
@click.option("-o", "--output-directory", type=str, default='', help=UNMUX_OUTPUT_DIRECTORY_OPTION_HELP)
|
@click.option("-o", "--output-directory", type=str, default='', help=UNMUX_OUTPUT_DIRECTORY_OPTION_HELP)
|
||||||
@click.option("-s", "--subtitles-only", is_flag=True, default=False)
|
@click.option("-s", "--subtitles-only", is_flag=True, default=False)
|
||||||
|
@click.option(
|
||||||
|
"--yes",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Create a missing unmux output directory without prompting.",
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--nice',
|
'--nice',
|
||||||
type=int,
|
type=int,
|
||||||
@@ -737,6 +907,7 @@ def unmux(ctx,
|
|||||||
label,
|
label,
|
||||||
output_directory,
|
output_directory,
|
||||||
subtitles_only,
|
subtitles_only,
|
||||||
|
yes,
|
||||||
nice,
|
nice,
|
||||||
cpu):
|
cpu):
|
||||||
from ffx.file_properties import FileProperties
|
from ffx.file_properties import FileProperties
|
||||||
@@ -752,15 +923,16 @@ def unmux(ctx,
|
|||||||
ctx.obj['resource_limits']['niceness'] = nice
|
ctx.obj['resource_limits']['niceness'] = nice
|
||||||
ctx.obj['resource_limits']['cpu_limit'] = cpu
|
ctx.obj['resource_limits']['cpu_limit'] = cpu
|
||||||
ctx.obj['resource_limits']['cpu_percent'] = cpu
|
ctx.obj['resource_limits']['cpu_percent'] = cpu
|
||||||
|
ctx.obj['yes'] = bool(yes)
|
||||||
|
|
||||||
output_directory, create_output_directory = resolveUnmuxOutputDirectory(
|
output_directory, requires_output_directory = resolveUnmuxOutputDirectory(
|
||||||
ctx.obj,
|
ctx.obj,
|
||||||
output_directory,
|
output_directory,
|
||||||
subtitles_only,
|
subtitles_only,
|
||||||
label,
|
label,
|
||||||
)
|
)
|
||||||
if create_output_directory and existingSourcePaths and not ctx.obj.get('dry_run', False):
|
if requires_output_directory and existingSourcePaths:
|
||||||
os.makedirs(output_directory, exist_ok=True)
|
ensureUnmuxOutputDirectory(ctx.obj, output_directory)
|
||||||
|
|
||||||
shiftedSeasonController = ShiftedSeasonController(ctx.obj)
|
shiftedSeasonController = ShiftedSeasonController(ctx.obj)
|
||||||
|
|
||||||
@@ -974,6 +1146,14 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
|||||||
|
|
||||||
@click.option('--subtitle-directory', type=str, default='', help=SUBTITLE_DIRECTORY_OPTION_HELP)
|
@click.option('--subtitle-directory', type=str, default='', help=SUBTITLE_DIRECTORY_OPTION_HELP)
|
||||||
@click.option('--subtitle-prefix', type=str, default='', help=SUBTITLE_PREFIX_OPTION_HELP)
|
@click.option('--subtitle-prefix', type=str, default='', help=SUBTITLE_PREFIX_OPTION_HELP)
|
||||||
|
@click.option(
|
||||||
|
'--subtitle-extension',
|
||||||
|
type=str,
|
||||||
|
default='vtt',
|
||||||
|
callback=normalizeSubtitleExtension,
|
||||||
|
show_default=True,
|
||||||
|
help=SUBTITLE_EXTENSION_OPTION_HELP,
|
||||||
|
)
|
||||||
|
|
||||||
@click.option('--language', type=str, multiple=True, help='Set stream language. Use format <stream index>:<3 letter iso code>')
|
@click.option('--language', type=str, multiple=True, help='Set stream language. Use format <stream index>:<3 letter iso code>')
|
||||||
@click.option('--title', type=str, multiple=True, help='Set stream title. Use format <stream index>:<title>')
|
@click.option('--title', type=str, multiple=True, help='Set stream title. Use format <stream index>:<title>')
|
||||||
@@ -1034,6 +1214,12 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
|
|||||||
@click.option("--dont-pass-dispositions", is_flag=True, default=False)
|
@click.option("--dont-pass-dispositions", is_flag=True, default=False)
|
||||||
|
|
||||||
@click.option("--no-prompt", is_flag=True, default=False)
|
@click.option("--no-prompt", is_flag=True, default=False)
|
||||||
|
@click.option(
|
||||||
|
"--yes",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Confirm partial external subtitle substitution without prompting.",
|
||||||
|
)
|
||||||
@click.option("--no-signature", is_flag=True, default=False)
|
@click.option("--no-signature", is_flag=True, default=False)
|
||||||
@click.option("--keep-mkvmerge-metadata", is_flag=True, default=False)
|
@click.option("--keep-mkvmerge-metadata", is_flag=True, default=False)
|
||||||
|
|
||||||
@@ -1070,6 +1256,7 @@ def convert(ctx,
|
|||||||
|
|
||||||
subtitle_directory,
|
subtitle_directory,
|
||||||
subtitle_prefix,
|
subtitle_prefix,
|
||||||
|
subtitle_extension,
|
||||||
|
|
||||||
language,
|
language,
|
||||||
title,
|
title,
|
||||||
@@ -1108,6 +1295,7 @@ def convert(ctx,
|
|||||||
no_pattern,
|
no_pattern,
|
||||||
dont_pass_dispositions,
|
dont_pass_dispositions,
|
||||||
no_prompt,
|
no_prompt,
|
||||||
|
yes,
|
||||||
no_signature,
|
no_signature,
|
||||||
keep_mkvmerge_metadata,
|
keep_mkvmerge_metadata,
|
||||||
|
|
||||||
@@ -1162,6 +1350,7 @@ def convert(ctx,
|
|||||||
context['use_tmdb'] = not no_tmdb
|
context['use_tmdb'] = not no_tmdb
|
||||||
context['use_pattern'] = not no_pattern
|
context['use_pattern'] = not no_pattern
|
||||||
context['no_prompt'] = no_prompt
|
context['no_prompt'] = no_prompt
|
||||||
|
context['yes'] = yes
|
||||||
context['no_signature'] = no_signature
|
context['no_signature'] = no_signature
|
||||||
context['keep_mkvmerge_metadata'] = keep_mkvmerge_metadata
|
context['keep_mkvmerge_metadata'] = keep_mkvmerge_metadata
|
||||||
|
|
||||||
@@ -1180,6 +1369,7 @@ def convert(ctx,
|
|||||||
context['import_subtitles'],
|
context['import_subtitles'],
|
||||||
resolvedSubtitleDirectory,
|
resolvedSubtitleDirectory,
|
||||||
resolvedSubtitlePrefix,
|
resolvedSubtitlePrefix,
|
||||||
|
context['subtitle_match_source_basename'],
|
||||||
) = resolveSubtitleImportOptions(
|
) = resolveSubtitleImportOptions(
|
||||||
context,
|
context,
|
||||||
subtitle_directory,
|
subtitle_directory,
|
||||||
@@ -1188,6 +1378,7 @@ def convert(ctx,
|
|||||||
if context['import_subtitles']:
|
if context['import_subtitles']:
|
||||||
context['subtitle_directory'] = resolvedSubtitleDirectory
|
context['subtitle_directory'] = resolvedSubtitleDirectory
|
||||||
context['subtitle_prefix'] = resolvedSubtitlePrefix
|
context['subtitle_prefix'] = resolvedSubtitlePrefix
|
||||||
|
context['subtitle_extension'] = subtitle_extension
|
||||||
|
|
||||||
|
|
||||||
existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in SUPPORTED_INPUT_FILE_EXTENSIONS]
|
existingSourcePaths = [p for p in paths if os.path.isfile(p) and p.split('.')[-1] in SUPPORTED_INPUT_FILE_EXTENSIONS]
|
||||||
@@ -1431,10 +1622,13 @@ def convert(ctx,
|
|||||||
currentShowDescriptor = None
|
currentShowDescriptor = None
|
||||||
|
|
||||||
if context['import_subtitles']:
|
if context['import_subtitles']:
|
||||||
sourceMediaDescriptor.importSubtitles(context['subtitle_directory'],
|
importExternalSubtitles(
|
||||||
context['subtitle_prefix'],
|
context,
|
||||||
|
sourceMediaDescriptor,
|
||||||
|
sourceFileBasename,
|
||||||
showSeason,
|
showSeason,
|
||||||
showEpisode)
|
showEpisode,
|
||||||
|
)
|
||||||
|
|
||||||
if cliOverrides:
|
if cliOverrides:
|
||||||
sourceMediaDescriptor.applyOverrides(cliOverrides)
|
sourceMediaDescriptor.applyOverrides(cliOverrides)
|
||||||
@@ -1478,11 +1672,14 @@ def convert(ctx,
|
|||||||
|
|
||||||
|
|
||||||
if context['import_subtitles']:
|
if context['import_subtitles']:
|
||||||
targetMediaDescriptor.importSubtitles(context['subtitle_directory'],
|
importExternalSubtitles(
|
||||||
context['subtitle_prefix'],
|
context,
|
||||||
|
targetMediaDescriptor,
|
||||||
|
sourceFileBasename,
|
||||||
showSeason,
|
showSeason,
|
||||||
showEpisode,
|
showEpisode,
|
||||||
preserve_dispositions=True)
|
preserveDispositions=True,
|
||||||
|
)
|
||||||
|
|
||||||
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
|
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
|
||||||
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")
|
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
VERSION='0.4.3'
|
VERSION='0.4.4'
|
||||||
DATABASE_VERSION = 3
|
DATABASE_VERSION = 3
|
||||||
|
|
||||||
DEFAULT_QUALITY = 32
|
DEFAULT_QUALITY = 32
|
||||||
|
|||||||
@@ -431,10 +431,13 @@ class MediaDescriptor:
|
|||||||
importedFilePath = td.getExternalSourceFilePath()
|
importedFilePath = td.getExternalSourceFilePath()
|
||||||
|
|
||||||
if importedFilePath:
|
if importedFilePath:
|
||||||
|
substitutionMessage = (
|
||||||
self.__logger.info(f"Substituting subtitle stream #{td.getIndex()} "
|
f"Substituting subtitle stream #{td.getIndex()} "
|
||||||
+ f"({td.getType().label()}:{td.getSubIndex()}) "
|
+ f"({td.getType().label()}:{td.getSubIndex()}) "
|
||||||
+ f"with import from file {td.getExternalSourceFilePath()}")
|
+ f"with import from file {td.getExternalSourceFilePath()}"
|
||||||
|
)
|
||||||
|
click.echo(substitutionMessage)
|
||||||
|
self.__logger.debug(substitutionMessage)
|
||||||
|
|
||||||
importFileTokens += [
|
importFileTokens += [
|
||||||
"-i",
|
"-i",
|
||||||
@@ -524,25 +527,72 @@ class MediaDescriptor:
|
|||||||
return inputMappingTokens
|
return inputMappingTokens
|
||||||
|
|
||||||
|
|
||||||
def searchSubtitleFiles(self, searchDirectory, prefix):
|
def searchSubtitleFiles(
|
||||||
|
self,
|
||||||
sesld_match = re.compile(f"{prefix}_{MediaDescriptor.SEASON_EPISODE_STREAM_LANGUAGE_DISPOSITIONS_MATCH}")
|
searchDirectory,
|
||||||
sld_match = re.compile(f"{prefix}_{MediaDescriptor.STREAM_LANGUAGE_DISPOSITIONS_MATCH}")
|
prefix,
|
||||||
|
extension=SUBTITLE_FILE_EXTENSION,
|
||||||
subtitleFileDescriptors = []
|
strict=False,
|
||||||
|
|
||||||
for subtitleFilename in os.listdir(searchDirectory):
|
|
||||||
if subtitleFilename.startswith(prefix) and subtitleFilename.endswith(
|
|
||||||
"." + MediaDescriptor.SUBTITLE_FILE_EXTENSION
|
|
||||||
):
|
):
|
||||||
|
|
||||||
sesld_result = sesld_match.search(subtitleFilename)
|
normalizedExtension = str(extension).strip().lower()
|
||||||
sld_result = None if not sesld_result is None else sld_match.search(subtitleFilename)
|
if normalizedExtension.startswith('.'):
|
||||||
|
normalizedExtension = normalizedExtension[1:]
|
||||||
|
escapedPrefix = re.escape(prefix)
|
||||||
|
sesld_match = re.compile(
|
||||||
|
f"{escapedPrefix}_{MediaDescriptor.SEASON_EPISODE_STREAM_LANGUAGE_DISPOSITIONS_MATCH}"
|
||||||
|
)
|
||||||
|
sld_match = re.compile(
|
||||||
|
f"{escapedPrefix}_{MediaDescriptor.STREAM_LANGUAGE_DISPOSITIONS_MATCH}"
|
||||||
|
)
|
||||||
|
|
||||||
if not sesld_result is None:
|
subtitleFileDescriptors = []
|
||||||
|
subtitleFilenames = []
|
||||||
|
|
||||||
|
for subtitleFilename in sorted(os.listdir(searchDirectory)):
|
||||||
|
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
|
||||||
|
subtitleFilenameStem, subtitleFilenameExtension = os.path.splitext(
|
||||||
|
subtitleFilename
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
os.path.isfile(subtitleFilePath)
|
||||||
|
and subtitleFilenameStem.startswith(prefix + '_')
|
||||||
|
and subtitleFilenameExtension.lower() == '.' + normalizedExtension
|
||||||
|
):
|
||||||
|
subtitleFilenames.append(subtitleFilename)
|
||||||
|
|
||||||
|
expectedSubtitleTrackIndices = {
|
||||||
|
subtitleTrack.getIndex()
|
||||||
|
for subtitleTrack in self.getSubtitleTracks()
|
||||||
|
}
|
||||||
|
if strict and len(subtitleFilenames) > len(expectedSubtitleTrackIndices):
|
||||||
|
raise ValueError(
|
||||||
|
f"Found {len(subtitleFilenames)} matching .{normalizedExtension} files "
|
||||||
|
+ f"for {len(expectedSubtitleTrackIndices)} subtitle tracks."
|
||||||
|
)
|
||||||
|
|
||||||
|
for subtitleFilename in subtitleFilenames:
|
||||||
|
subtitleFilenameStem = os.path.splitext(subtitleFilename)[0]
|
||||||
|
sesld_result = (
|
||||||
|
None
|
||||||
|
if strict
|
||||||
|
else sesld_match.fullmatch(subtitleFilenameStem)
|
||||||
|
)
|
||||||
|
sld_result = (
|
||||||
|
None
|
||||||
|
if sesld_result is not None
|
||||||
|
else sld_match.fullmatch(subtitleFilenameStem)
|
||||||
|
)
|
||||||
|
|
||||||
|
if strict and sesld_result is None and sld_result is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Subtitle filename does not match the expected pattern: "
|
||||||
|
+ subtitleFilename
|
||||||
|
)
|
||||||
|
|
||||||
|
if sesld_result is not None:
|
||||||
|
|
||||||
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
|
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
|
||||||
if os.path.isfile(subtitleFilePath):
|
|
||||||
|
|
||||||
subtitleFileDescriptor = {}
|
subtitleFileDescriptor = {}
|
||||||
subtitleFileDescriptor["path"] = subtitleFilePath
|
subtitleFileDescriptor["path"] = subtitleFilePath
|
||||||
@@ -556,17 +606,18 @@ class MediaDescriptor:
|
|||||||
numCaptGroups = len(dispCaptGroups)
|
numCaptGroups = len(dispCaptGroups)
|
||||||
if numCaptGroups > 4:
|
if numCaptGroups > 4:
|
||||||
for groupIndex in range(numCaptGroups - 4):
|
for groupIndex in range(numCaptGroups - 4):
|
||||||
disp = TrackDisposition.fromIndicator(dispCaptGroups[groupIndex + 4])
|
disp = TrackDisposition.fromIndicator(
|
||||||
|
dispCaptGroups[groupIndex + 4]
|
||||||
|
)
|
||||||
if disp is not None:
|
if disp is not None:
|
||||||
dispSet.add(disp)
|
dispSet.add(disp)
|
||||||
subtitleFileDescriptor["disposition_set"] = dispSet
|
subtitleFileDescriptor["disposition_set"] = dispSet
|
||||||
|
|
||||||
subtitleFileDescriptors.append(subtitleFileDescriptor)
|
subtitleFileDescriptors.append(subtitleFileDescriptor)
|
||||||
|
|
||||||
if not sld_result is None:
|
if sld_result is not None:
|
||||||
|
|
||||||
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
|
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
|
||||||
if os.path.isfile(subtitleFilePath):
|
|
||||||
|
|
||||||
subtitleFileDescriptor = {}
|
subtitleFileDescriptor = {}
|
||||||
subtitleFileDescriptor["path"] = subtitleFilePath
|
subtitleFileDescriptor["path"] = subtitleFilePath
|
||||||
@@ -578,13 +629,52 @@ class MediaDescriptor:
|
|||||||
numCaptGroups = len(dispCaptGroups)
|
numCaptGroups = len(dispCaptGroups)
|
||||||
if numCaptGroups > 2:
|
if numCaptGroups > 2:
|
||||||
for groupIndex in range(numCaptGroups - 2):
|
for groupIndex in range(numCaptGroups - 2):
|
||||||
disp = TrackDisposition.fromIndicator(dispCaptGroups[groupIndex + 2])
|
disp = TrackDisposition.fromIndicator(
|
||||||
|
dispCaptGroups[groupIndex + 2]
|
||||||
|
)
|
||||||
if disp is not None:
|
if disp is not None:
|
||||||
dispSet.add(disp)
|
dispSet.add(disp)
|
||||||
subtitleFileDescriptor["disposition_set"] = dispSet
|
subtitleFileDescriptor["disposition_set"] = dispSet
|
||||||
|
|
||||||
subtitleFileDescriptors.append(subtitleFileDescriptor)
|
subtitleFileDescriptors.append(subtitleFileDescriptor)
|
||||||
|
|
||||||
|
if strict:
|
||||||
|
discoveredTrackIndices = [
|
||||||
|
descriptor['index'] for descriptor in subtitleFileDescriptors
|
||||||
|
]
|
||||||
|
duplicateTrackIndices = sorted(
|
||||||
|
{
|
||||||
|
trackIndex
|
||||||
|
for trackIndex in discoveredTrackIndices
|
||||||
|
if discoveredTrackIndices.count(trackIndex) > 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if duplicateTrackIndices:
|
||||||
|
duplicateDescription = ', '.join(
|
||||||
|
f"#{index}" for index in duplicateTrackIndices
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
"Multiple external subtitle files refer to subtitle track(s) "
|
||||||
|
+ duplicateDescription
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
|
||||||
|
unexpectedTrackIndices = sorted(
|
||||||
|
set(discoveredTrackIndices) - expectedSubtitleTrackIndices
|
||||||
|
)
|
||||||
|
if unexpectedTrackIndices:
|
||||||
|
unexpectedDescription = ', '.join(
|
||||||
|
f"#{index}" for index in unexpectedTrackIndices
|
||||||
|
)
|
||||||
|
expectedDescription = ', '.join(
|
||||||
|
f"#{index}" for index in sorted(expectedSubtitleTrackIndices)
|
||||||
|
) or 'none'
|
||||||
|
raise ValueError(
|
||||||
|
"External subtitle track index pattern does not match the media "
|
||||||
|
+ f"subtitle tracks: found {unexpectedDescription}; "
|
||||||
|
+ f"expected a subset of {expectedDescription}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
self.__logger.debug(f"searchSubtitleFiles(): Available subtitle files {subtitleFileDescriptors}")
|
self.__logger.debug(f"searchSubtitleFiles(): Available subtitle files {subtitleFileDescriptors}")
|
||||||
|
|
||||||
@@ -598,12 +688,19 @@ class MediaDescriptor:
|
|||||||
season: int = -1,
|
season: int = -1,
|
||||||
episode: int = -1,
|
episode: int = -1,
|
||||||
preserve_dispositions: bool = False,
|
preserve_dispositions: bool = False,
|
||||||
|
extension: str = SUBTITLE_FILE_EXTENSION,
|
||||||
|
strict: bool = False,
|
||||||
):
|
):
|
||||||
|
|
||||||
# click.echo(f"Season: {season} Episode: {episode}")
|
# click.echo(f"Season: {season} Episode: {episode}")
|
||||||
self.__logger.debug(f"importSubtitles(): Season: {season} Episode: {episode}")
|
self.__logger.debug(f"importSubtitles(): Season: {season} Episode: {episode}")
|
||||||
|
|
||||||
availableFileSubtitleDescriptors = self.searchSubtitleFiles(searchDirectory, prefix)
|
availableFileSubtitleDescriptors = self.searchSubtitleFiles(
|
||||||
|
searchDirectory,
|
||||||
|
prefix,
|
||||||
|
extension=extension,
|
||||||
|
strict=strict,
|
||||||
|
)
|
||||||
|
|
||||||
self.__logger.debug(f"importSubtitles(): availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}")
|
self.__logger.debug(f"importSubtitles(): availableFileSubtitleDescriptors: {availableFileSubtitleDescriptors}")
|
||||||
|
|
||||||
@@ -616,7 +713,8 @@ class MediaDescriptor:
|
|||||||
[
|
[
|
||||||
d
|
d
|
||||||
for d in availableFileSubtitleDescriptors
|
for d in availableFileSubtitleDescriptors
|
||||||
if ((season == -1 and episode == -1)
|
if (strict
|
||||||
|
or (season == -1 and episode == -1)
|
||||||
or (
|
or (
|
||||||
d.get("season") == int(season)
|
d.get("season") == int(season)
|
||||||
and d.get("episode") == int(episode)
|
and d.get("episode") == int(episode)
|
||||||
@@ -630,6 +728,7 @@ class MediaDescriptor:
|
|||||||
|
|
||||||
self.__logger.debug(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}")
|
self.__logger.debug(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}")
|
||||||
|
|
||||||
|
importedTrackIndices = []
|
||||||
for msfd in matchingSubtitleFileDescriptors:
|
for msfd in matchingSubtitleFileDescriptors:
|
||||||
matchingSubtitleTrackDescriptor = [s for s in subtitleTracks if s.getIndex() == msfd["index"]]
|
matchingSubtitleTrackDescriptor = [s for s in subtitleTracks if s.getIndex() == msfd["index"]]
|
||||||
if matchingSubtitleTrackDescriptor:
|
if matchingSubtitleTrackDescriptor:
|
||||||
@@ -643,6 +742,19 @@ class MediaDescriptor:
|
|||||||
matchingTrack.getTags()["language"] = msfd["language"]
|
matchingTrack.getTags()["language"] = msfd["language"]
|
||||||
if msfd["disposition_set"] and not preserve_dispositions:
|
if msfd["disposition_set"] and not preserve_dispositions:
|
||||||
matchingTrack.setDispositionSet(msfd["disposition_set"])
|
matchingTrack.setDispositionSet(msfd["disposition_set"])
|
||||||
|
importedTrackIndices.append(matchingTrack.getIndex())
|
||||||
|
|
||||||
|
expectedTrackIndices = sorted(
|
||||||
|
subtitleTrack.getIndex() for subtitleTrack in subtitleTracks
|
||||||
|
)
|
||||||
|
importedTrackIndices = sorted(set(importedTrackIndices))
|
||||||
|
return {
|
||||||
|
"candidate_count": len(availableFileSubtitleDescriptors),
|
||||||
|
"imported_track_indices": importedTrackIndices,
|
||||||
|
"missing_track_indices": sorted(
|
||||||
|
set(expectedTrackIndices) - set(importedTrackIndices)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def getConfiguration(self, label: str = ''):
|
def getConfiguration(self, label: str = ''):
|
||||||
|
|||||||
@@ -421,6 +421,59 @@ class SubtrackMappingBundleTests(unittest.TestCase):
|
|||||||
self.assertIn("external subtitle payload", extracted_subtitle)
|
self.assertIn("external subtitle payload", extracted_subtitle)
|
||||||
self.assertNotIn("embedded subtitle payload", extracted_subtitle)
|
self.assertNotIn("embedded subtitle payload", extracted_subtitle)
|
||||||
|
|
||||||
|
def test_subtitle_directory_without_prefix_uses_source_basename(self):
|
||||||
|
source_filename = "basename_substitute.mkv"
|
||||||
|
subtitle_directory = self.workdir / "sidecars"
|
||||||
|
subtitle_directory.mkdir()
|
||||||
|
source_path = create_source_fixture(
|
||||||
|
self.workdir,
|
||||||
|
source_filename,
|
||||||
|
[
|
||||||
|
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||||
|
SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng"),
|
||||||
|
SourceTrackSpec(
|
||||||
|
TrackType.SUBTITLE,
|
||||||
|
identity="embedded-subtitle",
|
||||||
|
language="eng",
|
||||||
|
subtitle_lines=("embedded subtitle payload",),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
write_vtt(
|
||||||
|
subtitle_directory / "basename_substitute_2_deu_DEF.vtt",
|
||||||
|
("external subtitle payload",),
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = run_ffx_convert(
|
||||||
|
self.workdir,
|
||||||
|
self.home_dir,
|
||||||
|
self.database_path,
|
||||||
|
"--video-encoder",
|
||||||
|
"copy",
|
||||||
|
"--no-pattern",
|
||||||
|
"--no-tmdb",
|
||||||
|
"--no-prompt",
|
||||||
|
"--no-signature",
|
||||||
|
"--subtitle-directory",
|
||||||
|
str(subtitle_directory),
|
||||||
|
str(source_path),
|
||||||
|
)
|
||||||
|
self.assertCompleted(completed)
|
||||||
|
self.assertIn("matched subtitle tracks #2", completed.stdout)
|
||||||
|
self.assertIn("Substituting subtitle stream #2", completed.stdout)
|
||||||
|
|
||||||
|
output_path = expected_output_path(self.workdir, source_filename)
|
||||||
|
subtitle_stream = [
|
||||||
|
stream
|
||||||
|
for stream in ffprobe_json(output_path)["streams"]
|
||||||
|
if stream["codec_type"] == "subtitle"
|
||||||
|
][0]
|
||||||
|
self.assertEqual("deu", get_tag(subtitle_stream, "language"))
|
||||||
|
|
||||||
|
extracted_subtitle = extract_first_subtitle_text(self.workdir, output_path)
|
||||||
|
self.assertIn("external subtitle payload", extracted_subtitle)
|
||||||
|
self.assertNotIn("embedded subtitle payload", extracted_subtitle)
|
||||||
|
|
||||||
def test_subtitle_prefix_uses_configured_base_directory_when_directory_is_omitted(self):
|
def test_subtitle_prefix_uses_configured_base_directory_when_directory_is_omitted(self):
|
||||||
source_filename = "substitute_default_s01e01.mkv"
|
source_filename = "substitute_default_s01e01.mkv"
|
||||||
subtitle_prefix = "substitute_default"
|
subtitle_prefix = "substitute_default"
|
||||||
|
|||||||
@@ -35,7 +35,13 @@ if pytest is not None:
|
|||||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||||
|
|
||||||
|
|
||||||
def run_ffx_unmux(workdir: Path, home_dir: Path, database_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
def run_ffx_unmux(
|
||||||
|
workdir: Path,
|
||||||
|
home_dir: Path,
|
||||||
|
database_path: Path,
|
||||||
|
*args: str,
|
||||||
|
input_text: str | None = None,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["HOME"] = str(home_dir)
|
env["HOME"] = str(home_dir)
|
||||||
existing_pythonpath = env.get("PYTHONPATH", "")
|
existing_pythonpath = env.get("PYTHONPATH", "")
|
||||||
@@ -50,7 +56,14 @@ def run_ffx_unmux(workdir: Path, home_dir: Path, database_path: Path, *args: str
|
|||||||
"unmux",
|
"unmux",
|
||||||
*args,
|
*args,
|
||||||
]
|
]
|
||||||
return subprocess.run(command, cwd=workdir, env=env, capture_output=True, text=True)
|
return subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=workdir,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
input=input_text,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UnmuxCliTests(unittest.TestCase):
|
class UnmuxCliTests(unittest.TestCase):
|
||||||
@@ -161,6 +174,7 @@ class UnmuxCliTests(unittest.TestCase):
|
|||||||
self.home_dir,
|
self.home_dir,
|
||||||
self.database_path,
|
self.database_path,
|
||||||
"--subtitles-only",
|
"--subtitles-only",
|
||||||
|
"--yes",
|
||||||
"--label",
|
"--label",
|
||||||
"dball",
|
"dball",
|
||||||
str(source_path),
|
str(source_path),
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from pathlib import Path
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import click
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +19,10 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
|
|
||||||
|
|
||||||
from ffx import cli # noqa: E402
|
from ffx import cli # noqa: E402
|
||||||
|
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||||
|
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||||
|
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
||||||
|
from ffx.track_type import TrackType # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
class SubtitleDirectoryCliTests(unittest.TestCase):
|
class SubtitleDirectoryCliTests(unittest.TestCase):
|
||||||
@@ -48,6 +54,35 @@ class SubtitleDirectoryCliTests(unittest.TestCase):
|
|||||||
env={**os.environ, "HOME": str(self.home_dir)},
|
env={**os.environ, "HOME": str(self.home_dir)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def make_subtitle_descriptor(self, indices=(2, 3, 4)) -> MediaDescriptor:
|
||||||
|
return MediaDescriptor(
|
||||||
|
context={"logger": get_ffx_logger()},
|
||||||
|
track_descriptors=[
|
||||||
|
TrackDescriptor(
|
||||||
|
index=index,
|
||||||
|
source_index=index,
|
||||||
|
sub_index=subIndex,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
)
|
||||||
|
for subIndex, index in enumerate(indices)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_import_context(
|
||||||
|
self,
|
||||||
|
subtitleDirectory: Path,
|
||||||
|
noPrompt: bool,
|
||||||
|
yes: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"subtitle_match_source_basename": True,
|
||||||
|
"subtitle_directory": str(subtitleDirectory),
|
||||||
|
"subtitle_prefix": "",
|
||||||
|
"subtitle_extension": "vtt",
|
||||||
|
"no_prompt": noPrompt,
|
||||||
|
"yes": yes,
|
||||||
|
}
|
||||||
|
|
||||||
def test_subtitle_prefix_without_directory_or_default_fails(self):
|
def test_subtitle_prefix_without_directory_or_default_fails(self):
|
||||||
result = self.invoke_convert("--subtitle-prefix", "dball")
|
result = self.invoke_convert("--subtitle-prefix", "dball")
|
||||||
|
|
||||||
@@ -79,6 +114,153 @@ class SubtitleDirectoryCliTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(0, result.exit_code, result.output)
|
self.assertEqual(0, result.exit_code, result.output)
|
||||||
|
|
||||||
|
def test_explicit_directory_without_prefix_enables_basename_matching(self):
|
||||||
|
explicitSubtitleDirectory = self.home_dir / "manual-subtitles"
|
||||||
|
explicitSubtitleDirectory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
enabled, directory, prefix, matchBasename = cli.resolveSubtitleImportOptions(
|
||||||
|
{},
|
||||||
|
str(explicitSubtitleDirectory),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(enabled)
|
||||||
|
self.assertEqual(str(explicitSubtitleDirectory), directory)
|
||||||
|
self.assertEqual("", prefix)
|
||||||
|
self.assertTrue(matchBasename)
|
||||||
|
|
||||||
|
def test_subtitle_extension_accepts_optional_leading_dot(self):
|
||||||
|
self.assertEqual("mkv", cli.normalizeSubtitleExtension(None, None, "mkv"))
|
||||||
|
self.assertEqual("mkv", cli.normalizeSubtitleExtension(None, None, ".mkv"))
|
||||||
|
|
||||||
|
def test_subtitle_extension_rejects_multiple_leading_dots(self):
|
||||||
|
with self.assertRaises(click.BadParameter):
|
||||||
|
cli.normalizeSubtitleExtension(None, None, "..mkv")
|
||||||
|
|
||||||
|
def test_complete_basename_set_does_not_prompt(self):
|
||||||
|
subtitleDirectory = self.home_dir / "complete-subtitles"
|
||||||
|
subtitleDirectory.mkdir()
|
||||||
|
for basename in (
|
||||||
|
"A2_t01_2_deu_DEF",
|
||||||
|
"A2_t01_3_eng",
|
||||||
|
"A2_t01_4_eng",
|
||||||
|
):
|
||||||
|
(subtitleDirectory / f"{basename}.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
descriptor = self.make_subtitle_descriptor()
|
||||||
|
context = self.make_import_context(subtitleDirectory, noPrompt=True)
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mockedConfirm:
|
||||||
|
result = cli.importExternalSubtitles(
|
||||||
|
context,
|
||||||
|
descriptor,
|
||||||
|
"A2_t01",
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([], result["missing_track_indices"])
|
||||||
|
mockedConfirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_incomplete_basename_set_fails_with_no_prompt(self):
|
||||||
|
descriptor = self.make_subtitle_descriptor()
|
||||||
|
subtitleDirectory = self.home_dir / "partial-subtitles"
|
||||||
|
subtitleDirectory.mkdir()
|
||||||
|
(subtitleDirectory / "episode_2_deu.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
context = self.make_import_context(subtitleDirectory, noPrompt=True)
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mockedConfirm:
|
||||||
|
with self.assertRaisesRegex(click.ClickException, "--no-prompt is set"):
|
||||||
|
cli.importExternalSubtitles(
|
||||||
|
context,
|
||||||
|
descriptor,
|
||||||
|
"episode",
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
mockedConfirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_incomplete_basename_set_can_be_confirmed(self):
|
||||||
|
descriptor = self.make_subtitle_descriptor()
|
||||||
|
subtitleDirectory = self.home_dir / "partial-subtitles"
|
||||||
|
subtitleDirectory.mkdir()
|
||||||
|
(subtitleDirectory / "episode_2_deu.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
context = self.make_import_context(subtitleDirectory, noPrompt=False)
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm", return_value=True) as mockedConfirm:
|
||||||
|
result = cli.importExternalSubtitles(
|
||||||
|
context,
|
||||||
|
descriptor,
|
||||||
|
"episode",
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([3, 4], result["missing_track_indices"])
|
||||||
|
mockedConfirm.assert_called_once()
|
||||||
|
|
||||||
|
def test_incomplete_basename_set_with_yes_does_not_prompt(self):
|
||||||
|
descriptor = self.make_subtitle_descriptor()
|
||||||
|
subtitleDirectory = self.home_dir / "partial-subtitles"
|
||||||
|
subtitleDirectory.mkdir()
|
||||||
|
(subtitleDirectory / "episode_2_deu.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
context = self.make_import_context(
|
||||||
|
subtitleDirectory,
|
||||||
|
noPrompt=False,
|
||||||
|
yes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mockedConfirm:
|
||||||
|
result = cli.importExternalSubtitles(
|
||||||
|
context,
|
||||||
|
descriptor,
|
||||||
|
"episode",
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([2], result["imported_track_indices"])
|
||||||
|
self.assertEqual([3, 4], result["missing_track_indices"])
|
||||||
|
mockedConfirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_yes_takes_precedence_over_no_prompt_for_incomplete_set(self):
|
||||||
|
descriptor = self.make_subtitle_descriptor()
|
||||||
|
subtitleDirectory = self.home_dir / "partial-subtitles"
|
||||||
|
subtitleDirectory.mkdir()
|
||||||
|
(subtitleDirectory / "episode_2_deu.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
context = self.make_import_context(
|
||||||
|
subtitleDirectory,
|
||||||
|
noPrompt=True,
|
||||||
|
yes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mockedConfirm:
|
||||||
|
result = cli.importExternalSubtitles(
|
||||||
|
context,
|
||||||
|
descriptor,
|
||||||
|
"episode",
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([3, 4], result["missing_track_indices"])
|
||||||
|
mockedConfirm.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ class UnmuxOutputDirectoryTests(unittest.TestCase):
|
|||||||
self.assertEqual(str(Path(tempdir) / "subtitles" / "dball"), resolved_output_directory)
|
self.assertEqual(str(Path(tempdir) / "subtitles" / "dball"), resolved_output_directory)
|
||||||
self.assertTrue(should_create)
|
self.assertTrue(should_create)
|
||||||
|
|
||||||
def test_explicit_output_directory_keeps_existing_behavior(self):
|
def test_explicit_output_directory_requires_directory(self):
|
||||||
with tempfile.TemporaryDirectory() as tempdir:
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
context = {
|
context = {
|
||||||
"config": StaticConfig(str(Path(tempdir) / "subtitles")),
|
"config": StaticConfig(str(Path(tempdir) / "subtitles")),
|
||||||
@@ -57,7 +58,7 @@ class UnmuxOutputDirectoryTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(explicit_output_directory, resolved_output_directory)
|
self.assertEqual(explicit_output_directory, resolved_output_directory)
|
||||||
self.assertFalse(should_create)
|
self.assertTrue(should_create)
|
||||||
|
|
||||||
def test_subtitles_only_without_label_keeps_existing_behavior(self):
|
def test_subtitles_only_without_label_keeps_existing_behavior(self):
|
||||||
context = {
|
context = {
|
||||||
@@ -89,6 +90,110 @@ class UnmuxOutputDirectoryTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("subtitlesDirectory default", str(caught.exception))
|
self.assertIn("subtitlesDirectory default", str(caught.exception))
|
||||||
|
|
||||||
|
def test_missing_output_directory_can_be_confirmed_and_created_with_parents(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_directory = Path(tempdir) / "missing" / "parents" / "manual"
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm", return_value=True) as mocked_confirm:
|
||||||
|
created = cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": False},
|
||||||
|
str(output_directory),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(created)
|
||||||
|
self.assertTrue(output_directory.is_dir())
|
||||||
|
mocked_confirm.assert_called_once()
|
||||||
|
|
||||||
|
def test_tty_carriage_return_accepts_default_directory_creation(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_directory = Path(tempdir) / "missing" / "manual"
|
||||||
|
|
||||||
|
with patch("ffx.cli.sys.stdin.isatty", return_value=True), patch(
|
||||||
|
"ffx.cli.click.getchar",
|
||||||
|
return_value="\r",
|
||||||
|
) as mocked_getchar, patch("ffx.cli.click.confirm") as mocked_confirm:
|
||||||
|
created = cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": False},
|
||||||
|
str(output_directory),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(created)
|
||||||
|
self.assertTrue(output_directory.is_dir())
|
||||||
|
mocked_getchar.assert_called_once()
|
||||||
|
mocked_confirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_yes_creates_missing_output_directory_without_prompt(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_directory = Path(tempdir) / "missing" / "parents" / "manual"
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mocked_confirm:
|
||||||
|
created = cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": False, "yes": True},
|
||||||
|
str(output_directory),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(created)
|
||||||
|
self.assertTrue(output_directory.is_dir())
|
||||||
|
mocked_confirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_missing_output_directory_can_be_rejected(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_directory = Path(tempdir) / "missing" / "manual"
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm", return_value=False) as mocked_confirm:
|
||||||
|
with self.assertRaises(click.ClickException) as caught:
|
||||||
|
cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": False},
|
||||||
|
str(output_directory),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(output_directory.exists())
|
||||||
|
self.assertIn("aborted by user", str(caught.exception))
|
||||||
|
mocked_confirm.assert_called_once()
|
||||||
|
|
||||||
|
def test_existing_output_directory_does_not_prompt(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_directory = Path(tempdir) / "manual"
|
||||||
|
output_directory.mkdir()
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mocked_confirm:
|
||||||
|
created = cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": False},
|
||||||
|
str(output_directory),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(created)
|
||||||
|
mocked_confirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_existing_non_directory_output_path_fails_without_prompt(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_path = Path(tempdir) / "manual"
|
||||||
|
output_path.write_text("not a directory", encoding="utf-8")
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mocked_confirm:
|
||||||
|
with self.assertRaises(click.ClickException) as caught:
|
||||||
|
cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": False},
|
||||||
|
str(output_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("not a directory", str(caught.exception))
|
||||||
|
mocked_confirm.assert_not_called()
|
||||||
|
|
||||||
|
def test_dry_run_does_not_prompt_or_create_missing_output_directory(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
output_directory = Path(tempdir) / "missing" / "manual"
|
||||||
|
|
||||||
|
with patch("ffx.cli.click.confirm") as mocked_confirm:
|
||||||
|
created = cli.ensureUnmuxOutputDirectory(
|
||||||
|
{"dry_run": True},
|
||||||
|
str(output_directory),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(created)
|
||||||
|
self.assertFalse(output_directory.exists())
|
||||||
|
mocked_confirm.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -20,18 +20,32 @@ from ffx.track_type import TrackType # noqa: E402
|
|||||||
|
|
||||||
|
|
||||||
class MediaDescriptorImportSubtitlesTests(unittest.TestCase):
|
class MediaDescriptorImportSubtitlesTests(unittest.TestCase):
|
||||||
def make_descriptor(self) -> MediaDescriptor:
|
COMPLETE_SIDECAR_NAMES = (
|
||||||
|
"A2_t01_2_deu_DEF",
|
||||||
|
"A2_t01_3_eng",
|
||||||
|
"A2_t01_4_eng",
|
||||||
|
)
|
||||||
|
|
||||||
|
def write_complete_sidecar_set(self, directory: str, extension: str) -> None:
|
||||||
|
for basename in self.COMPLETE_SIDECAR_NAMES:
|
||||||
|
(Path(directory) / f"{basename}.{extension}").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_descriptor(self, indices=(3,)) -> MediaDescriptor:
|
||||||
return MediaDescriptor(
|
return MediaDescriptor(
|
||||||
context={"logger": get_ffx_logger()},
|
context={"logger": get_ffx_logger()},
|
||||||
track_descriptors=[
|
track_descriptors=[
|
||||||
TrackDescriptor(
|
TrackDescriptor(
|
||||||
index=3,
|
index=index,
|
||||||
source_index=3,
|
source_index=index,
|
||||||
sub_index=0,
|
sub_index=subIndex,
|
||||||
track_type=TrackType.SUBTITLE,
|
track_type=TrackType.SUBTITLE,
|
||||||
tags={"language": "eng", "title": "DB Subtitle"},
|
tags={"language": "eng", "title": "DB Subtitle"},
|
||||||
disposition_set={TrackDisposition.DEFAULT},
|
disposition_set={TrackDisposition.DEFAULT},
|
||||||
)
|
)
|
||||||
|
for subIndex, index in enumerate(indices)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,6 +88,114 @@ class MediaDescriptorImportSubtitlesTests(unittest.TestCase):
|
|||||||
self.assertEqual("deu", track.getTags()["language"])
|
self.assertEqual("deu", track.getTags()["language"])
|
||||||
self.assertEqual({TrackDisposition.FORCED}, track.getDispositionSet())
|
self.assertEqual({TrackDisposition.FORCED}, track.getDispositionSet())
|
||||||
|
|
||||||
|
def test_strict_basename_import_recognizes_vtt_asset_set(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2, 3, 4))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
self.write_complete_sidecar_set(tmpdir, "vtt")
|
||||||
|
result = descriptor.importSubtitles(
|
||||||
|
tmpdir,
|
||||||
|
"A2_t01",
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(3, result["candidate_count"])
|
||||||
|
self.assertEqual([2, 3, 4], result["imported_track_indices"])
|
||||||
|
self.assertEqual([], result["missing_track_indices"])
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"A2_t01_2_deu_DEF.vtt",
|
||||||
|
"A2_t01_3_eng.vtt",
|
||||||
|
"A2_t01_4_eng.vtt",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Path(track.getExternalSourceFilePath()).name
|
||||||
|
for track in descriptor.getSubtitleTracks()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_strict_basename_import_accepts_dotted_mkv_extension(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2, 3, 4))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
self.write_complete_sidecar_set(tmpdir, "mkv")
|
||||||
|
result = descriptor.importSubtitles(
|
||||||
|
tmpdir,
|
||||||
|
"A2_t01",
|
||||||
|
extension=".mkv",
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(3, result["candidate_count"])
|
||||||
|
self.assertEqual([2, 3, 4], result["imported_track_indices"])
|
||||||
|
self.assertEqual([], result["missing_track_indices"])
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
track.getExternalSourceFilePath().endswith(".mkv")
|
||||||
|
for track in descriptor.getSubtitleTracks()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_strict_basename_import_reports_missing_tracks(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2, 3, 4))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sidecarPath = Path(tmpdir) / "episode_2_deu.vtt"
|
||||||
|
sidecarPath.write_text("WEBVTT\n\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = descriptor.importSubtitles(
|
||||||
|
tmpdir,
|
||||||
|
"episode",
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([2], result["imported_track_indices"])
|
||||||
|
self.assertEqual([3, 4], result["missing_track_indices"])
|
||||||
|
|
||||||
|
def test_strict_basename_import_rejects_too_many_files(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2,))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
for filename in ("episode_2_deu.vtt", "episode_3_eng.vtt"):
|
||||||
|
(Path(tmpdir) / filename).write_text("WEBVTT\n\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "2 matching .* for 1 subtitle tracks"):
|
||||||
|
descriptor.importSubtitles(tmpdir, "episode", strict=True)
|
||||||
|
|
||||||
|
def test_strict_basename_import_rejects_unknown_track_index(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2, 3, 4))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
(Path(tmpdir) / "episode_9_eng.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "track index pattern does not match"):
|
||||||
|
descriptor.importSubtitles(tmpdir, "episode", strict=True)
|
||||||
|
|
||||||
|
def test_strict_basename_import_rejects_malformed_filtered_filename(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2, 3, 4))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
(Path(tmpdir) / "episode_s01e01_2_deu.vtt").write_text(
|
||||||
|
"WEBVTT\n\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "expected pattern"):
|
||||||
|
descriptor.importSubtitles(tmpdir, "episode", strict=True)
|
||||||
|
|
||||||
|
def test_strict_basename_import_rejects_duplicate_track_indices(self):
|
||||||
|
descriptor = self.make_descriptor(indices=(2, 3, 4))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
for filename in ("episode_2_deu.vtt", "episode_2_eng.vtt"):
|
||||||
|
(Path(tmpdir) / filename).write_text("WEBVTT\n\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "Multiple external subtitle files"):
|
||||||
|
descriptor.importSubtitles(tmpdir, "episode", strict=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user