iteration1
This commit is contained in:
198
requirements/metadata_editor.md
Normal file
198
requirements/metadata_editor.md
Normal file
@@ -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 <file>`
|
||||
- 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 <file>`
|
||||
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.
|
||||
@@ -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 <command> --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)
|
||||
|
||||
55
src/ffx/confirm_screen.py
Normal file
55
src/ffx/confirm_screen.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
89
src/ffx/metadata_editor.py
Normal file
89
src/ffx/metadata_editor.py
Normal file
@@ -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)
|
||||
@@ -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', []),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
144
tests/unit/test_metadata_editor.py
Normal file
144
tests/unit/test_metadata_editor.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user