Release v0.4.3
This commit is contained in:
@@ -99,6 +99,12 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
|
|||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
|
### 0.4.3
|
||||||
|
|
||||||
|
- styled ASS subtitle sources with embedded font attachments are now detected explicitly, keep MKV output, preserve current source font attachments, and reject incompatible sidecar subtitle import
|
||||||
|
- attachment descriptors are now treated as source-runtime data instead of pattern schema data, so pattern persistence skips them and source-vs-pattern validation ignores them
|
||||||
|
- inspect differences no longer report planned changes for attachment filename/count drift while still showing attachment streams in the stream table
|
||||||
|
|
||||||
### 0.4.2
|
### 0.4.2
|
||||||
|
|
||||||
- pattern details now show an inline `Show: <quality>` hint next to the quality field when the pattern itself has no stored quality but the selected show does
|
- pattern details now show an inline `Show: <quality>` hint next to the quality field when the pattern itself has no stored quality but the selected show does
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ffx"
|
name = "ffx"
|
||||||
description = "FFX recoding and metadata managing tool"
|
description = "FFX recoding and metadata managing tool"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
license = {file = "LICENSE.md"}
|
license = {file = "LICENSE.md"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"requests",
|
"requests",
|
||||||
|
|||||||
@@ -1139,6 +1139,7 @@ def convert(ctx,
|
|||||||
from ffx.tmdb_controller import TmdbController
|
from ffx.tmdb_controller import TmdbController
|
||||||
from ffx.track_codec import TrackCodec
|
from ffx.track_codec import TrackCodec
|
||||||
from ffx.track_disposition import TrackDisposition
|
from ffx.track_disposition import TrackDisposition
|
||||||
|
from ffx.track_type import TrackType
|
||||||
from ffx.video_encoder import VideoEncoder
|
from ffx.video_encoder import VideoEncoder
|
||||||
|
|
||||||
startTime = time.perf_counter()
|
startTime = time.perf_counter()
|
||||||
@@ -1393,13 +1394,29 @@ def convert(ctx,
|
|||||||
|
|
||||||
from ffx.attachment_format import AttachmentFormat
|
from ffx.attachment_format import AttachmentFormat
|
||||||
|
|
||||||
if ([smd for smd in sourceMediaDescriptor.getSubtitleTracks()
|
styledAssDetector = getattr(
|
||||||
if smd.getCodec() == TrackCodec.ASS]
|
sourceMediaDescriptor,
|
||||||
and [amd for amd in sourceMediaDescriptor.getAttachmentTracks()
|
"hasStyledAssSubtitlesWithFontAttachments",
|
||||||
if amd.getAttachmentFormat() == AttachmentFormat.TTF]):
|
None,
|
||||||
|
)
|
||||||
|
styledAssSourceDetected = (
|
||||||
|
bool(styledAssDetector())
|
||||||
|
if callable(styledAssDetector)
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
if styledAssSourceDetected:
|
||||||
|
styledAssMessage = (
|
||||||
|
"Styled ASS subtitles with embedded font attachments detected; "
|
||||||
|
+ "preserving source font attachments."
|
||||||
|
)
|
||||||
|
click.echo(styledAssMessage)
|
||||||
targetFormat = ''
|
targetFormat = ''
|
||||||
targetExtension = 'mkv'
|
targetExtension = 'mkv'
|
||||||
|
if context['import_subtitles']:
|
||||||
|
raise click.ClickException(
|
||||||
|
"External subtitle import is incompatible with styled ASS "
|
||||||
|
+ "sources that carry embedded font attachments."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#HINT: This is None if the filename did not match anything in database
|
#HINT: This is None if the filename did not match anything in database
|
||||||
@@ -1426,6 +1443,12 @@ def convert(ctx,
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj)
|
targetMediaDescriptor = currentPattern.getMediaDescriptor(ctx.obj)
|
||||||
|
if styledAssSourceDetected:
|
||||||
|
targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks(
|
||||||
|
sourceMediaDescriptor,
|
||||||
|
AttachmentFormat.TTF,
|
||||||
|
context=ctx.obj,
|
||||||
|
)
|
||||||
checkUniqueDispositions(context, targetMediaDescriptor)
|
checkUniqueDispositions(context, targetMediaDescriptor)
|
||||||
currentShowDescriptor = currentPattern.getShowDescriptor(ctx.obj)
|
currentShowDescriptor = currentPattern.getShowDescriptor(ctx.obj)
|
||||||
|
|
||||||
@@ -1435,6 +1458,8 @@ def convert(ctx,
|
|||||||
targetTrackDescriptorList = targetMediaDescriptor.getTrackDescriptors()
|
targetTrackDescriptorList = targetMediaDescriptor.getTrackDescriptors()
|
||||||
|
|
||||||
for ttd in targetTrackDescriptorList:
|
for ttd in targetTrackDescriptorList:
|
||||||
|
if ttd.getType() == TrackType.ATTACHMENT:
|
||||||
|
continue
|
||||||
|
|
||||||
tti = ttd.getIndex()
|
tti = ttd.getIndex()
|
||||||
ttsi = ttd.getSourceIndex()
|
ttsi = ttd.getSourceIndex()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
VERSION='0.4.2'
|
VERSION='0.4.3'
|
||||||
DATABASE_VERSION = 3
|
DATABASE_VERSION = 3
|
||||||
|
|
||||||
DEFAULT_QUALITY = 32
|
DEFAULT_QUALITY = 32
|
||||||
|
|||||||
@@ -329,6 +329,96 @@ class MediaDescriptor:
|
|||||||
if s.getType() == TrackType.ATTACHMENT
|
if s.getType() == TrackType.ATTACHMENT
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def hasStyledAssSubtitlesWithFontAttachments(self) -> bool:
|
||||||
|
return (
|
||||||
|
any(
|
||||||
|
trackDescriptor.getCodec() == TrackCodec.ASS
|
||||||
|
for trackDescriptor in self.getSubtitleTracks()
|
||||||
|
)
|
||||||
|
and any(
|
||||||
|
trackDescriptor.getAttachmentFormat() == AttachmentFormat.TTF
|
||||||
|
for trackDescriptor in self.getAttachmentTracks()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def withoutAttachmentTracks(
|
||||||
|
self,
|
||||||
|
attachmentFormat: AttachmentFormat | None = None,
|
||||||
|
context: dict | None = None,
|
||||||
|
):
|
||||||
|
filteredTrackDescriptors = []
|
||||||
|
for trackDescriptor in self.__trackDescriptors:
|
||||||
|
if trackDescriptor.getType() == TrackType.ATTACHMENT and (
|
||||||
|
attachmentFormat is None
|
||||||
|
or trackDescriptor.getAttachmentFormat() == attachmentFormat
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
filteredTrackDescriptors.append(
|
||||||
|
trackDescriptor.clone(
|
||||||
|
context=context if context is not None else self.__context
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
MediaDescriptor.TAGS_KEY: dict(self.__mediaTags),
|
||||||
|
MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY: filteredTrackDescriptors,
|
||||||
|
}
|
||||||
|
if context is not None:
|
||||||
|
kwargs[MediaDescriptor.CONTEXT_KEY] = context
|
||||||
|
elif self.__context:
|
||||||
|
kwargs[MediaDescriptor.CONTEXT_KEY] = self.__context
|
||||||
|
|
||||||
|
filteredMediaDescriptor = MediaDescriptor(**kwargs)
|
||||||
|
filteredMediaDescriptor.reindexSubIndices()
|
||||||
|
return filteredMediaDescriptor
|
||||||
|
|
||||||
|
def withoutAttachmentsForComparison(self):
|
||||||
|
return self.withoutAttachmentTracks(context=self.__context)
|
||||||
|
|
||||||
|
def withSourceAttachmentTracks(
|
||||||
|
self,
|
||||||
|
sourceMediaDescriptor: Self,
|
||||||
|
attachmentFormat: AttachmentFormat | None = None,
|
||||||
|
context: dict | None = None,
|
||||||
|
):
|
||||||
|
trackDescriptors = []
|
||||||
|
for trackDescriptor in self.__trackDescriptors:
|
||||||
|
if trackDescriptor.getType() == TrackType.ATTACHMENT and (
|
||||||
|
attachmentFormat is None
|
||||||
|
or trackDescriptor.getAttachmentFormat() == attachmentFormat
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
trackDescriptors.append(
|
||||||
|
trackDescriptor.clone(
|
||||||
|
context=context if context is not None else self.__context
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for sourceTrackDescriptor in sourceMediaDescriptor.getAttachmentTracks():
|
||||||
|
if (
|
||||||
|
attachmentFormat is not None
|
||||||
|
and sourceTrackDescriptor.getAttachmentFormat() != attachmentFormat
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
attachmentClone = sourceTrackDescriptor.clone(
|
||||||
|
context=context if context is not None else self.__context
|
||||||
|
)
|
||||||
|
attachmentClone.setIndex(len(trackDescriptors))
|
||||||
|
trackDescriptors.append(attachmentClone)
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
MediaDescriptor.TAGS_KEY: dict(self.__mediaTags),
|
||||||
|
MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY: trackDescriptors,
|
||||||
|
}
|
||||||
|
if context is not None:
|
||||||
|
kwargs[MediaDescriptor.CONTEXT_KEY] = context
|
||||||
|
elif self.__context:
|
||||||
|
kwargs[MediaDescriptor.CONTEXT_KEY] = self.__context
|
||||||
|
|
||||||
|
mergedMediaDescriptor = MediaDescriptor(**kwargs)
|
||||||
|
mergedMediaDescriptor.reindexSubIndices()
|
||||||
|
return mergedMediaDescriptor
|
||||||
|
|
||||||
|
|
||||||
def getImportFileTokens(self, use_sub_index: bool = True):
|
def getImportFileTokens(self, use_sub_index: bool = True):
|
||||||
"""Generate ffmpeg import options for external stream files"""
|
"""Generate ffmpeg import options for external stream files"""
|
||||||
|
|||||||
@@ -56,8 +56,24 @@ class MediaDescriptorChangeSet():
|
|||||||
and 'ignore' in metadataConfiguration['streams'].keys() else [])
|
and 'ignore' in metadataConfiguration['streams'].keys() else [])
|
||||||
|
|
||||||
|
|
||||||
self.__targetTrackDescriptors = targetMediaDescriptor.getTrackDescriptors() if targetMediaDescriptor is not None else []
|
self.__targetTrackDescriptors = (
|
||||||
self.__sourceTrackDescriptors = sourceMediaDescriptor.getTrackDescriptors() if sourceMediaDescriptor is not None else []
|
[
|
||||||
|
trackDescriptor
|
||||||
|
for trackDescriptor in targetMediaDescriptor.getTrackDescriptors()
|
||||||
|
if trackDescriptor.getType() != TrackType.ATTACHMENT
|
||||||
|
]
|
||||||
|
if targetMediaDescriptor is not None
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
self.__sourceTrackDescriptors = (
|
||||||
|
[
|
||||||
|
trackDescriptor
|
||||||
|
for trackDescriptor in sourceMediaDescriptor.getTrackDescriptors()
|
||||||
|
if trackDescriptor.getType() != TrackType.ATTACHMENT
|
||||||
|
]
|
||||||
|
if sourceMediaDescriptor is not None
|
||||||
|
else []
|
||||||
|
)
|
||||||
self.__targetTrackDescriptorsByIndex = {
|
self.__targetTrackDescriptorsByIndex = {
|
||||||
trackDescriptor.getIndex(): trackDescriptor
|
trackDescriptor.getIndex(): trackDescriptor
|
||||||
for trackDescriptor in self.__targetTrackDescriptors
|
for trackDescriptor in self.__targetTrackDescriptors
|
||||||
|
|||||||
@@ -166,10 +166,9 @@ class MediaWorkflowScreenBase(Screen):
|
|||||||
self._baselineMediaDescriptor = probedMediaDescriptor
|
self._baselineMediaDescriptor = probedMediaDescriptor
|
||||||
self._sourceMediaDescriptor = probedMediaDescriptor
|
self._sourceMediaDescriptor = probedMediaDescriptor
|
||||||
self._currentPattern = self._mediaFileProperties.getPattern()
|
self._currentPattern = self._mediaFileProperties.getPattern()
|
||||||
self._targetMediaDescriptor = (
|
self._targetMediaDescriptor = self._resolve_target_media_descriptor(
|
||||||
self._currentPattern.getMediaDescriptor(self.context)
|
self._currentPattern,
|
||||||
if self._currentPattern is not None
|
self._sourceMediaDescriptor,
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.rebuildChangeSet()
|
self.rebuildChangeSet()
|
||||||
@@ -205,6 +204,25 @@ class MediaWorkflowScreenBase(Screen):
|
|||||||
def getTrackEditSourceDescriptor(self) -> TrackDescriptor | None:
|
def getTrackEditSourceDescriptor(self) -> TrackDescriptor | None:
|
||||||
return self.getSelectedTrackDescriptor()
|
return self.getSelectedTrackDescriptor()
|
||||||
|
|
||||||
|
def _resolve_target_media_descriptor(self, currentPattern, sourceMediaDescriptor):
|
||||||
|
if currentPattern is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
targetMediaDescriptor = currentPattern.getMediaDescriptor(self.context)
|
||||||
|
styledAssDetector = getattr(
|
||||||
|
sourceMediaDescriptor,
|
||||||
|
"hasStyledAssSubtitlesWithFontAttachments",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if callable(styledAssDetector) and styledAssDetector():
|
||||||
|
targetMediaDescriptor = targetMediaDescriptor.withSourceAttachmentTracks(
|
||||||
|
sourceMediaDescriptor,
|
||||||
|
AttachmentFormat.TTF,
|
||||||
|
context=self.context,
|
||||||
|
)
|
||||||
|
|
||||||
|
return targetMediaDescriptor
|
||||||
|
|
||||||
def updateMediaTags(self):
|
def updateMediaTags(self):
|
||||||
displayedMediaDescriptor = self.getDisplayedMediaDescriptor()
|
displayedMediaDescriptor = self.getDisplayedMediaDescriptor()
|
||||||
self._sourceMediaTagRowData = populate_tag_table(
|
self._sourceMediaTagRowData = populate_tag_table(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from .show import Base, Show
|
|||||||
|
|
||||||
from ffx.media_descriptor import MediaDescriptor
|
from ffx.media_descriptor import MediaDescriptor
|
||||||
from ffx.show_descriptor import ShowDescriptor
|
from ffx.show_descriptor import ShowDescriptor
|
||||||
|
from ffx.track_type import TrackType
|
||||||
|
|
||||||
|
|
||||||
class Pattern(Base):
|
class Pattern(Base):
|
||||||
@@ -76,6 +77,8 @@ class Pattern(Base):
|
|||||||
subIndexCounter = {}
|
subIndexCounter = {}
|
||||||
for track in self.tracks:
|
for track in self.tracks:
|
||||||
trackType = track.getType()
|
trackType = track.getType()
|
||||||
|
if trackType == TrackType.ATTACHMENT:
|
||||||
|
continue
|
||||||
if not trackType in subIndexCounter.keys():
|
if not trackType in subIndexCounter.keys():
|
||||||
subIndexCounter[trackType] = 0
|
subIndexCounter[trackType] = 0
|
||||||
kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY].append(track.getDescriptor(context, subIndex = subIndexCounter[trackType]))
|
kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY].append(track.getDescriptor(context, subIndex = subIndexCounter[trackType]))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from ffx.model.track import Track
|
|||||||
from ffx.model.track_tag import TrackTag
|
from ffx.model.track_tag import TrackTag
|
||||||
from ffx.track_descriptor import TrackDescriptor
|
from ffx.track_descriptor import TrackDescriptor
|
||||||
from ffx.track_disposition import TrackDisposition
|
from ffx.track_disposition import TrackDisposition
|
||||||
|
from ffx.track_type import TrackType
|
||||||
|
|
||||||
|
|
||||||
class DuplicatePatternMatchError(click.ClickException):
|
class DuplicatePatternMatchError(click.ClickException):
|
||||||
@@ -86,12 +87,16 @@ class PatternController:
|
|||||||
)
|
)
|
||||||
|
|
||||||
normalized_descriptors = []
|
normalized_descriptors = []
|
||||||
|
filtered_attachments = False
|
||||||
for trackDescriptor in trackDescriptors:
|
for trackDescriptor in trackDescriptors:
|
||||||
if type(trackDescriptor) is not TrackDescriptor:
|
if type(trackDescriptor) is not TrackDescriptor:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"PatternController: All track descriptors are required to be of type TrackDescriptor"
|
"PatternController: All track descriptors are required to be of type TrackDescriptor"
|
||||||
)
|
)
|
||||||
normalized_descriptors.append(trackDescriptor)
|
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||||
|
filtered_attachments = True
|
||||||
|
continue
|
||||||
|
normalized_descriptors.append(trackDescriptor.clone())
|
||||||
|
|
||||||
if not normalized_descriptors:
|
if not normalized_descriptors:
|
||||||
raise InvalidPatternSchemaError(
|
raise InvalidPatternSchemaError(
|
||||||
@@ -102,6 +107,10 @@ class PatternController:
|
|||||||
normalized_descriptors, key=lambda descriptor: descriptor.getIndex()
|
normalized_descriptors, key=lambda descriptor: descriptor.getIndex()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if filtered_attachments:
|
||||||
|
for index, descriptor in enumerate(normalized_descriptors):
|
||||||
|
descriptor.setIndex(index)
|
||||||
|
|
||||||
index_set = {descriptor.getIndex() for descriptor in normalized_descriptors}
|
index_set = {descriptor.getIndex() for descriptor in normalized_descriptors}
|
||||||
expected_indexes = set(range(len(normalized_descriptors)))
|
expected_indexes = set(range(len(normalized_descriptors)))
|
||||||
if index_set != expected_indexes:
|
if index_set != expected_indexes:
|
||||||
@@ -170,7 +179,7 @@ class PatternController:
|
|||||||
pattern.tracks.append(self._build_track_row(trackDescriptor))
|
pattern.tracks.append(self._build_track_row(trackDescriptor))
|
||||||
|
|
||||||
def _validate_persisted_pattern(self, pattern: Pattern):
|
def _validate_persisted_pattern(self, pattern: Pattern):
|
||||||
if not pattern.tracks:
|
if not any(track.getType() != TrackType.ATTACHMENT for track in pattern.tracks):
|
||||||
raise InvalidPatternSchemaError(
|
raise InvalidPatternSchemaError(
|
||||||
f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no tracks."
|
f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no tracks."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class TrackController():
|
|||||||
|
|
||||||
|
|
||||||
def addTrack(self, trackDescriptor : TrackDescriptor, patternId = None):
|
def addTrack(self, trackDescriptor : TrackDescriptor, patternId = None):
|
||||||
|
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||||
|
return False
|
||||||
|
|
||||||
# option to override pattern id in case track descriptor has not set it
|
# option to override pattern id in case track descriptor has not set it
|
||||||
patId = int(trackDescriptor.getPatternId() if patternId is None else patternId)
|
patId = int(trackDescriptor.getPatternId() if patternId is None else patternId)
|
||||||
@@ -72,6 +74,8 @@ class TrackController():
|
|||||||
|
|
||||||
if type(trackDescriptor) is not TrackDescriptor:
|
if type(trackDescriptor) is not TrackDescriptor:
|
||||||
raise TypeError('TrackController.updateTrack(): Argument trackDescriptor is required to be of type TrackDescriptor')
|
raise TypeError('TrackController.updateTrack(): Argument trackDescriptor is required to be of type TrackDescriptor')
|
||||||
|
if trackDescriptor.getType() == TrackType.ATTACHMENT:
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s = self.Session()
|
s = self.Session()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from tests.support.ffx_bundle import (
|
|||||||
write_vtt,
|
write_vtt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ffx.attachment_format import AttachmentFormat
|
||||||
from ffx.track_type import TrackType
|
from ffx.track_type import TrackType
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -280,6 +281,72 @@ class SubtrackMappingBundleTests(unittest.TestCase):
|
|||||||
self.assertIn("non-existent source track #99", error_output)
|
self.assertIn("non-existent source track #99", error_output)
|
||||||
self.assertFalse(expected_output_path(self.workdir, source_filename).exists())
|
self.assertFalse(expected_output_path(self.workdir, source_filename).exists())
|
||||||
|
|
||||||
|
def test_styled_ass_source_preserves_current_font_attachments_when_pattern_count_differs(self):
|
||||||
|
source_filename = "styled_ass_s01e01.mkv"
|
||||||
|
source_path = create_source_fixture(
|
||||||
|
self.workdir,
|
||||||
|
source_filename,
|
||||||
|
[
|
||||||
|
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||||
|
SourceTrackSpec(TrackType.AUDIO, identity="audio-1", language="eng"),
|
||||||
|
SourceTrackSpec(
|
||||||
|
TrackType.SUBTITLE,
|
||||||
|
identity="subtitle-2",
|
||||||
|
language="eng",
|
||||||
|
subtitle_lines=("styled subtitle payload",),
|
||||||
|
),
|
||||||
|
SourceTrackSpec(TrackType.ATTACHMENT, attachment_name="current.ttf"),
|
||||||
|
],
|
||||||
|
subtitle_encoder="ass",
|
||||||
|
)
|
||||||
|
|
||||||
|
prepare_pattern_database(
|
||||||
|
self.database_path,
|
||||||
|
r"^styled_ass_(s[0-9]+e[0-9]+)\.mkv$",
|
||||||
|
[
|
||||||
|
PatternTrackSpec(index=0, source_index=0, track_type=TrackType.VIDEO),
|
||||||
|
PatternTrackSpec(index=1, source_index=1, track_type=TrackType.AUDIO),
|
||||||
|
PatternTrackSpec(index=2, source_index=2, track_type=TrackType.SUBTITLE),
|
||||||
|
PatternTrackSpec(
|
||||||
|
index=3,
|
||||||
|
source_index=3,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
),
|
||||||
|
PatternTrackSpec(
|
||||||
|
index=4,
|
||||||
|
source_index=4,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = run_ffx_convert(
|
||||||
|
self.workdir,
|
||||||
|
self.home_dir,
|
||||||
|
self.database_path,
|
||||||
|
"--video-encoder",
|
||||||
|
"copy",
|
||||||
|
"--no-tmdb",
|
||||||
|
"--no-prompt",
|
||||||
|
"--no-signature",
|
||||||
|
str(source_path),
|
||||||
|
)
|
||||||
|
self.assertCompleted(completed)
|
||||||
|
self.assertIn("Styled ASS subtitles", completed.stdout)
|
||||||
|
|
||||||
|
output_path = expected_output_path(self.workdir, source_filename)
|
||||||
|
streams = ffprobe_json(output_path)["streams"]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[stream["codec_type"] for stream in streams],
|
||||||
|
["video", "audio", "subtitle", "attachment"],
|
||||||
|
)
|
||||||
|
self.assertEqual(streams[2]["codec_name"], "ass")
|
||||||
|
self.assertEqual(streams[3]["codec_name"], "ttf")
|
||||||
|
self.assertEqual(get_tag(streams[3], "filename"), "current.ttf")
|
||||||
|
|
||||||
def test_external_subtitle_file_replaces_payload_and_overrides_metadata(self):
|
def test_external_subtitle_file_replaces_payload_and_overrides_metadata(self):
|
||||||
source_filename = "substitute_s01e01.mkv"
|
source_filename = "substitute_s01e01.mkv"
|
||||||
self.write_config(
|
self.write_config(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(SRC_ROOT))
|
sys.path.insert(0, str(SRC_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
from ffx.attachment_format import AttachmentFormat
|
||||||
from ffx.audio_layout import AudioLayout
|
from ffx.audio_layout import AudioLayout
|
||||||
from ffx.database import databaseContext
|
from ffx.database import databaseContext
|
||||||
from ffx.pattern_controller import PatternController
|
from ffx.pattern_controller import PatternController
|
||||||
@@ -56,6 +57,7 @@ class PatternTrackSpec:
|
|||||||
tags: Mapping[str, str] = field(default_factory=dict)
|
tags: Mapping[str, str] = field(default_factory=dict)
|
||||||
dispositions: tuple[TrackDisposition, ...] = ()
|
dispositions: tuple[TrackDisposition, ...] = ()
|
||||||
audio_layout: AudioLayout = AudioLayout.LAYOUT_STEREO
|
audio_layout: AudioLayout = AudioLayout.LAYOUT_STEREO
|
||||||
|
attachment_format: AttachmentFormat = AttachmentFormat.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
def make_logger(name: str) -> logging.Logger:
|
def make_logger(name: str) -> logging.Logger:
|
||||||
@@ -299,6 +301,8 @@ def prepare_pattern_database(database_path: Path, filename_pattern: str, track_s
|
|||||||
}
|
}
|
||||||
if track.track_type == TrackType.AUDIO:
|
if track.track_type == TrackType.AUDIO:
|
||||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = track.audio_layout
|
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = track.audio_layout
|
||||||
|
if track.track_type == TrackType.ATTACHMENT:
|
||||||
|
kwargs[TrackDescriptor.ATTACHMENT_FORMAT_KEY] = track.attachment_format
|
||||||
track_descriptors.append(TrackDescriptor(**kwargs))
|
track_descriptors.append(TrackDescriptor(**kwargs))
|
||||||
|
|
||||||
pattern_id = PatternController(context).savePatternSchema(
|
pattern_id = PatternController(context).savePatternSchema(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||||
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402
|
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402
|
||||||
|
from ffx.attachment_format import AttachmentFormat # noqa: E402
|
||||||
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
||||||
from ffx.track_type import TrackType # noqa: E402
|
from ffx.track_type import TrackType # noqa: E402
|
||||||
from ffx.i18n import set_current_language # noqa: E402
|
from ffx.i18n import set_current_language # noqa: E402
|
||||||
@@ -436,6 +437,47 @@ class MediaDescriptorChangeSetTests(unittest.TestCase):
|
|||||||
self.assertNotIn("creation_time=", metadata_tokens)
|
self.assertNotIn("creation_time=", metadata_tokens)
|
||||||
self.assertNotIn("BPS=", metadata_tokens)
|
self.assertNotIn("BPS=", metadata_tokens)
|
||||||
|
|
||||||
|
def test_attachment_tracks_are_ignored_for_pattern_comparison(self):
|
||||||
|
context = {
|
||||||
|
"logger": get_ffx_logger(),
|
||||||
|
"config": StaticConfig({}),
|
||||||
|
}
|
||||||
|
|
||||||
|
source_track = TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
tags={"filename": "current.ttf", "mimetype": "font/ttf"},
|
||||||
|
)
|
||||||
|
target_track = TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
tags={"filename": "stored.ttf", "mimetype": "font/ttf"},
|
||||||
|
)
|
||||||
|
stale_target_track = TrackDescriptor(
|
||||||
|
index=1,
|
||||||
|
source_index=1,
|
||||||
|
sub_index=1,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
tags={"filename": "missing.ttf", "mimetype": "font/ttf"},
|
||||||
|
)
|
||||||
|
|
||||||
|
change_set = MediaDescriptorChangeSet(
|
||||||
|
context,
|
||||||
|
MediaDescriptor(track_descriptors=[target_track, stale_target_track]),
|
||||||
|
MediaDescriptor(track_descriptors=[source_track]),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual({}, change_set.getChangeSetObj())
|
||||||
|
self.assertEqual([], change_set.generateMetadataTokens())
|
||||||
|
self.assertEqual([], change_set.generateDispositionTokens())
|
||||||
|
|
||||||
def test_normalization_can_be_disabled_per_context(self):
|
def test_normalization_can_be_disabled_per_context(self):
|
||||||
context = {
|
context = {
|
||||||
"logger": get_ffx_logger(),
|
"logger": get_ffx_logger(),
|
||||||
|
|||||||
@@ -193,6 +193,36 @@ class PatternManagementTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("at least one track", str(caught.exception))
|
self.assertIn("at least one track", str(caught.exception))
|
||||||
|
|
||||||
|
def test_save_pattern_schema_does_not_persist_attachment_tracks(self):
|
||||||
|
pattern_id = self.save_pattern(
|
||||||
|
1,
|
||||||
|
r"^noattachments_(s[0-9]+e[0-9]+)\.mkv$",
|
||||||
|
tracks=[
|
||||||
|
make_track_descriptor(0, track_type=TrackType.VIDEO),
|
||||||
|
make_track_descriptor(1, track_type=TrackType.ATTACHMENT),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
Session = self.context["database"]["session"]
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
tracks = session.query(Pattern).filter(Pattern.id == pattern_id).first().tracks
|
||||||
|
self.assertEqual(1, len(tracks))
|
||||||
|
self.assertEqual(TrackType.VIDEO, tracks[0].getType())
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_track_controller_does_not_add_attachment_tracks_to_patterns(self):
|
||||||
|
pattern_id = self.save_pattern(1, r"^skipadd_(s[0-9]+e[0-9]+)\.mkv$")
|
||||||
|
|
||||||
|
added = self.track_controller.addTrack(
|
||||||
|
make_track_descriptor(1, track_type=TrackType.ATTACHMENT),
|
||||||
|
patternId=pattern_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(added)
|
||||||
|
self.assertEqual(1, len(self.track_controller.findTracks(pattern_id)))
|
||||||
|
|
||||||
def test_match_filename_rejects_existing_trackless_pattern_rows(self):
|
def test_match_filename_rejects_existing_trackless_pattern_rows(self):
|
||||||
self.insert_trackless_pattern_row(1, r"^invalid_(s[0-9]+e[0-9]+)\.mkv$")
|
self.insert_trackless_pattern_row(1, r"^invalid_(s[0-9]+e[0-9]+)\.mkv$")
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from ffx.audio_layout import AudioLayout # noqa: E402
|
from ffx.audio_layout import AudioLayout # noqa: E402
|
||||||
from ffx.attachment_format import AttachmentFormat # noqa: E402
|
from ffx.attachment_format import AttachmentFormat # noqa: E402
|
||||||
from ffx.helper import DIFF_ADDED_KEY # noqa: E402
|
from ffx.helper import DIFF_ADDED_KEY, DIFF_REMOVED_KEY # noqa: E402
|
||||||
from ffx.iso_language import IsoLanguage # noqa: E402
|
from ffx.iso_language import IsoLanguage # noqa: E402
|
||||||
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||||
from ffx.inspect_details_screen import InspectDetailsScreen # noqa: E402
|
from ffx.inspect_details_screen import InspectDetailsScreen # noqa: E402
|
||||||
from ffx.i18n import set_current_language # noqa: E402
|
from ffx.i18n import set_current_language # noqa: E402
|
||||||
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||||
|
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet # noqa: E402
|
||||||
from ffx.media_edit_screen import MediaEditScreen # noqa: E402
|
from ffx.media_edit_screen import MediaEditScreen # noqa: E402
|
||||||
from ffx.pattern_details_screen import PatternDetailsScreen # noqa: E402
|
from ffx.pattern_details_screen import PatternDetailsScreen # noqa: E402
|
||||||
from ffx.show_descriptor import ShowDescriptor # noqa: E402
|
from ffx.show_descriptor import ShowDescriptor # noqa: E402
|
||||||
@@ -822,6 +823,89 @@ class TagTableScreenStateTests(unittest.TestCase):
|
|||||||
self.assertEqual("unknown", row[3])
|
self.assertEqual("unknown", row[3])
|
||||||
self.assertEqual(" ", row[5])
|
self.assertEqual(" ", row[5])
|
||||||
|
|
||||||
|
def test_inspect_details_screen_uses_source_font_attachments_for_styled_ass(self):
|
||||||
|
class _Config:
|
||||||
|
def getData(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
class _Pattern:
|
||||||
|
def __init__(self, media_descriptor):
|
||||||
|
self._media_descriptor = media_descriptor
|
||||||
|
|
||||||
|
def getMediaDescriptor(self, _context):
|
||||||
|
return self._media_descriptor
|
||||||
|
|
||||||
|
source_descriptor = MediaDescriptor(
|
||||||
|
track_descriptors=[
|
||||||
|
TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.ASS,
|
||||||
|
tags={"title": "Styled Subtitle"},
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=1,
|
||||||
|
source_index=1,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
tags={"filename": "current.ttf", "mimetype": "font/ttf"},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
pattern_descriptor = MediaDescriptor(
|
||||||
|
track_descriptors=[
|
||||||
|
TrackDescriptor(
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
codec_name=TrackCodec.ASS,
|
||||||
|
tags={"title": "Styled Subtitle"},
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=1,
|
||||||
|
source_index=1,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
tags={"filename": "old.ttf", "mimetype": "font/ttf"},
|
||||||
|
),
|
||||||
|
TrackDescriptor(
|
||||||
|
index=2,
|
||||||
|
source_index=2,
|
||||||
|
sub_index=1,
|
||||||
|
track_type=TrackType.ATTACHMENT,
|
||||||
|
attachment_format=AttachmentFormat.TTF,
|
||||||
|
tags={"filename": "missing.ttf", "mimetype": "font/ttf"},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
screen = object.__new__(InspectDetailsScreen)
|
||||||
|
screen.context = {"logger": get_ffx_logger(), "config": _Config()}
|
||||||
|
|
||||||
|
resolved_descriptor = screen._resolve_target_media_descriptor(
|
||||||
|
_Pattern(pattern_descriptor),
|
||||||
|
source_descriptor,
|
||||||
|
)
|
||||||
|
attachment_tracks = resolved_descriptor.getAttachmentTracks()
|
||||||
|
|
||||||
|
self.assertEqual(1, len(attachment_tracks))
|
||||||
|
self.assertEqual({"filename": "current.ttf", "mimetype": "font/ttf"}, attachment_tracks[0].getTags())
|
||||||
|
|
||||||
|
change_set = MediaDescriptorChangeSet(
|
||||||
|
screen.context,
|
||||||
|
resolved_descriptor,
|
||||||
|
source_descriptor,
|
||||||
|
).getChangeSetObj()
|
||||||
|
self.assertNotIn(
|
||||||
|
1,
|
||||||
|
change_set.get("tracks", {}).get(DIFF_REMOVED_KEY, {}),
|
||||||
|
)
|
||||||
|
|
||||||
def test_inspect_details_screen_maps_target_selection_back_to_source_track(self):
|
def test_inspect_details_screen_maps_target_selection_back_to_source_track(self):
|
||||||
source_track = TrackDescriptor(
|
source_track = TrackDescriptor(
|
||||||
index=3,
|
index=3,
|
||||||
|
|||||||
Reference in New Issue
Block a user