From 20ab08626b50bf26354a540624d3529f52bc5c02 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Fri, 22 May 2026 20:11:05 +0200 Subject: [PATCH 1/4] TF Fix styled ASS font tracks --- SCRATCHPAD.md | 4 ++ src/ffx/cli.py | 24 +++++-- src/ffx/media_descriptor.py | 43 ++++++++++++ .../subtrack_mapping/test_cli_bundle.py | 67 +++++++++++++++++++ tests/support/ffx_bundle.py | 4 ++ 5 files changed, 137 insertions(+), 5 deletions(-) diff --git a/SCRATCHPAD.md b/SCRATCHPAD.md index 89eaddd..2d68a22 100644 --- a/SCRATCHPAD.md +++ b/SCRATCHPAD.md @@ -69,3 +69,7 @@ ## Delete When - Delete this scratchpad once the optimization backlog is either converted into issues/work items or distilled into durable project guidance. + + + +## TODO: Review styled ASS separate handling \ No newline at end of file diff --git a/src/ffx/cli.py b/src/ffx/cli.py index 58e4878..bd8dcb1 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -1393,13 +1393,22 @@ def convert(ctx, from ffx.attachment_format import AttachmentFormat - if ([smd for smd in sourceMediaDescriptor.getSubtitleTracks() - if smd.getCodec() == TrackCodec.ASS] - and [amd for amd in sourceMediaDescriptor.getAttachmentTracks() - if amd.getAttachmentFormat() == AttachmentFormat.TTF]): - + styledAssSourceDetected = ( + sourceMediaDescriptor.hasStyledAssSubtitlesWithFontAttachments() + ) + if styledAssSourceDetected: + styledAssMessage = ( + "Styled ASS subtitles with embedded font attachments detected; " + + "preserving source font attachments." + ) + click.echo(styledAssMessage) targetFormat = '' targetExtension = 'mkv' + if context['import_subtitles']: + raise click.ClickException( + "External subtitle import is incompatible with styled ASS " + + "sources that carry embedded font attachments." + ) #HINT: This is None if the filename did not match anything in database @@ -1426,6 +1435,11 @@ def convert(ctx, else: targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj) + if styledAssSourceDetected: + targetMediaDescriptor = targetMediaDescriptor.withoutAttachmentTracks( + AttachmentFormat.TTF, + context=ctx.obj, + ) checkUniqueDispositions(context, targetMediaDescriptor) currentShowDescriptor = currentPattern.getShowDescriptor(ctx.obj) diff --git a/src/ffx/media_descriptor.py b/src/ffx/media_descriptor.py index ad9cf62..5281398 100644 --- a/src/ffx/media_descriptor.py +++ b/src/ffx/media_descriptor.py @@ -329,6 +329,49 @@ class MediaDescriptor: if s.getType() == TrackType.ATTACHMENT ] + def hasStyledAssSubtitlesWithFontAttachments(self) -> bool: + return ( + any( + trackDescriptor.getCodec() == TrackCodec.ASS + for trackDescriptor in self.getSubtitleTracks() + ) + and any( + trackDescriptor.getAttachmentFormat() == AttachmentFormat.TTF + for trackDescriptor in self.getAttachmentTracks() + ) + ) + + def withoutAttachmentTracks( + self, + attachmentFormat: AttachmentFormat | None = None, + context: dict | None = None, + ): + filteredTrackDescriptors = [] + for trackDescriptor in self.__trackDescriptors: + if trackDescriptor.getType() == TrackType.ATTACHMENT and ( + attachmentFormat is None + or trackDescriptor.getAttachmentFormat() == attachmentFormat + ): + continue + filteredTrackDescriptors.append( + trackDescriptor.clone( + context=context if context is not None else self.__context + ) + ) + + kwargs = { + MediaDescriptor.TAGS_KEY: dict(self.__mediaTags), + MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY: filteredTrackDescriptors, + } + if context is not None: + kwargs[MediaDescriptor.CONTEXT_KEY] = context + elif self.__context: + kwargs[MediaDescriptor.CONTEXT_KEY] = self.__context + + filteredMediaDescriptor = MediaDescriptor(**kwargs) + filteredMediaDescriptor.reindexSubIndices() + return filteredMediaDescriptor + def getImportFileTokens(self, use_sub_index: bool = True): """Generate ffmpeg import options for external stream files""" diff --git a/tests/integration/subtrack_mapping/test_cli_bundle.py b/tests/integration/subtrack_mapping/test_cli_bundle.py index 11e0a30..b472c2c 100644 --- a/tests/integration/subtrack_mapping/test_cli_bundle.py +++ b/tests/integration/subtrack_mapping/test_cli_bundle.py @@ -18,6 +18,7 @@ from tests.support.ffx_bundle import ( write_vtt, ) +from ffx.attachment_format import AttachmentFormat from ffx.track_type import TrackType try: @@ -280,6 +281,72 @@ class SubtrackMappingBundleTests(unittest.TestCase): self.assertIn("non-existent source track #99", error_output) self.assertFalse(expected_output_path(self.workdir, source_filename).exists()) + def test_styled_ass_source_preserves_current_font_attachments_when_pattern_count_differs(self): + source_filename = "styled_ass_s01e01.mkv" + source_path = create_source_fixture( + self.workdir, + source_filename, + [ + SourceTrackSpec(TrackType.VIDEO, identity="video-0"), + SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng"), + SourceTrackSpec( + TrackType.SUBTITLE, + identity="subtitle-2", + language="eng", + subtitle_lines=("styled subtitle payload",), + ), + SourceTrackSpec(TrackType.ATTACHMENT, attachment_name="current.ttf"), + ], + subtitle_encoder="ass", + ) + + prepare_pattern_database( + self.database_path, + r"^styled_ass_(s[0-9]+e[0-9]+)\.mkv$", + [ + PatternTrackSpec(index=0, source_index=0, track_type=TrackType.VIDEO), + PatternTrackSpec(index=1, source_index=1, track_type=TrackType.AUDIO), + PatternTrackSpec(index=2, source_index=2, track_type=TrackType.SUBTITLE), + PatternTrackSpec( + index=3, + source_index=3, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + ), + PatternTrackSpec( + index=4, + source_index=4, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + ), + ], + ) + + completed = run_ffx_convert( + self.workdir, + self.home_dir, + self.database_path, + "--video-encoder", + "copy", + "--no-tmdb", + "--no-prompt", + "--no-signature", + str(source_path), + ) + self.assertCompleted(completed) + self.assertIn("Styled ASS subtitles", completed.stdout) + + output_path = expected_output_path(self.workdir, source_filename) + streams = ffprobe_json(output_path)["streams"] + + self.assertEqual( + [stream["codec_type"] for stream in streams], + ["video", "audio", "subtitle", "attachment"], + ) + self.assertEqual(streams[2]["codec_name"], "ass") + self.assertEqual(streams[3]["codec_name"], "ttf") + self.assertEqual(get_tag(streams[3], "filename"), "current.ttf") + def test_external_subtitle_file_replaces_payload_and_overrides_metadata(self): source_filename = "substitute_s01e01.mkv" self.write_config( diff --git a/tests/support/ffx_bundle.py b/tests/support/ffx_bundle.py index 13d0ff7..800b62e 100644 --- a/tests/support/ffx_bundle.py +++ b/tests/support/ffx_bundle.py @@ -18,6 +18,7 @@ if str(SRC_ROOT) not in sys.path: sys.path.insert(0, str(SRC_ROOT)) +from ffx.attachment_format import AttachmentFormat from ffx.audio_layout import AudioLayout from ffx.database import databaseContext from ffx.pattern_controller import PatternController @@ -56,6 +57,7 @@ class PatternTrackSpec: tags: Mapping[str, str] = field(default_factory=dict) dispositions: tuple[TrackDisposition, ...] = () audio_layout: AudioLayout = AudioLayout.LAYOUT_STEREO + attachment_format: AttachmentFormat = AttachmentFormat.UNKNOWN def make_logger(name: str) -> logging.Logger: @@ -299,6 +301,8 @@ def prepare_pattern_database(database_path: Path, filename_pattern: str, track_s } if track.track_type == TrackType.AUDIO: kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = track.audio_layout + if track.track_type == TrackType.ATTACHMENT: + kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = track.attachment_format track_descriptors.append(TrackDescriptor(**kwargs)) pattern_id = PatternController(context).savePatternSchema( From 87568989fee47ca84d036bcc7688c9b9dd559c24 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Fri, 22 May 2026 21:04:50 +0200 Subject: [PATCH 2/4] fix styled ASS --- src/ffx/cli.py | 6 +- src/ffx/media_descriptor.py | 47 ++++++++++ src/ffx/media_descriptor_change_set.py | 20 ++++- src/ffx/media_workflow_screen_base.py | 21 ++++- src/ffx/model/pattern.py | 3 + src/ffx/pattern_controller.py | 13 ++- src/ffx/track_controller.py | 4 + .../unit/test_media_descriptor_change_set.py | 42 +++++++++ tests/unit/test_pattern_management.py | 30 +++++++ tests/unit/test_tag_table_screen_state.py | 86 ++++++++++++++++++- 10 files changed, 262 insertions(+), 10 deletions(-) diff --git a/src/ffx/cli.py b/src/ffx/cli.py index bd8dcb1..1c339a9 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -1139,6 +1139,7 @@ def convert(ctx, from ffx.tmdb_controller import TmdbController from ffx.track_codec import TrackCodec from ffx.track_disposition import TrackDisposition + from ffx.track_type import TrackType from ffx.video_encoder import VideoEncoder startTime = time.perf_counter() @@ -1436,7 +1437,8 @@ def convert(ctx, else: targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj) if styledAssSourceDetected: - targetMediaDescriptor = targetMediaDescriptor.withoutAttachmentTracks( + targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks( + sourceMediaDescriptor, AttachmentFormat.TTF, context=ctx.obj, ) @@ -1449,6 +1451,8 @@ def convert(ctx, targetTrackDescriptorList = targetMediaDescriptor.getTrackDescriptors() for ttd in targetTrackDescriptorList: + if ttd.getType() == TrackType.ATTACHMENT: + continue tti = ttd.getIndex() ttsi = ttd.getSourceIndex() diff --git a/src/ffx/media_descriptor.py b/src/ffx/media_descriptor.py index 5281398..1dde796 100644 --- a/src/ffx/media_descriptor.py +++ b/src/ffx/media_descriptor.py @@ -372,6 +372,53 @@ class MediaDescriptor: filteredMediaDescriptor.reindexSubIndices() return filteredMediaDescriptor + def withoutAttachmentsForComparison(self): + return self.withoutAttachmentTracks(context=self.__context) + + def withSourceAttachmentTracks( + self, + sourceMediaDescriptor: Self, + attachmentFormat: AttachmentFormat | None = None, + context: dict | None = None, + ): + trackDescriptors = [] + for trackDescriptor in self.__trackDescriptors: + if trackDescriptor.getType() == TrackType.ATTACHMENT and ( + attachmentFormat is None + or trackDescriptor.getAttachmentFormat() == attachmentFormat + ): + continue + trackDescriptors.append( + trackDescriptor.clone( + context=context if context is not None else self.__context + ) + ) + + for sourceTrackDescriptor in sourceMediaDescriptor.getAttachmentTracks(): + if ( + attachmentFormat is not None + and sourceTrackDescriptor.getAttachmentFormat() != attachmentFormat + ): + continue + attachmentClone = sourceTrackDescriptor.clone( + context=context if context is not None else self.__context + ) + attachmentClone.setIndex(len(trackDescriptors)) + trackDescriptors.append(attachmentClone) + + kwargs = { + MediaDescriptor.TAGS_KEY: dict(self.__mediaTags), + MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY: trackDescriptors, + } + if context is not None: + kwargs[MediaDescriptor.CONTEXT_KEY] = context + elif self.__context: + kwargs[MediaDescriptor.CONTEXT_KEY] = self.__context + + mergedMediaDescriptor = MediaDescriptor(**kwargs) + mergedMediaDescriptor.reindexSubIndices() + return mergedMediaDescriptor + def getImportFileTokens(self, use_sub_index: bool = True): """Generate ffmpeg import options for external stream files""" diff --git a/src/ffx/media_descriptor_change_set.py b/src/ffx/media_descriptor_change_set.py index 7151b4a..30597bb 100644 --- a/src/ffx/media_descriptor_change_set.py +++ b/src/ffx/media_descriptor_change_set.py @@ -56,8 +56,24 @@ class MediaDescriptorChangeSet(): and 'ignore' in metadataConfiguration['streams'].keys() else []) - self.__targetTrackDescriptors = targetMediaDescriptor.getTrackDescriptors() if targetMediaDescriptor is not None else [] - self.__sourceTrackDescriptors = sourceMediaDescriptor.getTrackDescriptors() if sourceMediaDescriptor is not None else [] + self.__targetTrackDescriptors = ( + [ + trackDescriptor + for trackDescriptor in targetMediaDescriptor.getTrackDescriptors() + if trackDescriptor.getType() != TrackType.ATTACHMENT + ] + if targetMediaDescriptor is not None + else [] + ) + self.__sourceTrackDescriptors = ( + [ + trackDescriptor + for trackDescriptor in sourceMediaDescriptor.getTrackDescriptors() + if trackDescriptor.getType() != TrackType.ATTACHMENT + ] + if sourceMediaDescriptor is not None + else [] + ) self.__targetTrackDescriptorsByIndex = { trackDescriptor.getIndex(): trackDescriptor for trackDescriptor in self.__targetTrackDescriptors diff --git a/src/ffx/media_workflow_screen_base.py b/src/ffx/media_workflow_screen_base.py index 7a2b79e..7968276 100644 --- a/src/ffx/media_workflow_screen_base.py +++ b/src/ffx/media_workflow_screen_base.py @@ -166,10 +166,9 @@ class MediaWorkflowScreenBase(Screen): self._baselineMediaDescriptor = probedMediaDescriptor self._sourceMediaDescriptor = probedMediaDescriptor self._currentPattern = self._mediaFileProperties.getPattern() - self._targetMediaDescriptor = ( - self._currentPattern.getMediaDescriptor(self.context) - if self._currentPattern is not None - else None + self._targetMediaDescriptor = self._resolve_target_media_descriptor( + self._currentPattern, + self._sourceMediaDescriptor, ) self.rebuildChangeSet() @@ -205,6 +204,20 @@ class MediaWorkflowScreenBase(Screen): def getTrackEditSourceDescriptor(self) -> TrackDescriptor | None: return self.getSelectedTrackDescriptor() + def _resolve_target_media_descriptor(self, currentPattern, sourceMediaDescriptor): + if currentPattern is None: + return None + + targetMediaDescriptor = currentPattern.getMediaDescriptor(self.context) + if sourceMediaDescriptor.hasStyledAssSubtitlesWithFontAttachments(): + targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks( + sourceMediaDescriptor, + AttachmentFormat.TTF, + context=self.context, + ) + + return targetMediaDescriptor + def updateMediaTags(self): displayedMediaDescriptor = self.getDisplayedMediaDescriptor() self._sourceMediaTagRowData = populate_tag_table( diff --git a/src/ffx/model/pattern.py b/src/ffx/model/pattern.py index e3a4986..1aaf771 100644 --- a/src/ffx/model/pattern.py +++ b/src/ffx/model/pattern.py @@ -7,6 +7,7 @@ from .show import Base, Show from ffx.media_descriptor import MediaDescriptor from ffx.show_descriptor import ShowDescriptor +from ffx.track_type import TrackType class Pattern(Base): @@ -76,6 +77,8 @@ class Pattern(Base): subIndexCounter = {} for track in self.tracks: trackType = track.getType() + if trackType == TrackType.ATTACHMENT: + continue if not trackType in subIndexCounter.keys(): subIndexCounter[trackType] = 0 kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY].append(track.getDescriptor(context, subIndex = subIndexCounter[trackType])) diff --git a/src/ffx/pattern_controller.py b/src/ffx/pattern_controller.py index 66ae97f..384bea9 100644 --- a/src/ffx/pattern_controller.py +++ b/src/ffx/pattern_controller.py @@ -8,6 +8,7 @@ from ffx.model.track import Track from ffx.model.track_tag import TrackTag from ffx.track_descriptor import TrackDescriptor from ffx.track_disposition import TrackDisposition +from ffx.track_type import TrackType class DuplicatePatternMatchError(click.ClickException): @@ -86,12 +87,16 @@ class PatternController: ) normalized_descriptors = [] + filtered_attachments = False for trackDescriptor in trackDescriptors: if type(trackDescriptor) is not TrackDescriptor: raise TypeError( "PatternController: All track descriptors are required to be of type TrackDescriptor" ) - normalized_descriptors.append(trackDescriptor) + if trackDescriptor.getType() == TrackType.ATTACHMENT: + filtered_attachments = True + continue + normalized_descriptors.append(trackDescriptor.clone()) if not normalized_descriptors: raise InvalidPatternSchemaError( @@ -102,6 +107,10 @@ class PatternController: normalized_descriptors, key=lambda descriptor: descriptor.getIndex() ) + if filtered_attachments: + for index, descriptor in enumerate(normalized_descriptors): + descriptor.setIndex(index) + index_set = {descriptor.getIndex() for descriptor in normalized_descriptors} expected_indexes = set(range(len(normalized_descriptors))) if index_set != expected_indexes: @@ -170,7 +179,7 @@ class PatternController: pattern.tracks.append(self._build_track_row(trackDescriptor)) def _validate_persisted_pattern(self, pattern: Pattern): - if not pattern.tracks: + if not any(track.getType() != TrackType.ATTACHMENT for track in pattern.tracks): raise InvalidPatternSchemaError( f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no tracks." ) diff --git a/src/ffx/track_controller.py b/src/ffx/track_controller.py index 209af40..3af9730 100644 --- a/src/ffx/track_controller.py +++ b/src/ffx/track_controller.py @@ -35,6 +35,8 @@ class TrackController(): def addTrack(self, trackDescriptor : TrackDescriptor, patternId = None): + if trackDescriptor.getType() == TrackType.ATTACHMENT: + return False # option to override pattern id in case track descriptor has not set it patId = int(trackDescriptor.getPatternId() if patternId is None else patternId) @@ -72,6 +74,8 @@ class TrackController(): if type(trackDescriptor) is not TrackDescriptor: raise TypeError('TrackController.updateTrack(): Argument trackDescriptor is required to be of type TrackDescriptor') + if trackDescriptor.getType() == TrackType.ATTACHMENT: + return False try: s = self.Session() diff --git a/tests/unit/test_media_descriptor_change_set.py b/tests/unit/test_media_descriptor_change_set.py index 9e475c1..f9cc7fc 100644 --- a/tests/unit/test_media_descriptor_change_set.py +++ b/tests/unit/test_media_descriptor_change_set.py @@ -13,6 +13,7 @@ if str(SRC_ROOT) not in sys.path: from ffx.media_descriptor import MediaDescriptor # noqa: E402 from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402 +from ffx.attachment_format import AttachmentFormat # noqa: E402 from ffx.track_descriptor import TrackDescriptor # noqa: E402 from ffx.track_type import TrackType # noqa: E402 from ffx.i18n import set_current_language # noqa: E402 @@ -436,6 +437,47 @@ class MediaDescriptorChangeSetTests(unittest.TestCase): self.assertNotIn("creation_time=", metadata_tokens) self.assertNotIn("BPS=", metadata_tokens) + def test_attachment_tracks_are_ignored_for_pattern_comparison(self): + context = { + "logger": get_ffx_logger(), + "config": StaticConfig({}), + } + + source_track = TrackDescriptor( + index=0, + source_index=0, + sub_index=0, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + tags={"filename": "current.ttf", "mimetype": "font/ttf"}, + ) + target_track = TrackDescriptor( + index=0, + source_index=0, + sub_index=0, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + tags={"filename": "stored.ttf", "mimetype": "font/ttf"}, + ) + stale_target_track = TrackDescriptor( + index=1, + source_index=1, + sub_index=1, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + tags={"filename": "missing.ttf", "mimetype": "font/ttf"}, + ) + + change_set = MediaDescriptorChangeSet( + context, + MediaDescriptor(track_descriptors=[target_track, stale_target_track]), + MediaDescriptor(track_descriptors=[source_track]), + ) + + self.assertEqual({}, change_set.getChangeSetObj()) + self.assertEqual([], change_set.generateMetadataTokens()) + self.assertEqual([], change_set.generateDispositionTokens()) + def test_normalization_can_be_disabled_per_context(self): context = { "logger": get_ffx_logger(), diff --git a/tests/unit/test_pattern_management.py b/tests/unit/test_pattern_management.py index eb5ef60..3a1aadd 100644 --- a/tests/unit/test_pattern_management.py +++ b/tests/unit/test_pattern_management.py @@ -193,6 +193,36 @@ class PatternManagementTests(unittest.TestCase): self.assertIn("at least one track", str(caught.exception)) + def test_save_pattern_schema_does_not_persist_attachment_tracks(self): + pattern_id = self.save_pattern( + 1, + r"^noattachments_(s[0-9]+e[0-9]+)\.mkv$", + tracks=[ + make_track_descriptor(0, track_type=TrackType.VIDEO), + make_track_descriptor(1, track_type=TrackType.ATTACHMENT), + ], + ) + + Session = self.context["database"]["session"] + session = Session() + try: + tracks = session.query(Pattern).filter(Pattern.id == pattern_id).first().tracks + self.assertEqual(1, len(tracks)) + self.assertEqual(TrackType.VIDEO, tracks[0].getType()) + finally: + session.close() + + def test_track_controller_does_not_add_attachment_tracks_to_patterns(self): + pattern_id = self.save_pattern(1, r"^skipadd_(s[0-9]+e[0-9]+)\.mkv$") + + added = self.track_controller.addTrack( + make_track_descriptor(1, track_type=TrackType.ATTACHMENT), + patternId=pattern_id, + ) + + self.assertFalse(added) + self.assertEqual(1, len(self.track_controller.findTracks(pattern_id))) + def test_match_filename_rejects_existing_trackless_pattern_rows(self): self.insert_trackless_pattern_row(1, r"^invalid_(s[0-9]+e[0-9]+)\.mkv$") diff --git a/tests/unit/test_tag_table_screen_state.py b/tests/unit/test_tag_table_screen_state.py index 3bf0b71..819a991 100644 --- a/tests/unit/test_tag_table_screen_state.py +++ b/tests/unit/test_tag_table_screen_state.py @@ -15,12 +15,13 @@ if str(SRC_ROOT) not in sys.path: from ffx.audio_layout import AudioLayout # noqa: E402 from ffx.attachment_format import AttachmentFormat # noqa: E402 -from ffx.helper import DIFF_ADDED_KEY # noqa: E402 +from ffx.helper import DIFF_ADDED_KEY, DIFF_REMOVED_KEY # noqa: E402 from ffx.iso_language import IsoLanguage # noqa: E402 from ffx.logging_utils import get_ffx_logger # noqa: E402 from ffx.inspect_details_screen import InspectDetailsScreen # noqa: E402 from ffx.i18n import set_current_language # noqa: E402 from ffx.media_descriptor import MediaDescriptor # noqa: E402 +from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402 from ffx.media_edit_screen import MediaEditScreen # noqa: E402 from ffx.pattern_details_screen import PatternDetailsScreen # noqa: E402 from ffx.show_descriptor import ShowDescriptor # noqa: E402 @@ -822,6 +823,89 @@ class TagTableScreenStateTests(unittest.TestCase): self.assertEqual("unknown", row[3]) self.assertEqual(" ", row[5]) + def test_inspect_details_screen_uses_source_font_attachments_for_styled_ass(self): + class _Config: + def getData(self): + return {} + + class _Pattern: + def __init__(self, media_descriptor): + self._media_descriptor = media_descriptor + + def getMediaDescriptor(self, _context): + return self._media_descriptor + + source_descriptor = MediaDescriptor( + track_descriptors=[ + TrackDescriptor( + index=0, + source_index=0, + sub_index=0, + track_type=TrackType.SUBTITLE, + codec_name=TrackCodec.ASS, + tags={"title": "Styled Subtitle"}, + ), + TrackDescriptor( + index=1, + source_index=1, + sub_index=0, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + tags={"filename": "current.ttf", "mimetype": "font/ttf"}, + ), + ] + ) + pattern_descriptor = MediaDescriptor( + track_descriptors=[ + TrackDescriptor( + index=0, + source_index=0, + sub_index=0, + track_type=TrackType.SUBTITLE, + codec_name=TrackCodec.ASS, + tags={"title": "Styled Subtitle"}, + ), + TrackDescriptor( + index=1, + source_index=1, + sub_index=0, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + tags={"filename": "old.ttf", "mimetype": "font/ttf"}, + ), + TrackDescriptor( + index=2, + source_index=2, + sub_index=1, + track_type=TrackType.ATTACHMENT, + attachment_format=AttachmentFormat.TTF, + tags={"filename": "missing.ttf", "mimetype": "font/ttf"}, + ), + ] + ) + + screen = object.__new__(InspectDetailsScreen) + screen.context = {"logger": get_ffx_logger(), "config": _Config()} + + resolved_descriptor = screen._resolve_target_media_descriptor( + _Pattern(pattern_descriptor), + source_descriptor, + ) + attachment_tracks = resolved_descriptor.getAttachmentTracks() + + self.assertEqual(1, len(attachment_tracks)) + self.assertEqual({"filename": "current.ttf", "mimetype": "font/ttf"}, attachment_tracks[0].getTags()) + + change_set = MediaDescriptorChangeSet( + screen.context, + resolved_descriptor, + source_descriptor, + ).getChangeSetObj() + self.assertNotIn( + 1, + change_set.get("tracks", {}).get(DIFF_REMOVED_KEY, {}), + ) + def test_inspect_details_screen_maps_target_selection_back_to_source_track(self): source_track = TrackDescriptor( index=3, From db43501ce28a97b72ac468cad0b7ee7d55499725 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Fri, 22 May 2026 21:13:48 +0200 Subject: [PATCH 3/4] v0.4.3 --- README.md | 6 ++++++ pyproject.toml | 2 +- src/ffx/constants.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d826c39..d5c2158 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,12 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ ## Version History +### 0.4.3 + +- styled ASS subtitle sources with embedded font attachments are now detected explicitly, keep MKV output, preserve current source font attachments, and reject incompatible sidecar subtitle import +- attachment descriptors are now treated as source-runtime data instead of pattern schema data, so pattern persistence skips them and source-vs-pattern validation ignores them +- inspect differences no longer report planned changes for attachment filename/count drift while still showing attachment streams in the stream table + ### 0.4.2 - pattern details now show an inline `Show: ` hint next to the quality field when the pattern itself has no stored quality but the selected show does diff --git a/pyproject.toml b/pyproject.toml index 1d50c85..648fc05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ffx" description = "FFX recoding and metadata managing tool" -version = "0.4.2" +version = "0.4.3" license = {file = "LICENSE.md"} dependencies = [ "requests", diff --git a/src/ffx/constants.py b/src/ffx/constants.py index 13ac0f7..cf735c1 100644 --- a/src/ffx/constants.py +++ b/src/ffx/constants.py @@ -1,4 +1,4 @@ -VERSION='0.4.2' +VERSION='0.4.3' DATABASE_VERSION = 3 DEFAULT_QUALITY = 32 From 93d19629dcb5096223ba85ac52e23986263f6d4f Mon Sep 17 00:00:00 2001 From: Javanaut Date: Fri, 22 May 2026 21:24:48 +0200 Subject: [PATCH 4/4] v0.4.3 --- src/ffx/cli.py | 9 ++++++++- src/ffx/media_workflow_screen_base.py | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/ffx/cli.py b/src/ffx/cli.py index 1c339a9..5e3b784 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -1394,8 +1394,15 @@ def convert(ctx, from ffx.attachment_format import AttachmentFormat + styledAssDetector = getattr( + sourceMediaDescriptor, + "hasStyledAssSubtitlesWithFontAttachments", + None, + ) styledAssSourceDetected = ( - sourceMediaDescriptor.hasStyledAssSubtitlesWithFontAttachments() + bool(styledAssDetector()) + if callable(styledAssDetector) + else False ) if styledAssSourceDetected: styledAssMessage = ( diff --git a/src/ffx/media_workflow_screen_base.py b/src/ffx/media_workflow_screen_base.py index 7968276..6dd882d 100644 --- a/src/ffx/media_workflow_screen_base.py +++ b/src/ffx/media_workflow_screen_base.py @@ -209,7 +209,12 @@ class MediaWorkflowScreenBase(Screen): return None targetMediaDescriptor = currentPattern.getMediaDescriptor(self.context) - if sourceMediaDescriptor.hasStyledAssSubtitlesWithFontAttachments(): + styledAssDetector = getattr( + sourceMediaDescriptor, + "hasStyledAssSubtitlesWithFontAttachments", + None, + ) + if callable(styledAssDetector) and styledAssDetector(): targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks( sourceMediaDescriptor, AttachmentFormat.TTF,