From c0b3977ea6b868058a5de3e8aebf044e13008c2a Mon Sep 17 00:00:00 2001 From: Javanaut Date: Mon, 13 Apr 2026 13:16:33 +0200 Subject: [PATCH] iteration1 --- requirements/metadata_editor.md | 198 +++ src/ffx/cli.py | 44 +- src/ffx/confirm_screen.py | 55 + src/ffx/ffx_app.py | 3 +- src/ffx/file_properties.py | 12 +- src/ffx/media_descriptor.py | 16 + src/ffx/media_descriptor_change_set.py | 19 +- src/ffx/media_details_screen.py | 1290 ++++++++++------- src/ffx/metadata_editor.py | 89 ++ src/ffx/screen_support.py | 7 +- src/ffx/track_descriptor.py | 22 + src/ffx/track_details_screen.py | 6 + tests/unit/test_cli_lazy_imports.py | 45 + tests/unit/test_file_properties_probe.py | 16 + .../unit/test_media_descriptor_change_set.py | 47 + tests/unit/test_metadata_editor.py | 144 ++ 16 files changed, 1485 insertions(+), 528 deletions(-) create mode 100644 requirements/metadata_editor.md create mode 100644 src/ffx/confirm_screen.py create mode 100644 src/ffx/metadata_editor.py create mode 100644 tests/unit/test_metadata_editor.py diff --git a/requirements/metadata_editor.md b/requirements/metadata_editor.md new file mode 100644 index 0000000..4b34ebb --- /dev/null +++ b/requirements/metadata_editor.md @@ -0,0 +1,198 @@ +# Metadata Editor + +This file defines the requirements for a database-free interactive metadata +editor command derived from the current file-inspection UI. + +Feasibility from the current codebase: yes, with a moderate refactor. + +The strongest reusable pieces already exist: + +- `ffprobe`-backed media probing through `FileProperties` and `MediaDescriptor` +- descriptor-level metadata and disposition mutation through `MediaDescriptor` + and `TrackDescriptor` +- diff and ffmpeg token generation through `MediaDescriptorChangeSet` +- stream-copy remux execution through `FfxController` with `VideoEncoder.COPY` +- reusable tag and track edit dialogs in the Textual UI + +The main missing pieces are: + +- a CLI bootstrap path that does not initialize SQLite +- a probe-only path that does not instantiate database-backed controllers +- a clean separation between original file state and editable draft state +- a safe temporary-output and replace workflow for writing changes back to the + same file path + +## Scope + +- One new command: `ffx edit ` +- One-file interactive editing through a Textual screen derived from + `MediaDetailsScreen` +- Editing container-level metadata and per-stream metadata already visible in + the application +- Editing stream dispositions that are represented as metadata-like output + state, especially `default` and `forced` +- Writing the result back to the original file path through a temporary output + file and replace step + +## Out Of Scope + +- SQLite reads, writes, migrations, or pattern matching +- TMDB lookups, show selection, pattern selection, or shifted-season logic +- Batch editing multiple files in one command invocation +- Video or audio transcoding +- Container changes, filename changes, or rename workflows +- Stream add, stream delete, stream reorder, or stream substitution from + external files in the first release +- Editing technical stream identity such as codec, stream type, source index, + or audio layout in the first release +- Chapter editing + +## Terms + +- `baseline descriptor`: immutable in-memory representation of the file as last + probed from disk +- `draft descriptor`: mutable in-memory representation of the desired output + state +- `edit mode`: the database-free TUI mode used by `ffx edit` +- `planned changes`: user-visible summary of the differences between baseline + and draft plus any configured cleanup actions +- `temporary output file`: the write target used before replacing the original + file path + +## Rules + +- `METADATA_EDITOR-0001`: The system shall provide a command `ffx edit ` + that requires exactly one existing media file path and opens an interactive + Textual editor for that file. +- `METADATA_EDITOR-0002`: `ffx edit` shall not initialize SQLite, shall not + open the configured database file, shall not prompt for database migration, + and shall not instantiate any controller that depends on `context['database']`. +- `METADATA_EDITOR-0003`: `ffx edit` may still read configuration and logging + settings from `~/.local/etc/ffx.json`, but any global database option shall + have no effect on this command's behavior. +- `METADATA_EDITOR-0004`: Edit mode shall be derived from the current + `MediaDetailsScreen` behavior and layout where practical, but all DB-only UI + elements and actions such as show selection, pattern input, and pattern CRUD + actions shall be hidden, disabled, or replaced. +- `METADATA_EDITOR-0005`: Edit mode shall keep the baseline descriptor and the + draft descriptor as separate objects. Editing actions shall mutate only the + draft descriptor until the operator explicitly applies changes. +- `METADATA_EDITOR-0006`: The application shall keep raw metadata values + separate from rendered labels. Rich or Textual markup may be used for + presentation, but it shall never be stored in descriptor state, reused as + source data, or written into the media file. +- `METADATA_EDITOR-0007`: The planned-changes view shall compare the baseline + descriptor with the draft descriptor using `MediaDescriptorChangeSet` or an + equivalent descriptor-diff mechanism. It shall no longer mean `file -> db`. +- `METADATA_EDITOR-0008`: The editor shall support container-tag add, edit, and + delete operations on the draft descriptor. +- `METADATA_EDITOR-0009`: The editor shall support per-stream metadata edit + operations on the draft descriptor, including at least language, title, and + arbitrary stream tag key-value pairs. +- `METADATA_EDITOR-0010`: The editor shall support setting and clearing + `default` and `forced` dispositions in the draft descriptor, while enforcing + that there is at most one `default` and at most one `forced` stream per track + type. +- `METADATA_EDITOR-0011`: The first released editor scope shall treat technical + stream structure as immutable. A user shall not be able to change stream + count, output order, codec, track type, audio layout, or source-index + mapping through `ffx edit`. +- `METADATA_EDITOR-0012`: The track-edit UI used in edit mode shall therefore + expose only metadata fields and supported disposition fields. Structural + fields that are editable in pattern-authoring workflows shall be read-only or + absent in edit mode. +- `METADATA_EDITOR-0013`: The command shall write changes through an ffmpeg + stream-copy remux workflow only. No transcoding shall be performed as part of + `ffx edit`. +- `METADATA_EDITOR-0014`: Because ffmpeg cannot rewrite the source file in + place, `ffx edit` shall write to a temporary output file on the same + filesystem as the source file and shall replace the original path only after + ffmpeg reports success. +- `METADATA_EDITOR-0015`: The temporary output path shall preserve the original + container type and file extension. The feature shall not silently change the + container or extension during a metadata-only edit. +- `METADATA_EDITOR-0016`: If the rewrite step fails, the original file shall + remain untouched. The system shall not leave the user with a partially + replaced source file. +- `METADATA_EDITOR-0017`: After a successful replace, the application shall + reprobe the rewritten file, refresh the baseline descriptor from disk, reset + the draft state to that fresh baseline, and clear the dirty state. +- `METADATA_EDITOR-0018`: Edit mode shall track whether unsaved draft changes + exist and shall require confirmation before dismissing the screen or quitting + the app when such changes would be lost. +- `METADATA_EDITOR-0019`: Edit mode shall not inject conversion-only encoding + metadata such as encoder quality or preset markers. +- `METADATA_EDITOR-0020`: Signature-tag behavior shall be explicit for + metadata-only editing. The default behavior shall not add a misleading + recoding-style signature to a file that was only remuxed for metadata + updates. +- `METADATA_EDITOR-0021`: Configured metadata-removal rules from the local + configuration shall be surfaced clearly in the UI and in the planned-changes + view. If those rules are applied during save, the operator shall be able to + tell that the file will be cleaned in addition to any manual edits. +- `METADATA_EDITOR-0022`: The command shall provide an invocation-level way to + disable config-driven cleanup when the operator wants a pure manual metadata + edit without automatic tag removal. +- `METADATA_EDITOR-0023`: The existing global `--dry-run` behavior shall apply + to `ffx edit`. In dry-run mode the command shall not replace the original + file and shall expose the planned write operation clearly enough for the user + to understand what would happen. + +## Acceptance + +- `ffx edit /path/to/file.mkv` opens successfully on a workstation where the + configured database is missing, empty, incompatible, or intentionally + inaccessible. +- Opening a file in edit mode does not trigger database bootstrap or migration + prompts. +- A user can change a container tag, save, and see the rewritten file at the + same path with the updated metadata. +- A user can change a stream title or language, save, and see the rewritten + file at the same path with the updated stream metadata. +- A user can change `default` or `forced` on a track, save, and see the + rewritten file at the same path with the updated dispositions. +- The planned-changes view reflects manual edits relative to the original file + and, when enabled, any configured cleanup removals. +- No rendered Rich or Textual color markup appears in the saved file metadata. +- If ffmpeg fails while saving, the original file remains present and readable + at the original path. +- In dry-run mode, the original file remains untouched. + +## Current Code Fit + +- Good fit: + - `FfxController.runJob(...)` already has a `VideoEncoder.COPY` path that + can remux streams and apply metadata and disposition tokens. + - `MediaDescriptorChangeSet` already computes container-tag, stream-tag, and + disposition differences and can generate ffmpeg metadata tokens. + - `TagDetailsScreen` and `TrackDetailsScreen` already provide reusable edit + dialogs for draft state. + - `PatternDetailsScreen` already demonstrates add, edit, and delete flows for + tags and tracks in a draft-first UI. +- Refactor required: + - `ffx` CLI initialization currently creates a database context for all + non-lightweight commands, so `edit` needs its own DB-free bootstrap path. + - `FileProperties` currently instantiates `PatternController` eagerly, so + probing must be split from pattern matching or made lazy. + - `MediaDetailsScreen` currently assumes `command == 'inspect'` and mixes + file state with database-backed target-pattern state. + - `MediaDetailsScreen` currently mutates the probed source descriptor + directly. Edit mode needs an immutable baseline descriptor and a separate + mutable draft descriptor. + - `TrackDetailsScreen` currently exposes structural fields that are valid for + pattern authoring but too dangerous for metadata-only file editing. + +## Risks + +- Container-level metadata support differs across formats, so some requested tag + changes may not round-trip identically through ffmpeg for every supported + container. +- The existing metadata-removal implementation is conversion-oriented and may + remove tags more aggressively than a user expects from a manual editor unless + cleanup policy is made explicit. +- The current codebase lacks a dedicated descriptor clone API, so draft-state + separation should be implemented deliberately instead of via accidental shared + references. +- Replacing a file path with a temporary output changes inode identity, so any + future requirement around preserving timestamps, hard links, or extended + attributes would need additional explicit handling. diff --git a/src/ffx/cli.py b/src/ffx/cli.py index 9cf1328..756ee91 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -34,6 +34,7 @@ if TYPE_CHECKING: from ffx.track_descriptor import TrackDescriptor LIGHTWEIGHT_COMMANDS = {None, 'version', 'help', 'setup', 'configure_workstation', 'upgrade', 'rename'} +CONFIG_ONLY_COMMANDS = {'edit'} CPU_OPTION_HELP = ( "Limit CPU for started processes. Use an absolute cpulimit value such as 200 " + "(about 2 cores), or use a percentage such as 25% for a share of present cores. " @@ -266,14 +267,10 @@ def ffx(ctx, database_file, verbose, dry_run): return from ffx.configuration_controller import ConfigurationController - from ffx.database import databaseContext from ffx.logging_utils import configure_ffx_logger ctx.obj['config'] = ConfigurationController() - ctx.obj['database'] = databaseContext(databasePath=database_file - if database_file else ctx.obj['config'].getDatabaseFilePath()) - ctx.obj['dry_run'] = dry_run ctx.obj['verbosity'] = verbose @@ -291,6 +288,17 @@ def ffx(ctx, database_file, verbose, dry_run): consoleLogVerbosity, ) + if ctx.invoked_subcommand in CONFIG_ONLY_COMMANDS: + return + + from ffx.database import databaseContext + + ctx.obj['database'] = databaseContext( + databasePath=database_file + if database_file + else ctx.obj['config'].getDatabaseFilePath() + ) + # Define a subcommand @ffx.command() @@ -303,7 +311,7 @@ def version(): def help(): click.echo(f"ffx {VERSION}\n") click.echo("Maintenance commands: setup, configure_workstation, upgrade") - click.echo("Media commands: shows, inspect, convert, rename, unmux, cropdetect") + click.echo("Media commands: shows, inspect, edit, convert, rename, unmux, cropdetect") click.echo("Use 'ffx --help' or 'ffx --help' for full command help.") @@ -510,6 +518,32 @@ def inspect(ctx, shift, filenames): app.run() +@ffx.command() +@click.pass_context +@click.option( + '--no-cleanup', + is_flag=True, + default=False, + help='Disable config-driven metadata cleanup and only apply manual edits.', +) +@click.argument('filename', nargs=1) +def edit(ctx, no_cleanup, filename): + if not os.path.isfile(filename): + raise click.ClickException(f"File not found: {filename}") + + from ffx.ffx_app import FfxApp + + ctx.obj['command'] = 'edit' + ctx.obj['arguments'] = {'filename': filename} + ctx.obj['use_pattern'] = False + ctx.obj['no_signature'] = True + ctx.obj['apply_metadata_cleanup'] = not no_cleanup + ctx.obj['resource_limits'] = ctx.obj.get('resource_limits', {}) + + app = FfxApp(ctx.obj) + app.run() + + @ffx.command() @click.pass_context @click.argument('paths', nargs=-1) diff --git a/src/ffx/confirm_screen.py b/src/ffx/confirm_screen.py new file mode 100644 index 0000000..f2e68ae --- /dev/null +++ b/src/ffx/confirm_screen.py @@ -0,0 +1,55 @@ +from textual.containers import Grid +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Static + + +class ConfirmScreen(Screen): + + CSS = """ + + Grid { + grid-size: 4 7; + grid-rows: 2 2 2 2 2 2 2; + grid-columns: 30 30 30 30; + height: 100%; + width: 100%; + padding: 1; + } + + Button { + border: none; + } + + .four { + column-span: 4; + } + """ + + def __init__( + self, + message: str, + confirm_label: str = "Confirm", + cancel_label: str = "Cancel", + ): + super().__init__() + self.__message = str(message) + self.__confirmLabel = str(confirm_label) + self.__cancelLabel = str(cancel_label) + + def compose(self): + yield Header() + + with Grid(): + yield Static(self.__message, classes="four") + yield Static(" ", classes="four") + yield Button(self.__confirmLabel, id="confirm_button") + yield Button(self.__cancelLabel, id="cancel_button") + + yield Footer() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "confirm_button": + self.dismiss(True) + + if event.button.id == "cancel_button": + self.dismiss(False) diff --git a/src/ffx/ffx_app.py b/src/ffx/ffx_app.py index 77c7969..5d345f2 100644 --- a/src/ffx/ffx_app.py +++ b/src/ffx/ffx_app.py @@ -28,11 +28,10 @@ class FfxApp(App): if self.context['command'] == 'shows': self.push_screen(ShowsScreen()) - if self.context['command'] == 'inspect': + if self.context['command'] in ('inspect', 'edit'): self.push_screen(MediaDetailsScreen()) def getContext(self): """Data 'output' method""" return self.context - diff --git a/src/ffx/file_properties.py b/src/ffx/file_properties.py index 20c5d94..0dcd310 100644 --- a/src/ffx/file_properties.py +++ b/src/ffx/file_properties.py @@ -63,11 +63,19 @@ class FileProperties(): self.__sourceFileBasename = self.__sourceFilename self.__sourceFilenameExtension = '' - self.__pc = PatternController(context) self.__usePattern = bool(self.context.get('use_pattern', True)) + self.__pc = ( + PatternController(context) + if self.__usePattern and 'database' in self.context + else None + ) # Checking if database contains matching pattern - matchResult = self.__pc.matchFilename(self.__sourceFilename) if self.__usePattern else {} + matchResult = ( + self.__pc.matchFilename(self.__sourceFilename) + if self.__pc is not None + else {} + ) self.__logger.debug(f"FileProperties.__init__(): Match result: {matchResult}") diff --git a/src/ffx/media_descriptor.py b/src/ffx/media_descriptor.py index deee087..a09ba1f 100644 --- a/src/ffx/media_descriptor.py +++ b/src/ffx/media_descriptor.py @@ -561,3 +561,19 @@ class MediaDescriptor: yield (f"{td.getIndex()}:{td.getType().indicator()}:{td.getSubIndex()} " + '|'.join([d.indicator() for d in td.getDispositionSet()]) + ' ' + ' '.join([str(k)+'='+str(v) for k,v in td.getTags().items()])) + + def clone(self, context: dict | None = None): + kwargs = { + MediaDescriptor.TAGS_KEY: dict(self.__mediaTags), + MediaDescriptor.TRACK_DESCRIPTOR_LIST_KEY: [ + trackDescriptor.clone(context=context if context is not None else self.__context) + for trackDescriptor in self.__trackDescriptors + ], + } + + if context is not None: + kwargs[MediaDescriptor.CONTEXT_KEY] = context + elif self.__context: + kwargs[MediaDescriptor.CONTEXT_KEY] = self.__context + + return MediaDescriptor(**kwargs) diff --git a/src/ffx/media_descriptor_change_set.py b/src/ffx/media_descriptor_change_set.py index 093542b..ee6c3b9 100644 --- a/src/ffx/media_descriptor_change_set.py +++ b/src/ffx/media_descriptor_change_set.py @@ -29,13 +29,24 @@ class MediaDescriptorChangeSet(): self.__configurationData = self.__context['config'].getData() metadataConfiguration = self.__configurationData['metadata'] if 'metadata' in self.__configurationData.keys() else {} + applyCleanup = bool(self.__context.get('apply_metadata_cleanup', True)) self.__signatureTags = metadataConfiguration['signature'] if 'signature' in metadataConfiguration.keys() else {} - self.__removeGlobalKeys = metadataConfiguration['remove'] if 'remove' in metadataConfiguration.keys() else [] + self.__removeGlobalKeys = ( + metadataConfiguration['remove'] + if applyCleanup and 'remove' in metadataConfiguration.keys() + else [] + ) self.__ignoreGlobalKeys = metadataConfiguration['ignore'] if 'ignore' in metadataConfiguration.keys() else [] - self.__removeTrackKeys = (metadataConfiguration['streams']['remove'] - if 'streams' in metadataConfiguration.keys() - and 'remove' in metadataConfiguration['streams'].keys() else []) + self.__removeTrackKeys = ( + metadataConfiguration['streams']['remove'] + if ( + applyCleanup + and 'streams' in metadataConfiguration.keys() + and 'remove' in metadataConfiguration['streams'].keys() + ) + else [] + ) self.__ignoreTrackKeys = (metadataConfiguration['streams']['ignore'] if 'streams' in metadataConfiguration.keys() and 'ignore' in metadataConfiguration['streams'].keys() else []) diff --git a/src/ffx/media_details_screen.py b/src/ffx/media_details_screen.py index 33006f1..5ec4bed 100644 --- a/src/ffx/media_details_screen.py +++ b/src/ffx/media_details_screen.py @@ -1,46 +1,49 @@ -import os, click, re +import os +import re + +import click -from textual.screen import Screen -from textual.widgets import Header, Footer, Static, Button, Input, DataTable from textual.containers import Grid - -from ffx.audio_layout import AudioLayout - -from .show_details_screen import ShowDetailsScreen -from .pattern_details_screen import PatternDetailsScreen -from .screen_support import build_screen_bootstrap, build_screen_controllers, populate_tag_table - -from ffx.track_type import TrackType -from ffx.model.track import Track - -from ffx.track_disposition import TrackDisposition -from ffx.track_descriptor import TrackDescriptor -from ffx.show_descriptor import ShowDescriptor - +from textual.screen import Screen +from textual.widgets import Button, DataTable, Footer, Header, Input, Static from textual.widgets._data_table import CellDoesNotExist -from ffx.media_descriptor import MediaDescriptor +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 ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY, DIFF_UNCHANGED_KEY +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 -# Screen[dict[int, str, int]] class MediaDetailsScreen(Screen): CSS = """ Grid { - grid-size: 5 8; - grid-rows: 8 2 2 2 2 8 2 2 8; + 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; @@ -59,19 +62,15 @@ class MediaDetailsScreen(Screen): } DataTable { - min-height: 40; + min-height: 24; } - #toplabel { - height: 1; - } .two { column-span: 2; - } + } .three { column-span: 3; } - .four { column-span: 4; } @@ -79,34 +78,11 @@ class MediaDetailsScreen(Screen): column-span: 5; } - .triple { - row-span: 3; - } - - .box { - height: 100%; - border: solid green; - } - - .purple { - tint: purple 40%; - } - - .yellow { - tint: yellow 40%; - } - #differences-table { - row-span: 8; - /* tint: magenta 40%; */ + row-span: 10; } - - /* #pattern_input { - tint: red 40%; - }*/ """ - TRACKS_TABLE_INDEX_COLUMN_LABEL = "Index" TRACKS_TABLE_TYPE_COLUMN_LABEL = "Type" TRACKS_TABLE_SUB_INDEX_COLUMN_LABEL = "SubIndex" @@ -117,58 +93,443 @@ class MediaDetailsScreen(Screen): TRACKS_TABLE_DEFAULT_COLUMN_LABEL = "Default" TRACKS_TABLE_FORCED_COLUMN_LABEL = "Forced" - DIFFERENCES_TABLE_DIFFERENCES_COLUMN_LABEL = 'Differences (file->db/output)' - + 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 - 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'] + command = self.context.get("command") + self.__inspectMode = command == "inspect" + self.__editMode = command == "edit" - if not 'command' in self.context.keys() or self.context['command'] != 'inspect': - raise click.ClickException(f"MediaDetailsScreen.__init__(): Can only perform command 'inspect'") - - if not 'arguments' in self.context.keys() or not 'filename' in self.context['arguments'].keys() or not self.context['arguments']['filename']: - raise click.ClickException(f"MediaDetailsScreen.__init__(): Argument 'filename' is required to be provided for command 'inspect'") + 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.__mediaFilename = self.context['arguments']['filename'] self.__showRowData: dict[object, ShowDescriptor | None] = {} self.__trackRowData: dict[object, TrackDescriptor] = {} self.__sourceMediaTagRowData: dict[object, tuple[str, str]] = {} - if not os.path.isfile(self.__mediaFilename): - raise click.ClickException(f"MediaDetailsScreen.__init__(): Media file {self.__mediaFilename} does not exist") + self.reloadProperties(reset_draft=True) - self.loadProperties() + 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 removeShow(self, showId : int = -1): - """Remove show entry from DataTable. - Removes the entry if showId is not set""" + 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) @@ -181,11 +542,7 @@ class MediaDetailsScreen(Screen): self.__showRowData.pop(row_key, None) return - - - def getRowIndexFromShowId(self, showId : int = -1) -> int: - """Find the index of the row where the value in the specified column matches the target_value.""" - + 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) @@ -198,373 +555,283 @@ class MediaDetailsScreen(Screen): return None - def _add_show_row(self, show_descriptor: ShowDescriptor | None): if show_descriptor is None: - row_key = self.showsTable.add_row(' ', '', ' ') + 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 - def loadProperties(self): + patternInput = self.query_one("#pattern_input", Input) + patternInput.styles.background = "red" if state else None - self.__mediaFileProperties = FileProperties(self.context, self.__mediaFilename) - self.__sourceMediaDescriptor = self.__mediaFileProperties.getMediaDescriptor() - - #HINT: This is None if the filename did not match anything in database - self.__currentPattern = self.__mediaFileProperties.getPattern() - - # keine tags vorhanden - self.__targetMediaDescriptor = self.__currentPattern.getMediaDescriptor(self.context) if self.__currentPattern is not None else None - - # Enumerating differences between media descriptors - # from file (=current) vs from stored in database (=target) + def getSelectedMediaTag(self): try: - mdcs = MediaDescriptorChangeSet(self.context, - self.__targetMediaDescriptor, - self.__sourceMediaDescriptor) - - self.__mediaChangeSetObj = mdcs.getChangeSetObj() - except ValueError: - self.__mediaChangeSetObj = {} - - - def updateDifferences(self): - - self.loadProperties() - - self.differencesTable.clear() - - - if MediaDescriptorChangeSet.TAGS_KEY in self.__mediaChangeSetObj.keys(): - - if DIFF_ADDED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY].keys(): - for tagKey, tagValue in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_ADDED_KEY].items(): - if tagKey not in self.__ignoreGlobalKeys: - row = (f"add media tag: key='{tagKey}' value='{tagValue}'",) - self.differencesTable.add_row(*map(str, row)) - - if DIFF_REMOVED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY].keys(): - for tagKey, tagValue in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_REMOVED_KEY].items(): - if tagKey not in self.__ignoreGlobalKeys and tagKey not in self.__removeGlobalKeys: - row = (f"remove media tag: key='{tagKey}' value='{tagValue}'",) - self.differencesTable.add_row(*map(str, row)) - - if DIFF_CHANGED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY].keys(): - for tagKey, tagValue in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_CHANGED_KEY].items(): - if tagKey not in self.__ignoreGlobalKeys: - row = (f"change media tag: key='{tagKey}' value='{tagValue}'",) - self.differencesTable.add_row(*map(str, row)) - - - if MediaDescriptorChangeSet.TRACKS_KEY in self.__mediaChangeSetObj.keys(): - - if DIFF_ADDED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY].keys(): - - trackDescriptor: TrackDescriptor - for trackIndex, trackDescriptor in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_ADDED_KEY].items(): - row = (f"add {trackDescriptor.getType().label()} track: index={trackDescriptor.getIndex()} lang={trackDescriptor.getLanguage().threeLetter()}",) - self.differencesTable.add_row(*map(str, row)) - - if DIFF_REMOVED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY].keys(): - for trackIndex, trackDescriptor in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_REMOVED_KEY].items(): - row = (f"remove stream #{trackIndex}",) - self.differencesTable.add_row(*map(str, row)) - - if DIFF_CHANGED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY].keys(): - - changedTracks: dict = self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_CHANGED_KEY] - - targetTrackDescriptors = self.__targetMediaDescriptor.getTrackDescriptors() - - trackDiffObj: dict - for trackIndex, trackDiffObj in changedTracks.items(): - - ttd: TrackDescriptor = targetTrackDescriptors[trackIndex] - - - if MediaDescriptorChangeSet.TAGS_KEY in trackDiffObj.keys(): - - removedTags = (trackDiffObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_REMOVED_KEY] - if DIFF_REMOVED_KEY in trackDiffObj[MediaDescriptorChangeSet.TAGS_KEY].keys() else {}) - for tagKey, tagValue in removedTags.items(): - row = (f"change stream #{ttd.getIndex()} ({ttd.getType().label()}:{ttd.getSubIndex()}) remove key={tagKey} value={tagValue}",) - self.differencesTable.add_row(*map(str, row)) - - addedTags = (trackDiffObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_ADDED_KEY] - if DIFF_ADDED_KEY in trackDiffObj[MediaDescriptorChangeSet.TAGS_KEY].keys() else {}) - for tagKey, tagValue in addedTags.items(): - row = (f"change stream #{ttd.getIndex()} ({ttd.getType().label()}:{ttd.getSubIndex()}) add key={tagKey} value={tagValue}",) - self.differencesTable.add_row(*map(str, row)) - - changedTags = (trackDiffObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_CHANGED_KEY] - if DIFF_CHANGED_KEY in trackDiffObj[MediaDescriptorChangeSet.TAGS_KEY].keys() else {}) - for tagKey, tagValue in changedTags.items(): - row = (f"change stream #{ttd.getIndex()} ({ttd.getType().label()}:{ttd.getSubIndex()}) change key={tagKey} value={tagValue}",) - self.differencesTable.add_row(*map(str, row)) - - - if MediaDescriptorChangeSet.DISPOSITION_SET_KEY in trackDiffObj.keys(): - - addedDispositions = (trackDiffObj[MediaDescriptorChangeSet.DISPOSITION_SET_KEY][DIFF_ADDED_KEY] - if DIFF_ADDED_KEY in trackDiffObj[MediaDescriptorChangeSet.DISPOSITION_SET_KEY].keys() else set()) - for ad in addedDispositions: - row = (f"change stream #{ttd.getIndex()} ({ttd.getType().label()}:{ttd.getSubIndex()}) add disposition={ad.label()}",) - self.differencesTable.add_row(*map(str, row)) - - removedDispositions = (trackDiffObj[MediaDescriptorChangeSet.DISPOSITION_SET_KEY][DIFF_REMOVED_KEY] - if DIFF_REMOVED_KEY in trackDiffObj[MediaDescriptorChangeSet.DISPOSITION_SET_KEY].keys() else set()) - for rd in removedDispositions: - row = (f"change stream #{ttd.getIndex()} ({ttd.getType().label()}:{ttd.getSubIndex()}) remove disposition={rd.label()}",) - self.differencesTable.add_row(*map(str, row)) - - - 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)) - - self.__sourceMediaTagRowData = populate_tag_table( - self.mediaTagsTable, - self.__sourceMediaDescriptor.getTags(), - ignore_keys=self.__ignoreGlobalKeys, - remove_keys=self.__removeGlobalKeys, - ) - - self.updateTracks() - - - 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() - - self.updateDifferences() - - else: - - self.query_one("#pattern_input", Input).value = self.__mediaFilename - self.highlightPattern(True) - - - def highlightPattern(self, state : bool): - if state: - self.query_one("#pattern_input", Input).styles.background = 'red' - else: - self.query_one("#pattern_input", Input).styles.background = None - - - def updateTracks(self): - - self.tracksTable.clear() - self.__trackRowData = {} - - # trackDescriptorList = self.__sourceMediaDescriptor.getAllTrackDescriptors() - trackDescriptorList = self.__sourceMediaDescriptor.getTrackDescriptors() - - typeCounter = {} - - for td in trackDescriptorList: - - trackType = td.getType() - if not trackType in typeCounter.keys(): - typeCounter[trackType] = 0 - - dispoSet = 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 dispoSet else 'No', - 'Yes' if TrackDisposition.FORCED in dispoSet else 'No') - - row_key = self.tracksTable.add_row(*map(str, row)) - self.__trackRowData[row_key] = td - - typeCounter[trackType] += 1 - - - def compose(self): - - # Create the DataTable widget - self.showsTable = DataTable(classes="two") - - # Define the columns with headers - self.column_key_show_id = self.showsTable.add_column("ID", width=10) - self.column_key_show_name = self.showsTable.add_column("Name", width=80) - self.column_key_show_year = self.showsTable.add_column("Year", width=10) - - self.showsTable.cursor_type = 'row' - - - self.mediaTagsTable = DataTable(classes="two") - - # Define the columns with headers - self.column_key_track_tag_key = self.mediaTagsTable.add_column("Key", width=30) - self.column_key_track_tag_value = self.mediaTagsTable.add_column("Value", width=70) - - self.mediaTagsTable.cursor_type = 'row' - - - self.tracksTable = DataTable(classes="two") - - # Define the columns with headers - self.column_key_track_index = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_INDEX_COLUMN_LABEL, width=5) - self.column_key_track_type = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_TYPE_COLUMN_LABEL, width=10) - self.column_key_track_sub_index = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_SUB_INDEX_COLUMN_LABEL, width=8) - self.column_key_track_codec = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_CODEC_COLUMN_LABEL, width=10) - self.column_key_track_layout = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_LAYOUT_COLUMN_LABEL, width=10) - self.column_key_track_language = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_LANGUAGE_COLUMN_LABEL, width=15) - self.column_key_track_title = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_TITLE_COLUMN_LABEL, width=48) - self.column_key_track_default = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_DEFAULT_COLUMN_LABEL, width=8) - self.column_key_track_forced = self.tracksTable.add_column(MediaDetailsScreen.TRACKS_TABLE_FORCED_COLUMN_LABEL, width=8) - - self.tracksTable.cursor_type = 'row' - - - # Create the DataTable widget - self.differencesTable = DataTable(id='differences-table') # classes="triple" - - # Define the columns with headers - self.column_key_differences = self.differencesTable.add_column(MediaDetailsScreen.DIFFERENCES_TABLE_DIFFERENCES_COLUMN_LABEL, width=100) - - self.differencesTable.cursor_type = 'row' - - yield Header() - - with Grid(): - - # 1 - yield Static("Show") - yield self.showsTable - yield Static(" ") - yield self.differencesTable - - # 2 - yield Static(" ", classes="four") - - # 3 - yield Static(" ") - yield Button("Substitute", id="pattern_button") - yield Static(" ", classes="two") - - # 4 - yield Static("Pattern") - yield Input(type="text", id='pattern_input', classes="two") - - yield Static(" ") - - # 5 - yield Static(" ", classes="four") - - # 6 - yield Static("Media Tags") - yield self.mediaTagsTable - yield Static(" ") - - # 7 - yield Static(" ", classes="four") - - # 8 - yield Static(" ") - yield Button("Set Default", id="select_default_button") - yield Button("Set Forced", id="select_forced_button") - yield Static(" ") - # 9 - yield Static("Streams") - yield self.tracksTable - yield Static(" ") - - yield Footer() - + 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): - """Returns show id and pattern as obj from corresponding inputs""" patternObj = {} try: - patternObj['show_id'] = self.getSelectedShowDescriptor().getId() - patternObj['pattern'] = str(self.query_one("#pattern_input", Input).value) - except: + 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": - + 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) - + 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() - self.__sourceMediaDescriptor.setDefaultSubTrack(selectedTrackDescriptor.getType(), selectedTrackDescriptor.getSubIndex()) - self.updateTracks() + if selectedTrackDescriptor is not None: + self.__sourceMediaDescriptor.setDefaultSubTrack( + selectedTrackDescriptor.getType(), + selectedTrackDescriptor.getSubIndex(), + ) + self.refreshAfterDraftChange() if event.button.id == "select_forced_button": selectedTrackDescriptor = self.getSelectedTrackDescriptor() - self.__sourceMediaDescriptor.setForcedSubTrack(selectedTrackDescriptor.getType(), selectedTrackDescriptor.getSubIndex()) - self.updateTracks() + if selectedTrackDescriptor is not None: + self.__sourceMediaDescriptor.setForcedSubTrack( + selectedTrackDescriptor.getType(), + selectedTrackDescriptor.getSubIndex(), + ) + self.refreshAfterDraftChange() + if not self.__editMode: + return - def getSelectedTrackDescriptor(self): - """Returns a partial track descriptor""" - try: + if event.button.id == "button_add_tag": + self.app.push_screen(TagDetailsScreen(), self.handle_update_media_tag) - # Fetch the currently selected row when 'Enter' is pressed - #selected_row_index = self.table.cursor_row - row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate) + 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 row_key is not None: - return self.__trackRowData.get(row_key) + 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: - return None + updatedTracks.append(currentTrack) - except CellDoesNotExist: - return None + 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 - def getSelectedShowDescriptor(self) -> ShowDescriptor: - 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 - row_key, col_key = self.showsTable.coordinate_to_cell_key(self.showsTable.cursor_coordinate) + if applyResult.get("dry_run", False): + self.setMessage( + f"Dry-run: would rewrite via temporary file {applyResult['target_path']}" + ) + return - if row_key is not None: - return self.__showRowData.get(row_key) + self.reloadProperties(reset_draft=True) + self.refreshAfterDraftChange() + self.setMessage("Changes applied and file reloaded.") - except CellDoesNotExist: - return None + 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") + raise TypeError( + "MediaDetailsScreen.handle_new_pattern(): Argument 'showDescriptor' has to be of type ShowDescriptor" + ) self.removeShow() @@ -572,18 +839,18 @@ class MediaDetailsScreen(Screen): if showRowIndex is None: self._add_show_row(showDescriptor) - showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId()) + 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(): - - # Filter tags that make no sense to preserve - if tagKey not in self.__ignoreGlobalKeys and not tagKey in self.__removeGlobalKeys: + if ( + tagKey not in self.__ignoreGlobalKeys + and tagKey not in self.__removeGlobalKeys + ): mediaTags[tagKey] = tagValue patternId = self.__pc.savePatternSchema( @@ -592,143 +859,140 @@ class MediaDetailsScreen(Screen): mediaTags=mediaTags, ) if patternId: + self.reloadProperties(reset_draft=True) + self.updateMediaTags() + self.updateTracks() + self.updateDifferences() self.highlightPattern(False) - def action_new_pattern(self): - """Adding new patterns - - If the corresponding show does not exists in DB it is added beforehand""" + if not self.__inspectMode: + return selectedShowDescriptor = self.getSelectedShowDescriptor() - - #HINT: Callback is invoked after this method has exited. As a workaround the callback is executed directly - # from here with a mock-up screen result containing the necessary part of keys to perform correctly. if selectedShowDescriptor is None: self.app.push_screen(ShowDetailsScreen(), self.handle_new_pattern) else: self.handle_new_pattern(selectedShowDescriptor) - def action_update_pattern(self): - """Updating patterns - - When updating the database the actions must reverse the difference (eq to diff db->file)""" + if not self.__inspectMode: + return if self.__currentPattern is not None: patternObj = self.getPatternObjFromInput() - if (patternObj - and self.__currentPattern.getPattern() != patternObj['pattern']): - return self.__pc.updatePattern(self.__currentPattern.getId(), patternObj) + 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.loadProperties() + self.reloadProperties(reset_draft=True) - # __mediaChangeSetObj is file vs database - if MediaDescriptorChangeSet.TAGS_KEY in self.__mediaChangeSetObj.keys(): + tagDifferences = self.__mediaChangeSetObj.get(MediaDescriptorChangeSet.TAGS_KEY, {}) + for addedTagKey in tagDifferences.get(DIFF_ADDED_KEY, {}).keys(): + self.__tac.deleteMediaTagByKey(self.__currentPattern.getId(), addedTagKey) - if DIFF_ADDED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY].keys(): - for addedTagKey in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_ADDED_KEY].keys(): - # click.ClickException(f"delete media tag patternId={self.__currentPattern.getId()} addedTagKey={addedTagKey}") - 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], + ) - if DIFF_REMOVED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY].keys(): - for removedTagKey in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_REMOVED_KEY].keys(): - currentTags = self.__sourceMediaDescriptor.getTags() - # click.ClickException(f"delete media tag patternId={self.__currentPattern.getId()} removedTagKey={removedTagKey} currentTags={currentTags[removedTagKey]}") - 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], + ) - if DIFF_CHANGED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY].keys(): - for changedTagKey in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TAGS_KEY][DIFF_CHANGED_KEY].keys(): - currentTags = self.__sourceMediaDescriptor.getTags() - # click.ClickException(f"delete media tag patternId={self.__currentPattern.getId()} changedTagKey={changedTagKey} currentTags={currentTags[changedTagKey]}") - self.__tac.updateMediaTag(self.__currentPattern.getId(), changedTagKey, currentTags[changedTagKey]) + trackDifferences = self.__mediaChangeSetObj.get(MediaDescriptorChangeSet.TRACKS_KEY, {}) - if MediaDescriptorChangeSet.TRACKS_KEY in self.__mediaChangeSetObj.keys(): + for trackDescriptor in trackDifferences.get(DIFF_ADDED_KEY, {}).values(): + self.__tc.addTrack(trackDescriptor, patternId=self.__currentPattern.getId()) - if DIFF_ADDED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY].keys(): + for trackDescriptor in trackDifferences.get(DIFF_REMOVED_KEY, {}).values(): + self.__tc.deleteTrack(trackDescriptor.getId()) - for trackIndex, trackDescriptor in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_ADDED_KEY].items(): - #targetTracks = [t for t in self.__targetMediaDescriptor.getAllTrackDescriptors() if t.getIndex() == addedTrackIndex] - # if targetTracks: - # self.__tc.deleteTrack(targetTracks[0].getId()) # id - # self.__tc.deleteTrack(targetTracks[0].getId()) - self.__tc.addTrack(trackDescriptor, patternId = self.__currentPattern.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 - if DIFF_REMOVED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY].keys(): - trackDescriptor: TrackDescriptor - for trackIndex, trackDescriptor in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_REMOVED_KEY].items(): - # Track per inspect/update hinzufügen - #self.__tc.addTrack(removedTrack, patternId = self.__currentPattern.getId()) - self.__tc.deleteTrack(trackDescriptor.getId()) + 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) - if DIFF_CHANGED_KEY in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY].keys(): - - # [vsTracks[tp].getIndex()] = trackDiff - for trackIndex, trackDiff in self.__mediaChangeSetObj[MediaDescriptorChangeSet.TRACKS_KEY][DIFF_CHANGED_KEY].items(): - - targetTracks = [t for t in self.__targetMediaDescriptor.getTrackDescriptors() if t.getIndex() == trackIndex] - targetTrackId = targetTracks[0].getId() if targetTracks else None - targetTrackIndex = targetTracks[0].getIndex() if targetTracks else None - - changedCurrentTracks = [t for t in self.__sourceMediaDescriptor.getTrackDescriptors() if t.getIndex() == trackIndex] - # changedCurrentTrackId #HINT: Undefined as track descriptors do not come from file with track_id - - if TrackDescriptor.TAGS_KEY in trackDiff.keys(): - tagsDiff = trackDiff[TrackDescriptor.TAGS_KEY] - - if DIFF_ADDED_KEY in tagsDiff.keys(): - for tagKey, tagValue in tagsDiff[DIFF_ADDED_KEY].items(): - - # if targetTracks: - # self.__tac.deleteTrackTagByKey(targetTrackId, addedTrackTagKey) - self.__tac.updateTrackTag(targetTrackId, tagKey, tagValue) - - - if DIFF_REMOVED_KEY in tagsDiff.keys(): - for tagKey, tagValue in tagsDiff[DIFF_REMOVED_KEY].items(): - # if changedCurrentTracks: - # self.__tac.updateTrackTag(targetTrackId, removedTrackTagKey, changedCurrentTracks[0].getTags()[removedTrackTagKey]) - self.__tac.deleteTrackTagByKey(targetTrackId, tagKey) - - if DIFF_CHANGED_KEY in tagsDiff.keys(): - for tagKey, tagValue in tagsDiff[DIFF_CHANGED_KEY].items(): - # if changedCurrentTracks: - # self.__tac.updateTrackTag(targetTrackId, changedTrackTagKey, changedCurrentTracks[0].getTags()[changedTrackTagKey]) - self.__tac.updateTrackTag(targetTrackId, tagKey, tagValue) - - - if TrackDescriptor.DISPOSITION_SET_KEY in trackDiff.keys(): - changedTrackDispositionDiff = trackDiff[TrackDescriptor.DISPOSITION_SET_KEY] - - if DIFF_ADDED_KEY in changedTrackDispositionDiff.keys(): - for changedDisposition in changedTrackDispositionDiff[DIFF_ADDED_KEY]: - if targetTrackIndex is not None: - self.__tc.setDispositionState(self.__currentPattern.getId(), targetTrackIndex, changedDisposition, True) - - if DIFF_REMOVED_KEY in changedTrackDispositionDiff.keys(): - for changedDisposition in changedTrackDispositionDiff[DIFF_REMOVED_KEY]: - if targetTrackIndex is not None: - self.__tc.setDispositionState(self.__currentPattern.getId(), targetTrackIndex, changedDisposition, False) - + 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['pattern']: - + if patternObj.get("pattern"): selectedPatternId = self.__pc.findPattern(patternObj) - if selectedPatternId is None: - raise click.ClickException(f"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) # <- + 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): - self.query_one("#pattern_input", Input).value = screenResult['pattern'] + 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/metadata_editor.py b/src/ffx/metadata_editor.py new file mode 100644 index 0000000..da0f2ac --- /dev/null +++ b/src/ffx/metadata_editor.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import os +import tempfile + +from .constants import ( + DEFAULT_AC3_BANDWIDTH, + DEFAULT_DTS_BANDWIDTH, + DEFAULT_STEREO_BANDWIDTH, +) +from .ffx_controller import FfxController +from .media_descriptor import MediaDescriptor +from .video_encoder import VideoEncoder + + +def create_temporary_output_path(source_path: str) -> str: + sourceDirectory = os.path.dirname(os.path.abspath(source_path)) or "." + sourceBasename = os.path.basename(source_path) + sourceStem, sourceExtension = os.path.splitext(sourceBasename) + + descriptor, temporaryPath = tempfile.mkstemp( + prefix=f".{sourceStem}.ffx-edit-", + suffix=sourceExtension or ".tmp", + dir=sourceDirectory, + ) + os.close(descriptor) + os.unlink(temporaryPath) + + return temporaryPath + + +def build_metadata_edit_context(context: dict) -> dict: + editContext = dict(context) + editContext["video_encoder"] = VideoEncoder.COPY + editContext["perform_cut"] = False + editContext["no_signature"] = bool(editContext.get("no_signature", True)) + editContext["resource_limits"] = dict(editContext.get("resource_limits", {})) + editContext["bitrates"] = dict( + editContext.get( + "bitrates", + { + "stereo": f"{DEFAULT_STEREO_BANDWIDTH}k", + "ac3": f"{DEFAULT_AC3_BANDWIDTH}k", + "dts": f"{DEFAULT_DTS_BANDWIDTH}k", + }, + ) + ) + editContext["encoding_metadata_tags"] = {} + return editContext + + +def apply_metadata_edits( + context: dict, + source_path: str, + baseline_descriptor: MediaDescriptor, + draft_descriptor: MediaDescriptor, +) -> dict[str, object]: + temporaryOutputPath = create_temporary_output_path(source_path) + editContext = build_metadata_edit_context(context) + controller = FfxController(editContext, draft_descriptor, baseline_descriptor) + + try: + controller.runJob( + source_path, + temporaryOutputPath, + targetFormat="", + chainIteration=[], + ) + + if editContext.get("dry_run", False): + return { + "applied": False, + "dry_run": True, + "target_path": temporaryOutputPath, + } + + os.replace(temporaryOutputPath, source_path) + return { + "applied": True, + "dry_run": False, + "target_path": source_path, + } + except Exception: + if os.path.exists(temporaryOutputPath): + os.remove(temporaryOutputPath) + raise + finally: + if editContext.get("dry_run", False) and os.path.exists(temporaryOutputPath): + os.remove(temporaryOutputPath) diff --git a/src/ffx/screen_support.py b/src/ffx/screen_support.py index cb654d6..2cb4c96 100644 --- a/src/ffx/screen_support.py +++ b/src/ffx/screen_support.py @@ -17,6 +17,7 @@ class ScreenBootstrap: context: dict configuration_data: dict signature_tags: dict + apply_cleanup: bool remove_global_keys: list ignore_global_keys: list remove_track_keys: list @@ -27,14 +28,16 @@ def build_screen_bootstrap(context: dict) -> ScreenBootstrap: configurationData = context['config'].getData() metadataConfiguration = configurationData.get('metadata', {}) streamMetadataConfiguration = metadataConfiguration.get('streams', {}) + applyCleanup = bool(context.get('apply_metadata_cleanup', True)) return ScreenBootstrap( context=context, configuration_data=configurationData, signature_tags=metadataConfiguration.get('signature', {}), - remove_global_keys=metadataConfiguration.get('remove', []), + apply_cleanup=applyCleanup, + remove_global_keys=metadataConfiguration.get('remove', []) if applyCleanup else [], ignore_global_keys=metadataConfiguration.get('ignore', []), - remove_track_keys=streamMetadataConfiguration.get('remove', []), + remove_track_keys=streamMetadataConfiguration.get('remove', []) if applyCleanup else [], ignore_track_keys=streamMetadataConfiguration.get('ignore', []), ) diff --git a/src/ffx/track_descriptor.py b/src/ffx/track_descriptor.py index 9a102b0..d29d85e 100644 --- a/src/ffx/track_descriptor.py +++ b/src/ffx/track_descriptor.py @@ -343,3 +343,25 @@ class TrackDescriptor: def getExternalSourceFilePath(self): return self.__externalSourceFilePath + + def clone(self, context: dict | None = None): + kwargs = { + TrackDescriptor.ID_KEY: int(self.__trackId), + TrackDescriptor.PATTERN_ID_KEY: int(self.__patternId), + TrackDescriptor.EXTERNAL_SOURCE_FILE_PATH_KEY: str(self.__externalSourceFilePath), + TrackDescriptor.INDEX_KEY: int(self.__index), + TrackDescriptor.SOURCE_INDEX_KEY: int(self.__sourceIndex), + TrackDescriptor.SUB_INDEX_KEY: int(self.__subIndex), + TrackDescriptor.TRACK_TYPE_KEY: self.__trackType, + TrackDescriptor.CODEC_KEY: self.__trackCodec, + TrackDescriptor.TAGS_KEY: dict(self.__trackTags), + TrackDescriptor.DISPOSITION_SET_KEY: set(self.__dispositionSet), + TrackDescriptor.AUDIO_LAYOUT_KEY: self.__audioLayout, + } + + if context is not None: + kwargs[TrackDescriptor.CONTEXT_KEY] = context + elif self.__context: + kwargs[TrackDescriptor.CONTEXT_KEY] = self.__context + + return TrackDescriptor(**kwargs) diff --git a/src/ffx/track_details_screen.py b/src/ffx/track_details_screen.py index bacbd7a..64f1644 100644 --- a/src/ffx/track_details_screen.py +++ b/src/ffx/track_details_screen.py @@ -94,6 +94,7 @@ class TrackDetailsScreen(Screen): trackType: TrackType = None, index=None, subIndex=None, + metadata_only: bool = False, ): super().__init__() @@ -117,6 +118,7 @@ class TrackDetailsScreen(Screen): ) self.__patternLabel = str(patternLabel) self.__siblingTrackDescriptors = list(siblingTrackDescriptors or []) + self.__metadataOnly = bool(metadata_only) if self.__isNew: self.__trackType = trackType @@ -192,6 +194,10 @@ class TrackDetailsScreen(Screen): self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle() self.updateTags() + if self.__metadataOnly: + self.query_one("#type_select", Select).disabled = True + self.query_one("#audio_layout_select", Select).disabled = True + def compose(self): self.trackTagsTable = DataTable(classes="five") diff --git a/tests/unit/test_cli_lazy_imports.py b/tests/unit/test_cli_lazy_imports.py index d55d630..b6bfddf 100644 --- a/tests/unit/test_cli_lazy_imports.py +++ b/tests/unit/test_cli_lazy_imports.py @@ -229,6 +229,51 @@ class CliLazyImportTests(unittest.TestCase): result["modules"], ) + def test_edit_command_avoids_database_bootstrap(self): + result = self.run_python( + textwrap.dedent( + f""" + import json + import os + import sys + import tempfile + from click.testing import CliRunner + + sys.path.insert(0, {str(SRC_ROOT)!r}) + + import ffx.cli + import ffx.ffx_app + import ffx.logging_utils + + ffx.ffx_app.FfxApp.run = lambda self: None + ffx.logging_utils.configure_ffx_logger = lambda *args, **kwargs: None + + runner = CliRunner() + with tempfile.TemporaryDirectory() as tmpdir: + sample_path = os.path.join(tmpdir, "sample.mkv") + with open(sample_path, "w", encoding="utf-8"): + pass + + invoke_result = runner.invoke( + ffx.cli.ffx, + ["--dry-run", "edit", sample_path], + ) + + print(json.dumps({{ + "exit_code": invoke_result.exit_code, + "output": invoke_result.output, + "modules": {{ + module_name: module_name in sys.modules + for module_name in {HEAVY_MODULES!r} + }}, + }})) + """ + ) + ) + + self.assertEqual(0, result["exit_code"], result["output"]) + self.assertFalse(result["modules"]["ffx.database"], result["modules"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_file_properties_probe.py b/tests/unit/test_file_properties_probe.py index d99012b..44dfc97 100644 --- a/tests/unit/test_file_properties_probe.py +++ b/tests/unit/test_file_properties_probe.py @@ -107,6 +107,22 @@ class FilePropertiesProbeTests(unittest.TestCase): + ["/tmp/example_s01e01.mkv"] ) + def test_use_pattern_false_skips_pattern_controller_construction(self): + file_properties_module = self.import_module() + + with patch.object( + file_properties_module, + "PatternController", + side_effect=AssertionError("PatternController should not be created"), + ): + file_properties = file_properties_module.FileProperties( + self.make_context(), + "/tmp/example_s01e01.mkv", + ) + + self.assertEqual(-1, file_properties.getShowId()) + self.assertIsNone(file_properties.getPattern()) + def test_cropdetect_uses_configured_window_and_caches_results(self): file_properties_module = self.import_module() file_properties_module.FileProperties._clear_cropdetect_cache() diff --git a/tests/unit/test_media_descriptor_change_set.py b/tests/unit/test_media_descriptor_change_set.py index 93c641a..309ea12 100644 --- a/tests/unit/test_media_descriptor_change_set.py +++ b/tests/unit/test_media_descriptor_change_set.py @@ -212,6 +212,53 @@ class MediaDescriptorChangeSetTests(unittest.TestCase): self.assertIn("BPS=", metadata_tokens) self.assertIn("KEEP_ME=keep-me", metadata_tokens) + def test_cleanup_can_be_disabled_per_context(self): + context = { + "logger": get_ffx_logger(), + "config": StaticConfig( + { + "metadata": { + "remove": ["creation_time"], + "streams": { + "remove": ["BPS"], + }, + } + } + ), + "apply_metadata_cleanup": False, + } + + source_track = TrackDescriptor( + index=0, + source_index=0, + sub_index=0, + track_type=TrackType.AUDIO, + tags={"BPS": "keep-me"}, + ) + target_track = TrackDescriptor( + index=0, + source_index=0, + sub_index=0, + track_type=TrackType.AUDIO, + tags={"BPS": "keep-me"}, + ) + + change_set = MediaDescriptorChangeSet( + context, + MediaDescriptor( + tags={"creation_time": "keep-me"}, + track_descriptors=[target_track], + ), + MediaDescriptor( + tags={"creation_time": "keep-me"}, + track_descriptors=[source_track], + ), + ) + + metadata_tokens = change_set.generateMetadataTokens() + self.assertNotIn("creation_time=", metadata_tokens) + self.assertNotIn("BPS=", metadata_tokens) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_metadata_editor.py b/tests/unit/test_metadata_editor.py new file mode 100644 index 0000000..2c1bad1 --- /dev/null +++ b/tests/unit/test_metadata_editor.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from pathlib import Path +import os +import sys +import tempfile +import unittest +from unittest.mock import patch + + +SRC_ROOT = Path(__file__).resolve().parents[2] / "src" + +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + + +from ffx.logging_utils import get_ffx_logger # noqa: E402 +from ffx.media_descriptor import MediaDescriptor # noqa: E402 +from ffx.metadata_editor import ( # noqa: E402 + apply_metadata_edits, + build_metadata_edit_context, + create_temporary_output_path, +) +from ffx.track_codec import TrackCodec # noqa: E402 +from ffx.track_descriptor import TrackDescriptor # noqa: E402 +from ffx.track_type import TrackType # noqa: E402 +from ffx.video_encoder import VideoEncoder # noqa: E402 + + +class StaticConfig: + def getData(self): + return {} + + +def make_context(*, dry_run: bool = False) -> dict: + return { + "logger": get_ffx_logger(), + "config": StaticConfig(), + "dry_run": dry_run, + "apply_metadata_cleanup": True, + } + + +def make_descriptor() -> MediaDescriptor: + return MediaDescriptor( + track_descriptors=[ + TrackDescriptor( + index=0, + source_index=0, + sub_index=0, + track_type=TrackType.VIDEO, + codec_name=TrackCodec.H264, + tags={"title": "Main"}, + ) + ], + tags={"TITLE": "Demo"}, + ) + + +class MetadataEditorTests(unittest.TestCase): + def test_build_metadata_edit_context_forces_copy_without_signature(self): + context = build_metadata_edit_context(make_context()) + + self.assertEqual(VideoEncoder.COPY, context["video_encoder"]) + self.assertFalse(context["perform_cut"]) + self.assertTrue(context["no_signature"]) + self.assertEqual({}, context["encoding_metadata_tags"]) + + def test_create_temporary_output_path_uses_same_directory_and_extension(self): + with tempfile.TemporaryDirectory() as tmpdir: + source_path = os.path.join(tmpdir, "episode.mkv") + temporary_path = create_temporary_output_path(source_path) + + self.assertEqual(".mkv", Path(temporary_path).suffix) + self.assertEqual(Path(source_path).parent, Path(temporary_path).parent) + + def test_apply_metadata_edits_rewrites_via_temporary_file_then_replaces_source(self): + context = make_context() + baseline_descriptor = make_descriptor() + draft_descriptor = baseline_descriptor.clone(context=context) + source_path = "/tmp/example.mkv" + + with ( + patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"), + patch("ffx.metadata_editor.FfxController.runJob") as mocked_run_job, + patch("ffx.metadata_editor.os.replace") as mocked_replace, + ): + result = apply_metadata_edits( + context, + source_path, + baseline_descriptor, + draft_descriptor, + ) + + mocked_run_job.assert_called_once_with( + source_path, + "/tmp/.edit.mkv", + targetFormat="", + chainIteration=[], + ) + mocked_replace.assert_called_once_with("/tmp/.edit.mkv", source_path) + self.assertEqual( + { + "applied": True, + "dry_run": False, + "target_path": source_path, + }, + result, + ) + + def test_apply_metadata_edits_dry_run_skips_replace_and_cleans_temp_path(self): + context = make_context(dry_run=True) + baseline_descriptor = make_descriptor() + draft_descriptor = baseline_descriptor.clone(context=context) + + with ( + patch("ffx.metadata_editor.create_temporary_output_path", return_value="/tmp/.edit.mkv"), + patch("ffx.metadata_editor.FfxController.runJob") as mocked_run_job, + patch("ffx.metadata_editor.os.path.exists", return_value=True), + patch("ffx.metadata_editor.os.remove") as mocked_remove, + patch("ffx.metadata_editor.os.replace") as mocked_replace, + ): + result = apply_metadata_edits( + context, + "/tmp/example.mkv", + baseline_descriptor, + draft_descriptor, + ) + + mocked_run_job.assert_called_once() + mocked_replace.assert_not_called() + mocked_remove.assert_called_once_with("/tmp/.edit.mkv") + self.assertEqual( + { + "applied": False, + "dry_run": True, + "target_path": "/tmp/.edit.mkv", + }, + result, + ) + + +if __name__ == "__main__": + unittest.main()