Splits screen classes

This commit is contained in:
Javanaut
2026-04-13 14:57:13 +02:00
parent c0b3977ea6
commit e614ca5d75
6 changed files with 1055 additions and 1010 deletions

View File

@@ -1,7 +1,8 @@
from textual.app import App from textual.app import App
from .shows_screen import ShowsScreen 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): class FfxApp(App):
@@ -28,8 +29,11 @@ class FfxApp(App):
if self.context['command'] == 'shows': if self.context['command'] == 'shows':
self.push_screen(ShowsScreen()) self.push_screen(ShowsScreen())
if self.context['command'] in ('inspect', 'edit'): if self.context['command'] == 'inspect':
self.push_screen(MediaDetailsScreen()) self.push_screen(InspectDetailsScreen())
if self.context['command'] == 'edit':
self.push_screen(MediaEditScreen())
def getContext(self): def getContext(self):

View File

@@ -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(" ", "<New show>", " ")
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()

View File

@@ -1,998 +1 @@
import os from .inspect_details_screen import InspectDetailsScreen as MediaDetailsScreen
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(" ", "<New show>", " ")
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()

View File

@@ -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.")

View File

@@ -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

View File

@@ -14,7 +14,8 @@ if str(SRC_ROOT) not in sys.path:
from ffx.audio_layout import AudioLayout # noqa: E402 from ffx.audio_layout import AudioLayout # noqa: E402
from ffx.iso_language import IsoLanguage # noqa: E402 from ffx.iso_language import IsoLanguage # noqa: E402
from ffx.logging_utils import get_ffx_logger # noqa: E402 from ffx.logging_utils import get_ffx_logger # noqa: E402
from ffx.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.pattern_details_screen import PatternDetailsScreen # noqa: E402
from ffx.show_descriptor import ShowDescriptor # noqa: E402 from ffx.show_descriptor import ShowDescriptor # noqa: E402
from ffx.show_details_screen import ShowDetailsScreen # noqa: E402 from ffx.show_details_screen import ShowDetailsScreen # noqa: E402
@@ -198,16 +199,16 @@ class TagTableScreenStateTests(unittest.TestCase):
screen.getSelectedTag(), 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) first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE) second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE)
screen = object.__new__(MediaDetailsScreen) screen = object.__new__(MediaEditScreen)
screen.tracksTable = FakeTagTable() screen.tracksTable = FakeTagTable()
screen._MediaDetailsScreen__sourceMediaDescriptor = FakeMediaDescriptor( screen._sourceMediaDescriptor = FakeMediaDescriptor(
[first_track, second_track] [first_track, second_track]
) )
screen._MediaDetailsScreen__trackRowData = {} screen._trackRowData = {}
screen.updateTracks() screen.updateTracks()
screen.tracksTable.select_row("row-1") screen.tracksTable.select_row("row-1")
@@ -299,10 +300,10 @@ class TagTableScreenStateTests(unittest.TestCase):
self.assertEqual(4, screen.getSelectedShowId()) self.assertEqual(4, screen.getSelectedShowId())
def test_media_details_screen_reads_selected_show_from_row_mapping(self): def test_inspect_details_screen_reads_selected_show_from_row_mapping(self):
screen = object.__new__(MediaDetailsScreen) screen = object.__new__(InspectDetailsScreen)
screen.showsTable = FakeTagTable() screen.showsTable = FakeTagTable()
screen._MediaDetailsScreen__showRowData = {} screen._showRowData = {}
placeholder_key = screen._add_show_row(None) placeholder_key = screen._add_show_row(None)
show_key = screen._add_show_row(make_show_descriptor(8, "Real Show", 2020)) 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)) self.assertEqual(1, screen.getRowIndexFromShowId(8))
screen.removeShow(-1) screen.removeShow(-1)
self.assertNotIn(placeholder_key, screen._MediaDetailsScreen__showRowData) self.assertNotIn(placeholder_key, screen._showRowData)
self.assertEqual(0, screen.getRowIndexFromShowId(8)) self.assertEqual(0, screen.getRowIndexFromShowId(8))