5 Commits

Author SHA1 Message Date
Javanaut
f9432ec564 v0.4.5ff 2026-07-08 11:09:29 +02:00
Javanaut
8f5873fbab v0.4.5 2026-07-08 10:54:51 +02:00
Javanaut
12e5438d22 fix capture group 2026-07-08 10:46:08 +02:00
Javanaut
6f09c80aaf ffcb 2026-07-08 09:47:07 +02:00
Javanaut
f49d6f4838 ff 2026-07-08 09:37:11 +02:00
11 changed files with 361 additions and 52 deletions

View File

@@ -1,6 +1,11 @@
Version History Version History
=============== ===============
0.4.5
-----
- Release version bumped to 0.4.5.
0.4.4 0.4.4
----- -----

View File

@@ -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.4" version = "0.4.5"
license = {file = "LICENSE.md"} license = {file = "LICENSE.md"}
dependencies = [ dependencies = [
"requests", "requests",

View File

@@ -1,4 +1,4 @@
VERSION='0.4.4' VERSION='0.4.5'
DATABASE_VERSION = 3 DATABASE_VERSION = 3
DEFAULT_QUALITY = 32 DEFAULT_QUALITY = 32

View File

@@ -1,9 +1,11 @@
from textual.app import App from textual.app import App
from .i18n import set_current_language, t from .i18n import set_current_language, t
from .confirm_screen import ConfirmScreen
from .shows_screen import ShowsScreen from .shows_screen import ShowsScreen
from .inspect_details_screen import InspectDetailsScreen from .inspect_details_screen import InspectDetailsScreen
from .media_edit_screen import MediaEditScreen from .media_edit_screen import MediaEditScreen
from .pattern_controller import InvalidPatternCaptureGroupError, PatternController
from .screen_support import configure_screen_log_handler, set_screen_log_pane_enabled from .screen_support import configure_screen_log_handler, set_screen_log_pane_enabled
@@ -40,7 +42,20 @@ class FfxApp(App):
self.push_screen(ShowsScreen()) self.push_screen(ShowsScreen())
if self.context['command'] == 'inspect': if self.context['command'] == 'inspect':
self.push_screen(InspectDetailsScreen()) try:
self.push_screen(InspectDetailsScreen())
except InvalidPatternCaptureGroupError as ex:
self._invalid_pattern_error = ex
self.push_screen(
ConfirmScreen(
t(
"Pattern #{pattern_id} matches the filename but has no capture group for the season/episode indicator. Delete this pattern and quit?"
).format(pattern_id=ex.pattern_id),
confirm_label=t("Delete"),
cancel_label=t("Quit"),
),
self._handle_invalid_pattern_confirmation,
)
if self.context['command'] == 'edit': if self.context['command'] == 'edit':
self.push_screen(MediaEditScreen()) self.push_screen(MediaEditScreen())
@@ -49,3 +64,13 @@ class FfxApp(App):
def getContext(self): def getContext(self):
"""Data 'output' method""" """Data 'output' method"""
return self.context return self.context
def _handle_invalid_pattern_confirmation(self, confirmed: bool):
invalid_pattern_error = getattr(self, "_invalid_pattern_error", None)
if confirmed and invalid_pattern_error is not None:
PatternController(self.context).deletePattern(
invalid_pattern_error.pattern_id
)
self.exit()

View File

@@ -10,6 +10,7 @@ from textual.widgets._data_table import CellDoesNotExist
from ffx.file_properties import FileProperties from ffx.file_properties import FileProperties
from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
from ffx.pattern_controller import InvalidPatternCaptureGroupError
from ffx.show_descriptor import ShowDescriptor from ffx.show_descriptor import ShowDescriptor
from ffx.track_descriptor import TrackDescriptor from ffx.track_descriptor import TrackDescriptor
@@ -472,11 +473,15 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
): ):
mediaTags[tagKey] = tagValue mediaTags[tagKey] = tagValue
patternId = self._pc.savePatternSchema( try:
patternObj, patternId = self._pc.savePatternSchema(
trackDescriptors=self._sourceMediaDescriptor.getTrackDescriptors(), patternObj,
mediaTags=mediaTags, trackDescriptors=self._sourceMediaDescriptor.getTrackDescriptors(),
) mediaTags=mediaTags,
)
except InvalidPatternCaptureGroupError as ex:
self.notify(str(ex), severity="error")
return
if patternId: if patternId:
self.reloadProperties(reset_draft=True) self.reloadProperties(reset_draft=True)
self.updateMediaTags() self.updateMediaTags()
@@ -498,10 +503,14 @@ class InspectDetailsScreen(MediaWorkflowScreenBase):
patternObj patternObj
and self._currentPattern.getPattern() != patternObj["pattern"] and self._currentPattern.getPattern() != patternObj["pattern"]
): ):
updated = self._pc.updatePattern( try:
self._currentPattern.getId(), updated = self._pc.updatePattern(
patternObj, self._currentPattern.getId(),
) patternObj,
)
except InvalidPatternCaptureGroupError as ex:
self.notify(str(ex), severity="error")
return False
if updated: if updated:
self.reloadProperties(reset_draft=True) self.reloadProperties(reset_draft=True)
self.updateMediaTags() self.updateMediaTags()

View File

@@ -19,6 +19,21 @@ class InvalidPatternSchemaError(click.ClickException):
pass pass
class InvalidPatternCaptureGroupError(click.ClickException):
def __init__(self, pattern_id: int, pattern_expression: str):
self.pattern_id = int(pattern_id)
self.pattern_expression = str(pattern_expression)
pattern_label = (
f"Pattern #{self.pattern_id}"
if self.pattern_id >= 0
else "Pattern"
)
super().__init__(
f"{pattern_label} must contain at least one capture group "
f"for the season/episode indicator: {self.pattern_expression!r}"
)
class PatternController: class PatternController:
_compiled_regex_cache: dict[str, re.Pattern] = {} _compiled_regex_cache: dict[str, re.Pattern] = {}
@@ -66,6 +81,13 @@ class PatternController:
cls._compiled_regex_cache[expression_text] = compiled cls._compiled_regex_cache[expression_text] = compiled
return compiled return compiled
@classmethod
def _validate_pattern_capture_groups(cls, pattern_id: int, expression: str):
compiled = cls._compile_pattern_expression(pattern_id, expression)
if compiled.groups < 1:
raise InvalidPatternCaptureGroupError(pattern_id, expression)
return compiled
def _coerce_pattern_fields(self, patternObj): def _coerce_pattern_fields(self, patternObj):
return { return {
"show_id": int(patternObj["show_id"]), "show_id": int(patternObj["show_id"]),
@@ -212,6 +234,10 @@ class PatternController:
fields = self._coerce_pattern_fields(patternObj) fields = self._coerce_pattern_fields(patternObj)
normalized_tracks = self._normalize_track_descriptors(trackDescriptors) normalized_tracks = self._normalize_track_descriptors(trackDescriptors)
normalized_tags = self._coerce_media_tags(mediaTags) normalized_tags = self._coerce_media_tags(mediaTags)
self._validate_pattern_capture_groups(
patternId if patternId is not None else -1,
fields["pattern"],
)
session = None session = None
try: try:
@@ -274,6 +300,7 @@ class PatternController:
def updatePattern(self, patternId, patternObj): def updatePattern(self, patternId, patternObj):
fields = self._coerce_pattern_fields(patternObj) fields = self._coerce_pattern_fields(patternObj)
self._validate_pattern_capture_groups(patternId, fields["pattern"])
session = None session = None
try: try:
@@ -409,6 +436,11 @@ class PatternController:
patternMatch = compiled.search(str(filename)) patternMatch = compiled.search(str(filename))
if patternMatch is None: if patternMatch is None:
continue continue
if not patternMatch.groups():
raise InvalidPatternCaptureGroupError(
pattern.getId(),
pattern.getPattern(),
)
self._validate_persisted_pattern(pattern) self._validate_persisted_pattern(pattern)
matches.append({"match": patternMatch, "pattern": pattern}) matches.append({"match": patternMatch, "pattern": pattern})

View File

@@ -2,14 +2,15 @@ import click, re
from typing import List from typing import List
from textual import events from textual import events
from textual.css.query import NoMatches
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, Input, DataTable, TextArea from textual.widgets import Header, Footer, Static, Button, Input, DataTable, TextArea
from textual.containers import Grid from textual.containers import Grid
from ffx.model.pattern import Pattern from ffx.model.pattern import Pattern
from ffx.pattern_controller import InvalidPatternCaptureGroupError
from .track_details_screen import TrackDetailsScreen from .track_details_screen import TrackDetailsScreen
from .track_delete_screen import TrackDeleteScreen
from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen
from .shifted_season_details_screen import ShiftedSeasonDetailsScreen from .shifted_season_details_screen import ShiftedSeasonDetailsScreen
@@ -154,6 +155,13 @@ class PatternDetailsScreen(Screen):
def updateTracks(self): def updateTracks(self):
selectedTrack = self.getSelectedTrackDescriptor()
selectedTrackIndex = (
self._track_row_index(selectedTrack)
if selectedTrack is not None
else None
)
selectedCursorColumn = getattr(self.tracksTable.cursor_coordinate, "column", 0)
self.tracksTable.clear() self.tracksTable.clear()
self.__trackRowData = {} self.__trackRowData = {}
@@ -161,6 +169,7 @@ class PatternDetailsScreen(Screen):
tracks = self.getCurrentTrackDescriptors() tracks = self.getCurrentTrackDescriptors()
typeCounter = {} typeCounter = {}
restoredRowIndex = None
td: TrackDescriptor td: TrackDescriptor
for td in tracks: for td in tracks:
@@ -190,9 +199,72 @@ class PatternDetailsScreen(Screen):
row_key = self.tracksTable.add_row(*map(str, row)) row_key = self.tracksTable.add_row(*map(str, row))
self.__trackRowData[row_key] = td self.__trackRowData[row_key] = td
if (
selectedTrack is not None
and self._descriptor_refs_same_track(td, selectedTrack)
):
restoredRowIndex = len(self.__trackRowData) - 1
typeCounter[trackType] += 1 typeCounter[trackType] += 1
if restoredRowIndex is None and selectedTrackIndex is not None and self.__trackRowData:
restoredRowIndex = min(selectedTrackIndex, len(self.__trackRowData) - 1)
if restoredRowIndex is not None:
self.tracksTable.move_cursor(
row=restoredRowIndex,
column=selectedCursorColumn,
animate=False,
scroll=False,
)
self.updateTrackDropButtonState()
def _track_row_index(self, trackDescriptor: TrackDescriptor | None):
if trackDescriptor is None:
return None
for rowIndex, descriptor in enumerate(self.__trackRowData.values()):
if self._descriptor_refs_same_track(descriptor, trackDescriptor):
return rowIndex
return None
def _descriptor_refs_same_track(
self,
descriptor: TrackDescriptor,
otherDescriptor: TrackDescriptor,
) -> bool:
if descriptor.getId() != -1 and otherDescriptor.getId() != -1:
return descriptor.getId() == otherDescriptor.getId()
return (
descriptor.getPatternId() == otherDescriptor.getPatternId()
and descriptor.getIndex() == otherDescriptor.getIndex()
and descriptor.getSubIndex() == otherDescriptor.getSubIndex()
and descriptor.getType() == otherDescriptor.getType()
)
def updateTrackDropButtonState(self):
try:
button = self.query_one("#button_toggle_track_drop", Button)
except (NoMatches, AttributeError):
return
selectedTrack = self.getSelectedTrackDescriptor()
button.disabled = selectedTrack is None
button.variant = (
"warning"
if selectedTrack is not None and selectedTrack.isDropped()
else "default"
)
def on_data_table_row_highlighted(
self,
event: DataTable.RowHighlighted,
) -> None:
if event.data_table.id == "tracks_table":
self.updateTrackDropButtonState()
def getCurrentTrackDescriptors(self) -> List[TrackDescriptor]: def getCurrentTrackDescriptors(self) -> List[TrackDescriptor]:
if self.__pattern is not None: if self.__pattern is not None:
@@ -483,7 +555,7 @@ class PatternDetailsScreen(Screen):
yield Static(t("Streams")) yield Static(t("Streams"))
yield Button(t("Add"), id="button_add_track") yield Button(t("Add"), id="button_add_track")
yield Button(t("Edit"), id="button_edit_track") yield Button(t("Edit"), id="button_edit_track")
yield Button(t("Delete"), id="button_delete_track") yield Button(t("Drop"), id="button_toggle_track_drop")
yield Static(" ") yield Static(" ")
yield Button(t("Up"), id="button_track_up") yield Button(t("Up"), id="button_track_up")
@@ -504,7 +576,7 @@ class PatternDetailsScreen(Screen):
yield Static(" ", classes="five") yield Static(" ", classes="five")
# Row 20 # Row 20
yield Static(" ", classes="seven") yield Static(" ", id="messagestatic", classes="seven")
yield build_screen_log_pane() yield build_screen_log_pane()
yield Footer() yield Footer()
@@ -539,6 +611,12 @@ class PatternDetailsScreen(Screen):
def getNotesFromInput(self): def getNotesFromInput(self):
return str(self.query_one("#notes_textarea", TextArea).text) return str(self.query_one("#notes_textarea", TextArea).text)
def setMessage(self, message: str):
try:
self.query_one("#messagestatic", Static).update(str(message) or " ")
except (NoMatches, AttributeError):
pass
def getSelectedTrackDescriptor(self): def getSelectedTrackDescriptor(self):
@@ -586,25 +664,26 @@ class PatternDetailsScreen(Screen):
patternDescriptor['quality'] = self.getQualityFromInput() patternDescriptor['quality'] = self.getQualityFromInput()
patternDescriptor['notes'] = self.getNotesFromInput() patternDescriptor['notes'] = self.getNotesFromInput()
if self.__pattern is not None: try:
if self.__pattern is not None:
if self.__pc.updatePattern(self.__pattern.getId(), patternDescriptor): if self.__pc.updatePattern(self.__pattern.getId(), patternDescriptor):
self.dismiss(patternDescriptor) self.dismiss(patternDescriptor)
else: else:
#TODO: Meldung self.setMessage(t("Unable to update pattern."))
self.app.pop_screen()
else:
patternId = self.__pc.savePatternSchema(
patternDescriptor,
trackDescriptors=self.__draftTracks,
mediaTags=self.__draftTags,
)
if patternId:
self.dismiss(patternDescriptor)
else: else:
#TODO: Meldung patternId = self.__pc.savePatternSchema(
self.app.pop_screen() patternDescriptor,
trackDescriptors=self.__draftTracks,
mediaTags=self.__draftTags,
)
if patternId:
self.dismiss(patternDescriptor)
else:
self.setMessage(t("Unable to save pattern."))
except InvalidPatternCaptureGroupError as ex:
self.setMessage(str(ex))
if event.button.id == "cancel_button": if event.button.id == "cancel_button":
@@ -665,11 +744,8 @@ class PatternDetailsScreen(Screen):
), ),
self.handle_edit_track, self.handle_edit_track,
) )
if event.button.id == "button_delete_track": if event.button.id == "button_toggle_track_drop":
self.app.push_screen( self.toggleSelectedTrackDropped(selectedTrack)
TrackDeleteScreen(trackDescriptor = selectedTrack),
self.handle_delete_track,
)
if event.button.id == "button_add_tag": if event.button.id == "button_add_tag":
@@ -753,6 +829,27 @@ class PatternDetailsScreen(Screen):
self.updateTracks() self.updateTracks()
def toggleSelectedTrackDropped(self, trackDescriptor: TrackDescriptor):
if trackDescriptor is None:
return
updatedTrack = trackDescriptor.clone(context=self.context)
updatedTrack.setDropped(not trackDescriptor.isDropped())
if self.__pattern is not None:
if not self.__tc.updateTrack(updatedTrack.getId(), updatedTrack):
raise click.ClickException("PatternDetailsScreen.toggleSelectedTrackDropped(): track update failed")
else:
for currentTrack in self.__draftTracks:
if (
currentTrack.getIndex() == trackDescriptor.getIndex()
and currentTrack.getSubIndex() == trackDescriptor.getSubIndex()
):
currentTrack.setDropped(updatedTrack.isDropped())
break
self.updateTracks()
def handle_delete_track(self, trackDescriptor : TrackDescriptor): def handle_delete_track(self, trackDescriptor : TrackDescriptor):
if trackDescriptor is None: if trackDescriptor is None:

View File

@@ -257,11 +257,7 @@ class TrackDetailsScreen(Screen):
self.query_one("#audio_layout_select", Select).value = self.__audioLayout self.query_one("#audio_layout_select", Select).value = self.__audioLayout
# CB
self.query_one("#drop_checkbox", Checkbox).value = self.__dropped self.query_one("#drop_checkbox", Checkbox).value = self.__dropped
# self.query_one("#drop_checkbox", SelectionList).add_option(
# (t("Yes"), 0, self.__dropped)
# )
for disposition in TrackDisposition: for disposition in TrackDisposition:
@@ -292,9 +288,7 @@ class TrackDetailsScreen(Screen):
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
# CB
self.query_one("#drop_checkbox", Checkbox).disabled = True self.query_one("#drop_checkbox", Checkbox).disabled = True
# self.query_one("#drop_checkbox", SelectionList).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":
@@ -358,9 +352,7 @@ class TrackDetailsScreen(Screen):
# Row 8 # Row 8
yield Static(t("Drop")) yield Static(t("Drop"))
# CB
yield Checkbox(id="drop_checkbox", classes="four") yield Checkbox(id="drop_checkbox", classes="four")
# yield SelectionList[int](id="drop_checkbox", classes="four")
# Row 9 # Row 9
yield Static(" ", classes="five") yield Static(" ", classes="five")
@@ -479,7 +471,7 @@ class TrackDetailsScreen(Screen):
kwargs[TrackDescriptor.TAGS_KEY] = trackTags kwargs[TrackDescriptor.TAGS_KEY] = trackTags
kwargs[TrackDescriptor.DROPPED_KEY] = bool( kwargs[TrackDescriptor.DROPPED_KEY] = bool(
self.query_one("#drop_checkbox", SelectionList).selected self.query_one("#drop_checkbox", Checkbox).value
) )
dispositionFlags = sum( dispositionFlags = sum(

View File

@@ -66,7 +66,7 @@ class PatternManagementCliTests(unittest.TestCase):
[to_track_descriptor(track_descriptors[0])], [to_track_descriptor(track_descriptors[0])],
) )
controller.savePatternSchema( controller.savePatternSchema(
{"show_id": 2, "pattern": r"^dup_.*$"}, {"show_id": 2, "pattern": r"^dup_(s[0-9]+e[0-9]+).*$"},
[to_track_descriptor(track_descriptors[0])], [to_track_descriptor(track_descriptors[0])],
) )
finally: finally:

View File

@@ -21,6 +21,7 @@ from ffx.file_properties import FileProperties # noqa: E402
from ffx.model.pattern import Pattern # noqa: E402 from ffx.model.pattern import Pattern # noqa: E402
from ffx.pattern_controller import ( # noqa: E402 from ffx.pattern_controller import ( # noqa: E402
DuplicatePatternMatchError, DuplicatePatternMatchError,
InvalidPatternCaptureGroupError,
InvalidPatternSchemaError, InvalidPatternSchemaError,
PatternController, PatternController,
) )
@@ -144,7 +145,7 @@ class PatternManagementTests(unittest.TestCase):
def test_match_filename_raises_for_duplicate_matches_in_same_show(self): def test_match_filename_raises_for_duplicate_matches_in_same_show(self):
self.save_pattern(1, r"^same_(s[0-9]+e[0-9]+)\.mkv$") self.save_pattern(1, r"^same_(s[0-9]+e[0-9]+)\.mkv$")
self.save_pattern(1, r"^same_.*$") self.save_pattern(1, r"^same_(.*)$")
with self.assertRaises(DuplicatePatternMatchError) as caught: with self.assertRaises(DuplicatePatternMatchError) as caught:
self.pattern_controller.matchFilename("same_s01e01.mkv") self.pattern_controller.matchFilename("same_s01e01.mkv")
@@ -154,7 +155,7 @@ class PatternManagementTests(unittest.TestCase):
def test_match_filename_raises_for_duplicate_matches_across_shows(self): def test_match_filename_raises_for_duplicate_matches_across_shows(self):
self.save_pattern(1, r"^cross_(s[0-9]+e[0-9]+)\.mkv$") self.save_pattern(1, r"^cross_(s[0-9]+e[0-9]+)\.mkv$")
self.save_pattern(2, r"^cross_.*$") self.save_pattern(2, r"^cross_(.*)$")
with self.assertRaises(DuplicatePatternMatchError) as caught: with self.assertRaises(DuplicatePatternMatchError) as caught:
self.pattern_controller.matchFilename("cross_s01e01.mkv") self.pattern_controller.matchFilename("cross_s01e01.mkv")
@@ -181,6 +182,48 @@ class PatternManagementTests(unittest.TestCase):
match = self.pattern_controller.matchFilename("after_s01e01.mkv") match = self.pattern_controller.matchFilename("after_s01e01.mkv")
self.assertEqual(pattern_id, match["pattern"].getId()) self.assertEqual(pattern_id, match["pattern"].getId())
def test_save_pattern_schema_rejects_pattern_without_capture_group(self):
self.add_show(1, "No Capture Pattern Show")
with self.assertRaises(InvalidPatternCaptureGroupError) as caught:
self.pattern_controller.savePatternSchema(
{
"show_id": 1,
"pattern": r"^nocapture_s[0-9]+e[0-9]+\.mkv$",
},
trackDescriptors=[make_track_descriptor(0)],
)
self.assertIn("capture group", str(caught.exception))
def test_update_pattern_rejects_pattern_without_capture_group(self):
pattern_id = self.save_pattern(1, r"^valid_(s[0-9]+e[0-9]+)\.mkv$")
with self.assertRaises(InvalidPatternCaptureGroupError):
self.pattern_controller.updatePattern(
pattern_id,
{
"show_id": 1,
"pattern": r"^invalid_s[0-9]+e[0-9]+\.mkv$",
"quality": 0,
"notes": "",
},
)
match = self.pattern_controller.matchFilename("valid_s01e01.mkv")
self.assertEqual(pattern_id, match["pattern"].getId())
def test_match_filename_raises_specific_error_for_legacy_pattern_without_capture_group(self):
pattern_id = self.insert_trackless_pattern_row(
1,
r"^legacy_s[0-9]+e[0-9]+\.mkv$",
)
with self.assertRaises(InvalidPatternCaptureGroupError) as caught:
self.pattern_controller.matchFilename("legacy_s01e01.mkv")
self.assertEqual(pattern_id, caught.exception.pattern_id)
def test_save_pattern_schema_rejects_zero_track_patterns(self): def test_save_pattern_schema_rejects_zero_track_patterns(self):
self.add_show(1, "Empty Pattern Show") self.add_show(1, "Empty Pattern Show")
@@ -276,7 +319,7 @@ class PatternManagementTests(unittest.TestCase):
def test_file_properties_skips_pattern_matching_when_disabled(self): def test_file_properties_skips_pattern_matching_when_disabled(self):
self.save_pattern(1, r"^nopattern_(s[0-9]+e[0-9]+)\.mkv$") self.save_pattern(1, r"^nopattern_(s[0-9]+e[0-9]+)\.mkv$")
self.save_pattern(2, r"^nopattern_.*$") self.save_pattern(2, r"^nopattern_(.*)$")
no_pattern_context = dict(self.context) no_pattern_context = dict(self.context)
no_pattern_context["use_pattern"] = False no_pattern_context["use_pattern"] = False

View File

@@ -76,6 +76,12 @@ class FakeTagTable:
def select_row(self, row_key): def select_row(self, row_key):
self._selected_row_key = row_key self._selected_row_key = row_key
def move_cursor(self, *, row=None, column=None, animate=False, scroll=True):
if row is not None and self._row_order:
self._selected_row_key = self._row_order[int(row)]
if column is not None:
self.cursor_coordinate = (self.cursor_coordinate[0], int(column))
def get_row_index(self, row_key): def get_row_index(self, row_key):
return self._row_order.index(row_key) return self._row_order.index(row_key)
@@ -110,6 +116,12 @@ class FakeValueWidget:
self.disabled = False self.disabled = False
class FakeButtonWidget:
def __init__(self):
self.disabled = False
self.variant = "default"
class FakeInputWidget: class FakeInputWidget:
def __init__(self, value): def __init__(self, value):
self.value = value self.value = value
@@ -190,7 +202,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": FakeSelectionListWidget({0}), "#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}),
@@ -220,7 +232,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": FakeSelectionListWidget(set()), "#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()),
@@ -329,7 +341,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": FakeSelectionListWidget(set()), "#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(""),
@@ -376,6 +388,100 @@ class TagTableScreenStateTests(unittest.TestCase):
self.assertIs(second_track, screen.getSelectedTrackDescriptor()) self.assertIs(second_track, screen.getSelectedTrackDescriptor())
self.assertEqual("Ja", screen.tracksTable.rows["row-1"][9]) self.assertEqual("Ja", screen.tracksTable.rows["row-1"][9])
def test_pattern_details_screen_toggles_draft_track_drop_state(self):
first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE)
drop_button = FakeButtonWidget()
screen = object.__new__(PatternDetailsScreen)
screen.context = {"logger": get_ffx_logger()}
screen.tracksTable = FakeTagTable()
screen._PatternDetailsScreen__draftTracks = [first_track, second_track]
screen._PatternDetailsScreen__pattern = None
screen._PatternDetailsScreen__trackRowData = {}
screen.query_one = lambda selector, _widget_type=None: drop_button
screen.updateTracks()
screen.tracksTable.select_row("row-1")
screen.toggleSelectedTrackDropped(screen.getSelectedTrackDescriptor())
self.assertFalse(first_track.isDropped())
self.assertTrue(second_track.isDropped())
self.assertEqual("Ja", list(screen.tracksTable.rows.values())[1][9])
self.assertIs(second_track, screen.getSelectedTrackDescriptor())
self.assertEqual("warning", drop_button.variant)
def test_pattern_details_screen_updates_drop_button_for_selected_track(self):
first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
second_track = make_track_descriptor(
1,
0,
TrackType.SUBTITLE,
dropped=True,
)
drop_button = FakeButtonWidget()
screen = object.__new__(PatternDetailsScreen)
screen.tracksTable = FakeTagTable()
screen._PatternDetailsScreen__draftTracks = [first_track, second_track]
screen._PatternDetailsScreen__pattern = None
screen._PatternDetailsScreen__trackRowData = {}
screen.query_one = lambda selector, _widget_type=None: drop_button
screen.updateTracks()
self.assertEqual("default", drop_button.variant)
screen.tracksTable.select_row("row-1")
screen.updateTrackDropButtonState()
self.assertEqual("warning", drop_button.variant)
screen.tracksTable.select_row("row-0")
screen.updateTrackDropButtonState()
self.assertEqual("default", drop_button.variant)
def test_pattern_details_screen_toggles_persisted_track_drop_state(self):
class _FakePattern:
def getId(self):
return 5
class _FakeTrackController:
def __init__(self):
self.updated = []
def updateTrack(self, track_id, track_descriptor):
self.updated.append((track_id, track_descriptor))
return True
track = TrackDescriptor(
id=17,
pattern_id=5,
index=1,
source_index=1,
sub_index=0,
track_type=TrackType.SUBTITLE,
codec_name=TrackCodec.UNKNOWN,
dropped=False,
)
track_controller = _FakeTrackController()
screen = object.__new__(PatternDetailsScreen)
screen.context = {"logger": get_ffx_logger()}
screen._PatternDetailsScreen__pattern = _FakePattern()
screen._PatternDetailsScreen__tc = track_controller
screen.updateTracks = lambda: None
screen.toggleSelectedTrackDropped(track)
self.assertEqual(1, len(track_controller.updated))
track_id, updated_track = track_controller.updated[0]
self.assertEqual(17, track_id)
self.assertFalse(track.isDropped())
self.assertTrue(updated_track.isDropped())
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)
screen.tagsTable = FakeTagTable() screen.tagsTable = FakeTagTable()