diff --git a/src/ffx/ffx_app.py b/src/ffx/ffx_app.py index 5d345f2..911baab 100644 --- a/src/ffx/ffx_app.py +++ b/src/ffx/ffx_app.py @@ -1,7 +1,8 @@ from textual.app import App from .shows_screen import ShowsScreen -from .media_details_screen import MediaDetailsScreen +from .inspect_details_screen import InspectDetailsScreen +from .media_edit_screen import MediaEditScreen class FfxApp(App): @@ -28,8 +29,11 @@ class FfxApp(App): if self.context['command'] == 'shows': self.push_screen(ShowsScreen()) - if self.context['command'] in ('inspect', 'edit'): - self.push_screen(MediaDetailsScreen()) + if self.context['command'] == 'inspect': + self.push_screen(InspectDetailsScreen()) + + if self.context['command'] == 'edit': + self.push_screen(MediaEditScreen()) def getContext(self): diff --git a/src/ffx/inspect_details_screen.py b/src/ffx/inspect_details_screen.py new file mode 100644 index 0000000..289a350 --- /dev/null +++ b/src/ffx/inspect_details_screen.py @@ -0,0 +1,370 @@ +import re + +import click + +from textual.containers import Grid +from textual.widgets import Button, Footer, Header, Input, Static +from textual.widgets._data_table import CellDoesNotExist + +from ffx.file_properties import FileProperties +from ffx.media_descriptor_change_set import MediaDescriptorChangeSet +from ffx.show_descriptor import ShowDescriptor +from ffx.track_descriptor import TrackDescriptor + +from .media_workflow_screen_base import MediaWorkflowScreenBase +from .pattern_details_screen import PatternDetailsScreen +from .screen_support import build_screen_controllers +from .show_details_screen import ShowDetailsScreen + + +class InspectDetailsScreen(MediaWorkflowScreenBase): + + COMMAND_NAME = "inspect" + DIFFERENCES_COLUMN_LABEL = "Differences (file->db/output)" + + BINDINGS = [ + ("q", "app.quit", "Quit"), + ("n", "new_pattern", "New Pattern"), + ("u", "update_pattern", "Update Pattern"), + ("e", "edit_pattern", "Edit Pattern"), + ] + + def __init__(self): + self._showRowData: dict[object, ShowDescriptor | None] = {} + super().__init__() + + controllers = build_screen_controllers( + self.context, + pattern=True, + show=True, + track=True, + tag=True, + ) + self._pc = controllers["pattern"] + self._sc = controllers["show"] + self._tc = controllers["track"] + self._tac = controllers["tag"] + + self.reloadProperties(reset_draft=True) + + def compose(self): + self._build_media_tags_table() + self._build_tracks_table() + self._build_differences_table() + + yield Header() + + with Grid(): + self.showsTable = self._build_shows_table() + + yield Static("Show") + yield self.showsTable + yield Static(" ") + yield self.differencesTable + + yield Static(" ", classes="four") + + yield Static(" ") + yield Button("Substitute", id="pattern_button") + yield Static(" ", classes="two") + + yield Static("Pattern") + yield Input(type="text", id="pattern_input", classes="two") + yield Static(" ") + + yield Static(" ", classes="four") + + yield Static("Media Tags") + yield self.mediaTagsTable + yield Static(" ") + + yield Static(" ", classes="four") + + yield Static(" ") + yield Button("Set Default", id="select_default_button") + yield Button("Set Forced", id="select_forced_button") + yield Static(" ") + + yield Static("Streams") + yield self.tracksTable + yield Static(" ") + + yield Footer() + + def _build_shows_table(self): + from textual.widgets import DataTable + + showsTable = DataTable(classes="two") + showsTable.add_column("ID", width=10) + showsTable.add_column("Name", width=80) + showsTable.add_column("Year", width=10) + showsTable.cursor_type = "row" + return showsTable + + def on_mount(self): + if self._currentPattern is None: + self._add_show_row(None) + + for show in self._sc.getAllShows(): + self._add_show_row(show.getDescriptor(self.context)) + + if self._currentPattern is not None: + showIdentifier = self._currentPattern.getShowId() + showRowIndex = self.getRowIndexFromShowId(showIdentifier) + if showRowIndex is not None: + self.showsTable.move_cursor(row=showRowIndex) + + self.query_one("#pattern_input", Input).value = self._currentPattern.getPattern() + else: + self.query_one("#pattern_input", Input).value = self._mediaFilename + self.highlightPattern(True) + + self.updateMediaTags() + self.updateTracks() + self.updateDifferences() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "pattern_button": + pattern = self.query_one("#pattern_input", Input).value + patternMatch = re.search(FileProperties.SE_INDICATOR_PATTERN, pattern) + if patternMatch: + self.query_one("#pattern_input", Input).value = pattern.replace( + patternMatch.group(1), + FileProperties.SE_INDICATOR_PATTERN, + ) + + if event.button.id == "select_default_button": + if self.setSelectedTrackDefault(): + self.updateTracks() + self.updateDifferences() + + if event.button.id == "select_forced_button": + if self.setSelectedTrackForced(): + self.updateTracks() + self.updateDifferences() + + def removeShow(self, showId: int = -1): + for row_key, show_descriptor in list(self._showRowData.items()): + if ( + (showId == -1 and show_descriptor is None) + or ( + show_descriptor is not None + and show_descriptor.getId() == showId + ) + ): + self.showsTable.remove_row(row_key) + self._showRowData.pop(row_key, None) + return + + def getRowIndexFromShowId(self, showId: int = -1) -> int | None: + for row_key, show_descriptor in self._showRowData.items(): + if ( + (showId == -1 and show_descriptor is None) + or ( + show_descriptor is not None + and show_descriptor.getId() == showId + ) + ): + return int(self.showsTable.get_row_index(row_key)) + + return None + + def _add_show_row(self, show_descriptor: ShowDescriptor | None): + if show_descriptor is None: + row_key = self.showsTable.add_row(" ", "", " ") + else: + row_key = self.showsTable.add_row( + str(show_descriptor.getId()), + str(show_descriptor.getName()), + str(show_descriptor.getYear()), + ) + + self._showRowData[row_key] = show_descriptor + return row_key + + def highlightPattern(self, state: bool): + patternInput = self.query_one("#pattern_input", Input) + patternInput.styles.background = "red" if state else None + + def getSelectedShowDescriptor(self) -> ShowDescriptor | None: + try: + row_key, _ = self.showsTable.coordinate_to_cell_key( + self.showsTable.cursor_coordinate + ) + + if row_key is not None: + return self._showRowData.get(row_key) + except (CellDoesNotExist, AttributeError): + return None + + return None + + def getPatternObjFromInput(self): + patternObj = {} + try: + patternObj["show_id"] = self.getSelectedShowDescriptor().getId() + patternObj["pattern"] = str(self.query_one("#pattern_input", Input).value) + except Exception: + return {} + return patternObj + + def handle_new_pattern(self, showDescriptor: ShowDescriptor): + if type(showDescriptor) is not ShowDescriptor: + raise TypeError( + "InspectDetailsScreen.handle_new_pattern(): Argument 'showDescriptor' has to be of type ShowDescriptor" + ) + + self.removeShow() + + showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId()) + if showRowIndex is None: + self._add_show_row(showDescriptor) + + showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId()) + if showRowIndex is not None: + self.showsTable.move_cursor(row=showRowIndex) + + patternObj = self.getPatternObjFromInput() + if patternObj: + mediaTags = {} + for tagKey, tagValue in self._sourceMediaDescriptor.getTags().items(): + if ( + tagKey not in self._ignoreGlobalKeys + and tagKey not in self._removeGlobalKeys + ): + mediaTags[tagKey] = tagValue + + patternId = self._pc.savePatternSchema( + patternObj, + trackDescriptors=self._sourceMediaDescriptor.getTrackDescriptors(), + mediaTags=mediaTags, + ) + if patternId: + self.reloadProperties(reset_draft=True) + self.updateMediaTags() + self.updateTracks() + self.updateDifferences() + self.highlightPattern(False) + + def action_new_pattern(self): + selectedShowDescriptor = self.getSelectedShowDescriptor() + if selectedShowDescriptor is None: + self.app.push_screen(ShowDetailsScreen(), self.handle_new_pattern) + else: + self.handle_new_pattern(selectedShowDescriptor) + + def action_update_pattern(self): + if self._currentPattern is not None: + patternObj = self.getPatternObjFromInput() + if ( + patternObj + and self._currentPattern.getPattern() != patternObj["pattern"] + ): + updated = self._pc.updatePattern( + self._currentPattern.getId(), + patternObj, + ) + if updated: + self.reloadProperties(reset_draft=True) + self.updateMediaTags() + self.updateTracks() + self.updateDifferences() + return updated + + self.reloadProperties(reset_draft=True) + + tagDifferences = self._mediaChangeSetObj.get(MediaDescriptorChangeSet.TAGS_KEY, {}) + for addedTagKey in tagDifferences.get(DIFF_ADDED_KEY, {}).keys(): + self._tac.deleteMediaTagByKey(self._currentPattern.getId(), addedTagKey) + + for removedTagKey in tagDifferences.get(DIFF_REMOVED_KEY, {}).keys(): + currentTags = self._sourceMediaDescriptor.getTags() + self._tac.updateMediaTag( + self._currentPattern.getId(), + removedTagKey, + currentTags[removedTagKey], + ) + + for changedTagKey in tagDifferences.get(DIFF_CHANGED_KEY, {}).keys(): + currentTags = self._sourceMediaDescriptor.getTags() + self._tac.updateMediaTag( + self._currentPattern.getId(), + changedTagKey, + currentTags[changedTagKey], + ) + + trackDifferences = self._mediaChangeSetObj.get(MediaDescriptorChangeSet.TRACKS_KEY, {}) + + for trackDescriptor in trackDifferences.get(DIFF_ADDED_KEY, {}).values(): + self._tc.addTrack(trackDescriptor, patternId=self._currentPattern.getId()) + + for trackDescriptor in trackDifferences.get(DIFF_REMOVED_KEY, {}).values(): + self._tc.deleteTrack(trackDescriptor.getId()) + + for trackIndex, trackDiff in trackDifferences.get(DIFF_CHANGED_KEY, {}).items(): + targetTracks = [ + track + for track in self._targetMediaDescriptor.getTrackDescriptors() + if track.getIndex() == trackIndex + ] + targetTrackId = targetTracks[0].getId() if targetTracks else None + targetTrackIndex = targetTracks[0].getIndex() if targetTracks else None + + tagsDiff = trackDiff.get(TrackDescriptor.TAGS_KEY, {}) + for tagKey, tagValue in tagsDiff.get(DIFF_ADDED_KEY, {}).items(): + self._tac.updateTrackTag(targetTrackId, tagKey, tagValue) + for tagKey in tagsDiff.get(DIFF_REMOVED_KEY, {}).keys(): + self._tac.deleteTrackTagByKey(targetTrackId, tagKey) + for tagKey, tagValue in tagsDiff.get(DIFF_CHANGED_KEY, {}).items(): + self._tac.updateTrackTag(targetTrackId, tagKey, tagValue) + + dispositionDiff = trackDiff.get(TrackDescriptor.DISPOSITION_SET_KEY, {}) + for changedDisposition in dispositionDiff.get(DIFF_ADDED_KEY, set()): + if targetTrackIndex is not None: + self._tc.setDispositionState( + self._currentPattern.getId(), + targetTrackIndex, + changedDisposition, + True, + ) + for changedDisposition in dispositionDiff.get(DIFF_REMOVED_KEY, set()): + if targetTrackIndex is not None: + self._tc.setDispositionState( + self._currentPattern.getId(), + targetTrackIndex, + changedDisposition, + False, + ) + + self.reloadProperties(reset_draft=True) + self.updateMediaTags() + self.updateTracks() + self.updateDifferences() + + def action_edit_pattern(self): + patternObj = self.getPatternObjFromInput() + if patternObj.get("pattern"): + selectedPatternId = self._pc.findPattern(patternObj) + if selectedPatternId is None: + raise click.ClickException( + "InspectDetailsScreen.action_edit_pattern(): Pattern to edit has no id" + ) + + self.app.push_screen( + PatternDetailsScreen( + patternId=selectedPatternId, + showId=self.getSelectedShowDescriptor().getId(), + ), + self.handle_edit_pattern, + ) + + def handle_edit_pattern(self, screenResult): + if not screenResult: + return + + self.reloadProperties(reset_draft=True) + if self._currentPattern is not None: + self.query_one("#pattern_input", Input).value = self._currentPattern.getPattern() + self.updateMediaTags() + self.updateTracks() + self.updateDifferences() diff --git a/src/ffx/media_details_screen.py b/src/ffx/media_details_screen.py index 5ec4bed..1dba1ef 100644 --- a/src/ffx/media_details_screen.py +++ b/src/ffx/media_details_screen.py @@ -1,998 +1 @@ -import os -import re - -import click - -from textual.containers import Grid -from textual.screen import Screen -from textual.widgets import Button, DataTable, Footer, Header, Input, Static -from textual.widgets._data_table import CellDoesNotExist - -from ffx.audio_layout import AudioLayout -from ffx.file_properties import FileProperties -from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY -from ffx.media_descriptor_change_set import MediaDescriptorChangeSet -from ffx.metadata_editor import apply_metadata_edits -from ffx.show_descriptor import ShowDescriptor -from ffx.track_descriptor import TrackDescriptor -from ffx.track_disposition import TrackDisposition -from ffx.track_type import TrackType - -from .confirm_screen import ConfirmScreen -from .pattern_details_screen import PatternDetailsScreen -from .screen_support import ( - build_screen_bootstrap, - build_screen_controllers, - populate_tag_table, -) -from .show_details_screen import ShowDetailsScreen -from .tag_delete_screen import TagDeleteScreen -from .tag_details_screen import TagDetailsScreen -from .track_details_screen import TrackDetailsScreen - - -class MediaDetailsScreen(Screen): - - CSS = """ - - Grid { - grid-size: 5 10; - grid-rows: 2 2 2 2 2 8 2 2 8 2; - grid-columns: 15 25 90 10 105; - height: 100%; - width: 100%; - padding: 1; - } - - DataTable .datatable--cursor { - background: darkorange; - color: black; - } - - DataTable .datatable--header { - background: steelblue; - color: white; - } - - Input { - border: none; - } - Button { - border: none; - } - - DataTable { - min-height: 24; - } - - .two { - column-span: 2; - } - .three { - column-span: 3; - } - .four { - column-span: 4; - } - .five { - column-span: 5; - } - - #differences-table { - row-span: 10; - } - """ - - TRACKS_TABLE_INDEX_COLUMN_LABEL = "Index" - TRACKS_TABLE_TYPE_COLUMN_LABEL = "Type" - TRACKS_TABLE_SUB_INDEX_COLUMN_LABEL = "SubIndex" - TRACKS_TABLE_CODEC_COLUMN_LABEL = "Codec" - TRACKS_TABLE_LAYOUT_COLUMN_LABEL = "Layout" - TRACKS_TABLE_LANGUAGE_COLUMN_LABEL = "Language" - TRACKS_TABLE_TITLE_COLUMN_LABEL = "Title" - TRACKS_TABLE_DEFAULT_COLUMN_LABEL = "Default" - TRACKS_TABLE_FORCED_COLUMN_LABEL = "Forced" - - INSPECT_DIFFERENCES_COLUMN_LABEL = "Differences (file->db/output)" - EDIT_DIFFERENCES_COLUMN_LABEL = "Planned Changes (file->edited output)" - - BINDINGS = [ - ("q", "quit_screen", "Quit"), - ("a", "apply_changes", "Apply"), - ("r", "revert_changes", "Revert"), - ("n", "new_pattern", "New Pattern"), - ("u", "update_pattern", "Update Pattern"), - ("e", "edit_pattern", "Edit Pattern"), - ] - - def __init__(self): - super().__init__() - - bootstrap = build_screen_bootstrap(self.app.getContext()) - self.context = bootstrap.context - - self.__applyCleanup = bootstrap.apply_cleanup - self.__removeGlobalKeys = bootstrap.remove_global_keys - self.__ignoreGlobalKeys = bootstrap.ignore_global_keys - - command = self.context.get("command") - self.__inspectMode = command == "inspect" - self.__editMode = command == "edit" - - if not (self.__inspectMode or self.__editMode): - raise click.ClickException( - "MediaDetailsScreen.__init__(): Can only perform command 'inspect' or 'edit'" - ) - - arguments = self.context.get("arguments", {}) - self.__mediaFilename = arguments.get("filename", "") - if not self.__mediaFilename: - raise click.ClickException( - "MediaDetailsScreen.__init__(): Argument 'filename' is required" - ) - if not os.path.isfile(self.__mediaFilename): - raise click.ClickException( - f"MediaDetailsScreen.__init__(): Media file {self.__mediaFilename} does not exist" - ) - - self.__pc = None - self.__sc = None - self.__tc = None - self.__tac = None - if self.__inspectMode: - controllers = build_screen_controllers( - self.context, - pattern=True, - show=True, - track=True, - tag=True, - ) - self.__pc = controllers["pattern"] - self.__sc = controllers["show"] - self.__tc = controllers["track"] - self.__tac = controllers["tag"] - - self.__baselineMediaDescriptor = None - self.__sourceMediaDescriptor = None - self.__targetMediaDescriptor = None - self.__currentPattern = None - self.__mediaChangeSetObj = {} - self.__messageText = "" - - self.__showRowData: dict[object, ShowDescriptor | None] = {} - self.__trackRowData: dict[object, TrackDescriptor] = {} - self.__sourceMediaTagRowData: dict[object, tuple[str, str]] = {} - - self.reloadProperties(reset_draft=True) - - def _build_media_tags_table(self): - self.mediaTagsTable = DataTable(classes="two") - self.mediaTagsTable.add_column("Key", width=30) - self.mediaTagsTable.add_column("Value", width=70) - self.mediaTagsTable.cursor_type = "row" - - def _build_tracks_table(self): - self.tracksTable = DataTable(classes="two") - self.tracksTable.add_column( - MediaDetailsScreen.TRACKS_TABLE_INDEX_COLUMN_LABEL, - width=5, - ) - self.tracksTable.add_column( - MediaDetailsScreen.TRACKS_TABLE_TYPE_COLUMN_LABEL, - width=10, - ) - self.tracksTable.add_column( - MediaDetailsScreen.TRACKS_TABLE_SUB_INDEX_COLUMN_LABEL, - width=8, - ) - self.tracksTable.add_column( - MediaDetailsScreen.TRACKS_TABLE_CODEC_COLUMN_LABEL, - width=10, - ) - self.tracksTable.add_column( - MediaDetailsScreen.TRACKS_TABLE_LAYOUT_COLUMN_LABEL, - width=10, - ) - self.tracksTable.add_column( - MediaDetailsScreen.TRACKS_TABLE_LANGUAGE_COLUMN_LABEL, - width=15, - ) - self.tracksTable.add_column( - MediaDetailsScreen.TRACKS_TABLE_TITLE_COLUMN_LABEL, - width=48, - ) - self.tracksTable.add_column( - MediaDetailsScreen.TRACKS_TABLE_DEFAULT_COLUMN_LABEL, - width=8, - ) - self.tracksTable.add_column( - MediaDetailsScreen.TRACKS_TABLE_FORCED_COLUMN_LABEL, - width=8, - ) - self.tracksTable.cursor_type = "row" - - def _build_differences_table(self): - self.differencesTable = DataTable(id="differences-table") - self.differencesTable.add_column( - ( - MediaDetailsScreen.INSPECT_DIFFERENCES_COLUMN_LABEL - if self.__inspectMode - else MediaDetailsScreen.EDIT_DIFFERENCES_COLUMN_LABEL - ), - width=100, - ) - self.differencesTable.cursor_type = "row" - - def compose(self): - self._build_media_tags_table() - self._build_tracks_table() - self._build_differences_table() - - yield Header() - - with Grid(): - if self.__inspectMode: - self.showsTable = DataTable(classes="two") - self.showsTable.add_column("ID", width=10) - self.showsTable.add_column("Name", width=80) - self.showsTable.add_column("Year", width=10) - self.showsTable.cursor_type = "row" - - yield Static("Show") - yield self.showsTable - yield Static(" ") - yield self.differencesTable - - yield Static(" ", classes="four") - - yield Static(" ") - yield Button("Substitute", id="pattern_button") - yield Static(" ", classes="two") - - yield Static("Pattern") - yield Input(type="text", id="pattern_input", classes="two") - yield Static(" ") - - yield Static(" ", classes="four") - - yield Static("Media Tags") - yield self.mediaTagsTable - yield Static(" ") - - yield Static(" ", classes="four") - - yield Static(" ") - yield Button("Set Default", id="select_default_button") - yield Button("Set Forced", id="select_forced_button") - yield Static(" ") - - yield Static("Streams") - yield self.tracksTable - yield Static(" ") - - else: - yield Static("File") - yield Static(self.__mediaFilename, id="file_label", classes="two", markup=False) - yield Static(" ") - yield self.differencesTable - - yield Static("Cleanup") - yield Static( - "Enabled" if self.__applyCleanup else "Disabled", - id="cleanup_label", - classes="two", - markup=False, - ) - yield Static(" ") - - yield Static("Status") - yield Static("", id="message_label", classes="two", markup=False) - yield Static(" ") - - yield Static("Media Tags") - yield Button("Add", id="button_add_tag") - yield Button("Edit", id="button_edit_tag") - yield Button("Delete", id="button_delete_tag") - - yield Static(" ") - yield self.mediaTagsTable - yield Static(" ") - - yield Static("Streams") - yield Button("Edit", id="button_edit_track") - yield Button("Set Default", id="select_default_button") - yield Button("Set Forced", id="select_forced_button") - - yield Static(" ") - yield self.tracksTable - yield Static(" ") - - yield Static(" ") - yield Button("Apply", id="apply_button") - yield Button("Revert", id="revert_button") - yield Button("Quit", id="quit_button") - - yield Footer() - - def on_mount(self): - if self.__inspectMode: - if self.__currentPattern is None: - self._add_show_row(None) - - for show in self.__sc.getAllShows(): - self._add_show_row(show.getDescriptor(self.context)) - - if self.__currentPattern is not None: - showIdentifier = self.__currentPattern.getShowId() - showRowIndex = self.getRowIndexFromShowId(showIdentifier) - if showRowIndex is not None: - self.showsTable.move_cursor(row=showRowIndex) - - self.query_one("#pattern_input", Input).value = self.__currentPattern.getPattern() - else: - self.query_one("#pattern_input", Input).value = self.__mediaFilename - self.highlightPattern(True) - - self.updateMediaTags() - self.updateTracks() - self.updateDifferences() - self.setMessage(self.__messageText) - - def setMessage(self, message: str): - self.__messageText = str(message) - - if self.__editMode: - try: - self.query_one("#message_label", Static).update(self.__messageText) - except Exception: - pass - - def reloadProperties(self, reset_draft: bool = True): - self.__mediaFileProperties = FileProperties(self.context, self.__mediaFilename) - probedMediaDescriptor = self.__mediaFileProperties.getMediaDescriptor() - - if self.__inspectMode: - self.__baselineMediaDescriptor = probedMediaDescriptor - self.__sourceMediaDescriptor = probedMediaDescriptor - self.__currentPattern = self.__mediaFileProperties.getPattern() - self.__targetMediaDescriptor = ( - self.__currentPattern.getMediaDescriptor(self.context) - if self.__currentPattern is not None - else None - ) - else: - self.__baselineMediaDescriptor = probedMediaDescriptor - if reset_draft or self.__sourceMediaDescriptor is None: - self.__sourceMediaDescriptor = probedMediaDescriptor.clone(context=self.context) - self.__currentPattern = None - self.__targetMediaDescriptor = self.__sourceMediaDescriptor - - self.rebuildChangeSet() - - def rebuildChangeSet(self): - try: - if self.__inspectMode: - if self.__targetMediaDescriptor is None: - self.__mediaChangeSetObj = {} - return - mdcs = MediaDescriptorChangeSet( - self.context, - self.__targetMediaDescriptor, - self.__sourceMediaDescriptor, - ) - else: - mdcs = MediaDescriptorChangeSet( - self.context, - self.__sourceMediaDescriptor, - self.__baselineMediaDescriptor, - ) - - self.__mediaChangeSetObj = mdcs.getChangeSetObj() - except ValueError: - self.__mediaChangeSetObj = {} - - def hasPendingChanges(self) -> bool: - return bool(self.__mediaChangeSetObj) - - def updateMediaTags(self): - currentDescriptor = self.__sourceMediaDescriptor - self.__sourceMediaTagRowData = populate_tag_table( - self.mediaTagsTable, - currentDescriptor.getTags(), - ignore_keys=self.__ignoreGlobalKeys, - remove_keys=self.__removeGlobalKeys, - ) - - def updateTracks(self): - self.tracksTable.clear() - self.__trackRowData = {} - - trackDescriptorList = self.__sourceMediaDescriptor.getTrackDescriptors() - typeCounter = {} - - for td in trackDescriptorList: - trackType = td.getType() - if trackType not in typeCounter: - typeCounter[trackType] = 0 - - dispositionSet = td.getDispositionSet() - audioLayout = td.getAudioLayout() - row = ( - td.getIndex(), - trackType.label(), - typeCounter[trackType], - td.getCodec().label(), - audioLayout.label() - if trackType == TrackType.AUDIO - and audioLayout != AudioLayout.LAYOUT_UNDEFINED - else " ", - td.getLanguage().label(), - td.getTitle(), - "Yes" if TrackDisposition.DEFAULT in dispositionSet else "No", - "Yes" if TrackDisposition.FORCED in dispositionSet else "No", - ) - - row_key = self.tracksTable.add_row(*map(str, row)) - self.__trackRowData[row_key] = td - typeCounter[trackType] += 1 - - def updateDifferences(self): - self.rebuildChangeSet() - self.differencesTable.clear() - - if self.__inspectMode and self.__currentPattern is None: - return - - targetDescriptor = ( - self.__targetMediaDescriptor - if self.__inspectMode - else self.__sourceMediaDescriptor - ) - targetTrackDescriptorsByIndex = { - trackDescriptor.getIndex(): trackDescriptor - for trackDescriptor in ( - targetDescriptor.getTrackDescriptors() - if targetDescriptor is not None - else [] - ) - } - - tagDifferences = self.__mediaChangeSetObj.get(MediaDescriptorChangeSet.TAGS_KEY, {}) - for tagKey, tagValue in tagDifferences.get(DIFF_ADDED_KEY, {}).items(): - if tagKey not in self.__ignoreGlobalKeys: - self.differencesTable.add_row( - f"add media tag: key='{tagKey}' value='{tagValue}'" - ) - - for tagKey, tagValue in tagDifferences.get(DIFF_REMOVED_KEY, {}).items(): - if tagKey in self.__ignoreGlobalKeys: - continue - if self.__inspectMode and tagKey in self.__removeGlobalKeys: - continue - self.differencesTable.add_row( - f"remove media tag: key='{tagKey}' value='{tagValue}'" - ) - - for tagKey, tagValue in tagDifferences.get(DIFF_CHANGED_KEY, {}).items(): - if tagKey not in self.__ignoreGlobalKeys: - self.differencesTable.add_row( - f"change media tag: key='{tagKey}' value='{tagValue}'" - ) - - trackDifferences = self.__mediaChangeSetObj.get(MediaDescriptorChangeSet.TRACKS_KEY, {}) - - for trackDescriptor in trackDifferences.get(DIFF_ADDED_KEY, {}).values(): - self.differencesTable.add_row( - f"add {trackDescriptor.getType().label()} track: " - + f"index={trackDescriptor.getIndex()} lang={trackDescriptor.getLanguage().threeLetter()}" - ) - - for trackIndex, _trackDescriptor in trackDifferences.get(DIFF_REMOVED_KEY, {}).items(): - self.differencesTable.add_row(f"remove stream #{trackIndex}") - - for trackIndex, trackDiffObj in trackDifferences.get(DIFF_CHANGED_KEY, {}).items(): - targetTrackDescriptor = targetTrackDescriptorsByIndex.get(trackIndex) - if targetTrackDescriptor is None: - continue - - tagsDiff = trackDiffObj.get(MediaDescriptorChangeSet.TAGS_KEY, {}) - for tagKey, tagValue in tagsDiff.get(DIFF_REMOVED_KEY, {}).items(): - self.differencesTable.add_row( - f"change stream #{targetTrackDescriptor.getIndex()} " - + f"({targetTrackDescriptor.getType().label()}:{targetTrackDescriptor.getSubIndex()}) " - + f"remove key={tagKey} value={tagValue}" - ) - for tagKey, tagValue in tagsDiff.get(DIFF_ADDED_KEY, {}).items(): - self.differencesTable.add_row( - f"change stream #{targetTrackDescriptor.getIndex()} " - + f"({targetTrackDescriptor.getType().label()}:{targetTrackDescriptor.getSubIndex()}) " - + f"add key={tagKey} value={tagValue}" - ) - for tagKey, tagValue in tagsDiff.get(DIFF_CHANGED_KEY, {}).items(): - self.differencesTable.add_row( - f"change stream #{targetTrackDescriptor.getIndex()} " - + f"({targetTrackDescriptor.getType().label()}:{targetTrackDescriptor.getSubIndex()}) " - + f"change key={tagKey} value={tagValue}" - ) - - dispositionDiff = trackDiffObj.get(MediaDescriptorChangeSet.DISPOSITION_SET_KEY, {}) - for addedDisposition in dispositionDiff.get(DIFF_ADDED_KEY, set()): - self.differencesTable.add_row( - f"change stream #{targetTrackDescriptor.getIndex()} " - + f"({targetTrackDescriptor.getType().label()}:{targetTrackDescriptor.getSubIndex()}) " - + f"add disposition={addedDisposition.label()}" - ) - for removedDisposition in dispositionDiff.get(DIFF_REMOVED_KEY, set()): - self.differencesTable.add_row( - f"change stream #{targetTrackDescriptor.getIndex()} " - + f"({targetTrackDescriptor.getType().label()}:{targetTrackDescriptor.getSubIndex()}) " - + f"remove disposition={removedDisposition.label()}" - ) - - def removeShow(self, showId: int = -1): - for row_key, show_descriptor in list(self.__showRowData.items()): - if ( - (showId == -1 and show_descriptor is None) - or ( - show_descriptor is not None - and show_descriptor.getId() == showId - ) - ): - self.showsTable.remove_row(row_key) - self.__showRowData.pop(row_key, None) - return - - def getRowIndexFromShowId(self, showId: int = -1) -> int | None: - for row_key, show_descriptor in self.__showRowData.items(): - if ( - (showId == -1 and show_descriptor is None) - or ( - show_descriptor is not None - and show_descriptor.getId() == showId - ) - ): - return int(self.showsTable.get_row_index(row_key)) - - return None - - def _add_show_row(self, show_descriptor: ShowDescriptor | None): - if show_descriptor is None: - row_key = self.showsTable.add_row(" ", "", " ") - else: - row_key = self.showsTable.add_row( - str(show_descriptor.getId()), - str(show_descriptor.getName()), - str(show_descriptor.getYear()), - ) - - self.__showRowData[row_key] = show_descriptor - return row_key - - def highlightPattern(self, state: bool): - if not self.__inspectMode: - return - - patternInput = self.query_one("#pattern_input", Input) - patternInput.styles.background = "red" if state else None - - def getSelectedMediaTag(self): - try: - row_key, _ = self.mediaTagsTable.coordinate_to_cell_key( - self.mediaTagsTable.cursor_coordinate - ) - if row_key is not None: - return self.__sourceMediaTagRowData.get(row_key) - return None - except CellDoesNotExist: - return None - - def getSelectedTrackDescriptor(self): - try: - row_key, _ = self.tracksTable.coordinate_to_cell_key( - self.tracksTable.cursor_coordinate - ) - if row_key is not None: - return self.__trackRowData.get(row_key) - return None - except CellDoesNotExist: - return None - - def getSelectedShowDescriptor(self) -> ShowDescriptor | None: - try: - row_key, _ = self.showsTable.coordinate_to_cell_key( - self.showsTable.cursor_coordinate - ) - if row_key is not None: - return self.__showRowData.get(row_key) - except (CellDoesNotExist, AttributeError): - return None - - return None - - def getPatternObjFromInput(self): - patternObj = {} - try: - patternObj["show_id"] = self.getSelectedShowDescriptor().getId() - patternObj["pattern"] = str(self.query_one("#pattern_input", Input).value) - except Exception: - return {} - return patternObj - - def refreshAfterDraftChange(self): - self.updateMediaTags() - self.updateTracks() - self.updateDifferences() - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "pattern_button" and self.__inspectMode: - pattern = self.query_one("#pattern_input", Input).value - patternMatch = re.search(FileProperties.SE_INDICATOR_PATTERN, pattern) - if patternMatch: - self.query_one("#pattern_input", Input).value = pattern.replace( - patternMatch.group(1), - FileProperties.SE_INDICATOR_PATTERN, - ) - - if event.button.id == "select_default_button": - selectedTrackDescriptor = self.getSelectedTrackDescriptor() - if selectedTrackDescriptor is not None: - self.__sourceMediaDescriptor.setDefaultSubTrack( - selectedTrackDescriptor.getType(), - selectedTrackDescriptor.getSubIndex(), - ) - self.refreshAfterDraftChange() - - if event.button.id == "select_forced_button": - selectedTrackDescriptor = self.getSelectedTrackDescriptor() - if selectedTrackDescriptor is not None: - self.__sourceMediaDescriptor.setForcedSubTrack( - selectedTrackDescriptor.getType(), - selectedTrackDescriptor.getSubIndex(), - ) - self.refreshAfterDraftChange() - - if not self.__editMode: - return - - if event.button.id == "button_add_tag": - self.app.push_screen(TagDetailsScreen(), self.handle_update_media_tag) - - if event.button.id == "button_edit_tag": - selectedTag = self.getSelectedMediaTag() - if selectedTag is not None: - self.app.push_screen( - TagDetailsScreen(key=selectedTag[0], value=selectedTag[1]), - self.handle_update_media_tag, - ) - - if event.button.id == "button_delete_tag": - selectedTag = self.getSelectedMediaTag() - if selectedTag is not None: - self.app.push_screen( - TagDeleteScreen(key=selectedTag[0], value=selectedTag[1]), - self.handle_delete_media_tag, - ) - - if event.button.id == "button_edit_track": - self.action_edit_selected_track() - - if event.button.id == "apply_button": - self.action_apply_changes() - - if event.button.id == "revert_button": - self.action_revert_changes() - - if event.button.id == "quit_button": - self.action_quit_screen() - - def action_edit_selected_track(self): - if not self.__editMode: - return - - selectedTrack = self.getSelectedTrackDescriptor() - if selectedTrack is None: - self.setMessage("Select a stream first.") - return - - self.app.push_screen( - TrackDetailsScreen( - trackDescriptor=selectedTrack, - patternLabel=os.path.basename(self.__mediaFilename), - siblingTrackDescriptors=self.__sourceMediaDescriptor.getTrackDescriptors(), - metadata_only=True, - ), - self.handle_edit_track, - ) - - def handle_update_media_tag(self, tag): - if not self.__editMode or tag is None: - return - - self.__sourceMediaDescriptor.getTags()[str(tag[0])] = str(tag[1]) - self.setMessage(f"Updated media tag {tag[0]!r}.") - self.refreshAfterDraftChange() - - def handle_delete_media_tag(self, tag): - if not self.__editMode or tag is None: - return - - self.__sourceMediaDescriptor.getTags().pop(str(tag[0]), None) - self.setMessage(f"Deleted media tag {tag[0]!r}.") - self.refreshAfterDraftChange() - - def handle_edit_track(self, trackDescriptor: TrackDescriptor): - if not self.__editMode or trackDescriptor is None: - return - - updatedTracks = [] - replaced = False - for currentTrack in self.__sourceMediaDescriptor.getTrackDescriptors(): - if ( - currentTrack.getIndex() == trackDescriptor.getIndex() - and currentTrack.getSubIndex() == trackDescriptor.getSubIndex() - ): - updatedTracks.append(trackDescriptor) - replaced = True - else: - updatedTracks.append(currentTrack) - - if not replaced: - self.setMessage("Unable to update selected stream.") - return - - self.__sourceMediaDescriptor = self.__sourceMediaDescriptor.clone(context=self.context) - self.__sourceMediaDescriptor.getTrackDescriptors().clear() - self.__sourceMediaDescriptor.getTrackDescriptors().extend(updatedTracks) - self.setMessage( - f"Updated stream #{trackDescriptor.getIndex()} ({trackDescriptor.getType().label()})." - ) - self.refreshAfterDraftChange() - - def action_apply_changes(self): - if not self.__editMode: - return - - if not self.hasPendingChanges(): - self.setMessage("No changes to apply.") - return - - try: - applyResult = apply_metadata_edits( - self.context, - self.__mediaFilename, - self.__baselineMediaDescriptor, - self.__sourceMediaDescriptor, - ) - except Exception as ex: - self.context["logger"].exception( - "Failed to apply metadata edits for %s", - self.__mediaFilename, - ) - self.setMessage(f"Apply failed: {ex}") - return - - if applyResult.get("dry_run", False): - self.setMessage( - f"Dry-run: would rewrite via temporary file {applyResult['target_path']}" - ) - return - - self.reloadProperties(reset_draft=True) - self.refreshAfterDraftChange() - self.setMessage("Changes applied and file reloaded.") - - def action_revert_changes(self): - if not self.__editMode: - return - - if not self.hasPendingChanges(): - self.setMessage("No changes to revert.") - return - - self.app.push_screen( - ConfirmScreen( - "Discard pending metadata changes and reload the file state?", - confirm_label="Discard", - cancel_label="Keep Editing", - ), - self.handle_revert_confirmation, - ) - - def handle_revert_confirmation(self, confirmed): - if not confirmed: - self.setMessage("Keeping pending changes.") - return - - self.reloadProperties(reset_draft=True) - self.refreshAfterDraftChange() - self.setMessage("Reverted pending changes.") - - def action_quit_screen(self): - if self.__editMode and self.hasPendingChanges(): - self.app.push_screen( - ConfirmScreen( - "Discard pending metadata changes and quit?", - confirm_label="Discard", - cancel_label="Stay", - ), - self.handle_quit_confirmation, - ) - return - - self.app.exit() - - def handle_quit_confirmation(self, confirmed): - if confirmed: - self.app.exit() - else: - self.setMessage("Continuing edit session.") - - def handle_new_pattern(self, showDescriptor: ShowDescriptor): - if type(showDescriptor) is not ShowDescriptor: - raise TypeError( - "MediaDetailsScreen.handle_new_pattern(): Argument 'showDescriptor' has to be of type ShowDescriptor" - ) - - self.removeShow() - - showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId()) - if showRowIndex is None: - self._add_show_row(showDescriptor) - - showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId()) - if showRowIndex is not None: - self.showsTable.move_cursor(row=showRowIndex) - - patternObj = self.getPatternObjFromInput() - if patternObj: - mediaTags = {} - for tagKey, tagValue in self.__sourceMediaDescriptor.getTags().items(): - if ( - tagKey not in self.__ignoreGlobalKeys - and tagKey not in self.__removeGlobalKeys - ): - mediaTags[tagKey] = tagValue - - patternId = self.__pc.savePatternSchema( - patternObj, - trackDescriptors=self.__sourceMediaDescriptor.getTrackDescriptors(), - mediaTags=mediaTags, - ) - if patternId: - self.reloadProperties(reset_draft=True) - self.updateMediaTags() - self.updateTracks() - self.updateDifferences() - self.highlightPattern(False) - - def action_new_pattern(self): - if not self.__inspectMode: - return - - selectedShowDescriptor = self.getSelectedShowDescriptor() - if selectedShowDescriptor is None: - self.app.push_screen(ShowDetailsScreen(), self.handle_new_pattern) - else: - self.handle_new_pattern(selectedShowDescriptor) - - def action_update_pattern(self): - if not self.__inspectMode: - return - - if self.__currentPattern is not None: - patternObj = self.getPatternObjFromInput() - if ( - patternObj - and self.__currentPattern.getPattern() != patternObj["pattern"] - ): - updated = self.__pc.updatePattern( - self.__currentPattern.getId(), - patternObj, - ) - if updated: - self.reloadProperties(reset_draft=True) - self.updateMediaTags() - self.updateTracks() - self.updateDifferences() - return updated - - self.reloadProperties(reset_draft=True) - - tagDifferences = self.__mediaChangeSetObj.get(MediaDescriptorChangeSet.TAGS_KEY, {}) - for addedTagKey in tagDifferences.get(DIFF_ADDED_KEY, {}).keys(): - self.__tac.deleteMediaTagByKey(self.__currentPattern.getId(), addedTagKey) - - for removedTagKey in tagDifferences.get(DIFF_REMOVED_KEY, {}).keys(): - currentTags = self.__sourceMediaDescriptor.getTags() - self.__tac.updateMediaTag( - self.__currentPattern.getId(), - removedTagKey, - currentTags[removedTagKey], - ) - - for changedTagKey in tagDifferences.get(DIFF_CHANGED_KEY, {}).keys(): - currentTags = self.__sourceMediaDescriptor.getTags() - self.__tac.updateMediaTag( - self.__currentPattern.getId(), - changedTagKey, - currentTags[changedTagKey], - ) - - trackDifferences = self.__mediaChangeSetObj.get(MediaDescriptorChangeSet.TRACKS_KEY, {}) - - for trackDescriptor in trackDifferences.get(DIFF_ADDED_KEY, {}).values(): - self.__tc.addTrack(trackDescriptor, patternId=self.__currentPattern.getId()) - - for trackDescriptor in trackDifferences.get(DIFF_REMOVED_KEY, {}).values(): - self.__tc.deleteTrack(trackDescriptor.getId()) - - for trackIndex, trackDiff in trackDifferences.get(DIFF_CHANGED_KEY, {}).items(): - targetTracks = [ - track - for track in self.__targetMediaDescriptor.getTrackDescriptors() - if track.getIndex() == trackIndex - ] - targetTrackId = targetTracks[0].getId() if targetTracks else None - targetTrackIndex = targetTracks[0].getIndex() if targetTracks else None - - tagsDiff = trackDiff.get(TrackDescriptor.TAGS_KEY, {}) - for tagKey, tagValue in tagsDiff.get(DIFF_ADDED_KEY, {}).items(): - self.__tac.updateTrackTag(targetTrackId, tagKey, tagValue) - for tagKey in tagsDiff.get(DIFF_REMOVED_KEY, {}).keys(): - self.__tac.deleteTrackTagByKey(targetTrackId, tagKey) - for tagKey, tagValue in tagsDiff.get(DIFF_CHANGED_KEY, {}).items(): - self.__tac.updateTrackTag(targetTrackId, tagKey, tagValue) - - dispositionDiff = trackDiff.get(TrackDescriptor.DISPOSITION_SET_KEY, {}) - for changedDisposition in dispositionDiff.get(DIFF_ADDED_KEY, set()): - if targetTrackIndex is not None: - self.__tc.setDispositionState( - self.__currentPattern.getId(), - targetTrackIndex, - changedDisposition, - True, - ) - for changedDisposition in dispositionDiff.get(DIFF_REMOVED_KEY, set()): - if targetTrackIndex is not None: - self.__tc.setDispositionState( - self.__currentPattern.getId(), - targetTrackIndex, - changedDisposition, - False, - ) - - self.reloadProperties(reset_draft=True) - self.updateMediaTags() - self.updateTracks() - self.updateDifferences() - - def action_edit_pattern(self): - if not self.__inspectMode: - return - - patternObj = self.getPatternObjFromInput() - if patternObj.get("pattern"): - selectedPatternId = self.__pc.findPattern(patternObj) - if selectedPatternId is None: - raise click.ClickException( - "MediaDetailsScreen.action_edit_pattern(): Pattern to edit has no id" - ) - - self.app.push_screen( - PatternDetailsScreen( - patternId=selectedPatternId, - showId=self.getSelectedShowDescriptor().getId(), - ), - self.handle_edit_pattern, - ) - - def handle_edit_pattern(self, screenResult): - if not screenResult: - return - - self.reloadProperties(reset_draft=True) - if self.__currentPattern is not None: - self.query_one("#pattern_input", Input).value = self.__currentPattern.getPattern() - self.updateMediaTags() - self.updateTracks() - self.updateDifferences() +from .inspect_details_screen import InspectDetailsScreen as MediaDetailsScreen diff --git a/src/ffx/media_edit_screen.py b/src/ffx/media_edit_screen.py new file mode 100644 index 0000000..98a6b53 --- /dev/null +++ b/src/ffx/media_edit_screen.py @@ -0,0 +1,269 @@ +import os + +from textual.containers import Grid +from textual.widgets import Button, Footer, Header, Static + +from ffx.metadata_editor import apply_metadata_edits +from ffx.track_descriptor import TrackDescriptor + +from .confirm_screen import ConfirmScreen +from .media_workflow_screen_base import MediaWorkflowScreenBase +from .tag_delete_screen import TagDeleteScreen +from .tag_details_screen import TagDetailsScreen +from .track_details_screen import TrackDetailsScreen + + +class MediaEditScreen(MediaWorkflowScreenBase): + + COMMAND_NAME = "edit" + EDIT_MODE = True + DIFFERENCES_COLUMN_LABEL = "Planned Changes (file->edited output)" + + BINDINGS = [ + ("q", "quit_screen", "Quit"), + ("a", "apply_changes", "Apply"), + ("r", "revert_changes", "Revert"), + ] + + def compose(self): + self._build_media_tags_table() + self._build_tracks_table() + self._build_differences_table() + + yield Header() + + with Grid(): + yield Static("File") + yield Static(self._mediaFilename, id="file_label", classes="two", markup=False) + yield Static(" ") + yield self.differencesTable + + yield Static("Cleanup") + yield Static( + "Enabled" if self._applyCleanup else "Disabled", + id="cleanup_label", + classes="two", + markup=False, + ) + yield Static(" ") + + yield Static("Status") + yield Static("", id="message_label", classes="two", markup=False) + yield Static(" ") + + yield Static("Media Tags") + yield Button("Add", id="button_add_tag") + yield Button("Edit", id="button_edit_tag") + yield Button("Delete", id="button_delete_tag") + + yield Static(" ") + yield self.mediaTagsTable + yield Static(" ") + + yield Static("Streams") + yield Button("Edit", id="button_edit_track") + yield Button("Set Default", id="select_default_button") + yield Button("Set Forced", id="select_forced_button") + + yield Static(" ") + yield self.tracksTable + yield Static(" ") + + yield Static(" ") + yield Button("Apply", id="apply_button") + yield Button("Revert", id="revert_button") + yield Button("Quit", id="quit_button") + + yield Footer() + + def on_mount(self): + self.updateMediaTags() + self.updateTracks() + self.updateDifferences() + self.setMessage(self._messageText) + + def setMessage(self, message: str): + self._messageText = str(message) + + try: + self.query_one("#message_label", Static).update(self._messageText) + except Exception: + pass + + def refreshAfterDraftChange(self): + self.updateMediaTags() + self.updateTracks() + self.updateDifferences() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "select_default_button": + if self.setSelectedTrackDefault(): + self.refreshAfterDraftChange() + + if event.button.id == "select_forced_button": + if self.setSelectedTrackForced(): + self.refreshAfterDraftChange() + + if event.button.id == "button_add_tag": + self.app.push_screen(TagDetailsScreen(), self.handle_update_media_tag) + + if event.button.id == "button_edit_tag": + selectedTag = self.getSelectedMediaTag() + if selectedTag is not None: + self.app.push_screen( + TagDetailsScreen(key=selectedTag[0], value=selectedTag[1]), + self.handle_update_media_tag, + ) + + if event.button.id == "button_delete_tag": + selectedTag = self.getSelectedMediaTag() + if selectedTag is not None: + self.app.push_screen( + TagDeleteScreen(key=selectedTag[0], value=selectedTag[1]), + self.handle_delete_media_tag, + ) + + if event.button.id == "button_edit_track": + self.action_edit_selected_track() + + if event.button.id == "apply_button": + self.action_apply_changes() + + if event.button.id == "revert_button": + self.action_revert_changes() + + if event.button.id == "quit_button": + self.action_quit_screen() + + def action_edit_selected_track(self): + selectedTrack = self.getSelectedTrackDescriptor() + if selectedTrack is None: + self.setMessage("Select a stream first.") + return + + self.app.push_screen( + TrackDetailsScreen( + trackDescriptor=selectedTrack, + patternLabel=os.path.basename(self._mediaFilename), + siblingTrackDescriptors=self._sourceMediaDescriptor.getTrackDescriptors(), + metadata_only=True, + ), + self.handle_edit_track, + ) + + def handle_update_media_tag(self, tag): + if tag is None: + return + + self._sourceMediaDescriptor.getTags()[str(tag[0])] = str(tag[1]) + self.setMessage(f"Updated media tag {tag[0]!r}.") + self.refreshAfterDraftChange() + + def handle_delete_media_tag(self, tag): + if tag is None: + return + + self._sourceMediaDescriptor.getTags().pop(str(tag[0]), None) + self.setMessage(f"Deleted media tag {tag[0]!r}.") + self.refreshAfterDraftChange() + + def handle_edit_track(self, trackDescriptor: TrackDescriptor): + if trackDescriptor is None: + return + + updatedTracks = [] + replaced = False + for currentTrack in self._sourceMediaDescriptor.getTrackDescriptors(): + if ( + currentTrack.getIndex() == trackDescriptor.getIndex() + and currentTrack.getSubIndex() == trackDescriptor.getSubIndex() + ): + updatedTracks.append(trackDescriptor) + replaced = True + else: + updatedTracks.append(currentTrack) + + if not replaced: + self.setMessage("Unable to update selected stream.") + return + + self._sourceMediaDescriptor = self._sourceMediaDescriptor.clone(context=self.context) + self._sourceMediaDescriptor.getTrackDescriptors().clear() + self._sourceMediaDescriptor.getTrackDescriptors().extend(updatedTracks) + self.setMessage( + f"Updated stream #{trackDescriptor.getIndex()} ({trackDescriptor.getType().label()})." + ) + self.refreshAfterDraftChange() + + def action_apply_changes(self): + if not self.hasPendingChanges(): + self.setMessage("No changes to apply.") + return + + try: + applyResult = apply_metadata_edits( + self.context, + self._mediaFilename, + self._baselineMediaDescriptor, + self._sourceMediaDescriptor, + ) + except Exception as ex: + self.context["logger"].exception( + "Failed to apply metadata edits for %s", + self._mediaFilename, + ) + self.setMessage(f"Apply failed: {ex}") + return + + if applyResult.get("dry_run", False): + self.setMessage( + f"Dry-run: would rewrite via temporary file {applyResult['target_path']}" + ) + return + + self.reloadProperties(reset_draft=True) + self.refreshAfterDraftChange() + self.setMessage("Changes applied and file reloaded.") + + def action_revert_changes(self): + if not self.hasPendingChanges(): + self.setMessage("No changes to revert.") + return + + self.app.push_screen( + ConfirmScreen( + "Discard pending metadata changes and reload the file state?", + confirm_label="Discard", + cancel_label="Keep Editing", + ), + self.handle_revert_confirmation, + ) + + def handle_revert_confirmation(self, confirmed): + if not confirmed: + self.setMessage("Keeping pending changes.") + return + + self.reloadProperties(reset_draft=True) + self.refreshAfterDraftChange() + self.setMessage("Reverted pending changes.") + + def action_quit_screen(self): + if self.hasPendingChanges(): + self.app.push_screen( + ConfirmScreen( + "Discard pending metadata changes and quit?", + confirm_label="Discard", + cancel_label="Stay", + ), + self.handle_quit_confirmation, + ) + return + + self.app.exit() + + def handle_quit_confirmation(self, confirmed): + if confirmed: + self.app.exit() + else: + self.setMessage("Continuing edit session.") diff --git a/src/ffx/media_workflow_screen_base.py b/src/ffx/media_workflow_screen_base.py new file mode 100644 index 0000000..034b6ae --- /dev/null +++ b/src/ffx/media_workflow_screen_base.py @@ -0,0 +1,398 @@ +import os + +import click + +from textual.screen import Screen +from textual.widgets import DataTable +from textual.widgets._data_table import CellDoesNotExist + +from ffx.audio_layout import AudioLayout +from ffx.file_properties import FileProperties +from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY +from ffx.media_descriptor_change_set import MediaDescriptorChangeSet +from ffx.track_descriptor import TrackDescriptor +from ffx.track_disposition import TrackDisposition +from ffx.track_type import TrackType + +from .screen_support import build_screen_bootstrap, populate_tag_table + + +class MediaWorkflowScreenBase(Screen): + + CSS = """ + + Grid { + grid-size: 5 10; + grid-rows: 2 2 2 2 8 2 2 2 8 2; + grid-columns: 15 25 90 10 105; + height: 100%; + width: 100%; + padding: 1; + } + + DataTable .datatable--cursor { + background: darkorange; + color: black; + } + + DataTable .datatable--header { + background: steelblue; + color: white; + } + + Input { + border: none; + } + Button { + border: none; + } + + DataTable { + min-height: 24; + } + + .two { + column-span: 2; + } + .three { + column-span: 3; + } + .four { + column-span: 4; + } + .five { + column-span: 5; + } + + #differences-table { + row-span: 10; + } + """ + + TRACKS_TABLE_INDEX_COLUMN_LABEL = "Index" + TRACKS_TABLE_TYPE_COLUMN_LABEL = "Type" + TRACKS_TABLE_SUB_INDEX_COLUMN_LABEL = "SubIndex" + TRACKS_TABLE_CODEC_COLUMN_LABEL = "Codec" + TRACKS_TABLE_LAYOUT_COLUMN_LABEL = "Layout" + TRACKS_TABLE_LANGUAGE_COLUMN_LABEL = "Language" + TRACKS_TABLE_TITLE_COLUMN_LABEL = "Title" + TRACKS_TABLE_DEFAULT_COLUMN_LABEL = "Default" + TRACKS_TABLE_FORCED_COLUMN_LABEL = "Forced" + + DIFFERENCES_COLUMN_LABEL = "Differences" + COMMAND_NAME = "" + EDIT_MODE = False + + def __init__(self): + super().__init__() + + bootstrap = build_screen_bootstrap(self.app.getContext()) + self.context = bootstrap.context + + self._applyCleanup = bootstrap.apply_cleanup + self._removeGlobalKeys = bootstrap.remove_global_keys + self._ignoreGlobalKeys = bootstrap.ignore_global_keys + + command = self.context.get("command") + if command != self.COMMAND_NAME: + raise click.ClickException( + f"{type(self).__name__}.__init__(): Can only perform command '{self.COMMAND_NAME}'" + ) + + arguments = self.context.get("arguments", {}) + self._mediaFilename = arguments.get("filename", "") + if not self._mediaFilename: + raise click.ClickException( + f"{type(self).__name__}.__init__(): Argument 'filename' is required" + ) + if not os.path.isfile(self._mediaFilename): + raise click.ClickException( + f"{type(self).__name__}.__init__(): Media file {self._mediaFilename} does not exist" + ) + + self._baselineMediaDescriptor = None + self._sourceMediaDescriptor = None + self._targetMediaDescriptor = None + self._currentPattern = None + self._mediaChangeSetObj = {} + self._messageText = "" + self._trackRowData: dict[object, TrackDescriptor] = {} + self._sourceMediaTagRowData: dict[object, tuple[str, str]] = {} + + self.reloadProperties(reset_draft=True) + + def _build_media_tags_table(self): + self.mediaTagsTable = DataTable(classes="two") + self.mediaTagsTable.add_column("Key", width=30) + self.mediaTagsTable.add_column("Value", width=70) + self.mediaTagsTable.cursor_type = "row" + + def _build_tracks_table(self): + self.tracksTable = DataTable(classes="two") + self.tracksTable.add_column( + self.TRACKS_TABLE_INDEX_COLUMN_LABEL, + width=5, + ) + self.tracksTable.add_column( + self.TRACKS_TABLE_TYPE_COLUMN_LABEL, + width=10, + ) + self.tracksTable.add_column( + self.TRACKS_TABLE_SUB_INDEX_COLUMN_LABEL, + width=8, + ) + self.tracksTable.add_column( + self.TRACKS_TABLE_CODEC_COLUMN_LABEL, + width=10, + ) + self.tracksTable.add_column( + self.TRACKS_TABLE_LAYOUT_COLUMN_LABEL, + width=10, + ) + self.tracksTable.add_column( + self.TRACKS_TABLE_LANGUAGE_COLUMN_LABEL, + width=15, + ) + self.tracksTable.add_column( + self.TRACKS_TABLE_TITLE_COLUMN_LABEL, + width=48, + ) + self.tracksTable.add_column( + self.TRACKS_TABLE_DEFAULT_COLUMN_LABEL, + width=8, + ) + self.tracksTable.add_column( + self.TRACKS_TABLE_FORCED_COLUMN_LABEL, + width=8, + ) + self.tracksTable.cursor_type = "row" + + def _build_differences_table(self): + self.differencesTable = DataTable(id="differences-table") + self.differencesTable.add_column(self.DIFFERENCES_COLUMN_LABEL, width=100) + self.differencesTable.cursor_type = "row" + + def reloadProperties(self, reset_draft: bool = True): + self._mediaFileProperties = FileProperties(self.context, self._mediaFilename) + probedMediaDescriptor = self._mediaFileProperties.getMediaDescriptor() + + if self.EDIT_MODE: + self._baselineMediaDescriptor = probedMediaDescriptor + if reset_draft or self._sourceMediaDescriptor is None: + self._sourceMediaDescriptor = probedMediaDescriptor.clone(context=self.context) + self._targetMediaDescriptor = self._sourceMediaDescriptor + self._currentPattern = None + else: + self._baselineMediaDescriptor = probedMediaDescriptor + self._sourceMediaDescriptor = probedMediaDescriptor + self._currentPattern = self._mediaFileProperties.getPattern() + self._targetMediaDescriptor = ( + self._currentPattern.getMediaDescriptor(self.context) + if self._currentPattern is not None + else None + ) + + self.rebuildChangeSet() + + def rebuildChangeSet(self): + try: + if self.EDIT_MODE: + mdcs = MediaDescriptorChangeSet( + self.context, + self._sourceMediaDescriptor, + self._baselineMediaDescriptor, + ) + else: + if self._targetMediaDescriptor is None: + self._mediaChangeSetObj = {} + return + mdcs = MediaDescriptorChangeSet( + self.context, + self._targetMediaDescriptor, + self._sourceMediaDescriptor, + ) + + self._mediaChangeSetObj = mdcs.getChangeSetObj() + except ValueError: + self._mediaChangeSetObj = {} + + def hasPendingChanges(self) -> bool: + return bool(self._mediaChangeSetObj) + + def updateMediaTags(self): + self._sourceMediaTagRowData = populate_tag_table( + self.mediaTagsTable, + self._sourceMediaDescriptor.getTags(), + ignore_keys=self._ignoreGlobalKeys, + remove_keys=self._removeGlobalKeys, + ) + + def updateTracks(self): + self.tracksTable.clear() + self._trackRowData = {} + + trackDescriptorList = self._sourceMediaDescriptor.getTrackDescriptors() + typeCounter = {} + + for trackDescriptor in trackDescriptorList: + trackType = trackDescriptor.getType() + if trackType not in typeCounter: + typeCounter[trackType] = 0 + + dispositionSet = trackDescriptor.getDispositionSet() + audioLayout = trackDescriptor.getAudioLayout() + row = ( + trackDescriptor.getIndex(), + trackType.label(), + typeCounter[trackType], + trackDescriptor.getCodec().label(), + audioLayout.label() + if trackType == TrackType.AUDIO + and audioLayout != AudioLayout.LAYOUT_UNDEFINED + else " ", + trackDescriptor.getLanguage().label(), + trackDescriptor.getTitle(), + "Yes" if TrackDisposition.DEFAULT in dispositionSet else "No", + "Yes" if TrackDisposition.FORCED in dispositionSet else "No", + ) + + row_key = self.tracksTable.add_row(*map(str, row)) + self._trackRowData[row_key] = trackDescriptor + typeCounter[trackType] += 1 + + def updateDifferences(self): + self.rebuildChangeSet() + self.differencesTable.clear() + + if not self.EDIT_MODE and self._currentPattern is None: + return + + targetDescriptor = ( + self._sourceMediaDescriptor + if self.EDIT_MODE + else self._targetMediaDescriptor + ) + targetTrackDescriptorsByIndex = { + trackDescriptor.getIndex(): trackDescriptor + for trackDescriptor in ( + targetDescriptor.getTrackDescriptors() + if targetDescriptor is not None + else [] + ) + } + + tagDifferences = self._mediaChangeSetObj.get(MediaDescriptorChangeSet.TAGS_KEY, {}) + for tagKey, tagValue in tagDifferences.get(DIFF_ADDED_KEY, {}).items(): + if tagKey not in self._ignoreGlobalKeys: + self.differencesTable.add_row( + f"add media tag: key='{tagKey}' value='{tagValue}'" + ) + + for tagKey, tagValue in tagDifferences.get(DIFF_REMOVED_KEY, {}).items(): + if tagKey in self._ignoreGlobalKeys: + continue + if not self.EDIT_MODE and tagKey in self._removeGlobalKeys: + continue + self.differencesTable.add_row( + f"remove media tag: key='{tagKey}' value='{tagValue}'" + ) + + for tagKey, tagValue in tagDifferences.get(DIFF_CHANGED_KEY, {}).items(): + if tagKey not in self._ignoreGlobalKeys: + self.differencesTable.add_row( + f"change media tag: key='{tagKey}' value='{tagValue}'" + ) + + trackDifferences = self._mediaChangeSetObj.get(MediaDescriptorChangeSet.TRACKS_KEY, {}) + + for trackDescriptor in trackDifferences.get(DIFF_ADDED_KEY, {}).values(): + self.differencesTable.add_row( + f"add {trackDescriptor.getType().label()} track: " + + f"index={trackDescriptor.getIndex()} lang={trackDescriptor.getLanguage().threeLetter()}" + ) + + for trackIndex in trackDifferences.get(DIFF_REMOVED_KEY, {}).keys(): + self.differencesTable.add_row(f"remove stream #{trackIndex}") + + for trackIndex, trackDiffObj in trackDifferences.get(DIFF_CHANGED_KEY, {}).items(): + targetTrackDescriptor = targetTrackDescriptorsByIndex.get(trackIndex) + if targetTrackDescriptor is None: + continue + + tagsDiff = trackDiffObj.get(MediaDescriptorChangeSet.TAGS_KEY, {}) + for tagKey, tagValue in tagsDiff.get(DIFF_REMOVED_KEY, {}).items(): + self.differencesTable.add_row( + f"change stream #{targetTrackDescriptor.getIndex()} " + + f"({targetTrackDescriptor.getType().label()}:{targetTrackDescriptor.getSubIndex()}) " + + f"remove key={tagKey} value={tagValue}" + ) + for tagKey, tagValue in tagsDiff.get(DIFF_ADDED_KEY, {}).items(): + self.differencesTable.add_row( + f"change stream #{targetTrackDescriptor.getIndex()} " + + f"({targetTrackDescriptor.getType().label()}:{targetTrackDescriptor.getSubIndex()}) " + + f"add key={tagKey} value={tagValue}" + ) + for tagKey, tagValue in tagsDiff.get(DIFF_CHANGED_KEY, {}).items(): + self.differencesTable.add_row( + f"change stream #{targetTrackDescriptor.getIndex()} " + + f"({targetTrackDescriptor.getType().label()}:{targetTrackDescriptor.getSubIndex()}) " + + f"change key={tagKey} value={tagValue}" + ) + + dispositionDiff = trackDiffObj.get(MediaDescriptorChangeSet.DISPOSITION_SET_KEY, {}) + for addedDisposition in dispositionDiff.get(DIFF_ADDED_KEY, set()): + self.differencesTable.add_row( + f"change stream #{targetTrackDescriptor.getIndex()} " + + f"({targetTrackDescriptor.getType().label()}:{targetTrackDescriptor.getSubIndex()}) " + + f"add disposition={addedDisposition.label()}" + ) + for removedDisposition in dispositionDiff.get(DIFF_REMOVED_KEY, set()): + self.differencesTable.add_row( + f"change stream #{targetTrackDescriptor.getIndex()} " + + f"({targetTrackDescriptor.getType().label()}:{targetTrackDescriptor.getSubIndex()}) " + + f"remove disposition={removedDisposition.label()}" + ) + + def getSelectedMediaTag(self): + try: + row_key, _ = self.mediaTagsTable.coordinate_to_cell_key( + self.mediaTagsTable.cursor_coordinate + ) + if row_key is not None: + return self._sourceMediaTagRowData.get(row_key) + return None + except CellDoesNotExist: + return None + + def getSelectedTrackDescriptor(self): + try: + row_key, _ = self.tracksTable.coordinate_to_cell_key( + self.tracksTable.cursor_coordinate + ) + if row_key is not None: + return self._trackRowData.get(row_key) + return None + except CellDoesNotExist: + return None + + def setSelectedTrackDefault(self): + selectedTrackDescriptor = self.getSelectedTrackDescriptor() + if selectedTrackDescriptor is None: + return False + + self._sourceMediaDescriptor.setDefaultSubTrack( + selectedTrackDescriptor.getType(), + selectedTrackDescriptor.getSubIndex(), + ) + return True + + def setSelectedTrackForced(self): + selectedTrackDescriptor = self.getSelectedTrackDescriptor() + if selectedTrackDescriptor is None: + return False + + self._sourceMediaDescriptor.setForcedSubTrack( + selectedTrackDescriptor.getType(), + selectedTrackDescriptor.getSubIndex(), + ) + return True diff --git a/tests/unit/test_tag_table_screen_state.py b/tests/unit/test_tag_table_screen_state.py index 70fc278..19b80ab 100644 --- a/tests/unit/test_tag_table_screen_state.py +++ b/tests/unit/test_tag_table_screen_state.py @@ -14,7 +14,8 @@ if str(SRC_ROOT) not in sys.path: from ffx.audio_layout import AudioLayout # noqa: E402 from ffx.iso_language import IsoLanguage # noqa: E402 from ffx.logging_utils import get_ffx_logger # noqa: E402 -from ffx.media_details_screen import MediaDetailsScreen # noqa: E402 +from ffx.inspect_details_screen import InspectDetailsScreen # noqa: E402 +from ffx.media_edit_screen import MediaEditScreen # noqa: E402 from ffx.pattern_details_screen import PatternDetailsScreen # noqa: E402 from ffx.show_descriptor import ShowDescriptor # noqa: E402 from ffx.show_details_screen import ShowDetailsScreen # noqa: E402 @@ -198,16 +199,16 @@ class TagTableScreenStateTests(unittest.TestCase): screen.getSelectedTag(), ) - def test_media_details_screen_reads_selected_track_from_row_mapping(self): + def test_media_edit_screen_reads_selected_track_from_row_mapping(self): first_track = make_track_descriptor(0, 0, TrackType.VIDEO) second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE) - screen = object.__new__(MediaDetailsScreen) + screen = object.__new__(MediaEditScreen) screen.tracksTable = FakeTagTable() - screen._MediaDetailsScreen__sourceMediaDescriptor = FakeMediaDescriptor( + screen._sourceMediaDescriptor = FakeMediaDescriptor( [first_track, second_track] ) - screen._MediaDetailsScreen__trackRowData = {} + screen._trackRowData = {} screen.updateTracks() screen.tracksTable.select_row("row-1") @@ -299,10 +300,10 @@ class TagTableScreenStateTests(unittest.TestCase): self.assertEqual(4, screen.getSelectedShowId()) - def test_media_details_screen_reads_selected_show_from_row_mapping(self): - screen = object.__new__(MediaDetailsScreen) + def test_inspect_details_screen_reads_selected_show_from_row_mapping(self): + screen = object.__new__(InspectDetailsScreen) screen.showsTable = FakeTagTable() - screen._MediaDetailsScreen__showRowData = {} + screen._showRowData = {} placeholder_key = screen._add_show_row(None) show_key = screen._add_show_row(make_show_descriptor(8, "Real Show", 2020)) @@ -317,7 +318,7 @@ class TagTableScreenStateTests(unittest.TestCase): self.assertEqual(1, screen.getRowIndexFromShowId(8)) screen.removeShow(-1) - self.assertNotIn(placeholder_key, screen._MediaDetailsScreen__showRowData) + self.assertNotIn(placeholder_key, screen._showRowData) self.assertEqual(0, screen.getRowIndexFromShowId(8))