drop flag

This commit is contained in:
Javanaut
2026-07-08 07:18:03 +02:00
parent 697645be25
commit aa864dfd4d
13 changed files with 299 additions and 9 deletions

View File

@@ -108,12 +108,18 @@ def ensureCurrentSchemaCompatibility(databaseContext):
column['name'] column['name']
for column in inspector.get_columns('shows') for column in inspector.get_columns('shows')
} }
trackColumns = {
column['name']
for column in inspector.get_columns('tracks')
}
alterStatements = [] alterStatements = []
if 'quality' not in showColumns: if 'quality' not in showColumns:
alterStatements.append("ALTER TABLE shows ADD COLUMN quality INTEGER DEFAULT 0") alterStatements.append("ALTER TABLE shows ADD COLUMN quality INTEGER DEFAULT 0")
if 'notes' not in showColumns: if 'notes' not in showColumns:
alterStatements.append("ALTER TABLE shows ADD COLUMN notes TEXT DEFAULT ''") alterStatements.append("ALTER TABLE shows ADD COLUMN notes TEXT DEFAULT ''")
if 'dropped' not in trackColumns:
alterStatements.append("ALTER TABLE tracks ADD COLUMN dropped INTEGER DEFAULT 0")
if not alterStatements: if not alterStatements:
return return

View File

@@ -75,13 +75,17 @@ class Pattern(Base):
# Set ordered subindices # Set ordered subindices
subIndexCounter = {} subIndexCounter = {}
outputIndex = 0
for track in self.tracks: for track in self.tracks:
trackType = track.getType() trackType = track.getType()
if trackType == TrackType.ATTACHMENT: if trackType == TrackType.ATTACHMENT or track.isDropped():
continue 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])) trackDescriptor = track.getDescriptor(context, subIndex = subIndexCounter[trackType])
trackDescriptor.setIndex(outputIndex)
kwargs[MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY].append(trackDescriptor)
subIndexCounter[trackType] += 1 subIndexCounter[trackType] += 1
outputIndex += 1
return MediaDescriptor(**kwargs) return MediaDescriptor(**kwargs)

View File

@@ -36,6 +36,7 @@ class Track(Base):
index = Column(Integer) index = Column(Integer)
source_index = Column(Integer) source_index = Column(Integer)
dropped = Column(Integer, default=0)
# v1.x # v1.x
pattern_id = Column(Integer, ForeignKey('patterns.id', ondelete="CASCADE")) pattern_id = Column(Integer, ForeignKey('patterns.id', ondelete="CASCADE"))
@@ -181,6 +182,9 @@ class Track(Base):
def getSourceIndex(self): def getSourceIndex(self):
return int(self.source_index) if self.source_index is not None else -1 return int(self.source_index) if self.source_index is not None else -1
def isDropped(self) -> bool:
return bool(self.dropped)
def getLanguage(self): def getLanguage(self):
tags = {t.key:t.value for t in self.track_tags} tags = {t.key:t.value for t in self.track_tags}
return IsoLanguage.findThreeLetter(tags['language']) if 'language' in tags.keys() else IsoLanguage.UNDEFINED return IsoLanguage.findThreeLetter(tags['language']) if 'language' in tags.keys() else IsoLanguage.UNDEFINED
@@ -221,6 +225,7 @@ class Track(Base):
kwargs[TrackDescriptor.INDEX_KEY] = self.getIndex() kwargs[TrackDescriptor.INDEX_KEY] = self.getIndex()
kwargs[TrackDescriptor.SOURCE_INDEX_KEY] = self.getSourceIndex() kwargs[TrackDescriptor.SOURCE_INDEX_KEY] = self.getSourceIndex()
kwargs[TrackDescriptor.DROPPED_KEY] = self.isDropped()
if subIndex > -1: if subIndex > -1:
kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex kwargs[TrackDescriptor.SUB_INDEX_KEY] = subIndex

View File

@@ -103,6 +103,14 @@ class PatternController:
"Patterns must define at least one track before they can be stored." "Patterns must define at least one track before they can be stored."
) )
if not any(
not descriptor.isDropped()
for descriptor in normalized_descriptors
):
raise InvalidPatternSchemaError(
"Patterns must define at least one non-dropped track before they can be stored."
)
normalized_descriptors = sorted( normalized_descriptors = sorted(
normalized_descriptors, key=lambda descriptor: descriptor.getIndex() normalized_descriptors, key=lambda descriptor: descriptor.getIndex()
) )
@@ -146,6 +154,7 @@ class PatternController:
codec_name=str(trackDescriptor.getFormatDescriptor().identifier()), codec_name=str(trackDescriptor.getFormatDescriptor().identifier()),
index=int(trackDescriptor.getIndex()), index=int(trackDescriptor.getIndex()),
source_index=int(trackDescriptor.getSourceIndex()), source_index=int(trackDescriptor.getSourceIndex()),
dropped=1 if trackDescriptor.isDropped() else 0,
disposition_flags=int( disposition_flags=int(
TrackDisposition.toFlags(trackDescriptor.getDispositionSet()) TrackDisposition.toFlags(trackDescriptor.getDispositionSet())
), ),
@@ -179,10 +188,19 @@ 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 any(track.getType() != TrackType.ATTACHMENT for track in pattern.tracks): persisted_tracks = [
track
for track in pattern.tracks
if track.getType() != TrackType.ATTACHMENT
]
if not persisted_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."
) )
if not any(not track.isDropped() for track in persisted_tracks):
raise InvalidPatternSchemaError(
f"Pattern #{pattern.getId()} ({pattern.getPattern()!r}) is invalid because it has no non-dropped tracks."
)
def savePatternSchema( def savePatternSchema(
self, self,

View File

@@ -185,6 +185,7 @@ class PatternDetailsScreen(Screen):
td.getTitle(), td.getTitle(),
t('Yes') if TrackDisposition.DEFAULT in dispoSet else t('No'), t('Yes') if TrackDisposition.DEFAULT in dispoSet else t('No'),
t('Yes') if TrackDisposition.FORCED in dispoSet else t('No'), t('Yes') if TrackDisposition.FORCED in dispoSet else t('No'),
t('Yes') if td.isDropped() else t('No'),
td.getSourceIndex()) td.getSourceIndex())
row_key = self.tracksTable.add_row(*map(str, row)) row_key = self.tracksTable.add_row(*map(str, row))
@@ -384,6 +385,7 @@ class PatternDetailsScreen(Screen):
self.column_key_track_title = add_auto_table_column(self.tracksTable, t("Title")) self.column_key_track_title = add_auto_table_column(self.tracksTable, t("Title"))
self.column_key_track_default = add_auto_table_column(self.tracksTable, t("Default")) self.column_key_track_default = add_auto_table_column(self.tracksTable, t("Default"))
self.column_key_track_forced = add_auto_table_column(self.tracksTable, t("Forced")) self.column_key_track_forced = add_auto_table_column(self.tracksTable, t("Forced"))
self.column_key_track_drop = add_auto_table_column(self.tracksTable, t("Drop"))
self.column_key_track_source_index = add_auto_table_column(self.tracksTable, t("SrcIndex")) self.column_key_track_source_index = add_auto_table_column(self.tracksTable, t("SrcIndex"))
self.tracksTable.cursor_type = 'row' self.tracksTable.cursor_type = 'row'

View File

@@ -48,6 +48,7 @@ class TrackController():
codec_name = str(trackDescriptor.getFormatDescriptor().identifier()), codec_name = str(trackDescriptor.getFormatDescriptor().identifier()),
index = int(trackDescriptor.getIndex()), index = int(trackDescriptor.getIndex()),
source_index = int(trackDescriptor.getSourceIndex()), source_index = int(trackDescriptor.getSourceIndex()),
dropped = 1 if trackDescriptor.isDropped() else 0,
disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())), disposition_flags = int(TrackDisposition.toFlags(trackDescriptor.getDispositionSet())),
audio_layout = trackDescriptor.getAudioLayout().index()) audio_layout = trackDescriptor.getAudioLayout().index())
@@ -83,7 +84,21 @@ class TrackController():
if track is not None: if track is not None:
patternId = int(track.pattern_id)
if trackDescriptor.isDropped() and not track.isDropped():
activeSiblingCount = (
s.query(Track)
.filter(Track.pattern_id == patternId, Track.dropped == 0)
.count()
)
if activeSiblingCount <= 1:
raise click.ClickException(
f"Cannot drop the last active track from pattern #{patternId}. Patterns must define at least one non-dropped track."
)
track.index = int(trackDescriptor.getIndex()) track.index = int(trackDescriptor.getIndex())
track.source_index = int(trackDescriptor.getSourceIndex())
track.dropped = 1 if trackDescriptor.isDropped() else 0
track.track_type = int(trackDescriptor.getType().index()) track.track_type = int(trackDescriptor.getType().index())
track.codec_name = str(trackDescriptor.getFormatDescriptor().identifier()) track.codec_name = str(trackDescriptor.getFormatDescriptor().identifier())
@@ -255,6 +270,17 @@ class TrackController():
f"Cannot delete the last track from pattern #{patternId}. Patterns must define at least one track." f"Cannot delete the last track from pattern #{patternId}. Patterns must define at least one track."
) )
if not track.isDropped():
activeSiblingTracks = [
siblingTrack
for siblingTrack in siblingTracks
if not siblingTrack.isDropped()
]
if len(activeSiblingTracks) <= 1:
raise click.ClickException(
f"Cannot delete the last active track from pattern #{patternId}. Patterns must define at least one non-dropped track."
)
index = 0 index = 0
for track in siblingTracks: for track in siblingTracks:

View File

@@ -21,6 +21,7 @@ class TrackDescriptor:
SUB_INDEX_KEY = "sub_index" SUB_INDEX_KEY = "sub_index"
PATTERN_ID_KEY = "pattern_id" PATTERN_ID_KEY = "pattern_id"
EXTERNAL_SOURCE_FILE_PATH_KEY = "external_source_file" EXTERNAL_SOURCE_FILE_PATH_KEY = "external_source_file"
DROPPED_KEY = "dropped"
DISPOSITION_SET_KEY = "disposition_set" DISPOSITION_SET_KEY = "disposition_set"
TAGS_KEY = "tags" TAGS_KEY = "tags"
@@ -77,6 +78,8 @@ class TrackDescriptor:
else: else:
self.__externalSourceFilePath = '' self.__externalSourceFilePath = ''
self.__dropped = bool(kwargs.get(TrackDescriptor.DROPPED_KEY, False))
if TrackDescriptor.INDEX_KEY in kwargs.keys(): if TrackDescriptor.INDEX_KEY in kwargs.keys():
if type(kwargs[TrackDescriptor.INDEX_KEY]) is not int: if type(kwargs[TrackDescriptor.INDEX_KEY]) is not int:
raise TypeError( raise TypeError(
@@ -378,11 +381,18 @@ class TrackDescriptor:
def getExternalSourceFilePath(self): def getExternalSourceFilePath(self):
return self.__externalSourceFilePath return self.__externalSourceFilePath
def isDropped(self) -> bool:
return self.__dropped
def setDropped(self, dropped: bool):
self.__dropped = bool(dropped)
def clone(self, context: dict | None = None): def clone(self, context: dict | None = None):
kwargs = { kwargs = {
TrackDescriptor.ID_KEY: int(self.__trackId), TrackDescriptor.ID_KEY: int(self.__trackId),
TrackDescriptor.PATTERN_ID_KEY: int(self.__patternId), TrackDescriptor.PATTERN_ID_KEY: int(self.__patternId),
TrackDescriptor.EXTERNAL_SOURCE_FILE_PATH_KEY: str(self.__externalSourceFilePath), TrackDescriptor.EXTERNAL_SOURCE_FILE_PATH_KEY: str(self.__externalSourceFilePath),
TrackDescriptor.DROPPED_KEY: bool(self.__dropped),
TrackDescriptor.INDEX_KEY: int(self.__index), TrackDescriptor.INDEX_KEY: int(self.__index),
TrackDescriptor.SOURCE_INDEX_KEY: int(self.__sourceIndex), TrackDescriptor.SOURCE_INDEX_KEY: int(self.__sourceIndex),
TrackDescriptor.SUB_INDEX_KEY: int(self.__subIndex), TrackDescriptor.SUB_INDEX_KEY: int(self.__subIndex),

View File

@@ -1,7 +1,7 @@
import click import click
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, SelectionList, Select, DataTable, Input from textual.widgets import Header, Footer, Static, Button, SelectionList, Select, DataTable, Input, Checkbox
from textual.containers import Grid from textual.containers import Grid
from textual.widgets._data_table import CellDoesNotExist from textual.widgets._data_table import CellDoesNotExist
@@ -146,6 +146,7 @@ class TrackDetailsScreen(Screen):
self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED self.__audioLayout = AudioLayout.LAYOUT_UNDEFINED
self.__index = index self.__index = index
self.__subIndex = subIndex self.__subIndex = subIndex
self.__dropped = False
self.__draftTrackTags = {} self.__draftTrackTags = {}
initial_language = IsoLanguage.UNDEFINED initial_language = IsoLanguage.UNDEFINED
initial_title = "" initial_title = ""
@@ -156,6 +157,7 @@ class TrackDetailsScreen(Screen):
self.__audioLayout = trackDescriptor.getAudioLayout() self.__audioLayout = trackDescriptor.getAudioLayout()
self.__index = trackDescriptor.getIndex() self.__index = trackDescriptor.getIndex()
self.__subIndex = trackDescriptor.getSubIndex() self.__subIndex = trackDescriptor.getSubIndex()
self.__dropped = trackDescriptor.isDropped()
self.__draftTrackTags = { self.__draftTrackTags = {
key: value key: value
for key, value in trackDescriptor.getTags().items() for key, value in trackDescriptor.getTags().items()
@@ -254,6 +256,7 @@ class TrackDetailsScreen(Screen):
self.query_one("#type_select", Select).value = self.__trackType self.query_one("#type_select", Select).value = self.__trackType
self.query_one("#audio_layout_select", Select).value = self.__audioLayout self.query_one("#audio_layout_select", Select).value = self.__audioLayout
self.query_one("#drop_checkbox", Checkbox).value = self.__dropped
for disposition in TrackDisposition: for disposition in TrackDisposition:
@@ -283,6 +286,7 @@ class TrackDetailsScreen(Screen):
if self.__metadataOnly: if self.__metadataOnly:
self.query_one("#type_select", Select).disabled = True self.query_one("#type_select", Select).disabled = True
self.query_one("#audio_layout_select", Select).disabled = True self.query_one("#audio_layout_select", Select).disabled = True
self.query_one("#drop_checkbox", Checkbox).disabled = True
def on_select_changed(self, event: Select.Changed) -> None: def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id == "language_select": if event.select.id == "language_select":
@@ -344,7 +348,8 @@ class TrackDetailsScreen(Screen):
) )
# Row 8 # Row 8
yield Static(" ", classes="five") yield Static(t("Drop"))
yield Checkbox(id="drop_checkbox", classes="four")
# Row 9 # Row 9
yield Static(" ", classes="five") yield Static(" ", classes="five")
@@ -462,6 +467,9 @@ class TrackDetailsScreen(Screen):
trackTags["title"] = title trackTags["title"] = title
kwargs[TrackDescriptor.TAGS_KEY] = trackTags kwargs[TrackDescriptor.TAGS_KEY] = trackTags
kwargs[TrackDescriptor.DROPPED_KEY] = bool(
self.query_one("#drop_checkbox", Checkbox).value
)
dispositionFlags = sum( dispositionFlags = sum(
[2 ** flag for flag in self.query_one("#dispositions_selection_list", SelectionList).selected] [2 ** flag for flag in self.query_one("#dispositions_selection_list", SelectionList).selected]
@@ -504,6 +512,7 @@ class TrackDetailsScreen(Screen):
descriptor descriptor
for descriptor in siblingTrackList for descriptor in siblingTrackList
if descriptor.getType() == trackDescriptor.getType() if descriptor.getType() == trackDescriptor.getType()
and not descriptor.isDropped()
] ]
numDefaultTracks = len( numDefaultTracks = len(
@@ -527,10 +536,12 @@ class TrackDetailsScreen(Screen):
trackDescriptor.setSubIndex(int(self.__subIndex)) trackDescriptor.setSubIndex(int(self.__subIndex))
if ( if (
TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet() not trackDescriptor.isDropped()
and TrackDisposition.DEFAULT in trackDescriptor.getDispositionSet()
and numDefaultTracks and numDefaultTracks
) or ( ) or (
TrackDisposition.FORCED in trackDescriptor.getDispositionSet() not trackDescriptor.isDropped()
and TrackDisposition.FORCED in trackDescriptor.getDispositionSet()
and numForcedTracks and numForcedTracks
): ):

View File

@@ -144,6 +144,73 @@ class SubtrackMappingBundleTests(unittest.TestCase):
self.assertIn("first embedded subtitle", extracted_subtitle) self.assertIn("first embedded subtitle", extracted_subtitle)
self.assertNotIn("second embedded subtitle", extracted_subtitle) self.assertNotIn("second embedded subtitle", extracted_subtitle)
def test_pattern_dropped_track_is_omitted_like_deleted_track(self):
source_filename = "dropped_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=("kept subtitle payload",),
),
SourceTrackSpec(
TrackType.SUBTITLE,
identity="subtitle-3",
language="eng",
subtitle_lines=("dropped subtitle payload",),
),
],
)
prepare_pattern_database(
self.database_path,
r"^dropped_(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.SUBTITLE,
dropped=True,
),
],
)
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)
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"],
)
self.assertNotIn(
"subtitle-3",
[get_tag(stream, "THIS_IS") for stream in streams],
)
extracted_subtitle = extract_first_subtitle_text(self.workdir, output_path)
self.assertIn("kept subtitle payload", extracted_subtitle)
self.assertNotIn("dropped subtitle payload", extracted_subtitle)
def test_cli_rearrange_streams_reorders_tracks_without_database_pattern(self): def test_cli_rearrange_streams_reorders_tracks_without_database_pattern(self):
source_filename = "cli_s01e01.mkv" source_filename = "cli_s01e01.mkv"
source_path = create_source_fixture( source_path = create_source_fixture(

View File

@@ -56,6 +56,7 @@ class PatternTrackSpec:
track_type: TrackType track_type: TrackType
tags: Mapping[str, str] = field(default_factory=dict) tags: Mapping[str, str] = field(default_factory=dict)
dispositions: tuple[TrackDisposition, ...] = () dispositions: tuple[TrackDisposition, ...] = ()
dropped: bool = False
audio_layout: AudioLayout = AudioLayout.LAYOUT_STEREO audio_layout: AudioLayout = AudioLayout.LAYOUT_STEREO
attachment_format: AttachmentFormat = AttachmentFormat.UNKNOWN attachment_format: AttachmentFormat = AttachmentFormat.UNKNOWN
@@ -298,6 +299,7 @@ def prepare_pattern_database(database_path: Path, filename_pattern: str, track_s
TrackDescriptor.TRACK_TYPE_KEY: track.track_type, TrackDescriptor.TRACK_TYPE_KEY: track.track_type,
TrackDescriptor.TAGS_KEY: dict(track.tags), TrackDescriptor.TAGS_KEY: dict(track.tags),
TrackDescriptor.DISPOSITION_SET_KEY: set(track.dispositions), TrackDescriptor.DISPOSITION_SET_KEY: set(track.dispositions),
TrackDescriptor.DROPPED_KEY: track.dropped,
} }
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

View File

@@ -149,6 +149,49 @@ class DatabaseContextTests(unittest.TestCase):
) )
cursor.execute("DROP TABLE shifted_seasons_current") cursor.execute("DROP TABLE shifted_seasons_current")
def rewrite_tracks_table_without_dropped(self, cursor):
cursor.execute("ALTER TABLE tracks RENAME TO tracks_current")
cursor.execute(
"""
CREATE TABLE tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_type INTEGER,
"index" INTEGER,
source_index INTEGER,
pattern_id INTEGER,
disposition_flags INTEGER,
codec_name VARCHAR,
audio_layout INTEGER,
FOREIGN KEY(pattern_id) REFERENCES patterns(id) ON DELETE CASCADE
)
"""
)
cursor.execute(
"""
INSERT INTO tracks (
id,
track_type,
"index",
source_index,
pattern_id,
disposition_flags,
codec_name,
audio_layout
)
SELECT
id,
track_type,
"index",
source_index,
pattern_id,
disposition_flags,
codec_name,
audio_layout
FROM tracks_current
"""
)
cursor.execute("DROP TABLE tracks_current")
def test_database_context_bootstraps_new_database_with_current_version(self): def test_database_context_bootstraps_new_database_with_current_version(self):
with patch("ffx.database.Base.metadata.create_all", wraps=Base.metadata.create_all) as mocked_create_all: with patch("ffx.database.Base.metadata.create_all", wraps=Base.metadata.create_all) as mocked_create_all:
context = databaseContext(str(self.database_path)) context = databaseContext(str(self.database_path))
@@ -321,6 +364,41 @@ class DatabaseContextTests(unittest.TestCase):
mocked_confirm.assert_not_called() mocked_confirm.assert_not_called()
mocked_echo.assert_not_called() mocked_echo.assert_not_called()
def test_database_context_repairs_current_track_schema_without_version_bump(self):
context = databaseContext(str(self.database_path))
context["engine"].dispose()
connection = sqlite3.connect(self.database_path)
try:
cursor = connection.cursor()
cursor.execute("PRAGMA foreign_keys=OFF")
self.rewrite_tracks_table_without_dropped(cursor)
connection.commit()
finally:
connection.close()
with patch("ffx.database.click.confirm") as mocked_confirm, patch(
"ffx.database.click.echo"
) as mocked_echo:
reopened_context = databaseContext(str(self.database_path))
try:
self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context))
connection = sqlite3.connect(self.database_path)
try:
column_names = {
row[1]
for row in connection.execute("PRAGMA table_info(tracks)").fetchall()
}
self.assertIn("dropped", column_names)
finally:
connection.close()
finally:
reopened_context["engine"].dispose()
mocked_confirm.assert_not_called()
mocked_echo.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -65,6 +65,7 @@ def make_track_descriptor(
track_type: TrackType = TrackType.VIDEO, track_type: TrackType = TrackType.VIDEO,
title: str = "", title: str = "",
dispositions: set[TrackDisposition] | None = None, dispositions: set[TrackDisposition] | None = None,
dropped: bool = False,
) -> TrackDescriptor: ) -> TrackDescriptor:
kwargs = { kwargs = {
TrackDescriptor.INDEX_KEY: index, TrackDescriptor.INDEX_KEY: index,
@@ -72,6 +73,7 @@ def make_track_descriptor(
TrackDescriptor.TRACK_TYPE_KEY: track_type, TrackDescriptor.TRACK_TYPE_KEY: track_type,
TrackDescriptor.TAGS_KEY: {"title": title} if title else {}, TrackDescriptor.TAGS_KEY: {"title": title} if title else {},
TrackDescriptor.DISPOSITION_SET_KEY: dispositions or set(), TrackDescriptor.DISPOSITION_SET_KEY: dispositions or set(),
TrackDescriptor.DROPPED_KEY: dropped,
} }
if track_type == TrackType.AUDIO: if track_type == TrackType.AUDIO:
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_STEREO kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_STEREO
@@ -193,6 +195,47 @@ 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_rejects_all_dropped_patterns(self):
self.add_show(1, "Dropped Pattern Show")
with self.assertRaises(InvalidPatternSchemaError) as caught:
self.pattern_controller.savePatternSchema(
{
"show_id": 1,
"pattern": r"^dropped_(s[0-9]+e[0-9]+)\.mkv$",
},
trackDescriptors=[make_track_descriptor(0, dropped=True)],
)
self.assertIn("non-dropped track", str(caught.exception))
def test_dropped_pattern_track_is_not_in_conversion_media_descriptor(self):
pattern_id = self.save_pattern(
1,
r"^active_(s[0-9]+e[0-9]+)\.mkv$",
tracks=[
make_track_descriptor(0, track_type=TrackType.VIDEO),
make_track_descriptor(1, track_type=TrackType.AUDIO, dropped=True),
make_track_descriptor(2, track_type=TrackType.SUBTITLE),
],
)
Session = self.context["database"]["session"]
session = Session()
try:
pattern = session.query(Pattern).filter(Pattern.id == pattern_id).first()
media_descriptor = pattern.getMediaDescriptor(self.context)
tracks = media_descriptor.getTrackDescriptors()
finally:
session.close()
self.assertEqual(
[TrackType.VIDEO, TrackType.SUBTITLE],
[track.getType() for track in tracks],
)
self.assertEqual([0, 1], [track.getIndex() for track in tracks])
self.assertEqual([0, 2], [track.getSourceIndex() for track in tracks])
def test_save_pattern_schema_does_not_persist_attachment_tracks(self): def test_save_pattern_schema_does_not_persist_attachment_tracks(self):
pattern_id = self.save_pattern( pattern_id = self.save_pattern(
1, 1,
@@ -257,6 +300,17 @@ class PatternManagementTests(unittest.TestCase):
self.assertIn("last track", str(caught.exception)) self.assertIn("last track", str(caught.exception))
def test_track_controller_refuses_to_drop_last_active_track(self):
pattern_id = self.save_pattern(1, r"^drop_(s[0-9]+e[0-9]+)\.mkv$")
track = self.track_controller.getTrack(pattern_id, 0)
descriptor = track.getDescriptor()
descriptor.setDropped(True)
with self.assertRaises(click.ClickException) as caught:
self.track_controller.updateTrack(track.getId(), descriptor)
self.assertIn("last active track", str(caught.exception))
def test_exact_duplicate_pattern_definition_is_rejected(self): def test_exact_duplicate_pattern_definition_is_rejected(self):
self.save_pattern(1, r"^unique_(s[0-9]+e[0-9]+)\.mkv$") self.save_pattern(1, r"^unique_(s[0-9]+e[0-9]+)\.mkv$")

View File

@@ -131,13 +131,14 @@ class FakeSelectionListWidget:
return None return None
def make_track_descriptor(index, sub_index, track_type): def make_track_descriptor(index, sub_index, track_type, *, dropped=False):
return TrackDescriptor( return TrackDescriptor(
index=index, index=index,
sub_index=sub_index, sub_index=sub_index,
track_type=track_type, track_type=track_type,
codec_name=TrackCodec.UNKNOWN, codec_name=TrackCodec.UNKNOWN,
audio_layout=AudioLayout.LAYOUT_UNDEFINED, audio_layout=AudioLayout.LAYOUT_UNDEFINED,
dropped=dropped,
) )
@@ -188,6 +189,7 @@ class TagTableScreenStateTests(unittest.TestCase):
widgets = { widgets = {
"#type_select": FakeValueWidget(TrackType.AUDIO), "#type_select": FakeValueWidget(TrackType.AUDIO),
"#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_STEREO), "#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_STEREO),
"#drop_checkbox": FakeValueWidget(True),
"#language_select": FakeValueWidget(IsoLanguage.GERMAN), "#language_select": FakeValueWidget(IsoLanguage.GERMAN),
"#title_input": FakeInputWidget("German Audio"), "#title_input": FakeInputWidget("German Audio"),
"#dispositions_selection_list": FakeSelectionListWidget({0, 6}), "#dispositions_selection_list": FakeSelectionListWidget({0, 6}),
@@ -201,6 +203,7 @@ class TagTableScreenStateTests(unittest.TestCase):
self.assertEqual("deu", descriptor.getTags()["language"]) self.assertEqual("deu", descriptor.getTags()["language"])
self.assertEqual("German Audio", descriptor.getTitle()) self.assertEqual("German Audio", descriptor.getTitle())
self.assertEqual("value", descriptor.getTags()["KEEP"]) self.assertEqual("value", descriptor.getTags()["KEEP"])
self.assertTrue(descriptor.isDropped())
def test_track_details_screen_preserves_attachment_format_for_attachment_tracks(self): def test_track_details_screen_preserves_attachment_format_for_attachment_tracks(self):
screen = object.__new__(TrackDetailsScreen) screen = object.__new__(TrackDetailsScreen)
@@ -216,6 +219,7 @@ class TagTableScreenStateTests(unittest.TestCase):
widgets = { widgets = {
"#type_select": FakeValueWidget(TrackType.ATTACHMENT), "#type_select": FakeValueWidget(TrackType.ATTACHMENT),
"#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_UNDEFINED), "#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_UNDEFINED),
"#drop_checkbox": FakeValueWidget(False),
"#language_select": FakeValueWidget(Select.NULL), "#language_select": FakeValueWidget(Select.NULL),
"#title_input": FakeInputWidget(""), "#title_input": FakeInputWidget(""),
"#dispositions_selection_list": FakeSelectionListWidget(set()), "#dispositions_selection_list": FakeSelectionListWidget(set()),
@@ -307,6 +311,7 @@ class TagTableScreenStateTests(unittest.TestCase):
audio_layout=AudioLayout.LAYOUT_STEREO, audio_layout=AudioLayout.LAYOUT_STEREO,
tags={"language": "ger"}, tags={"language": "ger"},
) )
screen._TrackDetailsScreen__dropped = False
screen._TrackDetailsScreen__metadataOnly = True screen._TrackDetailsScreen__metadataOnly = True
screen._TrackDetailsScreen__titleAutoManaged = True screen._TrackDetailsScreen__titleAutoManaged = True
screen._TrackDetailsScreen__suppressTitleChanged = False screen._TrackDetailsScreen__suppressTitleChanged = False
@@ -323,6 +328,7 @@ class TagTableScreenStateTests(unittest.TestCase):
"#pattern_label": FakeStaticWidget(), "#pattern_label": FakeStaticWidget(),
"#type_select": FakeValueWidget(None), "#type_select": FakeValueWidget(None),
"#audio_layout_select": FakeValueWidget(None), "#audio_layout_select": FakeValueWidget(None),
"#drop_checkbox": FakeValueWidget(False),
"#dispositions_selection_list": FakeSelectionListWidget(set()), "#dispositions_selection_list": FakeSelectionListWidget(set()),
"#language_select": FakeValueWidget(None), "#language_select": FakeValueWidget(None),
"#title_input": FakeInputWidget(""), "#title_input": FakeInputWidget(""),
@@ -355,7 +361,7 @@ class TagTableScreenStateTests(unittest.TestCase):
def test_pattern_details_screen_reads_selected_track_from_row_mapping(self): def test_pattern_details_screen_reads_selected_track_from_row_mapping(self):
first_track = make_track_descriptor(0, 0, TrackType.VIDEO) first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE) second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE, dropped=True)
screen = object.__new__(PatternDetailsScreen) screen = object.__new__(PatternDetailsScreen)
screen.tracksTable = FakeTagTable() screen.tracksTable = FakeTagTable()
@@ -367,6 +373,7 @@ class TagTableScreenStateTests(unittest.TestCase):
screen.tracksTable.select_row("row-1") screen.tracksTable.select_row("row-1")
self.assertIs(second_track, screen.getSelectedTrackDescriptor()) self.assertIs(second_track, screen.getSelectedTrackDescriptor())
self.assertEqual("Ja", screen.tracksTable.rows["row-1"][9])
def test_pattern_details_screen_reads_selected_tag_from_raw_row_mapping(self): def test_pattern_details_screen_reads_selected_tag_from_raw_row_mapping(self):
screen = object.__new__(PatternDetailsScreen) screen = object.__new__(PatternDetailsScreen)