Prefixless subtitle sidecar files

This commit is contained in:
Javanaut
2026-06-15 12:43:34 +02:00
parent f794f822f2
commit 176cfa06eb
7 changed files with 676 additions and 72 deletions

View File

@@ -33,11 +33,33 @@ 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
* ``--subtitle-directory`` for source-basename sidecar subtitle imports
* ``--subtitle-prefix`` for explicit or configured-prefix subtitle imports
* ``--subtitle-extension`` to select the imported sidecar format (default:
``vtt``)
* ``--yes`` to accept a valid partial sidecar set without prompting
* ``--copy-video`` or ``--copy-audio`` to preserve selected stream types
* ``--rename-only`` for filename normalization without media rewriting
Directory-only subtitle import matches the source basename. For example,
``A2_t01.mkv`` discovers files such as ``A2_t01_2_deu_DEF.vtt`` in the
provided directory:
.. code-block:: sh
ffx convert --subtitle-directory /path/to/subtitles A2_t01.mkv
Select a different sidecar set by extension, with or without the leading dot:
.. code-block:: sh
ffx convert --subtitle-directory /path/to/subtitles \
--subtitle-extension .mkv A2_t01.mkv
When only some source subtitle tracks have matching sidecar files, conversion
asks for confirmation. Use ``--yes`` to substitute that valid subset without
prompting. ``--yes`` also permits this case when ``--no-prompt`` is set.
Manage Shows And Patterns
-------------------------

View File

@@ -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-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-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
@@ -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.
- 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.
- 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.
## Test Notes

View File

@@ -41,13 +41,17 @@ CPU_OPTION_HELP = (
+ "Omit to disable; 0 also disables."
)
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."
)
SUBTITLE_PREFIX_OPTION_HELP = (
"Subtitle filename prefix. Requires --subtitle-directory, or a configured "
+ "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 = (
"Write extracted streams here. When omitted together with --subtitles-only and "
+ "--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
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:
if value is None:
return None
@@ -146,11 +162,21 @@ def resolveSubtitleImportOptions(context, subtitleDirectory, subtitlePrefix):
else ''
)
if not resolvedSubtitlePrefix:
return False, resolvedSubtitleDirectory, resolvedSubtitlePrefix
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()
if not configuredSubtitlesBaseDirectory:
@@ -170,7 +196,85 @@ def resolveSubtitleImportOptions(context, subtitleDirectory, subtitlePrefix):
+ 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):
@@ -974,6 +1078,14 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
@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-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('--title', type=str, multiple=True, help='Set stream title. Use format <stream index>:<title>')
@@ -1034,6 +1146,12 @@ def checkUniqueDispositions(context, mediaDescriptor: MediaDescriptor):
@click.option("--dont-pass-dispositions", 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("--keep-mkvmerge-metadata", is_flag=True, default=False)
@@ -1070,6 +1188,7 @@ def convert(ctx,
subtitle_directory,
subtitle_prefix,
subtitle_extension,
language,
title,
@@ -1108,6 +1227,7 @@ def convert(ctx,
no_pattern,
dont_pass_dispositions,
no_prompt,
yes,
no_signature,
keep_mkvmerge_metadata,
@@ -1162,6 +1282,7 @@ def convert(ctx,
context['use_tmdb'] = not no_tmdb
context['use_pattern'] = not no_pattern
context['no_prompt'] = no_prompt
context['yes'] = yes
context['no_signature'] = no_signature
context['keep_mkvmerge_metadata'] = keep_mkvmerge_metadata
@@ -1180,6 +1301,7 @@ def convert(ctx,
context['import_subtitles'],
resolvedSubtitleDirectory,
resolvedSubtitlePrefix,
context['subtitle_match_source_basename'],
) = resolveSubtitleImportOptions(
context,
subtitle_directory,
@@ -1188,6 +1310,7 @@ def convert(ctx,
if context['import_subtitles']:
context['subtitle_directory'] = resolvedSubtitleDirectory
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]
@@ -1431,10 +1554,13 @@ def convert(ctx,
currentShowDescriptor = None
if context['import_subtitles']:
sourceMediaDescriptor.importSubtitles(context['subtitle_directory'],
context['subtitle_prefix'],
showSeason,
showEpisode)
importExternalSubtitles(
context,
sourceMediaDescriptor,
sourceFileBasename,
showSeason,
showEpisode,
)
if cliOverrides:
sourceMediaDescriptor.applyOverrides(cliOverrides)
@@ -1478,11 +1604,14 @@ def convert(ctx,
if context['import_subtitles']:
targetMediaDescriptor.importSubtitles(context['subtitle_directory'],
context['subtitle_prefix'],
showSeason,
showEpisode,
preserve_dispositions=True)
importExternalSubtitles(
context,
targetMediaDescriptor,
sourceFileBasename,
showSeason,
showEpisode,
preserveDispositions=True,
)
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")

View File

@@ -431,10 +431,13 @@ class MediaDescriptor:
importedFilePath = td.getExternalSourceFilePath()
if importedFilePath:
self.__logger.info(f"Substituting subtitle stream #{td.getIndex()} "
+ f"({td.getType().label()}:{td.getSubIndex()}) "
+ f"with import from file {td.getExternalSourceFilePath()}")
substitutionMessage = (
f"Substituting subtitle stream #{td.getIndex()} "
+ f"({td.getType().label()}:{td.getSubIndex()}) "
+ f"with import from file {td.getExternalSourceFilePath()}"
)
click.echo(substitutionMessage)
self.__logger.debug(substitutionMessage)
importFileTokens += [
"-i",
@@ -524,66 +527,153 @@ class MediaDescriptor:
return inputMappingTokens
def searchSubtitleFiles(self, searchDirectory, prefix):
def searchSubtitleFiles(
self,
searchDirectory,
prefix,
extension=SUBTITLE_FILE_EXTENSION,
strict=False,
):
sesld_match = re.compile(f"{prefix}_{MediaDescriptor.SEASON_EPISODE_STREAM_LANGUAGE_DISPOSITIONS_MATCH}")
sld_match = re.compile(f"{prefix}_{MediaDescriptor.STREAM_LANGUAGE_DISPOSITIONS_MATCH}")
normalizedExtension = str(extension).strip().lower()
if normalizedExtension.startswith('.'):
normalizedExtension = normalizedExtension[1:]
escapedPrefix = re.escape(prefix)
sesld_match = re.compile(
f"{escapedPrefix}_{MediaDescriptor.SEASON_EPISODE_STREAM_LANGUAGE_DISPOSITIONS_MATCH}"
)
sld_match = re.compile(
f"{escapedPrefix}_{MediaDescriptor.STREAM_LANGUAGE_DISPOSITIONS_MATCH}"
)
subtitleFileDescriptors = []
subtitleFilenames = []
for subtitleFilename in os.listdir(searchDirectory):
if subtitleFilename.startswith(prefix) and subtitleFilename.endswith(
"." + MediaDescriptor.SUBTITLE_FILE_EXTENSION
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)
sesld_result = sesld_match.search(subtitleFilename)
sld_result = None if not sesld_result is None else sld_match.search(subtitleFilename)
if not sesld_result is None:
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."
)
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
if os.path.isfile(subtitleFilePath):
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)
)
subtitleFileDescriptor = {}
subtitleFileDescriptor["path"] = subtitleFilePath
subtitleFileDescriptor["season"] = int(sesld_result.group(1))
subtitleFileDescriptor["episode"] = int(sesld_result.group(2))
subtitleFileDescriptor["index"] = int(sesld_result.group(3))
subtitleFileDescriptor["language"] = sesld_result.group(4)
if strict and sesld_result is None and sld_result is None:
raise ValueError(
f"Subtitle filename does not match the expected pattern: "
+ subtitleFilename
)
dispSet = set()
dispCaptGroups = sesld_result.groups()
numCaptGroups = len(dispCaptGroups)
if numCaptGroups > 4:
for groupIndex in range(numCaptGroups - 4):
disp = TrackDisposition.fromIndicator(dispCaptGroups[groupIndex + 4])
if disp is not None:
dispSet.add(disp)
subtitleFileDescriptor["disposition_set"] = dispSet
if sesld_result is not None:
subtitleFileDescriptors.append(subtitleFileDescriptor)
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
if not sld_result is None:
subtitleFileDescriptor = {}
subtitleFileDescriptor["path"] = subtitleFilePath
subtitleFileDescriptor["season"] = int(sesld_result.group(1))
subtitleFileDescriptor["episode"] = int(sesld_result.group(2))
subtitleFileDescriptor["index"] = int(sesld_result.group(3))
subtitleFileDescriptor["language"] = sesld_result.group(4)
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
if os.path.isfile(subtitleFilePath):
dispSet = set()
dispCaptGroups = sesld_result.groups()
numCaptGroups = len(dispCaptGroups)
if numCaptGroups > 4:
for groupIndex in range(numCaptGroups - 4):
disp = TrackDisposition.fromIndicator(
dispCaptGroups[groupIndex + 4]
)
if disp is not None:
dispSet.add(disp)
subtitleFileDescriptor["disposition_set"] = dispSet
subtitleFileDescriptor = {}
subtitleFileDescriptor["path"] = subtitleFilePath
subtitleFileDescriptor["index"] = int(sld_result.group(1))
subtitleFileDescriptor["language"] = sld_result.group(2)
subtitleFileDescriptors.append(subtitleFileDescriptor)
dispSet = set()
dispCaptGroups = sld_result.groups()
numCaptGroups = len(dispCaptGroups)
if numCaptGroups > 2:
for groupIndex in range(numCaptGroups - 2):
disp = TrackDisposition.fromIndicator(dispCaptGroups[groupIndex + 2])
if disp is not None:
dispSet.add(disp)
subtitleFileDescriptor["disposition_set"] = dispSet
if sld_result is not None:
subtitleFileDescriptors.append(subtitleFileDescriptor)
subtitleFilePath = os.path.join(searchDirectory, subtitleFilename)
subtitleFileDescriptor = {}
subtitleFileDescriptor["path"] = subtitleFilePath
subtitleFileDescriptor["index"] = int(sld_result.group(1))
subtitleFileDescriptor["language"] = sld_result.group(2)
dispSet = set()
dispCaptGroups = sld_result.groups()
numCaptGroups = len(dispCaptGroups)
if numCaptGroups > 2:
for groupIndex in range(numCaptGroups - 2):
disp = TrackDisposition.fromIndicator(
dispCaptGroups[groupIndex + 2]
)
if disp is not None:
dispSet.add(disp)
subtitleFileDescriptor["disposition_set"] = dispSet
subtitleFileDescriptors.append(subtitleFileDescriptor)
if strict:
discoveredTrackIndices = [
descriptor['index'] for descriptor in subtitleFileDescriptors
]
duplicateTrackIndices = sorted(
{
trackIndex
for trackIndex in discoveredTrackIndices
if discoveredTrackIndices.count(trackIndex) > 1
}
)
if duplicateTrackIndices:
duplicateDescription = ', '.join(
f"#{index}" for index in duplicateTrackIndices
)
raise ValueError(
"Multiple external subtitle files refer to subtitle track(s) "
+ duplicateDescription
+ "."
)
unexpectedTrackIndices = sorted(
set(discoveredTrackIndices) - expectedSubtitleTrackIndices
)
if unexpectedTrackIndices:
unexpectedDescription = ', '.join(
f"#{index}" for index in unexpectedTrackIndices
)
expectedDescription = ', '.join(
f"#{index}" for index in sorted(expectedSubtitleTrackIndices)
) or 'none'
raise ValueError(
"External subtitle track index pattern does not match the media "
+ f"subtitle tracks: found {unexpectedDescription}; "
+ f"expected a subset of {expectedDescription}."
)
self.__logger.debug(f"searchSubtitleFiles(): Available subtitle files {subtitleFileDescriptors}")
@@ -598,12 +688,19 @@ class MediaDescriptor:
season: int = -1,
episode: int = -1,
preserve_dispositions: bool = False,
extension: str = SUBTITLE_FILE_EXTENSION,
strict: bool = False,
):
# click.echo(f"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}")
@@ -616,7 +713,8 @@ class MediaDescriptor:
[
d
for d in availableFileSubtitleDescriptors
if ((season == -1 and episode == -1)
if (strict
or (season == -1 and episode == -1)
or (
d.get("season") == int(season)
and d.get("episode") == int(episode)
@@ -630,6 +728,7 @@ class MediaDescriptor:
self.__logger.debug(f"importSubtitles(): matchingSubtitleFileDescriptors: {matchingSubtitleFileDescriptors}")
importedTrackIndices = []
for msfd in matchingSubtitleFileDescriptors:
matchingSubtitleTrackDescriptor = [s for s in subtitleTracks if s.getIndex() == msfd["index"]]
if matchingSubtitleTrackDescriptor:
@@ -643,6 +742,19 @@ class MediaDescriptor:
matchingTrack.getTags()["language"] = msfd["language"]
if msfd["disposition_set"] and not preserve_dispositions:
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 = ''):

View File

@@ -421,6 +421,59 @@ class SubtrackMappingBundleTests(unittest.TestCase):
self.assertIn("external 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):
source_filename = "substitute_default_s01e01.mkv"
subtitle_prefix = "substitute_default"

View File

@@ -6,7 +6,9 @@ from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
import click
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.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):
@@ -48,6 +54,35 @@ class SubtitleDirectoryCliTests(unittest.TestCase):
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):
result = self.invoke_convert("--subtitle-prefix", "dball")
@@ -79,6 +114,143 @@ class SubtitleDirectoryCliTests(unittest.TestCase):
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 = Path(__file__).resolve().parents[1] / "assets" / "subtitles"
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__":
unittest.main()

View File

@@ -7,6 +7,7 @@ import unittest
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
ASSETS_ROOT = Path(__file__).resolve().parents[1] / "assets"
if str(SRC_ROOT) not in sys.path:
sys.path.insert(0, str(SRC_ROOT))
@@ -20,18 +21,19 @@ from ffx.track_type import TrackType # noqa: E402
class MediaDescriptorImportSubtitlesTests(unittest.TestCase):
def make_descriptor(self) -> MediaDescriptor:
def make_descriptor(self, indices=(3,)) -> MediaDescriptor:
return MediaDescriptor(
context={"logger": get_ffx_logger()},
track_descriptors=[
TrackDescriptor(
index=3,
source_index=3,
sub_index=0,
index=index,
source_index=index,
sub_index=subIndex,
track_type=TrackType.SUBTITLE,
tags={"language": "eng", "title": "DB Subtitle"},
disposition_set={TrackDisposition.DEFAULT},
)
for subIndex, index in enumerate(indices)
],
)
@@ -74,6 +76,110 @@ class MediaDescriptorImportSubtitlesTests(unittest.TestCase):
self.assertEqual("deu", track.getTags()["language"])
self.assertEqual({TrackDisposition.FORCED}, track.getDispositionSet())
def test_strict_basename_import_recognizes_vtt_asset_set(self):
descriptor = self.make_descriptor(indices=(2, 3, 4))
result = descriptor.importSubtitles(
str(ASSETS_ROOT / "subtitles"),
"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))
result = descriptor.importSubtitles(
str(ASSETS_ROOT / "subtitles"),
"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__":
unittest.main()